Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@

## Unreleased

### Fixed
- Native Codex and Claude Code hooks no longer block standalone `agentguard` CLI results when output contains sensitive data. Codex replaces model-visible tool results with locally redacted feedback, Claude Code rewrites supported post-tool results, and the exact pending Agent JWT activation URL remains clickable. Compound commands and other tools remain protected.
- OpenClaw and QClaw integrations now rewrite correlated AgentGuard CLI text in supported OpenClaw-owned `tool_result_persist` transcripts; the native Hermes `transform_tool_result` hook and DSH `tools/post-execute` accept path provide equivalent non-blocking redaction where supported. An exact saved activation link stays clickable; other hooks and downstream policy decisions retain their existing boundaries.

## [1.2.2] - 2026-10-16

### Changed
- Clean runtime events stay local across DSH, Codex, and Claude Code.
- Reportable DSH and native-hook events now send bounded, redacted Cloud summaries with useful command previews, action details, web destinations, or file/configuration/model/policy context. Raw responses, output, credentials, and other sensitive details stay local.
Expand All @@ -16,6 +10,8 @@
### Fixed
- Blocked Claude Code `PostToolBatch` findings now reach Cloud with a redacted summary and batch counts.
- Codex and Claude Code hooks no longer flag masked output from the exact standalone `agentguard status` command as PII or bulk egress; modified commands and unmasked secrets remain inspected.
- Native Codex and Claude Code hooks no longer block standalone `agentguard` CLI results when output contains sensitive data. Codex replaces model-visible tool results with locally redacted feedback, Claude Code rewrites supported post-tool results, and the exact pending Agent JWT activation URL remains clickable. Compound commands and other tools remain protected.
- OpenClaw and QClaw integrations now rewrite correlated AgentGuard CLI text in supported OpenClaw-owned `tool_result_persist` transcripts; the native Hermes `transform_tool_result` hook and DSH `tools/post-execute` accept path provide equivalent non-blocking redaction where supported. An exact saved activation link stays clickable; other hooks and downstream policy decisions retain their existing boundaries.

## [1.2.1] - 2026-09-29

Expand Down
2 changes: 1 addition & 1 deletion docs/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Routine successful `PostToolUse` observations remain in the local audit and are

On Claude Code 2.1.251+, `PreModelSwitch` can deny or request approval for an explicit switch using `from_model`, `to_model`, `source`, and `context_tokens`. Explicit user/manual/API switches require approval, and any switch carrying 100,000 or more context tokens independently requires approval because it represents a material retransmission. Model IDs are labels, not endpoints, and are never treated as final transport destinations. `PostModelSwitch` audits automatic/session changes only. Automatic or one-shot fallbacks that do not emit the event remain `unsupported`.

`MessageDisplay` reads the official `delta` input and may return a masked top-level `displayContent`; it is always recorded as `display_only` with `canBlockCurrentAction=false`. It does not change the model response, transcript, or later `Stop` content. `Stop`, `InstructionsLoaded`, `PreCompact`, and `PostCompact` record redacted bounded metadata only; AgentGuard does not parse transcript files to invent payload, endpoint, credential, retry, or fallback facts.
`MessageDisplay` reads the official `delta` input and may return a masked `hookSpecificOutput.displayContent`. The exact saved pending activation link stays visible; other recognized credentials are masked. The replacement is returned to the hook only and is not stored in audit metadata. This event is always recorded as `display_only` with `canBlockCurrentAction=false`. It does not change the model response, transcript, or later `Stop` content. `Stop`, `InstructionsLoaded`, `PreCompact`, and `PostCompact` record redacted bounded metadata only; AgentGuard does not parse transcript files to invent payload, endpoint, credential, retry, or fallback facts.

## `@file` boundary

Expand Down
6 changes: 6 additions & 0 deletions docs/privacy-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ remains unchanged when it appears as an exact value, including a structured
links and other tokenized URLs are not exempt. Compound commands, unverified
executable paths, and unrelated tools retain their ordinary protection;
`agentguard approve` is not exempt from agent-self-approval checks.
Direct `npx @goplus/agentguard` calls with supported public subcommands receive
the same native output treatment. The exact documented
`curl -fsSL https://agentguard.one/install.sh | bash` command receives it only
when the result contains the complete saved activation link. Other installer
results follow ordinary privacy checks. Running the remote installer still
requires approval before execution.

| Host | Own-command result handling | Limit |
|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": "0.3",
"name": "agentguard",
"display_name": "GoPlus AgentGuard",
"version": "1.2.2",
"version": "1.2.4-beta.2",
"icon": "icon.png",
"description": "Security guard for AI agents — blocks malicious skills, prevents data leaks, protects secrets.",
"long_description": "GoPlus AgentGuard is an AI-agent security framework. It exposes MCP tools for scanning skills, looking up and managing a trust registry, evaluating runtime actions against policy, and simulating Web3 transactions. 20+ detection rules cover dangerous commands, secret exfiltration, and risky on-chain actions.",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@goplus/agentguard",
"version": "1.2.2",
"version": "1.2.4-beta.2",
"description": "GoPlus AgentGuard — Security guard for AI agents. Blocks dangerous commands, prevents data leaks, protects secrets. 20 detection rules, runtime action evaluation, trust registry.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
42 changes: 28 additions & 14 deletions src/runtime/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import type { AgentGuardConfig } from '../config.js';
import { consumeApprovedApproval, writePendingApproval, type ApprovalRecord } from './approvals.js';
import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js';
import { evaluateRuntimeAction } from './decision.js';
import { isAgentGuardCliCommand, isAgentGuardStatusCommand, isStandaloneAgentGuardCliCommand } from './self-command.js';
import { isAgentGuardCliCommand, isAgentGuardOwnedOutputCommand, isAgentGuardStatusCommand,
isOfficialAgentGuardInstallerCommand, isStandaloneAgentGuardCliCommand } from './self-command.js';
import { redactText, summarizeSensitiveData } from './redaction.js';
import { redactAgentGuardSelfToolOutput, redactAgentGuardSelfToolValue } from './self-output.js';
import { hasExactSavedActivationUrl, redactAgentGuardSelfToolOutput, redactAgentGuardSelfToolValue,
redactTextPreservingSavedActivationUrl } from './self-output.js';
import {
hasSensitivePublicSearchResult, isNativePublicSearchTool,
isVisiblePublicSearchResult, inspectWithPublicSearchProvenance,
Expand Down Expand Up @@ -53,6 +55,8 @@ export interface ProtectResult {
policySource: 'cloud' | 'cache' | 'default' | 'cloud-decision';
/** Hook-only response, never written to the audit log or Cloud. */
selfToolOutput?: { text: string; value: unknown };
/** Display-only replacement, never written to the audit log or Cloud. */
displayContent?: string;
}

type CodexHookEvent =
Expand Down Expand Up @@ -195,7 +199,13 @@ export async function protectAction(options: ProtectOptions): Promise<ProtectRes
? writePendingApproval(approvalStorePath, action, decision)
: undefined;

return { decision, event, approvalChannel, pendingApproval, policySource };
let displayContent: string | undefined;
if (claudeHookEvent === 'MessageDisplay') {
const delta = firstString(parseRawInput(options.rawInput, options.stdinText)?.delta);
const safeDelta = redactTextPreservingSavedActivationUrl(delta, options.config);
if (safeDelta !== delta) displayContent = safeDelta;
}
return { decision, event, approvalChannel, pendingApproval, policySource, displayContent };
}

function nativeAgentGuardSelfOutput(
Expand All @@ -208,11 +218,15 @@ function nativeAgentGuardSelfOutput(
&& claudeEvent !== 'PostToolUseFailure') return undefined;
if (action.metadata?.nativeToolActionType !== 'shell'
|| typeof action.metadata.nativeToolInput !== 'string'
|| !isStandaloneAgentGuardCliCommand(action.metadata.nativeToolInput)) return undefined;
|| !isAgentGuardOwnedOutputCommand(action.metadata.nativeToolInput)) return undefined;

// buildRuntimeAction already validated native payloads. Inspect the original
// result here, not action.input (which can contain an inspection projection).
const raw = parseRawInput(options.rawInput, options.stdinText);
if (isOfficialAgentGuardInstallerCommand(action.metadata.nativeToolInput)) {
const output = raw?.tool_response ?? raw?.toolResponse ?? raw?.error ?? raw?.error_message;
if (!hasExactSavedActivationUrl(output, options.config)) return undefined;
}
if (claudeEvent === 'PostToolUseFailure') {
const error = firstString(raw?.error, raw?.error_message);
return hasUnmaskedSelfCredential(error) || hasEncodedSensitiveSelfText(error, options.config)
Expand Down Expand Up @@ -886,9 +900,10 @@ function formatClaudeHookResult(result: ProtectResult): string | null {
return decision === 'warn' ? JSON.stringify({ systemMessage: reason }) : '';
}
if (event === 'MessageDisplay') {
const updatedMessage = result.event.metadata?.claudeUpdatedMessage;
if (typeof updatedMessage === 'string') {
return JSON.stringify({ displayContent: updatedMessage });
if (result.displayContent !== undefined) {
return JSON.stringify({ hookSpecificOutput: {
hookEventName: 'MessageDisplay', displayContent: result.displayContent,
} });
}
return '';
}
Expand Down Expand Up @@ -987,9 +1002,6 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction {
? hasSensitivePublicSearchResult(publicSearchResult)
|| (toolInput !== undefined && redactText(JSON.stringify(toolInput)) !== JSON.stringify(toolInput))
: undefined;
const displayMessage = claudeHookEvent === 'MessageDisplay'
? firstString(raw?.delta)
: '';
let actionInput = process.env.TOOL_INPUT
|| (observedSearchResult !== undefined
? typeof observedSearchResult === 'string' ? observedSearchResult : JSON.stringify(observedSearchResult)
Expand Down Expand Up @@ -1101,9 +1113,6 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction {
: {}),
...(observedSearchResult !== undefined && !process.env.TOOL_INPUT ? { observedSearchResult: true } : {}),
...(suppressedPublicSearchValues > 0 ? { suppressedPublicSearchValues } : {}),
...(displayMessage && redactText(displayMessage) !== displayMessage
? { claudeUpdatedMessage: redactText(displayMessage) }
: {}),
},
};
}
Expand Down Expand Up @@ -1544,10 +1553,15 @@ function projectClaudeBatchForInspection(
if (!isPlainRecord(call)) return call;
const toolName = firstString(call.tool_name, call.toolName, call.name);
const toolInput = pickToolInput(call);
const command = pickNativeToolAuditInput('shell', toolInput);
if (mapToolToRuntimeAction(toolName, call) !== 'shell'
|| !isStandaloneAgentGuardCliCommand(pickNativeToolAuditInput('shell', toolInput))) return call;
|| !isAgentGuardOwnedOutputCommand(command)) return call;
const responseKey = Object.prototype.hasOwnProperty.call(call, 'tool_response')
? 'tool_response' : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null;
if (isOfficialAgentGuardInstallerCommand(command)) {
const output = responseKey ? call[responseKey] : call.error ?? call.error_message;
if (!hasExactSavedActivationUrl(output, config)) return call;
}
if (!responseKey) {
const error = firstString(call.error, call.error_message);
if (redactText(error) !== error || hasUnmaskedSelfCredential(error)
Expand Down
25 changes: 24 additions & 1 deletion src/runtime/self-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@ export function isAgentGuardCliCommand(command: string): boolean {
}

/** Accept a single AgentGuard invocation, including simple shell wrappers.
* An explicit executable path must resolve to this package's CLI entrypoint.
* An explicit executable path must resolve to this package's CLI entrypoint;
* npx must name the official scoped package and a supported public command.
*/
export function isStandaloneAgentGuardCliCommand(command: string): boolean {
return isStandaloneAgentGuardCliCommandInner(command, 0);
}

/** Recognize the documented installer only for post-tool output sanitization. */
export function isAgentGuardOwnedOutputCommand(command: string): boolean {
return isStandaloneAgentGuardCliCommand(command) || isOfficialAgentGuardInstallerCommand(command);
}

export function isOfficialAgentGuardInstallerCommand(command: string): boolean {
return /^curl[ \t]+-fsSL[ \t]+https:\/\/(?:www\.)?agentguard\.one\/install\.sh[ \t]*\|[ \t]*bash$/.test(command.trim());
}

export function isAgentGuardStatusCommand(command: string): boolean {
const trimmed = command.trim();
if (!trimmed || /[\r\n]/.test(trimmed)) return false;
Expand Down Expand Up @@ -58,6 +68,7 @@ function isAgentGuardCliCommandInner(command: string, depth: number): boolean {
if (SUPPORTED_AGENT_COMMANDS.some((command) => matchesCommand(tokens, index, command))) {
return true;
}
if (isVerifiedAgentGuardNpxInvocation(tokens, index)) return true;

const wrappedCommand = shellWrapperCommand(tokens, index);
return wrappedCommand ? isAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false;
Expand All @@ -78,11 +89,23 @@ function isStandaloneAgentGuardCliCommandInner(command: string, depth: number):
}
while (['command', 'builtin'].includes(basename(tokens[index] || ''))) index += 1;
if (isVerifiedAgentGuardExecutable(tokens[index])) return true;
if (isVerifiedAgentGuardNpxInvocation(tokens, index)) return true;

const wrappedCommand = shellWrapperCommand(tokens, index);
return wrappedCommand ? isStandaloneAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false;
}

function isVerifiedAgentGuardNpxInvocation(tokens: string[], start: number): boolean {
if (tokens[start] !== 'npx'
|| !tokens.slice(0, start).every((token) => token === 'command' || token === 'builtin')) return false;
let index = start + 1;
if (tokens[index] === '-y' || tokens[index] === '--yes') index += 1;
return /^@goplus\/agentguard(?:@(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?|latest|beta))?$/.test(tokens[index] || '')
&& ['init', 'connect', 'disconnect', 'status', 'policy', 'privacy', 'doctor',
'scan', 'dsh-scan', 'dsh-scan-batch', 'dsh-compare', 'approvals',
'subscribe', 'checkup'].includes(tokens[index + 1] || '');
}

function isVerifiedAgentGuardExecutable(executable: string | undefined): boolean {
if (executable === 'agentguard') return true;
if (!executable || basename(executable) !== 'agentguard' || !isAbsolute(executable)) return false;
Expand Down
29 changes: 29 additions & 0 deletions src/runtime/self-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ function redactSelfText(text: string, config: AgentGuardConfig): string {
}
} catch { /* Plain text still receives the bounded text redactor. */ }
}
return redactTextPreservingSavedActivationUrl(text, config);
}

/** Keep only this installation's pending activation link visible to the user. */
export function redactTextPreservingSavedActivationUrl(text: string, config: AgentGuardConfig): string {
const activationUrl = config.agentJwt && config.agentRegisterUrl;
const marker = randomMarker('AGENTGUARDLINK');
const exampleMarker = randomMarker('AGENTGUARDEXAMPLE');
Expand All @@ -63,6 +68,30 @@ function redactSelfText(text: string, config: AgentGuardConfig): string {
}
}

/** Require a complete saved link before treating an installer result as owned output. */
export function hasExactSavedActivationUrl(value: unknown, config: AgentGuardConfig): boolean {
const activationUrl = config.agentJwt && config.agentRegisterUrl;
if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) return false;
try {
const url = new URL(activationUrl);
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) return false;
const seen = new WeakSet<object>();
let visited = 0;
const contains = (item: unknown, depth: number): boolean => {
if (++visited > 1000 || depth > 16) return false;
if (typeof item === 'string') {
return protectExactActivationUrl(item, activationUrl, '[validated activation link]') !== item;
}
if (!item || typeof item !== 'object' || seen.has(item)) return false;
seen.add(item);
return Object.values(item).some((nested) => contains(nested, depth + 1));
};
return contains(value, 0);
} catch {
return false;
}
}

/** Letters cannot accidentally match numeric PII rules during redaction. */
function randomMarker(prefix: string): string {
const letters = Array.from(randomBytes(16), (byte) =>
Expand Down
Loading
Loading