diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 5919718..99a2355 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -33,10 +33,19 @@ markers can be imitated by another script; this is the accepted limit of attributing a merged shell result without a trusted process identity. In Claude Code, `PostToolBatch` excludes the whole recognized call from its inspection copy, while retaining the host's original result. Other calls in a mixed batch remain -subject to the normal privacy gate. Compound commands, unverified paths, and -unrelated tools retain ordinary protection. The installer and `agentguard +subject to the normal privacy gate. A shell sequence made entirely of verified +AgentGuard CLI calls is also AgentGuard-owned. Compound calls that include any +other command, unverified paths, and unrelated tools retain ordinary protection. +The installer and `agentguard approve` still receive their normal checks before execution. +For Codex and Claude Code, a pre-tool hook rejects a shell call that joins an +AgentGuard CLI statement with other shell statements. It tells the agent to +retry the CLI command in its own tool call. This also covers a new session in +which the agent tries to read its skill documentation and run `agentguard +status` in one Bash call. No file read from that combined call runs; separate +file results keep their normal privacy checks. + This intentionally means credentials or personal data printed by AgentGuard commands can reach the agent unchanged. AgentGuard also does not rewrite those results in OpenClaw, QClaw, Hermes, or DSH. Claude `MessageDisplay` protects @@ -54,6 +63,12 @@ Local audit and Cloud previews keep the activation token masked or omit the link. A different site can imitate this URL shape, so the exception should not be treated as proof that the link came from AgentGuard. +The literal documentation examples `TYPESAFE_API_KEY=` and +`AGENTGUARD_API_KEY=` are also ignored by post-tool privacy checks. +This keeps `agentguard status` usable when an agent wraps the shell command and +the hook cannot attribute the result to the CLI. Real credentials elsewhere in +the same result remain subject to normal protection. + ## Public web-search results Bare card- or national-ID-shaped digits in a **verified, host-owned web-search diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index a0c6cd7..eb7a598 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -89,6 +89,11 @@ If no subcommand is given, or the first argument is a path, default to **scan**. This skill is allowed to run `agentguard *`, so CLI commands and flags are available even when the skill has a higher-level workflow for the same area. +Run each AgentGuard CLI command in its own tool call. Read documentation or +other local files in separate calls, before or after the CLI call. If one Bash +call combines file reads with `agentguard status`, the hook receives one merged +result and must inspect the file content too. + The skill's routed subcommands take priority over similarly named CLI commands. Do not route these through the packaged CLI unless the user explicitly prefixes the request with `/agentguard cli`: `scan`, `action`, `patrol`, `trust`, `report`, `config`, `checkup`, `hermes-hooks`. The sole internal exception is the patrol workflow's canonical collector: `patrol run` and locally scheduled patrols execute the existing `agentguard checkup --json` command as specified below. Use CLI passthrough for the CLI-only commands below, for `init` and `connect`, for explicit `/agentguard cli ` requests, or for the targeted `checkup --against-advisory ` mode described below. diff --git a/src/adapters/engine.ts b/src/adapters/engine.ts index 0ebdd0b..7980de9 100644 --- a/src/adapters/engine.ts +++ b/src/adapters/engine.ts @@ -7,7 +7,7 @@ import { getSkillTrustPolicy, isActionAllowedByCapabilities, } from './common.js'; -import { isAgentGuardCliCommand } from '../runtime/self-command.js'; +import { isAgentGuardCliCommand, isAgentGuardOwnedOutputSequence } from '../runtime/self-command.js'; /** * Evaluate a hook event using the common AgentGuard decision engine. @@ -125,5 +125,6 @@ export async function evaluateHook( function isAgentGuardHookCommand(adapter: HookAdapter, input: HookInput): boolean { if (adapter.mapToolToActionType(input.toolName) !== 'exec_command') return false; const command = input.toolInput.command; - return typeof command === 'string' && isAgentGuardCliCommand(command); + return typeof command === 'string' + && (isAgentGuardCliCommand(command) || isAgentGuardOwnedOutputSequence(command)); } diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index a09165d..5705947 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -40,7 +40,7 @@ import { } from '../runtime/protect.js'; import type { RuntimeActionType } from '../runtime/types.js'; import type { AgentGuardConfig } from '../config.js'; -import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; +import { isAgentGuardOwnedOutputSequence, isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; // --------------------------------------------------------------------------- // OpenClaw Types (subset we use) @@ -589,7 +589,8 @@ export function registerOpenClawPlugin( const command = args && typeof args === 'object' ? (args as Record).command ?? (args as Record).cmd : undefined; if (normalized.actionType === 'shell' && typeof command === 'string' - && isStandaloneAgentGuardCliCommand(command)) return; + && (isStandaloneAgentGuardCliCommand(command) + || isAgentGuardOwnedOutputSequence(command))) return; const toolName = normalized.toolName; const pluginId = toolName ? getPluginIdFromTool(toolName) : null; if (runtimeProtectionEnabled) { diff --git a/src/dsh/runtime.ts b/src/dsh/runtime.ts index 205739f..154eea6 100644 --- a/src/dsh/runtime.ts +++ b/src/dsh/runtime.ts @@ -13,7 +13,7 @@ import { } from '../runtime/public-search.js'; import { redactText } from '../runtime/redaction.js'; import { isAgentGuardApprovalCommand } from '../runtime/protect.js'; -import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; +import { isAgentGuardOwnedOutputSequence, isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; import { planDshEnforcement, type DshRuntimePhase } from './enforcement-plan.js'; import { mergeDshPostDecisions, @@ -238,7 +238,8 @@ export function buildDshRuntimeAction( } function isDshAgentGuardCliAction(action: RuntimeAction): boolean { - return action.actionType === 'shell' && isStandaloneAgentGuardCliCommand(action.input); + return action.actionType === 'shell' + && (isStandaloneAgentGuardCliCommand(action.input) || isAgentGuardOwnedOutputSequence(action.input)); } export async function observeDshToolCall( diff --git a/src/installers.ts b/src/installers.ts index 4a7b6c5..a9ec6ee 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -525,6 +525,11 @@ This Skill documents the AgentGuard workflow. It is not a security boundary. Enforcement comes from the synchronous native hooks in \`.codex/hooks.json\` after the user reviews and trusts them with \`/hooks\`. +Run AgentGuard CLI commands such as \`agentguard status\` in their own tool +call. Read documentation or local files in separate calls before or after the +CLI call. A shell command that combines them has one merged result, so the +hook must inspect the file content as well as AgentGuard's output. + When a hook denies an action that requires approval, show the action id and redacted reason to the user. The user may approve it from their own terminal with \`agentguard approve --action-id --once\`, then explicitly retry the diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index 40f7b14..044540c 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -621,6 +621,7 @@ const OPAQUE_URL_PATH_SEGMENT = /^(?=.{24,128}$)(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9 const SAFE_MODEL_SWITCH_LABEL = /^[A-Za-z0-9._/-]{1,160}$/; const SAFE_REASON_TITLES: Readonly> = { ACTION_TYPE_REQUIRES_APPROVAL: 'Tool type requires approval', + AGENTGUARD_MIXED_COMMAND: 'Separate AgentGuard CLI call required', AGENT_SELF_APPROVAL: 'Agent approval command denied', CUSTOM_BLOCKED_COMMAND: 'Custom blocked command', CUSTOM_BLOCKED_DOMAIN: 'Custom blocked domain', diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 9c6160a..580557e 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -7,9 +7,10 @@ import { consumeApprovedApproval, writePendingApproval, type ApprovalRecord } fr import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js'; import { evaluateRuntimeAction } from './decision.js'; import { isAgentGuardCliCommand, isAgentGuardOwnedOutputCommand, isAgentGuardOwnedOutputResult, + isAgentGuardOwnedOutputSequence, isMixedAgentGuardCliCommand, isStandaloneAgentGuardCliCommand } from './self-command.js'; import { redactText, summarizeSensitiveData } from './redaction.js'; -import { maskActivationUrlsForInspection, redactTextPreservingSavedActivationUrl } from './self-output.js'; +import { maskAgentGuardPublicOutputForInspection, redactTextPreservingSavedActivationUrl } from './self-output.js'; import { hasSensitivePublicSearchResult, isNativePublicSearchTool, isVisiblePublicSearchResult, inspectWithPublicSearchProvenance, @@ -90,6 +91,9 @@ export async function protectAction(options: ProtectOptions): Promise reason.code === 'AGENTGUARD_MIXED_COMMAND') + ? ' Run AgentGuard CLI in a separate tool call; read other files separately, then retry.' : '') ); } @@ -844,10 +864,10 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { const nativePostToolEvent = codexHookEvent === 'PostToolUse' || claudeHookEvent === 'PostToolUse' || claudeHookEvent === 'PostToolUseFailure'; - // An activation link is safe to show after a tool has run. Do not exempt it - // from pre-tool or model-request checks, where the URL may be an egress target. + // Preserve activation links and literal CLI documentation examples in + // results. Pre-tool and model-request checks retain the original input. if (nativePostToolEvent || claudeHookEvent === 'MessageDisplay' || claudeHookEvent === 'Stop') { - actionInput = maskActivationUrlsForInspection(actionInput); + actionInput = maskAgentGuardPublicOutputForInspection(actionInput); } const nativeToolActionType = nativePostToolEvent ? mapToolToRuntimeAction(toolName, raw) @@ -1379,18 +1399,18 @@ function projectClaudeBatchForInspection( const projected = { ...call }; for (const key of ['tool_response', 'toolResponse', 'result', 'error', 'error_message', 'stdout', 'stderr', 'output']) { - if (key in projected) projected[key] = maskActivationUrlsInValue(projected[key]); + if (key in projected) projected[key] = maskAgentGuardPublicOutputInValue(projected[key]); } return projected; }), suppressedCount }; } -function maskActivationUrlsInValue(value: unknown): unknown { - if (typeof value === 'string') return maskActivationUrlsForInspection(value); - if (Array.isArray(value)) return value.map(maskActivationUrlsInValue); +function maskAgentGuardPublicOutputInValue(value: unknown): unknown { + if (typeof value === 'string') return maskAgentGuardPublicOutputForInspection(value); + if (Array.isArray(value)) return value.map(maskAgentGuardPublicOutputInValue); if (!isPlainRecord(value)) return value; return Object.fromEntries(Object.entries(value) - .map(([key, item]) => [key, maskActivationUrlsInValue(item)])); + .map(([key, item]) => [key, maskAgentGuardPublicOutputInValue(item)])); } function hasSensitiveClaudeBatchContent(responses: unknown): boolean { diff --git a/src/runtime/self-command.ts b/src/runtime/self-command.ts index a560101..593e765 100644 --- a/src/runtime/self-command.ts +++ b/src/runtime/self-command.ts @@ -43,9 +43,93 @@ export function isAgentGuardOwnedOutputCommand(command: string): boolean { /** The installer is attributed from its finished result, never from its URL alone. */ export function isAgentGuardOwnedOutputResult(command: string, result: unknown): boolean { return isAgentGuardOwnedOutputCommand(command) + || isAgentGuardOwnedOutputSequence(command) || (isInstallerPipeline(command) && hasInstallerOutputSignature(result)); } +/** Every statement must be a verified AgentGuard invocation. */ +export function isAgentGuardOwnedOutputSequence(command: string): boolean { + return isAgentGuardOwnedOutputSequenceInner(command, 0); +} + +function isAgentGuardOwnedOutputSequenceInner(command: string, depth: number): boolean { + if (depth > 2) return false; + const statements = splitShellStatements(command); + if (!statements) return false; + if (statements.length > 1) { + return statements.every((statement) => isAgentGuardOwnedOutputCommand(statement) + || isAgentGuardOwnedOutputSequenceInner(statement, depth + 1)); + } + const wrapped = shellWrappedCommand(statements[0] || ''); + return wrapped ? isAgentGuardOwnedOutputSequenceInner(wrapped, depth + 1) : false; +} + +/** A separate CLI statement inside one shell call has no separate result provenance. */ +export function isMixedAgentGuardCliCommand(command: string): boolean { + return isMixedAgentGuardCliCommandInner(command, 0); +} + +function isMixedAgentGuardCliCommandInner(command: string, depth: number): boolean { + if (depth > 2) return false; + const statements = splitShellStatements(command); + if (!statements) return false; + const isOwnStatement = (statement: string): boolean => isAgentGuardOwnedOutputCommand(statement) + || isAgentGuardOwnedOutputSequenceInner(statement, depth + 1); + if (statements.length > 1 + && statements.some(isOwnStatement) + && statements.some((statement) => !isOwnStatement(statement))) return true; + return statements.some((statement) => { + const wrapped = shellWrappedCommand(statement); + return wrapped ? isMixedAgentGuardCliCommandInner(wrapped, depth + 1) : false; + }); +} + +function shellWrappedCommand(statement: string): string | null { + const tokens = shellTokens(statement); + let index = skipSafeAssignments(tokens, 0); + if (index === null) return null; + if (basename(tokens[index] || '') === 'env') { + index += 1; + while (tokens[index]?.startsWith('-')) index += 1; + index = skipSafeAssignments(tokens, index); + if (index === null) return null; + } + while (['command', 'builtin'].includes(basename(tokens[index] || ''))) index += 1; + return shellWrapperCommand(tokens, index); +} + +function splitShellStatements(command: string): string[] | null { + const input = command.replace(/\\\r?\n/g, ''); + const statements: string[] = []; + let start = 0; + let quote: '"' | "'" | null = null; + let escaped = false; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]!; + if (escaped) { escaped = false; continue; } + if (char === '\\' && quote !== "'") { escaped = true; continue; } + if (char === '"' || char === "'") { + if (!quote) quote = char; + else if (quote === char) quote = null; + continue; + } + // Here-doc bodies, comments, and nested shell expressions are not + // statements we can attribute safely from a flat command string. + if (!quote && ((char === '<' && input[index + 1] === '<') + || (char === '#' && (index === 0 || /[\s;&|()]/.test(input[index - 1]!))) + || (char === '$' && input[index + 1] === '(') + || char === '`' || char === '(' || char === ')')) return null; + if (quote || ![';', '\n', '&', '|'].includes(char)) continue; + if ((char === '&' || char === '|') && input[index + 1] !== char) continue; + statements.push(input.slice(start, index).trim()); + if (char === '&' || char === '|') index += 1; + start = index + 1; + } + if (quote || escaped) return null; + statements.push(input.slice(start).trim()); + return statements.filter(Boolean); +} + function isInstallerPipeline(command: string): boolean { const match = /^curl[ \t]+-fsSL[ \t]+(?:"([^"]+)"|'([^']+)'|([^ \t]+))[ \t]*\|[ \t]*(.+)$/.exec(command.trim()); if (!match) return false; @@ -128,6 +212,7 @@ function isAgentGuardCliCommandInner(command: string, depth: number): boolean { let index = skipSafeAssignments(tokens, 0); if (index === null) return false; + if (!tokens[index]) return false; if (basename(tokens[index]) === 'env') { index += 1; while (tokens[index]?.startsWith('-')) index += 1; @@ -157,6 +242,7 @@ function isStandaloneAgentGuardCliCommandInner(command: string, depth: number, o let index = skipSafeAssignments(tokens, 0); if (index === null) return false; + if (!tokens[index]) return false; if (basename(tokens[index]) === 'env') { index += 1; while (tokens[index]?.startsWith('-')) index += 1; diff --git a/src/runtime/self-output.ts b/src/runtime/self-output.ts index 2cfc5b5..b99e6d4 100644 --- a/src/runtime/self-output.ts +++ b/src/runtime/self-output.ts @@ -3,6 +3,7 @@ import type { AgentGuardConfig } from '../config.js'; import { redactText } from './redaction.js'; const PUBLIC_KEY_EXAMPLES = ['TYPESAFE_API_KEY=', 'AGENTGUARD_API_KEY=']; +const PUBLIC_KEY_EXAMPLE_INSPECTION_MARKER = '[AgentGuard public key example]'; // The token charset deliberately stops at separators, so adjacent query // parameters or prose cannot become part of the exempt span. const ACTIVATION_URL = /https?:\/\/[^\s"'`<>()\[\]{}\\?,;!]+\/activate\?token=[A-Za-z0-9._~%+=-]+/g; @@ -13,6 +14,13 @@ export function maskActivationUrlsForInspection(text: string): string { return replaceActivationUrls(text, () => ACTIVATION_INSPECTION_MARKER); } +/** Static documentation placeholders are not credentials, even in wrapped tool results. */ +export function maskAgentGuardPublicOutputForInspection(text: string): string { + const withoutExamples = PUBLIC_KEY_EXAMPLES.reduce((value, example) => + value.replaceAll(example, PUBLIC_KEY_EXAMPLE_INSPECTION_MARKER), text); + return maskActivationUrlsForInspection(withoutExamples); +} + /** Keep activation links in a verified result while applying its normal redactor. */ export function redactTextPreservingActivationUrls( text: string, options?: Parameters[1], diff --git a/src/scanner/rules/privacy.ts b/src/scanner/rules/privacy.ts index 7f8bd6b..9b2d60b 100644 --- a/src/scanner/rules/privacy.ts +++ b/src/scanner/rules/privacy.ts @@ -33,6 +33,18 @@ export function isDecimalNumericContext(content: string, start: number, end: num return beforeDot === undefined || /\d/.test(beforeDot); } +/** The 13-digit Date.now() component of our action ID is an event timestamp. */ +function isLocalActionIdTimestamp(content: string, start: number, end: number): boolean { + const prefix = 'act_local_'; + if (end - start !== 13 || content.slice(start - prefix.length, start) !== prefix) return false; + const before = content[start - prefix.length - 1]; + if (before !== undefined && /[A-Za-z0-9_]/.test(before)) return false; + // An explicit account field is stronger evidence than an ID-shaped value. + const preceding = content.slice(Math.max(0, start - prefix.length - 80), start - prefix.length); + if (/(?:bank[_-]?account|account[_-]?(?:number|no)|card[_-]?(?:number|no)|credit[_-]?card|debit[_-]?card|iban|收款账号|银行卡号?)(?:\\*["'])?\s*[:=]\s*(?:\\*["'])?\s*$/i.test(preceding)) return false; + return /^_\d{1,10}(?![A-Za-z0-9_])/.test(content.slice(end)); +} + export interface PiiRedactionOptions { /** Only for a verified public WebSearch result, never for prompts or files. */ skipBareNumericIdentifiers?: boolean; @@ -197,7 +209,9 @@ export function redactPiiText(content: string, options: PiiRedactionOptions = {} isValidBankIdentifier(value, 'iban') ? '[REDACTED:PII_BANK_ACCOUNT]' : value); if (!options.skipBareNumericIdentifiers) { redacted = redacted.replace(/(? - !isDecimalNumericContext(source, offset, offset + value.length) && isValidBankIdentifier(value, 'card_number') + !isDecimalNumericContext(source, offset, offset + value.length) + && !isLocalActionIdTimestamp(source, offset, offset + value.length) + && isValidBankIdentifier(value, 'card_number') ? '[REDACTED:PII_BANK_ACCOUNT]' : value); } diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index 1f3e8ce..237ffa2 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -175,6 +175,91 @@ describe('Claude Code native lifecycle hooks', () => { assert.equal(existsSync(fixture.config.auditPath), false); }); + it('asks for a separate status call before a mixed Bash command runs', async () => { + const fixture = lifecycleFixture(); + const mixed = await runHook(fixture.config, { + hook_event_name: 'PreToolUse', session_id: 'sess-mixed-status-pre', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'cat guide.md && agentguard status' }, + }); + assert.equal(mixed.output.hookSpecificOutput.permissionDecision, 'deny'); + assert.match(mixed.output.hookSpecificOutput.permissionDecisionReason, /separate tool call/); + const standalone = await protectAction({ config: fixture.config, agentHost: 'claude-code', + rawInput: { hook_event_name: 'PreToolUse', session_id: 'sess-status-pre', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard status' } } }); + assert.equal(standalone, null); + }); + + it('passes an all-AgentGuard command sequence through native hooks', async () => { + const fixture = lifecycleFixture(); + const command = 'agentguard status && agentguard doctor'; + const base = { session_id: 'sess-own-sequence', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command } }; + const pre = await protectAction({ config: fixture.config, agentHost: 'claude-code', + rawInput: { ...base, hook_event_name: 'PreToolUse' } }); + const post = await protectAction({ config: fixture.config, agentHost: 'claude-code', + rawInput: { ...base, hook_event_name: 'PostToolUse', + tool_response: { stdout: 'api_key=sk-own-sequence-1234567890' } } }); + const batch = await protectAction({ config: fixture.config, agentHost: 'claude-code', + rawInput: { hook_event_name: 'PostToolBatch', session_id: 'sess-own-sequence', + cwd: fixture.root, tool_calls: [{ ...base, + tool_response: 'api_key=sk-own-sequence-1234567890' }] } }); + assert.equal(pre, null); + assert.equal(post, null); + assert.equal(batch, null); + }); + + it('keeps wrapped status output through PostToolBatch and checks unrelated secrets', async () => { + const fixture = lifecycleFixture(); + const output = 'Config: /config.json\n' + + 'Protection level: balanced\nCloud auth: connected via Agent JWT\n' + + 'Agent JWT: configured\nAgent account: bound\n' + + 'Optional: enhanced privacy detection is available but off.\n' + + ' export TYPESAFE_API_KEY= supply the key without writing it to disk\n' + + ' AGENTGUARD_API_KEY= agentguard connect'; + const call = (result: string) => ({ tool_name: 'functions.exec', + tool_input: { code: 'const result = await tools.exec_command({ cmd: "agentguard status" })' }, + tool_response: result }); + const clean = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-wrapped-status', cwd: fixture.root, + tool_calls: [call(output)], + }); + assert.notEqual(clean.result.decision.decision, 'block'); + const mixed = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-wrapped-status-mixed', cwd: fixture.root, + tool_calls: [call(`${output}\napi_key=sk-unrelated-status-secret-1234567890`)], + }); + assert.equal(mixed.result.decision.decision, 'block'); + assert.doesNotMatch(mixed.text, /unrelated-status-secret/); + }); + + it('does not block PostToolBatch for an approval action ID in audit output', async () => { + const fixture = lifecycleFixture(); + const actionId = 'act_local_1792392754751_83214'; + const call = (response: string) => ({ + tool_name: 'Bash', + tool_input: { command: `grep -n "${actionId}" /tmp/audit.jsonl` }, + tool_response: response, + }); + const clean = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-approval-id', cwd: fixture.root, + tool_calls: [call(`AgentGuard policy decision; action=${actionId}; risk=medium`)], + }); + assert.notEqual(clean.result.decision.decision, 'block'); + assert.equal(clean.result.decision.ruleEvaluations?.find((rule) => rule.ruleId === 'PII_EGRESS')?.detected, false); + + const sensitive = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-approval-id-sensitive', cwd: fixture.root, + tool_calls: [call(`AgentGuard policy decision; action=${actionId}; card_number=5284917302468138`)], + }); + assert.equal(sensitive.result.decision.decision, 'block'); + + const disguised = await runHook(fixture.config, { + hook_event_name: 'PostToolBatch', session_id: 'sess-approval-id-labeled', cwd: fixture.root, + tool_calls: [call(JSON.stringify({ bank_account: actionId }))], + }); + assert.equal(disguised.result.decision.decision, 'block'); + }); + it('passes all standalone AgentGuard results unchanged across native post hooks', async () => { const fixture = lifecycleFixture(); for (const command of ['agentguard status', 'agentguard connect', 'agentguard scan ./skill', diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 8576fba..29fc03f 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -5,7 +5,8 @@ import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { installAgentTemplates } from '../installers.js'; -import { isAgentGuardOwnedOutputResult, isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; +import { isAgentGuardOwnedOutputResult, isAgentGuardOwnedOutputSequence, isMixedAgentGuardCliCommand, + isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; const installerCommand = `curl -fsSL https://${['staging', 'example', 'invalid'].join('.')}/install.sh | bash`; const installerOutput = 'AgentGuard Cloud — Local-first AI agent protection\n' @@ -54,6 +55,26 @@ describe('installed Codex native hooks', () => { assert.equal(isStandaloneAgentGuardCliCommand('npx @goplus/agentguard@1.2.4-beta.1 connect; echo extra'), false); }); + it('identifies a separate AgentGuard statement without matching quoted examples', () => { + assert.equal(isMixedAgentGuardCliCommand('cat guide.md &&\\\n agentguard status'), true); + assert.equal(isMixedAgentGuardCliCommand('agentguard status; cat guide.md'), true); + assert.equal(isMixedAgentGuardCliCommand('cat guide.md && agentguard status\n'), true); + assert.equal(isMixedAgentGuardCliCommand("bash -lc 'cat guide.md && agentguard status'"), true); + assert.equal(isMixedAgentGuardCliCommand("printf 'agentguard status; cat guide.md'"), false); + assert.equal(isMixedAgentGuardCliCommand("cat <<'EOF'\nagentguard status\nEOF"), false); + assert.equal(isMixedAgentGuardCliCommand('echo done # && agentguard status'), false); + assert.equal(isMixedAgentGuardCliCommand('echo $(printf done && agentguard status)'), false); + assert.equal(isMixedAgentGuardCliCommand('echo agentguard status && cat guide.md'), false); + assert.equal(isMixedAgentGuardCliCommand('agentguard status'), false); + assert.equal(isMixedAgentGuardCliCommand('agentguard status && agentguard doctor'), false); + assert.equal(isAgentGuardOwnedOutputSequence('agentguard status && agentguard doctor'), true); + assert.equal(isAgentGuardOwnedOutputSequence("sh -c 'agentguard status && agentguard doctor'"), true); + assert.equal(isMixedAgentGuardCliCommand("sh -c 'agentguard status && agentguard doctor' && cat guide.md"), true); + assert.equal(isAgentGuardOwnedOutputSequence('cat guide.md && agentguard status'), false); + assert.equal(isAgentGuardOwnedOutputSequence('a=agentguard; "$a" status'), false); + assert.equal(isMixedAgentGuardCliCommand('cat guide.md && /tmp/agentguard status'), false); + }); + it('attributes installer results by output signature without trusting a URL', () => { assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: installerOutput }), true); assert.equal(isAgentGuardOwnedOutputResult(installerCommand, { output: `${'package log\n'.repeat(18000)}${installerTail}` }), true); @@ -350,6 +371,20 @@ describe('installed Codex native hooks', () => { assert.equal(auditEvent.metadata?.toolFailed, undefined); }); + it('keeps an AgentGuard approval action ID visible in post-tool output', () => { + const fixture = installFixture(); + const actionId = 'act_local_1792392754751_83214'; + const run = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess_action_id', + turn_id: 'turn_action_id', cwd: fixture.project, + tool_name: 'Bash', tool_use_id: 'tool_action_id', + tool_input: { command: `grep -n "${actionId}" /tmp/audit.jsonl` }, + tool_response: { output: `AgentGuard policy decision; action=${actionId}; risk=medium`, exit_code: 0 }, + }); + assert.equal(run.status, 0); + assert.equal(run.stdout.trim(), ''); + }); + it('does not rescan agentguard status output in PostToolUse', () => { const fixture = installFixture(); const run = fixture.run('agentguard-post-tool.sh', { @@ -372,6 +407,21 @@ describe('installed Codex native hooks', () => { assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); + it('keeps a sequence of only AgentGuard commands usable', () => { + const fixture = installFixture(); + const command = 'agentguard status && agentguard doctor'; + const base = { session_id: 'sess_own_sequence', turn_id: 'turn_own_sequence', + cwd: fixture.project, tool_name: 'Bash', tool_use_id: 'tool_own_sequence', + tool_input: { command } }; + const pre = fixture.run('agentguard-pre-tool.sh', { ...base, hook_event_name: 'PreToolUse' }); + assert.equal(pre.status, 0); + assert.equal(pre.stdout.trim(), ''); + const post = fixture.run('agentguard-post-tool.sh', { ...base, hook_event_name: 'PostToolUse', + tool_response: { output: 'api_key=sk-own-sequence-1234567890', exit_code: 0 } }); + assert.equal(post.status, 0); + assert.equal(post.stdout.trim(), ''); + }); + it('does not block the real status output when it contains documented key placeholders', () => { const fixture = installFixture(); const status = fixture.runCli('status'); @@ -395,6 +445,83 @@ describe('installed Codex native hooks', () => { assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); + it('keeps a wrapped status result visible without exempting real credentials', () => { + const fixture = installFixture(); + const status = fixture.runCli('status'); + assert.equal(status.status, 0); + assert.match(status.stdout, /TYPESAFE_API_KEY=/); + const post = (output: string) => fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess_wrapped_status', + turn_id: 'turn_wrapped_status', cwd: fixture.project, + tool_name: 'functions.exec', tool_use_id: 'tool_wrapped_status', + tool_input: { code: 'const result = await tools.exec_command({ cmd: "agentguard status" })' }, + tool_response: { output, exit_code: 0 }, + }); + const clean = post(status.stdout); + assert.equal(clean.status, 0); + assert.equal(clean.stdout.trim(), ''); + const mixed = post(`${status.stdout}\napi_key=sk-unrelated-status-secret-1234567890`); + assert.equal(mixed.status, 0); + assert.equal(JSON.parse(mixed.stdout).decision, 'block'); + assert.doesNotMatch(mixed.stdout + mixed.stderr, /unrelated-status-secret/); + }); + + it('checks earlier file output when status shares one Bash result', () => { + const fixture = installFixture(); + const status = fixture.runCli('status'); + assert.equal(status.status, 0); + const command = 'cat /workspace/codex-tools.md &&\\\n' + + "printf '\\n--- AGENTGUARD ---\\n' &&\\\n" + + 'cat /workspace/agentguard/SKILL.md &&\\\n' + + "printf '\\n--- STATUS ---\\n' &&\\\n" + + 'agentguard status'; + const pre = fixture.run('agentguard-pre-tool.sh', { + hook_event_name: 'PreToolUse', session_id: 'sess_compound_pre', + turn_id: 'turn_compound_pre', cwd: fixture.project, + tool_name: 'Bash', tool_use_id: 'tool_compound_pre', + tool_input: { command }, + }); + assert.equal(pre.status, 0); + const denial = JSON.parse(pre.stdout).hookSpecificOutput; + assert.equal(denial.permissionDecision, 'deny'); + assert.match(denial.permissionDecisionReason, /separate tool call/); + assert.match(denial.permissionDecisionReason, /AGENTGUARD_MIXED_COMMAND/); + const dangerous = fixture.run('agentguard-pre-tool.sh', { + hook_event_name: 'PreToolUse', session_id: 'sess_compound_dangerous', + turn_id: 'turn_compound_dangerous', cwd: fixture.project, + tool_name: 'Bash', tool_use_id: 'tool_compound_dangerous', + tool_input: { command: 'rm -rf / && agentguard status' }, + }); + assert.equal(dangerous.status, 0); + const dangerousReason = JSON.parse(dangerous.stdout).hookSpecificOutput.permissionDecisionReason; + assert.match(dangerousReason, /AGENTGUARD_MIXED_COMMAND/); + assert.match(dangerousReason, /DESTRUCTIVE_COMMAND/); + const standalone = fixture.run('agentguard-pre-tool.sh', { + hook_event_name: 'PreToolUse', session_id: 'sess_standalone_pre', + turn_id: 'turn_standalone_pre', cwd: fixture.project, + tool_name: 'Bash', tool_use_id: 'tool_standalone_pre', + tool_input: { command: 'agentguard status' }, + }); + assert.equal(standalone.status, 0); + assert.equal(standalone.stdout.trim(), ''); + const post = (firstFile: string) => fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess_compound_status', + turn_id: 'turn_compound_status', cwd: fixture.project, + tool_name: 'Bash', tool_use_id: 'tool_compound_status', + tool_input: { command }, + tool_response: { output: `${firstFile}\n--- AGENTGUARD ---\npublic skill text\n--- STATUS ---\n${status.stdout}`, exit_code: 0 }, + }); + + const clean = post('public tool documentation'); + assert.equal(clean.status, 0); + assert.equal(clean.stdout.trim(), ''); + + const sensitive = post('api_key=sk-file-secret-1234567890'); + assert.equal(sensitive.status, 0); + assert.equal(JSON.parse(sensitive.stdout).decision, 'block'); + assert.doesNotMatch(sensitive.stdout + sensitive.stderr, /file-secret/); + }); + it('does not intercept saved, forged, or sensitive AgentGuard status output', () => { const fixture = installFixture(); const url = 'https://agentguard.example/activate?token=pending-link-123456'; diff --git a/src/tests/dsh-runtime.test.ts b/src/tests/dsh-runtime.test.ts index 112cc17..8296738 100644 --- a/src/tests/dsh-runtime.test.ts +++ b/src/tests/dsh-runtime.test.ts @@ -152,6 +152,20 @@ describe('DSH runtime Phase 2A observer', () => { assert.equal(after, null); }); + it('passes an all-AgentGuard shell sequence without scanning its result', async () => { + const statusExec = execution({ arguments: { command: 'agentguard status && agentguard doctor' } }); + const dependencies = { + loadAgentGuardConfig: () => config, + fetchPolicyFor: () => undefined, + writeAudit() {}, + }; + assert.equal(await protectDshToolCall(statusExec, dependencies), null); + assert.equal(await protectDshToolResult(statusExec, { + isError: false, + content: [{ type: 'text', text: 'api_key=sk-own-sequence-1234567890' }], + }, dependencies), null); + }); + it('keeps wrapped self-approval commands in the DSH pre-execute policy path', async () => { const dependencies = { loadAgentGuardConfig: () => config, diff --git a/src/tests/installer.test.ts b/src/tests/installer.test.ts index 3ae74f0..1677b87 100644 --- a/src/tests/installer.test.ts +++ b/src/tests/installer.test.ts @@ -173,7 +173,9 @@ describe('Agent template installers', () => { const configPath = join(dir, '.codex', 'hooks.json'); const config = JSON.parse(readFileSync(configPath, 'utf8')); - assert.ok(existsSync(join(dir, '.codex', 'skills', 'agentguard', 'SKILL.md'))); + const skillPath = join(dir, '.codex', 'skills', 'agentguard', 'SKILL.md'); + assert.ok(existsSync(skillPath)); + assert.match(readFileSync(skillPath, 'utf8'), /Run AgentGuard CLI commands.*in their own tool/s); assert.ok(existsSync(join(dir, '.codex', 'hooks', 'agentguard-user-prompt.sh'))); assert.ok(existsSync(join(dir, '.codex', 'hooks', 'agentguard-pre-tool.sh'))); assert.ok(existsSync(join(dir, '.codex', 'hooks', 'agentguard-post-tool.sh'))); diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 7d7b1bf..3a2298e 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -42,6 +42,20 @@ describe('Integration: Claude Code evaluateHook', () => { assert.equal(result.decision, 'allow'); }); + it('allows only all-AgentGuard shell sequences through the shared adapter', async () => { + ctx = createTestContext('balanced'); + const own = await evaluateHook(ctx.claudeAdapter, { + hook_event_name: 'PreToolUse', tool_name: 'Bash', + tool_input: { command: 'agentguard status && agentguard doctor' }, + }, ctx.options); + const mixed = await evaluateHook(ctx.claudeAdapter, { + hook_event_name: 'PreToolUse', tool_name: 'Bash', + tool_input: { command: 'agentguard status && rm -rf /' }, + }, ctx.options); + assert.equal(own.decision, 'allow'); + assert.notEqual(mixed.decision, 'allow'); + }); + it('should DENY rm -rf /', async () => { ctx = createTestContext('balanced'); const result = await evaluateHook(ctx.claudeAdapter, { diff --git a/src/tests/privacy-numeric-context.test.ts b/src/tests/privacy-numeric-context.test.ts index b884dcf..00dbb45 100644 --- a/src/tests/privacy-numeric-context.test.ts +++ b/src/tests/privacy-numeric-context.test.ts @@ -22,6 +22,27 @@ const candidatesOf = (text: string) => { }; describe('Numeric PII context', () => { + it('does not treat AgentGuard action timestamps as bank accounts', () => { + const actionId = 'act_local_1792392754751_83214'; // Timestamp happens to pass Luhn. + assert.equal(redactText(actionId), actionId); + assert.deepEqual(summarizeSensitiveData(actionId), []); + + const card = '5284917302468138'; + const mixed = `action=${actionId}; card_number=${card}`; + assert.equal(redactText(mixed), `action=${actionId}; [REDACTED:PII_BANK_ACCOUNT]`); + assert.ok(summarizeSensitiveData(mixed).some((item) => item.kind === 'bank_account')); + assert.match(redactText(`card_number=1792392754751`), /REDACTED:PII_BANK_ACCOUNT/); + assert.match(redactText(`other_${actionId}`), /REDACTED:PII_BANK_ACCOUNT/); + for (const labeled of [ + `card_number=${actionId}`, + `{"bank_account":"${actionId}"}`, + `银行卡号: ${actionId}`, + JSON.stringify({ content: JSON.stringify({ bank_account: actionId }) }), + ]) { + assert.match(redactText(labeled), /REDACTED:PII_BANK_ACCOUNT/, labeled); + } + }); + it('excludes real decimal fractions but never globally exempts technical-looking keys', () => { const decimals = [ '{"price":"0.110105199001010002"}', // Valid ID checksum inside a fractional price.