From c263f7bdb1e97ee41200a708dc156a181d9b62cb Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Fri, 16 Oct 2026 19:27:10 +0800 Subject: [PATCH 1/6] fix: allow verified status activation link in native hooks --- src/runtime/protect.ts | 44 +++++++++++++++++++------ src/tests/claude-code-lifecycle.test.ts | 37 +++++++++++++++++++++ src/tests/codex-hooks.test.ts | 40 ++++++++++++++++++++++ 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index c66b064..5ace86b 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -210,11 +210,34 @@ function isAgentGuardStatusResult(action: RuntimeAction): boolean { && action.metadata?.claudeBatchOnlyAgentGuardStatus === true; } -function isSafelyMaskedAgentGuardStatusOutput(output: unknown): boolean { +function isSafelyMaskedAgentGuardStatusOutput( + output: unknown, + config: AgentGuardConfig, + originalResponse: unknown = output, +): boolean { const serialized = typeof output === 'string' ? output : JSON.stringify(output); // The status help intentionally prints this literal placeholder. Normalize only // that documented value so real keys and unrelated sensitive output stay strict. - const normalized = serialized.replaceAll('TYPESAFE_API_KEY=', 'TYPESAFE_API_KEY=[REDACTED]'); + let normalized = serialized.replaceAll('TYPESAFE_API_KEY=', 'TYPESAFE_API_KEY=[REDACTED]'); + const activationUrl = config.agentJwt && config.agentRegisterUrl; + if (activationUrl && activationUrl.length <= 2048 && !/[\x00-\x1f\x7f]/.test(activationUrl)) { + try { + const url = new URL(activationUrl); + if (url.protocol === 'https:' && !url.username && !url.password) { + const statusLine = `Agent activation URL: ${activationUrl}`; + const stdout = typeof originalResponse === 'string' ? originalResponse + : isPlainRecord(originalResponse) ? firstString(originalResponse.stdout, originalResponse.output) : ''; + // Only the exact link printed by this installation's pending status is + // exempt. A forged or additional credential in the tool result is not. + if (stdout.split(/\r?\n/).filter((line) => line === statusLine).length === 1) { + normalized = normalized.replace( + JSON.stringify(statusLine).slice(1, -1), + 'Agent activation URL: [validated activation link]', + ); + } + } + } catch { /* Invalid stored URLs do not weaken the content scan. */ } + } return redactText(normalized) === normalized; } @@ -771,7 +794,7 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { : codexLifecycleFields(codexHookEvent); const sessionId = options.sessionId || process.env.AGENTGUARD_SESSION_ID || pickSessionId(raw); const claudeBatchProjection = claudeHookEvent === 'PostToolBatch' - ? projectClaudeBatchForInspection(raw?.tool_calls) + ? projectClaudeBatchForInspection(raw?.tool_calls, options.config) : undefined; const claudeBatch = claudeBatchProjection ? claudeBatchFacts( @@ -846,7 +869,7 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { const agentGuardStatusOutputSuppressed = nativePostToolEvent && nativeToolActionType === 'shell' && isAgentGuardStatusCommand(nativeToolInput) - && isSafelyMaskedAgentGuardStatusOutput(actionInput); + && isSafelyMaskedAgentGuardStatusOutput(actionInput, options.config, raw?.tool_response ?? raw?.toolResponse); if (agentGuardStatusOutputSuppressed) actionInput = AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER; const unknownSensitiveToolOutput = claudeHookEvent === 'PostToolUse' && verifiedOutput === undefined && redactText(actionInput) !== actionInput; @@ -1337,18 +1360,19 @@ function claudeBatchFacts( function projectClaudeBatchForInspection( responses: unknown, + config: AgentGuardConfig, ): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number } { if (!Array.isArray(responses)) { return { originalCalls: [], inspectionCalls: [], suppressedCount: 0 }; } let suppressedCount = 0; const inspectionCalls = responses.map((call) => { - if (!isClaudeAgentGuardStatusCall(call) || !isPlainRecord(call)) return call; + if (!isClaudeAgentGuardStatusCall(call, config) || !isPlainRecord(call)) return call; const outputKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') ? 'tool_response' : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; if (!outputKey) return call; - const outputProjection = projectAgentGuardStatusOutput(call[outputKey]); + const outputProjection = projectAgentGuardStatusOutput(call[outputKey], config); if (!outputProjection.suppressed) return call; const projected = { ...call }; projected[outputKey] = outputProjection.value; @@ -1358,8 +1382,8 @@ function projectClaudeBatchForInspection( return { originalCalls: responses, inspectionCalls, suppressedCount }; } -function projectAgentGuardStatusOutput(output: unknown): { value: unknown; suppressed: boolean } { - if (!isSafelyMaskedAgentGuardStatusOutput(output)) return { value: output, suppressed: false }; +function projectAgentGuardStatusOutput(output: unknown, config: AgentGuardConfig): { value: unknown; suppressed: boolean } { + if (!isSafelyMaskedAgentGuardStatusOutput(output, config)) return { value: output, suppressed: false }; if (typeof output === 'string') { return { value: AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER, suppressed: true }; } @@ -1375,7 +1399,7 @@ function projectAgentGuardStatusOutput(output: unknown): { value: unknown; suppr return { value: suppressed ? projected : output, suppressed }; } -function isClaudeAgentGuardStatusCall(call: unknown): boolean { +function isClaudeAgentGuardStatusCall(call: unknown, config: AgentGuardConfig): boolean { if (!isPlainRecord(call)) return false; const toolName = firstString(call.tool_name, call.toolName, call.name); const toolInput = pickToolInput(call); @@ -1384,7 +1408,7 @@ function isClaudeAgentGuardStatusCall(call: unknown): boolean { const command = pickNativeToolAuditInput(actionType, toolInput); if (!command || !isAgentGuardStatusCommand(command)) return false; const output = call.tool_response ?? call.toolResponse; - return projectAgentGuardStatusOutput(output).suppressed; + return projectAgentGuardStatusOutput(output, config).suppressed; } function hasSensitiveClaudeBatchContent(responses: unknown): boolean { diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index 5a6b2da..2acb061 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -175,6 +175,43 @@ describe('Claude Code native lifecycle hooks', () => { assert.equal(existsSync(fixture.config.auditPath), false); }); + it('allows only the configured activation link in native status and batch results', async () => { + const fixture = lifecycleFixture(); + const activationUrl = 'https://agentguard.example/activate?token=pending-claude-link-123456'; + fixture.config.agentJwt = 'test.jwt.pending'; + fixture.config.agentRegisterUrl = activationUrl; + const statusOutput = `Agent activation URL: ${activationUrl}\nAPI key: not used for this connection`; + const statusCall = { + tool_name: 'Bash', tool_input: { command: 'agentguard status' }, + tool_response: { stdout: statusOutput, exit_code: 0 }, + }; + const post = await protectAction({ + config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { + hook_event_name: 'PostToolUse', session_id: 'sess-pending-status', cwd: fixture.root, + ...statusCall, + }, + }); + assert.equal(post, null); + const batch = await protectAction({ + config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { + hook_event_name: 'PostToolBatch', session_id: 'sess-pending-batch', cwd: fixture.root, + tool_calls: [statusCall], + }, + }); + assert.equal(batch, null); + assert.equal(existsSync(fixture.config.auditPath), false); + + const forgedBatch = await protectAction({ + config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { + hook_event_name: 'PostToolBatch', session_id: 'sess-forged-batch', cwd: fixture.root, + tool_calls: [{ ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 } }], + }, + }); + assert.ok(forgedBatch); + assert.equal(forgedBatch.decision.decision, 'block'); + assert.doesNotMatch(readFileSync(fixture.config.auditPath, 'utf8'), /pending-claude-link/); + }); + it('does not exempt wrapped, prefixed, alternate-path, or extended status commands', async () => { const commands = [ "sh -c 'agentguard status'", diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 1951c08..00b461a 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -309,6 +309,46 @@ describe('installed Codex native hooks', () => { assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); + it('keeps the actual pending activation link visible without blocking status', () => { + const fixture = installFixture(); + const activationUrl = 'https://agentguard.example/activate?token=pending-activation-link-123456'; + writeFileSync(join(fixture.home, 'config.json'), JSON.stringify({ + version: 1, level: 'balanced', cloudUrl: 'http://127.0.0.1:9', + agentHost: 'codex', agentId: 'agt_pending', agentJwt: 'test.jwt.pending', + agentRegisterUrl: activationUrl, + policyCachePath: join(fixture.home, 'policy-cache.json'), + auditPath: join(fixture.home, 'audit.jsonl'), + eventSpoolPath: join(fixture.home, 'events-spool.jsonl'), + })); + const status = fixture.runCli('status'); + assert.equal(status.status, 0); + assert.match(status.stdout, /TYPESAFE_API_KEY=/); + assert.ok(status.stdout.includes(`Agent activation URL: ${activationUrl}`)); + + const payload = (output: string, command = 'agentguard status') => ({ + hook_event_name: 'PostToolUse', session_id: 'sess_pending', turn_id: 'turn_pending', + cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool_pending', + tool_input: { cmd: command }, tool_response: { output, exit_code: 0 }, + }); + const allowed = fixture.run('agentguard-post-tool.sh', payload(status.stdout)); + assert.equal(allowed.status, 0); + assert.equal(allowed.stdout.trim(), ''); + assert.equal(allowed.stderr.trim(), ''); + assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); + + // Matching the local URL is necessary, not a blanket exception for URLs. + for (const [index, result] of [ + payload(status.stdout.replace(activationUrl, `${activationUrl}-forged`)), + payload(`${status.stdout}api_key=sk-unsafe-addition-1234567890\n`), + payload(status.stdout, 'agentguard status; echo extra'), + ].entries()) { + const blocked = fixture.run('agentguard-post-tool.sh', { ...result, session_id: `sess_pending_${index}` }); + assert.equal(blocked.status, 0); + assert.equal(JSON.parse(blocked.stdout).decision, 'block'); + assert.doesNotMatch(`${blocked.stdout}\n${blocked.stderr}`, /pending-activation-link|unsafe-addition/); + } + }); + it('still blocks an unexpectedly unmasked secret in agentguard status output', () => { const fixture = installFixture(); const run = fixture.run('agentguard-post-tool.sh', { From 039a9d07e5d0ec39bf22b7a7644f898bc80f444a Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Fri, 16 Oct 2026 20:32:51 +0800 Subject: [PATCH 2/6] fix: redact AgentGuard CLI hook results without blocking --- CHANGELOG.md | 5 + src/runtime/protect.ts | 158 +++++++++++++++++------- src/runtime/self-command.ts | 8 ++ src/tests/claude-code-lifecycle.test.ts | 23 ++-- src/tests/cli-connect.test.ts | 15 ++- src/tests/cli-subscribe.test.ts | 12 +- src/tests/codex-hooks.test.ts | 123 +++++++++++++++--- 7 files changed, 271 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a15e7..990d487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 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. + ## [1.2.2] - 2026-10-16 ### Changed diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 5ace86b..05c3570 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -1,3 +1,4 @@ +import { randomBytes } from 'node:crypto'; import { cwd } from 'node:process'; import { dirname, join } from 'node:path'; import { closeSync, openSync, readSync } from 'node:fs'; @@ -6,7 +7,7 @@ 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 } from './self-command.js'; +import { isAgentGuardCliCommand, isAgentGuardStatusCommand, isStandaloneAgentGuardCliCommand } from './self-command.js'; import { redactText, summarizeSensitiveData } from './redaction.js'; import { hasSensitivePublicSearchResult, isNativePublicSearchTool, @@ -50,6 +51,8 @@ export interface ProtectResult { approvalChannel?: 'agent' | null; pendingApproval?: ApprovalRecord; policySource: 'cloud' | 'cache' | 'default' | 'cloud-decision'; + /** Hook-only response, never written to the audit log or Cloud. */ + selfToolOutput?: { text: string; value: unknown }; } type CodexHookEvent = @@ -86,6 +89,8 @@ export async function protectAction(options: ProtectOptions): Promise !recognized.has(key))) return fallback; + const stdout = firstString(value.stdout, value.output); + const stderr = firstString(value.stderr); + const display = [stdout, stderr].filter(Boolean).join('\n'); + const exitCode = value.exit_code ?? value.exitCode; + if (!display) return fallback; + return typeof exitCode === 'number' && exitCode !== 0 + ? `Exit code: ${exitCode}\n${display}` : display; +} + +function redactAgentGuardSelfToolOutput(response: unknown, config: AgentGuardConfig): string { + const text = typeof response === 'string' ? response : JSON.stringify(response); + const activationUrl = config.agentJwt && config.agentRegisterUrl; + if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) { + return redactText(text); + } + try { + const url = new URL(activationUrl); + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { + return redactText(text); + } + // The exact URL saved after registration is user-facing and must remain + // clickable. A fresh per-call marker keeps every other credential subject + // to normal redaction, even elsewhere in the same result. + const marker = `AGENTGUARDLINK${randomBytes(16).toString('hex')}END`; + const protectedText = text.replace(/https?:\/\/[^\s\"'`<>\\]+/gi, (candidate) => + candidate === activationUrl ? marker : candidate); + return redactText(protectedText).replaceAll(marker, activationUrl); + } catch { + return redactText(text); + } +} + function isAgentGuardRuntimeAction(action: RuntimeAction): boolean { return action.actionType === 'shell' && isAgentGuardCliCommand(action.input); } @@ -608,6 +691,11 @@ function formatCodexHookResult(result: ProtectResult): string | null { const decision = result.decision.decision; const denied = decision === 'block' || decision === 'require_approval'; const reason = safeCodexReason(result, decision === 'require_approval'); + if (event === 'PostToolUse' && result.selfToolOutput) { + // Codex replaces the original model-visible result with hook feedback in + // code mode; this does not reject the completed tool call. + return JSON.stringify({ continue: false, stopReason: result.selfToolOutput.text }); + } if (event === 'UserPromptSubmit') { if (denied) return JSON.stringify({ decision: 'block', reason }); @@ -655,6 +743,11 @@ function formatClaudeHookResult(result: ProtectResult): string | null { const decision = result.decision.decision; const denied = decision === 'block' || decision === 'require_approval'; const reason = safeCodexReason(result, decision === 'require_approval'); + if (event === 'PostToolUse' && result.selfToolOutput) { + return JSON.stringify({ hookSpecificOutput: { + hookEventName: 'PostToolUse', updatedToolOutput: result.selfToolOutput.value, + } }); + } if (event === 'UserPromptSubmit' || event === 'UserPromptExpansion') { if (denied) return JSON.stringify({ decision: 'block', reason }); @@ -1362,55 +1455,36 @@ function projectClaudeBatchForInspection( responses: unknown, config: AgentGuardConfig, ): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number } { - if (!Array.isArray(responses)) { - return { originalCalls: [], inspectionCalls: [], suppressedCount: 0 }; - } + if (!Array.isArray(responses)) return { originalCalls: [], inspectionCalls: [], suppressedCount: 0 }; let suppressedCount = 0; const inspectionCalls = responses.map((call) => { - if (!isClaudeAgentGuardStatusCall(call, config) || !isPlainRecord(call)) return call; - const outputKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') - ? 'tool_response' - : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; - if (!outputKey) return call; - const outputProjection = projectAgentGuardStatusOutput(call[outputKey], config); - if (!outputProjection.suppressed) return call; - const projected = { ...call }; - projected[outputKey] = outputProjection.value; + if (!isPlainRecord(call)) return call; + const toolName = firstString(call.tool_name, call.toolName, call.name); + const toolInput = pickToolInput(call); + if (mapToolToRuntimeAction(toolName, call) !== 'shell' + || !isStandaloneAgentGuardCliCommand(pickNativeToolAuditInput('shell', toolInput))) return call; + const responseKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') + ? 'tool_response' : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; + if (!responseKey) return call; + // Batch inspection never includes AgentGuard's own output or arguments; + // PostToolUse already provides the redacted model-visible replacement. + const response = call[responseKey]; + const projectedResponse = isPlainRecord(response) + ? { exit_code: nonNegativeInteger(response.exit_code ?? response.exitCode), + output: AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER } + : AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER; + const projected = { ...call, [responseKey]: projectedResponse }; + for (const key of ['tool_input', 'toolInput']) { + if (Object.prototype.hasOwnProperty.call(projected, key)) { + projected[key] = { command: '[AgentGuard CLI invocation suppressed]' }; + } + } suppressedCount += 1; return projected; }); return { originalCalls: responses, inspectionCalls, suppressedCount }; } -function projectAgentGuardStatusOutput(output: unknown, config: AgentGuardConfig): { value: unknown; suppressed: boolean } { - if (!isSafelyMaskedAgentGuardStatusOutput(output, config)) return { value: output, suppressed: false }; - if (typeof output === 'string') { - return { value: AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER, suppressed: true }; - } - if (!isPlainRecord(output)) return { value: output, suppressed: false }; - - const projected = { ...output }; - let suppressed = false; - for (const key of ['stdout', 'output']) { - if (typeof projected[key] !== 'string') continue; - projected[key] = AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER; - suppressed = true; - } - return { value: suppressed ? projected : output, suppressed }; -} - -function isClaudeAgentGuardStatusCall(call: unknown, config: AgentGuardConfig): boolean { - if (!isPlainRecord(call)) return false; - const toolName = firstString(call.tool_name, call.toolName, call.name); - const toolInput = pickToolInput(call); - const actionType = mapToolToRuntimeAction(toolName, call); - if (actionType !== 'shell') return false; - const command = pickNativeToolAuditInput(actionType, toolInput); - if (!command || !isAgentGuardStatusCommand(command)) return false; - const output = call.tool_response ?? call.toolResponse; - return projectAgentGuardStatusOutput(output, config).suppressed; -} - function hasSensitiveClaudeBatchContent(responses: unknown): boolean { if (!Array.isArray(responses)) return redactText(JSON.stringify(responses)) !== JSON.stringify(responses); return responses.some((call: unknown) => { diff --git a/src/runtime/self-command.ts b/src/runtime/self-command.ts index 9b91b4e..fdc41a9 100644 --- a/src/runtime/self-command.ts +++ b/src/runtime/self-command.ts @@ -20,6 +20,14 @@ export function isAgentGuardCliCommand(command: string): boolean { return isAgentGuardCliCommandInner(command, 0); } +/** Only the bare AgentGuard CLI can use the native self-output rewrite. + * Wrapped, alternate-path and compound shell calls remain on the normal guard. + */ +export function isStandaloneAgentGuardCliCommand(command: string): boolean { + const trimmed = command.trim(); + return /^agentguard(?:[ \t]|$)/.test(trimmed) && isAgentGuardCliCommand(trimmed); +} + export function isAgentGuardStatusCommand(command: string): boolean { const trimmed = command.trim(); if (!trimmed || /[\r\n]/.test(trimmed)) return false; diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index 2acb061..c80e15c 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -207,12 +207,16 @@ describe('Claude Code native lifecycle hooks', () => { tool_calls: [{ ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 } }], }, }); - assert.ok(forgedBatch); - assert.equal(forgedBatch.decision.decision, 'block'); - assert.doesNotMatch(readFileSync(fixture.config.auditPath, 'utf8'), /pending-claude-link/); + assert.equal(forgedBatch, null, 'self-only batches do not block for output content'); + const forgedPost = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-forged-post', cwd: fixture.root, + ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 }, + }); + assert.equal(forgedPost.result.decision.decision, 'allow'); + assert.doesNotMatch(forgedPost.text, /pending-claude-link-123456-forged/); }); - it('does not exempt wrapped, prefixed, alternate-path, or extended status commands', async () => { + it('does not exempt wrapped, prefixed, or alternate-path status commands', async () => { const commands = [ "sh -c 'agentguard status'", 'env MODE=test agentguard status', @@ -221,7 +225,6 @@ describe('Claude Code native lifecycle hooks', () => { 'builtin agentguard status', '/tmp/agentguard status', 'agentguard-mcp status', - 'agentguard status --json', ]; for (const [index, command] of commands.entries()) { const fixture = lifecycleFixture(); @@ -243,14 +246,16 @@ describe('Claude Code native lifecycle hooks', () => { } }); - it('still protects an unexpectedly unmasked secret in agentguard status output', async () => { + it('rewrites an unexpectedly unmasked secret in agentguard status output', async () => { const fixture = lifecycleFixture(); const leaked = await runHook(fixture.config, { hook_event_name: 'PostToolUse', session_id: 'sess-status-leak', cwd: fixture.root, tool_name: 'Bash', tool_input: { command: 'agentguard status' }, tool_response: { stdout: 'api_key=sk-status-leak-1234567890', exit_code: 0 }, }); - assert.equal(leaked.result.decision.decision, 'block'); + assert.equal(leaked.result.decision.decision, 'allow'); + assert.match(leaked.text, /updatedToolOutput/); + assert.match(leaked.text, /api_key=\[REDACTED\]/); assert.doesNotMatch(leaked.text, /status-leak/); }); @@ -352,8 +357,7 @@ describe('Claude Code native lifecycle hooks', () => { }], }, }); - assert.ok(unsafeStatus); - assert.equal(unsafeStatus.decision.decision, 'block'); + assert.equal(unsafeStatus, null, 'AgentGuard-only tool batches must never block'); for (const [index, command] of [ "sh -c 'agentguard status'", @@ -361,7 +365,6 @@ describe('Claude Code native lifecycle hooks', () => { 'command agentguard status', 'builtin agentguard status', '/tmp/agentguard status', - 'agentguard status --json', ].entries()) { const nonExempt = await protectAction({ config: fixture.config, diff --git a/src/tests/cli-connect.test.ts b/src/tests/cli-connect.test.ts index 314a2bf..55a6a57 100644 --- a/src/tests/cli-connect.test.ts +++ b/src/tests/cli-connect.test.ts @@ -6,7 +6,9 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import type { AgentGuardConfig } from '../config.js'; import { getDefaultEffectiveRuntimePolicy } from '../runtime/policy.js'; +import { protectAction } from '../runtime/protect.js'; const projectRoot = resolve(__dirname, '..', '..'); const CLI_PATH = join(projectRoot, 'dist', 'cli.js'); @@ -99,16 +101,17 @@ describe('CLI connect Agent JWT mode', () => { assert.match(result.stdout, /Registered local AgentGuard agent \(agt_cli_test\)/); assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=cli-test/); assert.equal(requests[0].url, '/api/agent/register'); - const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as { - agentId?: string; - agentJwt?: string; - agentRegisterUrl?: string; - cloudUrl?: string; - }; + const config = JSON.parse(readFileSync(join(home, 'config.json'), 'utf8')) as AgentGuardConfig; assert.equal(config.cloudUrl, cloudUrl); assert.equal(config.agentId, 'agt_cli_test'); assert.equal(config.agentJwt, 'agent.jwt.cli-test'); assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=cli-test'); + const native = await protectAction({ config, agentHost: 'codex', auditSafe: true, rawInput: { + hook_event_name: 'PostToolUse', session_id: 'sess-connect', cwd: home, + tool_name: 'functions.exec_command', tool_input: { cmd: `agentguard connect --url ${cloudUrl}` }, + tool_response: { output: result.stdout, exit_code: result.exitCode }, + } }); + assert.equal(native, null, 'real connect output and registration link remain untouched'); } finally { await new Promise((resolvePromise) => server.close(() => resolvePromise())); } diff --git a/src/tests/cli-subscribe.test.ts b/src/tests/cli-subscribe.test.ts index e65caad..1100114 100644 --- a/src/tests/cli-subscribe.test.ts +++ b/src/tests/cli-subscribe.test.ts @@ -6,7 +6,9 @@ import http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; +import type { AgentGuardConfig } from '../config.js'; import type { Advisory } from '../feed/types.js'; +import { protectAction } from '../runtime/protect.js'; const projectRoot = resolve(__dirname, '..', '..'); const CLI_PATH = join(projectRoot, 'dist', 'cli.js'); @@ -335,10 +337,12 @@ describe('CLI subscribe command modes', () => { eventSpoolPath: join(home, 'events-spool.jsonl'), })); - const result = await runCliNoConfigWrite(['subscribe', '--json'], home); + const result = await runCliNoConfigWrite(['subscribe'], home); assert.equal(result.exitCode, 0); assert.equal(result.stderr, ''); + assert.match(result.stdout, /AgentGuard Cloud activation is ready:/); + assert.match(result.stdout, /https:\/\/agentguard\.example\/activate\?token=hermes-subscribe/); assert.deepEqual(requests.map((request) => `${request.method} ${request.url}`), [ 'POST /api/agent/register', 'POST /api/v1/feed/subscribe', @@ -357,6 +361,12 @@ describe('CLI subscribe command modes', () => { assert.equal(config.agentId, 'agt_hermes_subscribe'); assert.equal(config.agentJwt, 'agent.jwt.hermes-subscribe'); assert.equal(config.agentRegisterUrl, 'https://agentguard.example/activate?token=hermes-subscribe'); + const native = await protectAction({ config: config as AgentGuardConfig, agentHost: 'codex', auditSafe: true, rawInput: { + hook_event_name: 'PostToolUse', session_id: 'sess-subscribe', cwd: home, + tool_name: 'functions.exec_command', tool_input: { cmd: 'agentguard subscribe' }, + tool_response: { output: result.stdout, exit_code: result.exitCode }, + } }); + assert.equal(native, null, 'real subscribe output and registration link remain untouched'); } finally { await new Promise((resolvePromise) => server.close(() => resolvePromise())); } diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 00b461a..fbab2a4 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -304,7 +304,10 @@ describe('installed Codex native hooks', () => { }); assert.equal(run.status, 0); - assert.equal(run.stdout.trim(), ''); + const feedback = JSON.parse(run.stdout); + assert.equal(feedback.continue, false); + assert.match(feedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); + assert.doesNotMatch(feedback.stopReason, /TYPESAFE_API_KEY=/); assert.equal(run.stderr.trim(), ''); assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); @@ -332,24 +335,107 @@ describe('installed Codex native hooks', () => { }); const allowed = fixture.run('agentguard-post-tool.sh', payload(status.stdout)); assert.equal(allowed.status, 0); - assert.equal(allowed.stdout.trim(), ''); + const allowedFeedback = JSON.parse(allowed.stdout); + assert.equal(allowedFeedback.continue, false); + assert.ok(allowedFeedback.stopReason.includes(activationUrl)); + assert.match(allowedFeedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); assert.equal(allowed.stderr.trim(), ''); assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); - // Matching the local URL is necessary, not a blanket exception for URLs. + // A different link or another secret is redacted, never block the CLI. for (const [index, result] of [ payload(status.stdout.replace(activationUrl, `${activationUrl}-forged`)), payload(`${status.stdout}api_key=sk-unsafe-addition-1234567890\n`), payload(status.stdout, 'agentguard status; echo extra'), ].entries()) { - const blocked = fixture.run('agentguard-post-tool.sh', { ...result, session_id: `sess_pending_${index}` }); - assert.equal(blocked.status, 0); - assert.equal(JSON.parse(blocked.stdout).decision, 'block'); - assert.doesNotMatch(`${blocked.stdout}\n${blocked.stderr}`, /pending-activation-link|unsafe-addition/); + const run = fixture.run('agentguard-post-tool.sh', { ...result, session_id: `sess_pending_${index}` }); + assert.equal(run.status, 0); + const feedback = JSON.parse(run.stdout); + if (index === 2) assert.equal(feedback.decision, 'block'); // Compound command is not AgentGuard-only. + else { + assert.equal(feedback.continue, false); + assert.doesNotMatch(feedback.stopReason, /unsafe-addition|pending-activation-link-123456-forged/); + } } }); - it('still blocks an unexpectedly unmasked secret in agentguard status output', () => { + it('keeps real init and privacy status working with a sanitized key hint', () => { + const fixture = installFixture(); + const post = (cmd: string, output: string) => fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-self-hints', turn_id: 'turn-self-hints', + cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-hints', + tool_input: { cmd }, tool_response: { output, exit_code: 0 }, + }); + const init = fixture.runCli('init', '--agent', 'codex'); + assert.equal(init.status, 0); + assert.match(init.stdout, /TYPESAFE_API_KEY=/); + const initFeedback = JSON.parse(post('agentguard init --agent codex', init.stdout).stdout); + assert.equal(initFeedback.continue, false); + assert.match(initFeedback.stopReason, /AgentGuard initialized/); + assert.match(initFeedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); + + writeFileSync(join(fixture.home, 'config.json'), JSON.stringify({ + version: 1, level: 'balanced', agentHost: 'codex', privacy: { mode: 'jev' }, + policyCachePath: join(fixture.home, 'policy-cache.json'), + auditPath: join(fixture.home, 'audit.jsonl'), + eventSpoolPath: join(fixture.home, 'events-spool.jsonl'), + })); + const privacy = fixture.runCli('privacy', 'status'); + assert.equal(privacy.status, 0); + assert.match(privacy.stdout, /TYPESAFE_API_KEY=/); + const privacyFeedback = JSON.parse(post('agentguard privacy status', privacy.stdout).stdout); + assert.equal(privacyFeedback.continue, false); + assert.match(privacyFeedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); + assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); + }); + + it('redacts all standalone CLI output but retains the saved JWT activation URL', () => { + const fixture = installFixture(); + const url = 'https://agentguard.example/activate?token=allowed-jwt-link-123456'; + writeFileSync(join(fixture.home, 'config.json'), JSON.stringify({ + version: 1, level: 'balanced', agentHost: 'codex', agentJwt: 'test.jwt.pending', + agentRegisterUrl: url, cloudUrl: 'http://127.0.0.1:9', + policyCachePath: join(fixture.home, 'policy-cache.json'), + auditPath: join(fixture.home, 'audit.jsonl'), + eventSpoolPath: join(fixture.home, 'events-spool.jsonl'), + })); + const run = (cmd: string, output: string) => fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-self-links', turn_id: 'turn-self-links', + cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-links', + tool_input: { cmd }, tool_response: { output, exit_code: 0 }, + }); + const output = `Open this link to bind this agent to your account:\n${url}\n` + + 'api_key=sk-self-secret-1234567890\n身份证号=11010519491231002X\n'; + for (const cmd of ['agentguard connect', 'agentguard subscribe', 'agentguard scan ./skill']) { + const response = run(cmd, output); + assert.equal(response.status, 0, cmd); + const feedback = JSON.parse(response.stdout); + assert.equal(feedback.continue, false, cmd); + assert.ok(feedback.stopReason.includes(url), cmd); + assert.match(feedback.stopReason, /api_key=\[REDACTED\]/, cmd); + assert.doesNotMatch(feedback.stopReason, /self-secret|11010519491231002X/, cmd); + } + const forged = JSON.parse(run('agentguard connect', output.replace(url, `${url}-forged`)).stdout); + assert.equal(forged.continue, false); + assert.doesNotMatch(forged.stopReason, /allowed-jwt-link-123456-forged/); + const failed = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-self-failure', turn_id: 'turn-self-failure', + cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-failure', + tool_input: { cmd: 'agentguard subscribe' }, tool_response: { + stderr: `Open this link to bind this agent to your account:\n${url}\napi_key=sk-failed-secret-1234567890`, + exit_code: 1, + }, + }); + const failedFeedback = JSON.parse(failed.stdout); + assert.equal(failedFeedback.continue, false); + assert.match(failedFeedback.stopReason, /Exit code: 1/); + assert.ok(failedFeedback.stopReason.includes(url)); + assert.doesNotMatch(failedFeedback.stopReason, /failed-secret/); + assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); + assert.equal(JSON.parse(run('cat ./other-tool-output', output).stdout).decision, 'block'); + }); + + it('redacts an unexpectedly unmasked secret without blocking agentguard status output', () => { const fixture = installFixture(); const run = fixture.run('agentguard-post-tool.sh', { hook_event_name: 'PostToolUse', @@ -363,11 +449,13 @@ describe('installed Codex native hooks', () => { }); assert.equal(run.status, 0); - assert.equal(JSON.parse(run.stdout).decision, 'block'); + const feedback = JSON.parse(run.stdout); + assert.equal(feedback.continue, false); + assert.match(feedback.stopReason, /api_key=\[REDACTED\]/); assert.doesNotMatch(`${run.stdout}\n${run.stderr}`, /status-leak/); }); - it('does not exempt wrapped, prefixed, alternate-path, or extended status output', () => { + it('does not exempt wrapped, prefixed, or alternate-path status output', () => { const commands = [ "sh -c 'agentguard status'", 'env MODE=test agentguard status', @@ -376,7 +464,6 @@ describe('installed Codex native hooks', () => { 'builtin agentguard status', '/tmp/agentguard status', 'agentguard-mcp status', - 'agentguard status --json', ]; for (const [index, command] of commands.entries()) { const fixture = installFixture(); @@ -401,8 +488,8 @@ describe('installed Codex native hooks', () => { } }); - it('keeps post-tool protection for non-status and compound AgentGuard commands', () => { - const commands = ['agentguard scan', 'agentguard status; printf unsafe']; + it('redacts any bare AgentGuard CLI output but retains compound command blocking', () => { + const commands = ['agentguard scan', 'agentguard status --json', 'agentguard status; printf unsafe']; for (const [index, command] of commands.entries()) { const fixture = installFixture(); const run = fixture.run('agentguard-post-tool.sh', { @@ -417,7 +504,12 @@ describe('installed Codex native hooks', () => { }); assert.equal(run.status, 0, command); - assert.equal(JSON.parse(run.stdout).decision, 'block', command); + const feedback = JSON.parse(run.stdout); + if (command.includes(';')) assert.equal(feedback.decision, 'block', command); + else { + assert.equal(feedback.continue, false, command); + assert.match(feedback.stopReason, /api_key=\[REDACTED\]/, command); + } assert.doesNotMatch(`${run.stdout}\n${run.stderr}`, /non-status-secret/, command); } }); @@ -566,6 +658,9 @@ function installFixture(): { ...process.env, PATH: `${bin}:${process.env.PATH || ''}`, AGENTGUARD_HOME: home, + HOME: home, + DSH_HOME: '', + HERMES_HOME: '', TYPESAFE_API_KEY: '', }, }); From c95e21afce264edfc8c3b5a1465abfe46e8cc591 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Fri, 16 Oct 2026 21:05:05 +0800 Subject: [PATCH 3/6] fix: extend AgentGuard self-output handling across native hosts --- CHANGELOG.md | 1 + docs/dsh-runtime.md | 2 + docs/privacy-boundary.md | 19 ++++++ plugins/hermes/README.md | 1 + plugins/hermes/bridge.py | 19 +++++- plugins/hermes/plugin.py | 33 +++++++++++ plugins/hermes/tests/test_allow.py | 1 + plugins/hermes/tests/test_self_output.py | 54 ++++++++++++++++++ src/adapters/openclaw-plugin.ts | 73 +++++++++++++++++++++++- src/cli.ts | 6 ++ src/dsh/runtime.ts | 57 +++++++++++++++++- src/runtime/protect.ts | 24 +------- src/runtime/self-output.ts | 27 +++++++++ src/tests/dsh-runtime.test.ts | 48 +++++++++++++++- src/tests/integration.test.ts | 73 +++++++++++++++++++++++- 15 files changed, 403 insertions(+), 35 deletions(-) create mode 100644 plugins/hermes/tests/test_self_output.py create mode 100644 src/runtime/self-output.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 990d487..4edf24e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### 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 diff --git a/docs/dsh-runtime.md b/docs/dsh-runtime.md index d737ec5..e79db73 100644 --- a/docs/dsh-runtime.md +++ b/docs/dsh-runtime.md @@ -61,6 +61,8 @@ The reason passed into DSH contains only a bounded risk score, normalized policy ## Response containment boundary +Standalone AgentGuard CLI shell commands are exempt from AgentGuard's own pre-execute policy evaluation (except `agentguard approve`). When their post-execute result exposes text content, the runtime can return an `accept` decision with locally redacted text while preserving the exact saved pending Agent JWT activation URL. It never weakens a downstream `block` decision. This does not claim to rewrite arbitrary structured result values without rendered text. + Network results pass through `tools/post-execute`. AgentGuard extracts a bounded response preview plus available status, content type, headers, and byte count, then evaluates response and network-volume anomalies. Post-execute remains audit-only in `observe` and in the default `protect` configuration. Set `postResponseMode: block-malicious` together with `mode: protect` to suppress only results whose final AgentGuard post-execute decision is `block`. The original result value and rendered content are not returned; DSH receives bounded AgentGuard feedback that excludes the untrusted preview and evidence. A stronger downstream block is preserved. diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 6fb2a8b..4e1c63b 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -16,6 +16,25 @@ Redacted request and response observations may be written to the local audit file, but routine model-request records and all model-response records are not uploaded to Cloud. +## AgentGuard-owned CLI results + +A standalone `agentguard` shell invocation is not blocked by AgentGuard merely +because its own result contains sensitive text. Supported native result hooks +redact recognized secrets in host-specific result or persistence surfaces. The **exact** +pending Agent JWT activation URL saved in the local AgentGuard configuration +remains unchanged so the user can open it. Other tokenized URLs are still +redacted. Compound commands, alternate executable paths, and unrelated tools +retain their ordinary protection; `agentguard approve` is not exempt from +agent-self-approval checks. + +| Host | Own-command result handling | Limit | +|---|---|---| +| Codex | `PostToolUse` replaces sensitive results with redacted feedback. | Requires the native hook; no raw output replacement for unsupported lifecycle events. | +| Claude Code | `PostToolUse` rewrites supported result shapes; `PostToolBatch` excludes the own-command call from content scanning. | `PostToolUseFailure` has no output rewrite channel. | +| OpenClaw / QClaw | `tool_result_persist` synchronously rewrites matched text in OpenClaw-owned session transcripts. | Requires host support and a matching `toolCallId`; cannot change results already displayed or Codex-native records. | +| Hermes | Native `transform_tool_result` rewrites results before model context; `/agentguard` uses the same redactor. | Legacy shell hooks expose only an audit-only post event and cannot rewrite tool output. | +| DSH | `tools/post-execute` can return redacted text content with `kind: accept`. | Structured results without rendered text and results blocked by another policy are not rewritten by this hook. | + ## Public web-search results Bare card- or national-ID-shaped digits in a **verified, host-owned web-search diff --git a/plugins/hermes/README.md b/plugins/hermes/README.md index 2dfc6c1..c90dc5f 100644 --- a/plugins/hermes/README.md +++ b/plugins/hermes/README.md @@ -38,6 +38,7 @@ Or copy this directory to `~/.hermes/plugins/agentguard/` manually. | `post_api_request` | Observes and correlates main-loop responses; never blocks them. | | `pre_tool_call` | Evaluates the call; returns `{"action":"block","message":...}` to veto a dangerous action. | | `post_tool_call` | Audit-only; never blocks. | +| `transform_tool_result` | Redacts AgentGuard CLI results before model context; preserves the saved JWT activation link. | | `on_session_start` | Best-effort background scan of installed skills (opt out: `AGENTGUARD_HERMES_AUTOSCAN=0`). | | `/agentguard` | Slash command — `status`, `report` (default), or `checkup`. | diff --git a/plugins/hermes/bridge.py b/plugins/hermes/bridge.py index 958588d..03a4220 100644 --- a/plugins/hermes/bridge.py +++ b/plugins/hermes/bridge.py @@ -258,6 +258,19 @@ def _observe_llm( request_id=request_id, ) + def redact_self_output(self, text: str) -> str: + """Use AgentGuard's shared local redactor for one own-command result.""" + bin_path = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard") + if not bin_path: + raise RuntimeError("AgentGuard redactor is not installed") + proc = subprocess.run( + [bin_path, "self-output"], input=text, capture_output=True, + text=True, timeout=self.timeout, encoding="utf-8", + ) + if proc.returncode != 0: + raise RuntimeError("AgentGuard redactor failed") + return proc.stdout + def run_cli(self, args: list) -> str: """Run an ``agentguard`` subcommand and return stdout (for /agentguard).""" bin_path = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard") @@ -274,7 +287,11 @@ def run_cli(self, args: list) -> str: ) except subprocess.SubprocessError as exc: return "AgentGuard CLI failed: %s" % exc - return (proc.stdout or proc.stderr or "").strip() + output = (proc.stdout or proc.stderr or "").strip() + try: + return self.redact_self_output(output) + except Exception: + return "AgentGuard output could not be safely redacted; run the command directly in your terminal." # -- internals --------------------------------------------------------- diff --git a/plugins/hermes/plugin.py b/plugins/hermes/plugin.py index 12ccffe..e96bb3d 100644 --- a/plugins/hermes/plugin.py +++ b/plugins/hermes/plugin.py @@ -9,6 +9,7 @@ from __future__ import annotations import os +import shlex import logging import shutil import subprocess @@ -33,6 +34,7 @@ def register(ctx: Any, bridge: Optional[AgentGuardBridge] = None) -> None: ctx.register_hook("post_api_request", _make_post_api_request(guard)) ctx.register_hook("pre_tool_call", _make_pre_tool_call(guard)) ctx.register_hook("post_tool_call", _make_post_tool_call(guard)) + ctx.register_hook("transform_tool_result", _make_transform_tool_result(guard)) ctx.register_hook("on_session_start", _make_session_start(guard)) register_command = getattr(ctx, "register_command", None) @@ -136,6 +138,37 @@ def post_tool_call(tool_name: str, args: Optional[Dict[str, Any]] = None, **kwar return post_tool_call +def _is_standalone_agentguard_command(command: Any) -> bool: + if not isinstance(command, str) or any(c in command for c in ";&|<>`\n\r\t") or "$(" in command: + return False + try: + tokens = shlex.split(command) + except ValueError: + return False + return bool(tokens) and tokens[0] == "agentguard" + + +def _make_transform_tool_result(guard: AgentGuardBridge): + def transform_tool_result(tool_name: str, args: Optional[Dict[str, Any]] = None, **kwargs: Any): + if tool_name not in ("terminal", "execute_code") or not isinstance(args, dict): + return None + command = args.get("command") or args.get("code") + if not _is_standalone_agentguard_command(command): + return None + result = kwargs.get("result") + if not isinstance(result, str): + return None + try: + redacted = guard.redact_self_output(result) + return redacted if redacted != result else None + except Exception: + # This hook runs before Hermes puts the result in model context. + # Never hand raw credentials to the model if the local redactor fails. + return "AgentGuard output could not be safely redacted; run the command directly in your terminal." + + return transform_tool_result + + def _make_session_start(guard: AgentGuardBridge): def on_session_start(*_args: Any, **_kwargs: Any): # Best-effort background scan of installed skills, mirroring the shell diff --git a/plugins/hermes/tests/test_allow.py b/plugins/hermes/tests/test_allow.py index 96cdcf5..ed2b87d 100644 --- a/plugins/hermes/tests/test_allow.py +++ b/plugins/hermes/tests/test_allow.py @@ -17,6 +17,7 @@ def test_register_wires_all_hooks_and_command(): "post_api_request", "pre_tool_call", "post_tool_call", + "transform_tool_result", "on_session_start", } assert "agentguard" in ctx.commands diff --git a/plugins/hermes/tests/test_self_output.py b/plugins/hermes/tests/test_self_output.py new file mode 100644 index 0000000..0ebcc97 --- /dev/null +++ b/plugins/hermes/tests/test_self_output.py @@ -0,0 +1,54 @@ +"""Native Hermes own-command results are rewritten before model context.""" + +import json +import shlex +import shutil +from pathlib import Path + +from helpers import make_protect_runner, register_with + + +def test_transform_hook_scopes_standalone_commands_and_fails_safe(monkeypatch): + ctx, guard = register_with(make_protect_runner()) + guard.redact_self_output = lambda text: text.replace("sk-test-secret-1234567890", "[REDACTED]") + transform = ctx.hooks["transform_tool_result"] + own = '{"output":"api_key=sk-test-secret-1234567890"}' + assert transform("terminal", {"command": "agentguard scan ./skill"}, result=own) == '{"output":"api_key=[REDACTED]"}' + assert transform("execute_code", {"code": "agentguard status"}, result=own) == '{"output":"api_key=[REDACTED]"}' + assert transform("terminal", {"command": "agentguard status; cat .env"}, result=own) is None + assert transform("terminal", {"command": "cat .env"}, result=own) is None + assert transform("read_file", {"path": "x"}, result=own) is None + assert transform("terminal", {"command": "agentguard status"}, result="public") is None + guard.redact_self_output = lambda _text: (_ for _ in ()).throw(RuntimeError("sk-private-value")) + failed = transform("terminal", {"command": "agentguard status"}, result=own) + assert "safely redacted" in failed + assert "sk-test-secret" not in failed + + +def test_bridge_uses_shared_redactor_and_preserves_only_the_saved_link(monkeypatch, tmp_path): + node = shutil.which("node") + assert node + cli = Path(__file__).resolve().parents[3] / "dist" / "cli.js" + shim = tmp_path / "agentguard" + shim.write_text("#!/bin/sh\nexec " + shlex.quote(node) + " " + shlex.quote(str(cli)) + ' "$@"\n') + shim.chmod(0o755) + monkeypatch.setenv("AGENTGUARD_BIN", str(shim)) + monkeypatch.setenv("AGENTGUARD_HOME", str(tmp_path)) + link = "https://agentguard.example/activate?token=hermes-self-link-123456" + (tmp_path / "config.json").write_text(json.dumps({ + "version": 1, "level": "balanced", "agentJwt": "agent.jwt.test", "agentRegisterUrl": link, + "cloudUrl": "http://127.0.0.1:9", + })) + ctx, guard = register_with(make_protect_runner()) + output = f"{link}\napi_key=sk-hermes-secret-1234567890" + rewritten = ctx.hooks["transform_tool_result"]("terminal", {"command": "agentguard connect"}, result=output) + assert link in rewritten + assert "hermes-secret" not in rewritten + assert "api_key=[REDACTED]" in rewritten + assert ctx.hooks["transform_tool_result"]("terminal", {"command": "agentguard status"}, result=link) is None + forged = guard.redact_self_output(output.replace(link, link + "-forged")) + assert "hermes-self-link-123456-forged" not in forged + assert "sk-hermes-secret" not in forged + slash_status = ctx.commands["agentguard"][0]("status") + assert link in slash_status + assert "TYPESAFE_API_KEY=[REDACTED]" in slash_status diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index cfe6eab..d6594f4 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -40,6 +40,8 @@ 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 { redactAgentGuardSelfToolOutput } from '../runtime/self-output.js'; // --------------------------------------------------------------------------- // OpenClaw Types (subset we use) @@ -74,8 +76,8 @@ interface OpenClawPluginApi { source: string; registrationMode?: string; pluginConfig?: Record; - on(event: string, handler: (event: unknown, ctx?: unknown) => Promise): void; - on(event: string, options: Record, handler: (event: unknown, ctx?: unknown) => Promise): void; + on(event: string, handler: (event: unknown, ctx?: unknown) => Promise | unknown): void; + on(event: string, options: Record, handler: (event: unknown, ctx?: unknown) => Promise | unknown): void; } // --------------------------------------------------------------------------- @@ -368,6 +370,9 @@ export function registerOpenClawPlugin( const trustRegistry = options.registry ?? new SkillRegistry(); const runProtectAction = options.protectAction ?? protectAction; const runtimeProtectionEnabled = options.runtimeProtection !== false; + // Only a verified before_tool_call can mark a later persisted tool result as + // AgentGuard-owned. tool_result_persist does not expose tool arguments. + const pendingSelfResults = new Map(); // Simple logger const logger = (msg: string) => console.log(msg); @@ -497,6 +502,22 @@ export function registerOpenClawPlugin( // Try to infer plugin from tool name const toolName = normalized.toolName; const pluginId = toolName ? getPluginIdFromTool(toolName) : null; + const rawEvent = event && typeof event === 'object' ? event as Record : {}; + const rawCtx = ctx && typeof ctx === 'object' ? ctx as Record : {}; + const callId = rawEvent.toolCallId ?? rawCtx.toolCallId; + const args = rawEvent.params ?? rawEvent.toolInput ?? rawEvent.args; + const command = args && typeof args === 'object' + ? (args as Record).command ?? (args as Record).cmd : undefined; + if (runtimeProtectionEnabled && normalized.actionType === 'shell' + && typeof command === 'string' && isStandaloneAgentGuardCliCommand(command) + && typeof callId === 'string' && callId.length > 0 && callId.length <= 160) { + if (pendingSelfResults.size >= 512) pendingSelfResults.delete(pendingSelfResults.keys().next().value!); + pendingSelfResults.set(callId, { + toolName, + sessionKey: typeof rawCtx.sessionKey === 'string' ? rawCtx.sessionKey : undefined, + expiresAt: Date.now() + 60_000, + }); + } // Check if plugin is untrusted if (pluginId) { @@ -585,6 +606,12 @@ export function registerOpenClawPlugin( try { const input = adapter.parseInput(event); const [normalized] = adapter.normalizeLifecycleEvent('after_tool_call', event, ctx); + const actionArgs = event && typeof event === 'object' ? event as Record : {}; + const args = actionArgs.params ?? actionArgs.toolInput ?? actionArgs.args; + 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; const toolName = normalized.toolName; const pluginId = toolName ? getPluginIdFromTool(toolName) : null; if (runtimeProtectionEnabled) { @@ -603,6 +630,48 @@ export function registerOpenClawPlugin( } }); + // OpenClaw-owned transcripts expose a synchronous result rewrite hook. + // This cannot rewrite Codex-native records or results already shown in a UI. + try { + api.on('tool_result_persist', (rawEvent: unknown, rawContext?: unknown) => { + const event = rawEvent && typeof rawEvent === 'object' ? rawEvent as Record : {}; + const ctx = rawContext && typeof rawContext === 'object' ? rawContext as Record : {}; + const toolMessage = event.message && typeof event.message === 'object' + ? event.message as Record : {}; + const callId = event.toolCallId ?? ctx.toolCallId ?? toolMessage.toolCallId; + if (event.isSynthetic === true || typeof callId !== 'string') return undefined; + const pending = pendingSelfResults.get(callId); + if (!pending) return undefined; + pendingSelfResults.delete(callId); + if (pending.expiresAt < Date.now() || pending.toolName !== (event.toolName ?? ctx.toolName ?? toolMessage.toolName) + || (pending.sessionKey && pending.sessionKey !== ctx.sessionKey)) return undefined; + const message = event.message; + if (!message || typeof message !== 'object' || Array.isArray(message)) return undefined; + const toolResult = message as Record; + if (toolResult.role !== 'toolResult') return undefined; + const current = toolResult.content; + let changed = false; + const sanitize = (text: string) => { + let safe: string; + try { safe = redactAgentGuardSelfToolOutput(text, loadAgentGuardConfig()); } + catch { safe = 'AgentGuard output could not be safely redacted; run the command in your terminal.'; } + if (safe !== text) changed = true; + return safe; + }; + const content = Array.isArray(current) + ? current.map((block) => { + if (!block || typeof block !== 'object' || Array.isArray(block)) return block; + const part = block as Record; + return part.type === 'text' && typeof part.text === 'string' + ? { ...part, text: sanitize(part.text) } : block; + }) + : typeof current === 'string' ? sanitize(current) : current; + return changed ? { message: { ...toolResult, content } } : undefined; + }); + } catch { + logger('[AgentGuard] tool_result_persist is unavailable; AgentGuard CLI output rewrite is limited to supported hosts.'); + } + logger(`[AgentGuard] Registered with OpenClaw (protection level: ${config.level || 'balanced'})`); } diff --git a/src/cli.ts b/src/cli.ts index 5333654..dd3e840 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,7 @@ import { SkillRegistry } from './registry/index.js'; import type { TrustRecord } from './types/registry.js'; import { resolveScanSource } from './scanner/source.js'; import { formatProtectResult, protectAction, exitCodeForDecision } from './runtime/protect.js'; +import { redactAgentGuardSelfToolOutput } from './runtime/self-output.js'; import { approvePendingApproval, listPendingApprovals } from './runtime/approvals.js'; import { getDefaultEffectiveRuntimePolicy, loadCachedPolicy, saveCachedPolicy } from './runtime/policy.js'; import type { RuntimeActionType, RuntimeAgentHost } from './runtime/types.js'; @@ -688,6 +689,11 @@ async function main() { } }); + // Internal synchronous bridge for native hosts that rewrite tool results. + program.command('self-output', { hidden: true }).action(() => { + process.stdout.write(redactAgentGuardSelfToolOutput(readStdinIfAvailable(), ensureConfig())); + }); + program .command('protect') .description('Evaluate one runtime action from stdin or hook environment') diff --git a/src/dsh/runtime.ts b/src/dsh/runtime.ts index 47ebab0..7715824 100644 --- a/src/dsh/runtime.ts +++ b/src/dsh/runtime.ts @@ -12,6 +12,8 @@ import { rememberPublicSearchResult, } from '../runtime/public-search.js'; import { redactText } from '../runtime/redaction.js'; +import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; +import { redactAgentGuardSelfToolOutput } from '../runtime/self-output.js'; import { planDshEnforcement, type DshRuntimePhase } from './enforcement-plan.js'; import { mergeDshPostDecisions, @@ -97,6 +99,8 @@ export interface DshContentBlock { export type DshPostToolDecision = { kind: 'accept'; content?: ReadonlyArray; + value?: unknown; + additionalContexts?: ReadonlyArray; } | { kind: 'block'; feedback: ReadonlyArray; @@ -233,6 +237,47 @@ export function buildDshRuntimeAction( }; } +function isDshAgentGuardCliAction(action: RuntimeAction): boolean { + return action.actionType === 'shell' && isStandaloneAgentGuardCliCommand(action.input); +} + +function rewriteDshAgentGuardResult( + exec: DshToolExecution, + result: DshToolExecutionResult, + downstream: DshPostToolDecision, + dependencies: DshRuntimeDependencies, +): DshPostToolDecision { + if (downstream.kind === 'block' || isAgentGuardDshTool(exec.name)) return downstream; + const action = buildDshRuntimeAction(exec, dependencies.attribution); + if (!isDshAgentGuardCliAction(action)) return downstream; + const content = downstream.content ?? result.content; + if (!content && downstream.value === undefined) return downstream; + try { + const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); + if (downstream.value !== undefined) { + // DSH forbids replacing value and content in the same accept decision. + const original = JSON.stringify(downstream.value); + const safe = redactAgentGuardSelfToolOutput(downstream.value, config); + if (safe === original) return downstream; + try { return { ...downstream, value: JSON.parse(safe) as unknown }; } + catch { return { ...downstream, value: 'AgentGuard output could not be safely redacted.' }; } + } + if (!content) return downstream; + let changed = false; + const rewritten = content.map((block) => { + if (block.type !== 'text' || typeof block.text !== 'string') return block as DshContentBlock; + const safe = redactAgentGuardSelfToolOutput(block.text, config); + if (safe === block.text) return block as DshContentBlock; + changed = true; + return { ...block, text: safe } as DshContentBlock; + }); + return changed ? { ...downstream, content: rewritten } : downstream; + } catch { + return { kind: 'accept', content: [{ type: 'text', + text: 'AgentGuard output could not be safely redacted; run the command in your terminal.' }] }; + } +} + export async function observeDshToolCall( exec: DshToolExecution, dependencies: DshRuntimeDependencies = {} @@ -241,6 +286,7 @@ export async function observeDshToolCall( const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); const action = buildDshRuntimeAction(exec, dependencies.attribution); + if (isDshAgentGuardCliAction(action) && !/^agentguard[ \t]+approve(?:[ \t]|$)/.test(action.input)) return null; action.metadata = { ...action.metadata, runtimePhase: 'pre' }; return evaluateAndAuditDshAction( action, @@ -259,6 +305,7 @@ export async function protectDshToolCall( const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); const action = buildDshRuntimeAction(exec, dependencies.attribution); + if (isDshAgentGuardCliAction(action) && !/^agentguard[ \t]+approve(?:[ \t]|$)/.test(action.input)) return null; action.metadata = { ...action.metadata, runtimePhase: 'pre' }; return evaluateAndAuditDshAction(action, config, dependencies, DSH_PROTECT_MODE, true); } @@ -532,8 +579,10 @@ export function createDshPostExecuteObserver( } catch (error) { dependencies.onError?.(error, exec); } - // Response observations are audit-only and never replace or block the result. - return next(); + // Only AgentGuard's own shell output can be locally rewritten; other + // observations remain audit-only and downstream blocks are never relaxed. + const downstream = await next(); + return rewriteDshAgentGuardResult(exec, result, downstream, dependencies); }; } @@ -554,7 +603,9 @@ export function createDshPostExecuteProtector( return next(); } const downstream = await next(); - if (!observed || observed.evaluation.decision.decision !== 'block') return downstream; + if (!observed || observed.evaluation.decision.decision !== 'block') { + return rewriteDshAgentGuardResult(exec, result, downstream, dependencies); + } return mergeDshPostDecisions( translateDshPostDecision(observed.evaluation.decision), downstream diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 05c3570..e2d9498 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -1,4 +1,3 @@ -import { randomBytes } from 'node:crypto'; import { cwd } from 'node:process'; import { dirname, join } from 'node:path'; import { closeSync, openSync, readSync } from 'node:fs'; @@ -9,6 +8,7 @@ import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js'; import { evaluateRuntimeAction } from './decision.js'; import { isAgentGuardCliCommand, isAgentGuardStatusCommand, isStandaloneAgentGuardCliCommand } from './self-command.js'; import { redactText, summarizeSensitiveData } from './redaction.js'; +import { redactAgentGuardSelfToolOutput } from './self-output.js'; import { hasSensitivePublicSearchResult, isNativePublicSearchTool, isVisiblePublicSearchResult, inspectWithPublicSearchProvenance, @@ -253,28 +253,6 @@ function codexSelfToolFeedback(value: unknown, fallback: string): string { ? `Exit code: ${exitCode}\n${display}` : display; } -function redactAgentGuardSelfToolOutput(response: unknown, config: AgentGuardConfig): string { - const text = typeof response === 'string' ? response : JSON.stringify(response); - const activationUrl = config.agentJwt && config.agentRegisterUrl; - if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) { - return redactText(text); - } - try { - const url = new URL(activationUrl); - if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { - return redactText(text); - } - // The exact URL saved after registration is user-facing and must remain - // clickable. A fresh per-call marker keeps every other credential subject - // to normal redaction, even elsewhere in the same result. - const marker = `AGENTGUARDLINK${randomBytes(16).toString('hex')}END`; - const protectedText = text.replace(/https?:\/\/[^\s\"'`<>\\]+/gi, (candidate) => - candidate === activationUrl ? marker : candidate); - return redactText(protectedText).replaceAll(marker, activationUrl); - } catch { - return redactText(text); - } -} function isAgentGuardRuntimeAction(action: RuntimeAction): boolean { return action.actionType === 'shell' && isAgentGuardCliCommand(action.input); diff --git a/src/runtime/self-output.ts b/src/runtime/self-output.ts new file mode 100644 index 0000000..11c3013 --- /dev/null +++ b/src/runtime/self-output.ts @@ -0,0 +1,27 @@ +import { randomBytes } from 'node:crypto'; +import type { AgentGuardConfig } from '../config.js'; +import { redactText } from './redaction.js'; + +/** Locally sanitize AgentGuard-owned results without masking the saved JWT binding link. */ +export function redactAgentGuardSelfToolOutput(response: unknown, config: AgentGuardConfig): string { + const text = typeof response === 'string' ? response : JSON.stringify(response); + const activationUrl = config.agentJwt && config.agentRegisterUrl; + if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) { + return redactText(text); + } + try { + const url = new URL(activationUrl); + if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { + return redactText(text); + } + // The exact URL saved after registration is user-facing and must remain + // clickable. A fresh per-call marker keeps every other credential subject + // to normal redaction, even elsewhere in the same result. + const marker = `AGENTGUARDLINK${randomBytes(16).toString('hex')}END`; + const protectedText = text.replace(/https?:\/\/[^\s\"'`<>\\]+/gi, (candidate) => + candidate === activationUrl ? marker : candidate); + return redactText(protectedText).replaceAll(marker, activationUrl); + } catch { + return redactText(text); + } +} diff --git a/src/tests/dsh-runtime.test.ts b/src/tests/dsh-runtime.test.ts index 41017a7..fac7bde 100644 --- a/src/tests/dsh-runtime.test.ts +++ b/src/tests/dsh-runtime.test.ts @@ -140,9 +140,7 @@ describe('DSH runtime Phase 2A observer', () => { }; const before = await protectDshToolCall(statusExec, dependencies); - assert.ok(before); - assert.equal(before.evaluation.decision.decision, 'allow'); - assert.equal(before.evaluation.decision.riskScore, 0); + assert.equal(before, null, 'self commands bypass the DSH policy evaluator'); const after = await protectDshToolResult(statusExec, { isError: false, @@ -151,6 +149,50 @@ describe('DSH runtime Phase 2A observer', () => { assert.equal(after, null); }); + it('rewrites AgentGuard CLI results without blocking DSH shell tools', async () => { + const url = 'https://agentguard.example/activate?token=owned-dsh-link-123456'; + const ownConfig: AgentGuardConfig = { ...config, agentJwt: 'agent.jwt.test', agentRegisterUrl: url }; + let evaluations = 0; + const dependencies = { + loadAgentGuardConfig: () => ownConfig, + evaluate: async () => { evaluations += 1; return { decision: decision('block'), policySource: 'default' as const }; }, + writeAudit() {}, + }; + const exec = execution({ arguments: { command: 'agentguard connect' } }); + const result = { isError: false, content: [{ type: 'text', text: `${url}\napi_key=sk-dsh-secret-1234567890` }] }; + assert.equal(await protectDshToolCall(exec, dependencies), null); + const accept = async () => ({ kind: 'accept' as const }); + for (const hook of [createDshPostExecuteObserver(dependencies), createDshPostExecuteProtector(dependencies)]) { + const decisionResult = await hook(exec, result, accept); + assert.equal(decisionResult.kind, 'accept'); + if (decisionResult.kind === 'accept') { + assert.ok(decisionResult.content?.[0]?.text?.toString().includes(url)); + assert.match(String(decisionResult.content?.[0]?.text), /api_key=\[REDACTED\]/); + } + } + const structured = await createDshPostExecuteProtector(dependencies)(exec, result, + async () => ({ kind: 'accept', value: { output: `${url}\napi_key=sk-dsh-value-secret-1234567890` }, + additionalContexts: [{ role: 'user', content: 'downstream-context' }] })); + assert.equal(structured.kind, 'accept'); + if (structured.kind === 'accept') { + assert.equal(structured.content, undefined); + assert.ok(JSON.stringify(structured.value).includes(url)); + assert.doesNotMatch(JSON.stringify(structured.value), /dsh-value-secret/); + assert.equal(structured.additionalContexts?.length, 1); + } + assert.equal(evaluations, 0); + const compound = execution({ arguments: { command: 'agentguard status; echo secret' } }); + assert.ok(await protectDshToolCall(compound, dependencies), 'compound calls remain protected'); + const downstreamBlock = await createDshPostExecuteProtector(dependencies)(exec, result, + async () => ({ kind: 'block' as const, feedback: [{ type: 'text', text: 'other policy' }] })); + assert.equal(downstreamBlock.kind, 'block', 'AgentGuard does not undo another hook’s block'); + const failedRedaction = await createDshPostExecuteProtector({ + ...dependencies, loadAgentGuardConfig: () => { throw new Error('unavailable'); }, + })(exec, result, accept); + assert.deepEqual(failedRedaction, { kind: 'accept', content: [{ type: 'text', + text: 'AgentGuard output could not be safely redacted; run the command in your terminal.' }] }); + }); + it('attributes exact configured tool owners and preserves verified DSH call context', () => { const attribution = normalizeDshRuntimeAttribution({ toolOwners: { bash: '@deepseek-ai/dsh-tool-bash' }, diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 8103468..54502f5 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -1,6 +1,6 @@ import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { evaluateHook } from '../adapters/engine.js'; @@ -178,13 +178,13 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { }); function createMockApi() { - const handlers: Record Promise> = {}; + const handlers: Record Promise | unknown> = {}; const api = { id: 'test-plugin', name: 'Test Plugin', source: '/tmp/test-plugin/index.ts', on(event: string, ...args: unknown[]) { - handlers[event] = args[args.length - 1] as (...args: unknown[]) => Promise; + handlers[event] = args[args.length - 1] as (...args: unknown[]) => Promise | unknown; }, }; return { api, handlers }; @@ -205,6 +205,7 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { 'llm_output', 'model_call_ended', 'model_call_started', + 'tool_result_persist', ]); assert.equal(handlers['wrapStreamFn'], undefined); assert.equal(handlers['before_compaction'], undefined); @@ -339,6 +340,72 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { assert.equal(result, undefined, 'Ordinary OpenClaw exec command should be allowed'); }); + it('keeps QClaw-compatible plugin registration working without a persistence hook', async () => { + ctx = createTestContext(); + const { api, handlers } = createMockApi(); + const originalOn = api.on; + api.on = (event: string, ...args: unknown[]) => { + if (event === 'tool_result_persist') throw new Error('unsupported hook'); + originalOn(event, ...args); + }; + registerOpenClawPlugin(api as never, { + skipAutoScan: true, agentguardFactory: () => ctx.agentguard as never, + }); + assert.equal(handlers.tool_result_persist, undefined); + assert.equal(await handlers.before_tool_call({ toolName: 'exec', + params: { command: 'agentguard status' } }), undefined); + }); + + it('rewrites only correlated AgentGuard CLI results in OpenClaw-owned transcripts', async () => { + ctx = createTestContext(); + const home = mkdtempSync(join(tmpdir(), 'agentguard-openclaw-own-output-')); + temporaryRoots.push(home); + const oldHome = process.env.AGENTGUARD_HOME; + process.env.AGENTGUARD_HOME = home; + const url = 'https://agentguard.example/activate?token=owned-openclaw-link-123456'; + writeFileSync(join(home, 'config.json'), JSON.stringify({ version: 1, level: 'balanced' })); + try { + const { api, handlers } = createMockApi(); + registerOpenClawPlugin(api as never, { + skipAutoScan: true, + agentguardFactory: () => ctx.agentguard as never, + }); + await handlers.before_tool_call({ + toolName: 'exec', toolCallId: 'own-1', params: { command: 'agentguard connect' }, + }, { sessionKey: 'sess-one' }); + // `connect` saves the link after plugin registration. The sync result + // rewrite must load the current config rather than the startup snapshot. + writeFileSync(join(home, 'config.json'), JSON.stringify({ + version: 1, level: 'balanced', agentJwt: 'agent.jwt.test', agentRegisterUrl: url, + })); + const original = { role: 'toolResult', content: [ + { type: 'text', text: `${url}\napi_key=sk-own-secret-1234567890` }, + { type: 'image', data: 'opaque' }, + ] }; + const event = { toolName: 'exec', toolCallId: 'own-1', message: original }; + const rewrite = handlers.tool_result_persist(event, { sessionKey: 'sess-one' }) as { + message?: { content: Array<{ type: string; text?: string; data?: string }> }; + }; + assert.ok(rewrite?.message); + assert.ok(rewrite.message.content[0].text?.includes(url)); + assert.match(rewrite.message.content[0].text || '', /api_key=\[REDACTED\]/); + assert.doesNotMatch(JSON.stringify(rewrite), /own-secret/); + assert.deepEqual(rewrite.message.content[1], original.content[1]); + assert.equal(handlers.tool_result_persist(event, { sessionKey: 'sess-one' }), undefined); + await handlers.before_tool_call({ + toolName: 'exec', toolCallId: 'own-2', params: { command: 'agentguard status; cat ~/.env' }, + }, { sessionKey: 'sess-one' }); + assert.equal(handlers.tool_result_persist({ ...event, toolCallId: 'own-2' }, { sessionKey: 'sess-one' }), undefined); + await handlers.before_tool_call({ + toolName: 'exec', toolCallId: 'own-3', params: { command: 'agentguard status' }, + }, { sessionKey: 'sess-one' }); + assert.equal(handlers.tool_result_persist({ ...event, toolCallId: 'own-3' }, { sessionKey: 'other-session' }), undefined); + } finally { + if (oldHome === undefined) delete process.env.AGENTGUARD_HOME; + else process.env.AGENTGUARD_HOME = oldHome; + } + }); + it('should allow AgentGuard CLI commands from OpenClaw args/cmd payloads', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); From d6e344a47af1d843002182e9e253a1886977bbad Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 16 Oct 2026 23:35:01 +0800 Subject: [PATCH 4/6] fix: preserve AgentGuard self output across hosts --- docs/dsh-runtime.md | 2 +- docs/privacy-boundary.md | 15 +++--- plugins/hermes/plugin.py | 40 ++++++++++++++- plugins/hermes/tests/test_self_output.py | 5 +- src/adapters/openclaw-plugin.ts | 12 +++-- src/dsh/runtime.ts | 10 ++-- src/runtime/protect.ts | 23 +++++---- src/runtime/self-command.ts | 40 +++++++++++++-- src/runtime/self-output.ts | 42 ++++++++++++---- src/tests/claude-code-lifecycle.test.ts | 48 +++++++++++++----- src/tests/codex-hooks.test.ts | 64 +++++++++++++++--------- src/tests/dsh-runtime.test.ts | 4 ++ src/tests/integration.test.ts | 9 ++++ 13 files changed, 237 insertions(+), 77 deletions(-) diff --git a/docs/dsh-runtime.md b/docs/dsh-runtime.md index e79db73..8b58005 100644 --- a/docs/dsh-runtime.md +++ b/docs/dsh-runtime.md @@ -61,7 +61,7 @@ The reason passed into DSH contains only a bounded risk score, normalized policy ## Response containment boundary -Standalone AgentGuard CLI shell commands are exempt from AgentGuard's own pre-execute policy evaluation (except `agentguard approve`). When their post-execute result exposes text content, the runtime can return an `accept` decision with locally redacted text while preserving the exact saved pending Agent JWT activation URL. It never weakens a downstream `block` decision. This does not claim to rewrite arbitrary structured result values without rendered text. +Single AgentGuard CLI shell commands are exempt from AgentGuard's own pre-execute policy evaluation (except `agentguard approve`). Simple shell wrappers are recognized, while explicit executable paths must resolve to the installed AgentGuard CLI. When their post-execute result exposes text or a structured value, the runtime can return an `accept` decision with locally redacted sensitive fields while preserving the exact saved pending Agent JWT activation URL and public key examples. It never weakens a downstream `block` decision. Network results pass through `tools/post-execute`. AgentGuard extracts a bounded response preview plus available status, content type, headers, and byte count, then evaluates response and network-volume anomalies. diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 4e1c63b..ab6c6fb 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -18,22 +18,25 @@ uploaded to Cloud. ## AgentGuard-owned CLI results -A standalone `agentguard` shell invocation is not blocked by AgentGuard merely -because its own result contains sensitive text. Supported native result hooks -redact recognized secrets in host-specific result or persistence surfaces. The **exact** +A single `agentguard` shell invocation is not blocked by AgentGuard merely +because its own result contains sensitive text. Simple `command`, `env`, and +shell wrappers are recognized; an explicit executable path must resolve to this +package's CLI. Supported native result hooks redact recognized secrets in +host-specific result or persistence surfaces. The documented +`TYPESAFE_API_KEY=` example remains unchanged. The **exact** pending Agent JWT activation URL saved in the local AgentGuard configuration remains unchanged so the user can open it. Other tokenized URLs are still -redacted. Compound commands, alternate executable paths, and unrelated tools +redacted. Compound commands, unverified executable paths, and unrelated tools retain their ordinary protection; `agentguard approve` is not exempt from agent-self-approval checks. | Host | Own-command result handling | Limit | |---|---|---| | Codex | `PostToolUse` replaces sensitive results with redacted feedback. | Requires the native hook; no raw output replacement for unsupported lifecycle events. | -| Claude Code | `PostToolUse` rewrites supported result shapes; `PostToolBatch` excludes the own-command call from content scanning. | `PostToolUseFailure` has no output rewrite channel. | +| Claude Code | `PostToolUse` rewrites supported result shapes; `PostToolBatch` excludes an own-command result only after confirming that its model-visible content is already safe. | `PostToolUseFailure` has no output rewrite channel; a sensitive failure result in the batch stops continuation. | | OpenClaw / QClaw | `tool_result_persist` synchronously rewrites matched text in OpenClaw-owned session transcripts. | Requires host support and a matching `toolCallId`; cannot change results already displayed or Codex-native records. | | Hermes | Native `transform_tool_result` rewrites results before model context; `/agentguard` uses the same redactor. | Legacy shell hooks expose only an audit-only post event and cannot rewrite tool output. | -| DSH | `tools/post-execute` can return redacted text content with `kind: accept`. | Structured results without rendered text and results blocked by another policy are not rewritten by this hook. | +| DSH | `tools/post-execute` can return redacted text or structured values with `kind: accept`. | Results blocked by another policy are not rewritten by this hook. | ## Public web-search results diff --git a/plugins/hermes/plugin.py b/plugins/hermes/plugin.py index e96bb3d..59df859 100644 --- a/plugins/hermes/plugin.py +++ b/plugins/hermes/plugin.py @@ -138,14 +138,50 @@ def post_tool_call(tool_name: str, args: Optional[Dict[str, Any]] = None, **kwar return post_tool_call -def _is_standalone_agentguard_command(command: Any) -> bool: +def _is_standalone_agentguard_command(command: Any, depth: int = 0) -> bool: + if depth > 2: + return False if not isinstance(command, str) or any(c in command for c in ";&|<>`\n\r\t") or "$(" in command: return False try: tokens = shlex.split(command) except ValueError: return False - return bool(tokens) and tokens[0] == "agentguard" + if not tokens: + return False + index = 0 + while index < len(tokens) and _is_shell_assignment(tokens[index]): + index += 1 + if index < len(tokens) and os.path.basename(tokens[index]) == "env": + index += 1 + while index < len(tokens) and tokens[index].startswith("-"): + index += 1 + while index < len(tokens) and _is_shell_assignment(tokens[index]): + index += 1 + while index < len(tokens) and tokens[index] in ("command", "builtin"): + index += 1 + if index >= len(tokens): + return False + executable = tokens[index] + if executable == "agentguard": + return True + installed = os.environ.get("AGENTGUARD_BIN") or shutil.which("agentguard") + if (os.path.isabs(executable) and os.path.basename(executable) == "agentguard" + and installed and os.path.realpath(executable) == os.path.realpath(installed)): + return True + if os.path.basename(executable) in ("sh", "bash", "zsh", "dash", "fish"): + for flag_index in range(index + 1, len(tokens) - 1): + flag = tokens[flag_index] + if not flag.startswith("-"): + break + if "c" in flag: + return _is_standalone_agentguard_command(tokens[flag_index + 1], depth + 1) + return False + + +def _is_shell_assignment(token: str) -> bool: + name, separator, _value = token.partition("=") + return bool(separator) and name.isidentifier() def _make_transform_tool_result(guard: AgentGuardBridge): diff --git a/plugins/hermes/tests/test_self_output.py b/plugins/hermes/tests/test_self_output.py index 0ebcc97..79f8930 100644 --- a/plugins/hermes/tests/test_self_output.py +++ b/plugins/hermes/tests/test_self_output.py @@ -14,6 +14,7 @@ def test_transform_hook_scopes_standalone_commands_and_fails_safe(monkeypatch): transform = ctx.hooks["transform_tool_result"] own = '{"output":"api_key=sk-test-secret-1234567890"}' assert transform("terminal", {"command": "agentguard scan ./skill"}, result=own) == '{"output":"api_key=[REDACTED]"}' + assert transform("terminal", {"command": "command agentguard status"}, result=own) == '{"output":"api_key=[REDACTED]"}' assert transform("execute_code", {"code": "agentguard status"}, result=own) == '{"output":"api_key=[REDACTED]"}' assert transform("terminal", {"command": "agentguard status; cat .env"}, result=own) is None assert transform("terminal", {"command": "cat .env"}, result=own) is None @@ -45,10 +46,12 @@ def test_bridge_uses_shared_redactor_and_preserves_only_the_saved_link(monkeypat assert link in rewritten assert "hermes-secret" not in rewritten assert "api_key=[REDACTED]" in rewritten + absolute = ctx.hooks["transform_tool_result"]("terminal", {"command": f"{shim} status"}, result=output) + assert link in absolute and "hermes-secret" not in absolute assert ctx.hooks["transform_tool_result"]("terminal", {"command": "agentguard status"}, result=link) is None forged = guard.redact_self_output(output.replace(link, link + "-forged")) assert "hermes-self-link-123456-forged" not in forged assert "sk-hermes-secret" not in forged slash_status = ctx.commands["agentguard"][0]("status") assert link in slash_status - assert "TYPESAFE_API_KEY=[REDACTED]" in slash_status + assert "TYPESAFE_API_KEY=" in slash_status diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index d6594f4..373ec69 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -642,13 +642,19 @@ export function registerOpenClawPlugin( if (event.isSynthetic === true || typeof callId !== 'string') return undefined; const pending = pendingSelfResults.get(callId); if (!pending) return undefined; - pendingSelfResults.delete(callId); - if (pending.expiresAt < Date.now() || pending.toolName !== (event.toolName ?? ctx.toolName ?? toolMessage.toolName) - || (pending.sessionKey && pending.sessionKey !== ctx.sessionKey)) return undefined; + if (pending.expiresAt < Date.now()) { + pendingSelfResults.delete(callId); + return undefined; + } + const sessionKey = typeof ctx.sessionKey === 'string' ? ctx.sessionKey + : typeof event.sessionKey === 'string' ? event.sessionKey : undefined; + if (pending.toolName !== (event.toolName ?? ctx.toolName ?? toolMessage.toolName) + || (pending.sessionKey && sessionKey && pending.sessionKey !== sessionKey)) return undefined; const message = event.message; if (!message || typeof message !== 'object' || Array.isArray(message)) return undefined; const toolResult = message as Record; if (toolResult.role !== 'toolResult') return undefined; + pendingSelfResults.delete(callId); const current = toolResult.content; let changed = false; const sanitize = (text: string) => { diff --git a/src/dsh/runtime.ts b/src/dsh/runtime.ts index 7715824..6de6d40 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 { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; -import { redactAgentGuardSelfToolOutput } from '../runtime/self-output.js'; +import { redactAgentGuardSelfToolOutput, redactAgentGuardSelfToolValue } from '../runtime/self-output.js'; import { planDshEnforcement, type DshRuntimePhase } from './enforcement-plan.js'; import { mergeDshPostDecisions, @@ -256,11 +256,9 @@ function rewriteDshAgentGuardResult( const config = (dependencies.loadAgentGuardConfig ?? loadConfig)(); if (downstream.value !== undefined) { // DSH forbids replacing value and content in the same accept decision. - const original = JSON.stringify(downstream.value); - const safe = redactAgentGuardSelfToolOutput(downstream.value, config); - if (safe === original) return downstream; - try { return { ...downstream, value: JSON.parse(safe) as unknown }; } - catch { return { ...downstream, value: 'AgentGuard output could not be safely redacted.' }; } + const safe = redactAgentGuardSelfToolValue(downstream.value, config); + return JSON.stringify(safe) === JSON.stringify(downstream.value) + ? downstream : { ...downstream, value: safe }; } if (!content) return downstream; let changed = false; diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index e2d9498..99383bd 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -8,7 +8,7 @@ import { flushEventSpool, spoolEvent, writeAuditLog } from './audit.js'; import { evaluateRuntimeAction } from './decision.js'; import { isAgentGuardCliCommand, isAgentGuardStatusCommand, isStandaloneAgentGuardCliCommand } from './self-command.js'; import { redactText, summarizeSensitiveData } from './redaction.js'; -import { redactAgentGuardSelfToolOutput } from './self-output.js'; +import { redactAgentGuardSelfToolOutput, redactAgentGuardSelfToolValue } from './self-output.js'; import { hasSensitivePublicSearchResult, isNativePublicSearchTool, isVisiblePublicSearchResult, inspectWithPublicSearchProvenance, @@ -216,14 +216,10 @@ function nativeAgentGuardSelfOutput( if (claudeEvent === 'PostToolUseFailure') return null; // No output-rewrite contract for failure hooks. const response = raw?.tool_response ?? raw?.toolResponse; if (response === undefined) return null; - const safeText = redactAgentGuardSelfToolOutput(response, options.config); + const safeValue = redactAgentGuardSelfToolValue(response, options.config); + const safeText = typeof safeValue === 'string' ? safeValue : JSON.stringify(safeValue); const originalText = typeof response === 'string' ? response : JSON.stringify(response); if (safeText === originalText) return null; - - let safeValue: unknown = safeText; - if (typeof response !== 'string') { - try { safeValue = JSON.parse(safeText) as unknown; } catch { /* Safe text fallback. */ } - } const decision: RuntimeDecision = { actionId: 'act_agentguard_self_output', decision: 'allow', riskScore: 0, riskLevel: 'low', reasons: [], policyVersion: 'local-self-output', @@ -305,6 +301,12 @@ function isSafelyMaskedAgentGuardStatusOutput( function isAgentGuardApprovalCommand(action: RuntimeAction): boolean { if (action.actionType !== 'shell') return false; const normalized = action.input.replace(/\\(.)/gs, '$1').replace(/["']/g, ' '); + // Approval is the immediate subcommand. A scan path named "approve" is not + // an attempt to approve an action. + if (/(?:^|[^A-Za-z0-9_-])(?:[^\s;|&()]*[\\/])?agentguard[ \t]+approve(?:[ \t]|$|[;|&()])/i.test(normalized)) + return true; + if (isStandaloneAgentGuardCliCommand(action.input)) return false; + // Keep detecting indirect self-approval through aliases and shell expansion. return /(?:^|[^A-Za-z0-9_-])(?:[^\s;|&()]*[\\/])?agentguard(?:[^A-Za-z0-9_-]|$)/i.test(normalized) && /(?:^|[^A-Za-z0-9_-])approve(?:[^A-Za-z0-9_-]|$)/i.test(normalized); } @@ -1444,9 +1446,12 @@ function projectClaudeBatchForInspection( const responseKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') ? 'tool_response' : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; if (!responseKey) return call; - // Batch inspection never includes AgentGuard's own output or arguments; - // PostToolUse already provides the redacted model-visible replacement. const response = call[responseKey]; + const originalText = typeof response === 'string' ? response : JSON.stringify(response); + // PostToolBatch carries model-visible content. Suppress only content that + // is already safe. Failed or rejected PostToolUse rewrites remain visible + // to the batch privacy gate. + if (redactAgentGuardSelfToolOutput(response, config) !== originalText) return call; const projectedResponse = isPlainRecord(response) ? { exit_code: nonNegativeInteger(response.exit_code ?? response.exitCode), output: AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER } diff --git a/src/runtime/self-command.ts b/src/runtime/self-command.ts index fdc41a9..64bafbe 100644 --- a/src/runtime/self-command.ts +++ b/src/runtime/self-command.ts @@ -1,3 +1,6 @@ +import { realpathSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; + const SUPPORTED_AGENT_COMMANDS = [ 'agentguard', 'agentguard-mcp', @@ -20,12 +23,11 @@ export function isAgentGuardCliCommand(command: string): boolean { return isAgentGuardCliCommandInner(command, 0); } -/** Only the bare AgentGuard CLI can use the native self-output rewrite. - * Wrapped, alternate-path and compound shell calls remain on the normal guard. +/** Accept a single AgentGuard invocation, including simple shell wrappers. + * An explicit executable path must resolve to this package's CLI entrypoint. */ export function isStandaloneAgentGuardCliCommand(command: string): boolean { - const trimmed = command.trim(); - return /^agentguard(?:[ \t]|$)/.test(trimmed) && isAgentGuardCliCommand(trimmed); + return isStandaloneAgentGuardCliCommandInner(command, 0); } export function isAgentGuardStatusCommand(command: string): boolean { @@ -61,6 +63,36 @@ function isAgentGuardCliCommandInner(command: string, depth: number): boolean { return wrappedCommand ? isAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false; } +function isStandaloneAgentGuardCliCommandInner(command: string, depth: number): boolean { + if (depth > 2) return false; + const trimmed = command.trim(); + if (!trimmed || SHELL_CONTROL_RE.test(trimmed)) return false; + const tokens = shellTokens(trimmed); + if (!tokens.length) return false; + + let index = skipAssignments(tokens, 0); + if (basename(tokens[index]) === 'env') { + index += 1; + while (tokens[index]?.startsWith('-')) index += 1; + index = skipAssignments(tokens, index); + } + while (['command', 'builtin'].includes(basename(tokens[index] || ''))) index += 1; + if (isVerifiedAgentGuardExecutable(tokens[index])) return true; + + const wrappedCommand = shellWrapperCommand(tokens, index); + return wrappedCommand ? isStandaloneAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false; +} + +function isVerifiedAgentGuardExecutable(executable: string | undefined): boolean { + if (executable === 'agentguard') return true; + if (!executable || basename(executable) !== 'agentguard' || !isAbsolute(executable)) return false; + try { + return realpathSync(executable) === realpathSync(join(__dirname, '..', 'cli.js')); + } catch { + return false; + } +} + function matchesCommand(tokens: string[], start: number, command: string): boolean { const expected = command.split(/\s+/); if (start + expected.length > tokens.length) return false; diff --git a/src/runtime/self-output.ts b/src/runtime/self-output.ts index 11c3013..d038637 100644 --- a/src/runtime/self-output.ts +++ b/src/runtime/self-output.ts @@ -2,26 +2,50 @@ import { randomBytes } from 'node:crypto'; import type { AgentGuardConfig } from '../config.js'; import { redactText } from './redaction.js'; +const PUBLIC_KEY_EXAMPLE = 'TYPESAFE_API_KEY='; +const SECRET_FIELD = /^(?:token|api[_-]?key|secret|password|passwd|authorization|access[_-]?key|client[_-]?secret|signature|sig|agentJwt)$/i; + /** Locally sanitize AgentGuard-owned results without masking the saved JWT binding link. */ export function redactAgentGuardSelfToolOutput(response: unknown, config: AgentGuardConfig): string { - const text = typeof response === 'string' ? response : JSON.stringify(response); - const activationUrl = config.agentJwt && config.agentRegisterUrl; - if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) { - return redactText(text); + const safe = redactAgentGuardSelfToolValue(response, config); + return typeof safe === 'string' ? safe : JSON.stringify(safe); +} + +/** Preserve structured tool results so native hosts can accept the replacement. */ +export function redactAgentGuardSelfToolValue(response: unknown, config: AgentGuardConfig): unknown { + if (typeof response === 'string') return redactSelfText(response, config); + if (Array.isArray(response)) return response.map((item) => redactAgentGuardSelfToolValue(item, config)); + if (response && typeof response === 'object') { + return Object.fromEntries(Object.entries(response).map(([key, value]) => [ + key, + SECRET_FIELD.test(key) && typeof value === 'string' && value !== '' + ? '[REDACTED]' + : redactAgentGuardSelfToolValue(value, config), + ])); } + return response; +} + +function redactSelfText(text: string, config: AgentGuardConfig): string { + const activationUrl = config.agentJwt && config.agentRegisterUrl; + const marker = `AGENTGUARDLINK${randomBytes(16).toString('hex')}END`; + const exampleMarker = `AGENTGUARDEXAMPLE${randomBytes(16).toString('hex')}END`; + const withExample = text.replaceAll(PUBLIC_KEY_EXAMPLE, exampleMarker); + if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) + return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); try { const url = new URL(activationUrl); if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { - return redactText(text); + return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); } // The exact URL saved after registration is user-facing and must remain // clickable. A fresh per-call marker keeps every other credential subject // to normal redaction, even elsewhere in the same result. - const marker = `AGENTGUARDLINK${randomBytes(16).toString('hex')}END`; - const protectedText = text.replace(/https?:\/\/[^\s\"'`<>\\]+/gi, (candidate) => + const protectedText = withExample.replace(/https?:\/\/[^\s\"'`<>\\]+/gi, (candidate) => candidate === activationUrl ? marker : candidate); - return redactText(protectedText).replaceAll(marker, activationUrl); + return redactText(protectedText).replaceAll(marker, activationUrl) + .replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); } catch { - return redactText(text); + return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); } } diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index c80e15c..fbd2858 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -207,7 +207,8 @@ describe('Claude Code native lifecycle hooks', () => { tool_calls: [{ ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 } }], }, }); - assert.equal(forgedBatch, null, 'self-only batches do not block for output content'); + assert.equal(forgedBatch?.decision.decision, 'block', + 'the batch must catch a result that was not rewritten before model use'); const forgedPost = await runHook(fixture.config, { hook_event_name: 'PostToolUse', session_id: 'sess-forged-post', cwd: fixture.root, ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 }, @@ -216,13 +217,8 @@ describe('Claude Code native lifecycle hooks', () => { assert.doesNotMatch(forgedPost.text, /pending-claude-link-123456-forged/); }); - it('does not exempt wrapped, prefixed, or alternate-path status commands', async () => { + it('rewrites simple wrappers but does not trust unrelated executable paths', async () => { const commands = [ - "sh -c 'agentguard status'", - 'env MODE=test agentguard status', - 'MODE=test agentguard status', - 'command agentguard status', - 'builtin agentguard status', '/tmp/agentguard status', 'agentguard-mcp status', ]; @@ -244,6 +240,18 @@ describe('Claude Code native lifecycle hooks', () => { assert.ok(result, command); assert.notEqual(result.event.metadata?.agentGuardStatusOutputSuppressed, true, command); } + for (const command of ["sh -c 'agentguard status'", 'env MODE=test agentguard status', + 'MODE=test agentguard status', 'command agentguard status']) { + const fixture = lifecycleFixture(); + const rewritten = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-wrapped-status', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command }, + tool_response: { stdout: 'api_key=sk-wrapped-secret-1234567890', exit_code: 0 }, + }); + assert.equal(rewritten.result.decision.decision, 'allow', command); + assert.match(rewritten.text, /updatedToolOutput/, command); + assert.doesNotMatch(rewritten.text, /wrapped-secret/, command); + } }); it('rewrites an unexpectedly unmasked secret in agentguard status output', async () => { @@ -257,6 +265,14 @@ describe('Claude Code native lifecycle hooks', () => { assert.match(leaked.text, /updatedToolOutput/); assert.match(leaked.text, /api_key=\[REDACTED\]/); assert.doesNotMatch(leaked.text, /status-leak/); + + const structured = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-status-structured', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard status' }, + tool_response: { stdout: 'healthy', token: 'sk-structured-secret-1234567890', count: 4 }, + }); + assert.deepEqual(structured.output.hookSpecificOutput.updatedToolOutput, + { stdout: 'healthy', token: '[REDACTED]', count: 4 }); }); it('suppresses only agentguard status output while preserving PostToolBatch protection', async () => { @@ -357,14 +373,12 @@ describe('Claude Code native lifecycle hooks', () => { }], }, }); - assert.equal(unsafeStatus, null, 'AgentGuard-only tool batches must never block'); + assert.equal(unsafeStatus?.decision.decision, 'block', + 'an unsafe model-visible result cannot be hidden from the batch gate'); for (const [index, command] of [ - "sh -c 'agentguard status'", - 'env MODE=test agentguard status', - 'command agentguard status', - 'builtin agentguard status', '/tmp/agentguard status', + 'agentguard status; echo extra', ].entries()) { const nonExempt = await protectAction({ config: fixture.config, @@ -380,6 +394,16 @@ describe('Claude Code native lifecycle hooks', () => { assert.ok(nonExempt, command); assert.equal(nonExempt.event.metadata?.claudeBatchAgentGuardStatusOutputsSuppressed, undefined, command); } + + const failedOwnCall = await protectAction({ + config: fixture.config, agentHost: 'claude-code', auditSafe: true, + rawInput: { + hook_event_name: 'PostToolBatch', session_id: 'sess-failed-own-call', cwd: fixture.root, + tool_calls: [{ ...statusCall, + tool_response: 'Exit code 1\napi_key=sk-failed-own-secret-1234567890' }], + }, + }); + assert.equal(failedOwnCall?.decision.decision, 'block'); }); it('blocks a sensitive PostToolBatch before the next model call with bounded counts', async () => { diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index fbab2a4..09a2ec1 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -1,10 +1,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { installAgentTemplates } from '../installers.js'; +import { isStandaloneAgentGuardCliCommand } from '../runtime/self-command.js'; interface HookRun { status: number | null; @@ -13,6 +14,16 @@ interface HookRun { } describe('installed Codex native hooks', () => { + it('recognizes the installed CLI symlink but rejects unrelated absolute executables', () => { + const root = mkdtempSync(join(tmpdir(), 'agentguard-self-command-')); + const installed = join(root, 'agentguard'); + symlinkSync(join(__dirname, '..', 'cli.js'), installed); + assert.equal(isStandaloneAgentGuardCliCommand(`${installed} status`), true); + assert.equal(isStandaloneAgentGuardCliCommand(`command ${installed} status`), true); + assert.equal(isStandaloneAgentGuardCliCommand('/tmp/agentguard status'), false); + assert.equal(isStandaloneAgentGuardCliCommand(`${installed} status; echo extra`), false); + }); + it('blocks sensitive user prompts before the model request without echoing secrets', () => { const fixture = installFixture(); const sensitivePrompts = [ @@ -191,7 +202,8 @@ describe('installed Codex native hooks', () => { assert.equal(JSON.parse(run.stdout).hookSpecificOutput.permissionDecision, 'deny', command); } - for (const command of ['agentguard status', "sh -c 'agentguard status'"]) { + for (const command of ['agentguard status', "sh -c 'agentguard status'", + 'agentguard scan ./approve', 'agentguard scan ./approve.json']) { const run = fixture.run('agentguard-pre-tool.sh', { hook_event_name: 'PreToolUse', session_id: 'sess_safe_self_command', @@ -304,10 +316,7 @@ describe('installed Codex native hooks', () => { }); assert.equal(run.status, 0); - const feedback = JSON.parse(run.stdout); - assert.equal(feedback.continue, false); - assert.match(feedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); - assert.doesNotMatch(feedback.stopReason, /TYPESAFE_API_KEY=/); + assert.equal(run.stdout.trim(), '', 'a public placeholder needs no output rewrite'); assert.equal(run.stderr.trim(), ''); assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); @@ -335,10 +344,7 @@ describe('installed Codex native hooks', () => { }); const allowed = fixture.run('agentguard-post-tool.sh', payload(status.stdout)); assert.equal(allowed.status, 0); - const allowedFeedback = JSON.parse(allowed.stdout); - assert.equal(allowedFeedback.continue, false); - assert.ok(allowedFeedback.stopReason.includes(activationUrl)); - assert.match(allowedFeedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); + assert.equal(allowed.stdout.trim(), '', 'the saved link and public hint stay in the original result'); assert.equal(allowed.stderr.trim(), ''); assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); @@ -359,7 +365,7 @@ describe('installed Codex native hooks', () => { } }); - it('keeps real init and privacy status working with a sanitized key hint', () => { + it('keeps real init and privacy status working with the public key example', () => { const fixture = installFixture(); const post = (cmd: string, output: string) => fixture.run('agentguard-post-tool.sh', { hook_event_name: 'PostToolUse', session_id: 'sess-self-hints', turn_id: 'turn-self-hints', @@ -369,10 +375,7 @@ describe('installed Codex native hooks', () => { const init = fixture.runCli('init', '--agent', 'codex'); assert.equal(init.status, 0); assert.match(init.stdout, /TYPESAFE_API_KEY=/); - const initFeedback = JSON.parse(post('agentguard init --agent codex', init.stdout).stdout); - assert.equal(initFeedback.continue, false); - assert.match(initFeedback.stopReason, /AgentGuard initialized/); - assert.match(initFeedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); + assert.equal(post('agentguard init --agent codex', init.stdout).stdout.trim(), ''); writeFileSync(join(fixture.home, 'config.json'), JSON.stringify({ version: 1, level: 'balanced', agentHost: 'codex', privacy: { mode: 'jev' }, @@ -383,9 +386,7 @@ describe('installed Codex native hooks', () => { const privacy = fixture.runCli('privacy', 'status'); assert.equal(privacy.status, 0); assert.match(privacy.stdout, /TYPESAFE_API_KEY=/); - const privacyFeedback = JSON.parse(post('agentguard privacy status', privacy.stdout).stdout); - assert.equal(privacyFeedback.continue, false); - assert.match(privacyFeedback.stopReason, /TYPESAFE_API_KEY=\[REDACTED\]/); + assert.equal(post('agentguard privacy status', privacy.stdout).stdout.trim(), ''); assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); }); @@ -455,13 +456,8 @@ describe('installed Codex native hooks', () => { assert.doesNotMatch(`${run.stdout}\n${run.stderr}`, /status-leak/); }); - it('does not exempt wrapped, prefixed, or alternate-path status output', () => { + it('keeps unverified executable paths on the ordinary guard', () => { const commands = [ - "sh -c 'agentguard status'", - 'env MODE=test agentguard status', - 'MODE=test agentguard status', - 'command agentguard status', - 'builtin agentguard status', '/tmp/agentguard status', 'agentguard-mcp status', ]; @@ -488,6 +484,26 @@ describe('installed Codex native hooks', () => { } }); + it('redacts simple wrappers without blocking their AgentGuard output', () => { + const fixture = installFixture(); + for (const [index, command] of ["sh -c 'agentguard status'", + 'env MODE=test agentguard status', 'MODE=test agentguard status', + 'command agentguard status'].entries()) { + const run = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: `sess_wrapped_${index}`, + turn_id: `turn_wrapped_${index}`, cwd: fixture.project, + tool_name: 'Bash', tool_use_id: `tool_wrapped_${index}`, + tool_input: { command }, + tool_response: { output: 'api_key=sk-wrapped-secret-1234567890', exit_code: 0 }, + }); + assert.equal(run.status, 0, command); + const feedback = JSON.parse(run.stdout); + assert.equal(feedback.continue, false, command); + assert.match(feedback.stopReason, /api_key=\[REDACTED\]/, command); + assert.doesNotMatch(run.stdout, /wrapped-secret/, command); + } + }); + it('redacts any bare AgentGuard CLI output but retains compound command blocking', () => { const commands = ['agentguard scan', 'agentguard status --json', 'agentguard status; printf unsafe']; for (const [index, command] of commands.entries()) { diff --git a/src/tests/dsh-runtime.test.ts b/src/tests/dsh-runtime.test.ts index fac7bde..149c162 100644 --- a/src/tests/dsh-runtime.test.ts +++ b/src/tests/dsh-runtime.test.ts @@ -180,6 +180,10 @@ describe('DSH runtime Phase 2A observer', () => { assert.doesNotMatch(JSON.stringify(structured.value), /dsh-value-secret/); assert.equal(structured.additionalContexts?.length, 1); } + const keyedValue = await createDshPostExecuteProtector(dependencies)(exec, result, + async () => ({ kind: 'accept' as const, + value: { token: 'sk-dsh-keyed-secret-1234567890', count: 4 } })); + assert.deepEqual(keyedValue, { kind: 'accept', value: { token: '[REDACTED]', count: 4 } }); assert.equal(evaluations, 0); const compound = execution({ arguments: { command: 'agentguard status; echo secret' } }); assert.ok(await protectDshToolCall(compound, dependencies), 'compound calls remain protected'); diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 54502f5..c59a8fb 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -400,6 +400,15 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { toolName: 'exec', toolCallId: 'own-3', params: { command: 'agentguard status' }, }, { sessionKey: 'sess-one' }); assert.equal(handlers.tool_result_persist({ ...event, toolCallId: 'own-3' }, { sessionKey: 'other-session' }), undefined); + const afterMismatch = handlers.tool_result_persist({ ...event, toolCallId: 'own-3' }, + { sessionKey: 'sess-one' }) as { message?: { content: Array<{ text?: string }> } }; + assert.match(afterMismatch.message?.content[0]?.text || '', /api_key=\[REDACTED\]/); + await handlers.before_tool_call({ + toolName: 'exec', toolCallId: 'own-4', params: { command: 'command agentguard status' }, + }, { sessionKey: 'sess-one' }); + const missingContext = handlers.tool_result_persist({ ...event, toolCallId: 'own-4' }, + {}) as { message?: { content: Array<{ text?: string }> } }; + assert.match(missingContext.message?.content[0]?.text || '', /api_key=\[REDACTED\]/); } finally { if (oldHome === undefined) delete process.env.AGENTGUARD_HOME; else process.env.AGENTGUARD_HOME = oldHome; From f9952e26750f871ff6bfa55684aa93b4976be9f1 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 16 Oct 2026 23:40:08 +0800 Subject: [PATCH 5/6] ci: run Hermes regression tests --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba14a7c..8ffe322 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,17 @@ jobs: - name: Test run: npm test + + - name: Use Python for Hermes tests + if: matrix.node-version == 22 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Hermes test dependencies + if: matrix.node-version == 22 + run: python -m pip install pytest==8.4.2 + + - name: Test Hermes plugin + if: matrix.node-version == 22 + run: python -m pytest plugins/hermes/tests -q From ed5997e0097e80e32cb290374a15a6712ae10100 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Sat, 17 Oct 2026 00:06:18 +0800 Subject: [PATCH 6/6] fix: fail closed on unsafe native AgentGuard output --- docs/privacy-boundary.md | 14 ++- src/runtime/protect.ts | 143 ++++++++++++++++++++++-- src/runtime/self-output.ts | 48 +++++++- src/tests/claude-code-lifecycle.test.ts | 88 ++++++++++++++- src/tests/codex-hooks.test.ts | 8 ++ 5 files changed, 277 insertions(+), 24 deletions(-) diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index ab6c6fb..4742147 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -18,17 +18,19 @@ uploaded to Cloud. ## AgentGuard-owned CLI results -A single `agentguard` shell invocation is not blocked by AgentGuard merely -because its own result contains sensitive text. Simple `command`, `env`, and +A single `agentguard` shell invocation with a supported result shape is locally +redacted when its result contains recognized sensitive text. Unknown or malformed +result shapes are blocked. Simple `command`, `env`, and shell wrappers are recognized; an explicit executable path must resolve to this package's CLI. Supported native result hooks redact recognized secrets in host-specific result or persistence surfaces. The documented `TYPESAFE_API_KEY=` example remains unchanged. The **exact** pending Agent JWT activation URL saved in the local AgentGuard configuration -remains unchanged so the user can open it. Other tokenized URLs are still -redacted. Compound commands, unverified executable paths, and unrelated tools -retain their ordinary protection; `agentguard approve` is not exempt from -agent-self-approval checks. +remains unchanged when it appears as an exact value, including a structured +`agentRegisterUrl` field or a link followed by sentence punctuation. Encoded +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. | Host | Own-command result handling | Limit | |---|---|---| diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 99383bd..862d695 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -213,12 +213,28 @@ function nativeAgentGuardSelfOutput( // 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 (claudeEvent === 'PostToolUseFailure') return null; // No output-rewrite contract for failure hooks. + if (claudeEvent === 'PostToolUseFailure') { + const error = firstString(raw?.error, raw?.error_message); + return hasUnmaskedSelfCredential(error) || hasEncodedSensitiveSelfText(error, options.config) + || redactText(error) !== error + ? blockedAgentGuardSelfOutput(action) : null; + } const response = raw?.tool_response ?? raw?.toolResponse; if (response === undefined) return null; - const safeValue = redactAgentGuardSelfToolValue(response, options.config); - const safeText = typeof safeValue === 'string' ? safeValue : JSON.stringify(safeValue); - const originalText = typeof response === 'string' ? response : JSON.stringify(response); + if (!isSupportedNativeSelfOutput(response, options.config) + || hasEncodedSensitiveSelfText(response, options.config)) return blockedAgentGuardSelfOutput(action); + let safeValue: unknown; + let safeText: string; + let originalText: string; + try { + safeValue = redactAgentGuardSelfToolValue(response, options.config); + safeText = typeof safeValue === 'string' ? safeValue : JSON.stringify(safeValue); + originalText = typeof response === 'string' ? response : JSON.stringify(response); + if (redactAgentGuardSelfToolOutput(safeValue, options.config) !== safeText + || hasUnmaskedSelfCredential(safeValue)) return blockedAgentGuardSelfOutput(action); + } catch { + return blockedAgentGuardSelfOutput(action); + } if (safeText === originalText) return null; const decision: RuntimeDecision = { actionId: 'act_agentguard_self_output', decision: 'allow', riskScore: 0, @@ -236,6 +252,82 @@ function nativeAgentGuardSelfOutput( }; } +function blockedAgentGuardSelfOutput(action: RuntimeAction): ProtectResult { + const decision: RuntimeDecision = { + actionId: 'act_agentguard_self_output_unsafe', decision: 'block', riskScore: 100, + riskLevel: 'critical', policyVersion: 'local-self-output', + reasons: [{ code: 'SELF_OUTPUT_UNSAFE', severity: 'critical', + title: 'AgentGuard output could not be safely rewritten', + description: 'The result shape or remaining sensitive content was not safe for model use.' }], + }; + const { claudeUpdatedToolOutput: _unsafeOutput, ...metadata } = action.metadata || {}; + return { + decision, + event: { ...action, input: '[AgentGuard CLI output withheld locally]', + actionId: decision.actionId, decision: 'block', riskScore: 100, + riskLevel: 'critical', reasons: decision.reasons, policyVersion: decision.policyVersion, + metadata }, + policySource: 'default', + }; +} + +function isSupportedNativeSelfOutput(response: unknown, config: AgentGuardConfig): boolean { + if (typeof response === 'string') { + const trimmed = response.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return true; + try { return isSupportedNativeSelfOutput(JSON.parse(response) as unknown, config); } + catch { return false; } + } + if (!isPlainRecord(response) || Object.keys(response).length === 0) return false; + return Object.entries(response).every(([key, value]) => { + if (key === 'stdout' || key === 'output' || key === 'stderr' || key === 'content') + return typeof value === 'string' && isSupportedNativeSelfOutput(value, config); + if (key === 'exit_code' || key === 'exitCode') return Number.isSafeInteger(value); + if (key === 'interrupted' || key === 'isImage') return typeof value === 'boolean'; + return key === 'agentRegisterUrl' && Boolean(config.agentJwt) + && value === config.agentRegisterUrl; + }); +} + +function hasEncodedSensitiveSelfText(value: unknown, config: AgentGuardConfig): boolean { + if (typeof value === 'string') { + if (!/%[0-9a-f]{2}/i.test(value)) return false; + try { + let inspected = value; + const savedUrl = config.agentJwt && config.agentRegisterUrl; + if (savedUrl && savedUrl.length <= 2048) { + const url = new URL(savedUrl); + if ((url.protocol === 'https:' || url.protocol === 'http:') + && !url.username && !url.password) { + inspected = inspected.replaceAll(savedUrl, '[validated activation link]'); + } + } + const decoded = decodeURIComponent(inspected); + return decoded !== inspected && redactText(decoded) !== decoded; + } catch { + return true; + } + } + if (Array.isArray(value)) return value.some((item) => hasEncodedSensitiveSelfText(item, config)); + return isPlainRecord(value) && Object.values(value).some((item) => hasEncodedSensitiveSelfText(item, config)); +} + +function hasUnmaskedSelfCredential(value: unknown): boolean { + if (typeof value === 'string') { + return value.split(/\r?\n/).some((line) => { + const match = line.match(/^\s*(?:api[_ -]?key|authorization|token|secret|password|passwd|agent[_ -]?jwt)\s*[:=]\s*(.+)$/i); + if (!match) return false; + const detail = match[1]!.trim(); + if (/^(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/i.test(detail)) return true; + const first = detail.split(/\s+/)[0]!.replace(/[.,;!?]+$/, ''); + return !/^(?:configured|disabled|enabled|inactive|missing|not|none|present|redacted|unset|unavailable)$/i.test(first) + && /^[A-Za-z0-9._~+/-]{8,}$/.test(first); + }); + } + if (Array.isArray(value)) return value.some(hasUnmaskedSelfCredential); + return isPlainRecord(value) && Object.values(value).some(hasUnmaskedSelfCredential); +} + function codexSelfToolFeedback(value: unknown, fallback: string): string { if (!isPlainRecord(value)) return typeof value === 'string' ? value : fallback; const recognized = new Set(['stdout', 'output', 'stderr', 'exit_code', 'exitCode']); @@ -348,6 +440,7 @@ function enforceNativeHookDecision( && action.metadata.claudeBatchSensitive === false; const visibleContentNeedsProtection = action.metadata?.publicSearchSensitive === true || action.metadata?.claudeBatchSensitive === true + || action.metadata?.claudeBatchUnsafeAgentGuardOutput === true || (strictSensitive && !verifiedSearchException && !verifiedBatchException); const containsSensitiveContent = (scansVisibleContent || blockingVisibleModelInput) && (visibleContentNeedsProtection || action.metadata?.configSensitive === true); @@ -746,8 +839,11 @@ function formatClaudeHookResult(result: ProtectResult): string | null { return decision === 'warn' ? JSON.stringify({ systemMessage: reason }) : ''; } if (event === 'PostToolUse') { - if (Object.prototype.hasOwnProperty.call(result.event.metadata || {}, 'claudeUpdatedToolOutput')) { + const hasUpdatedOutput = Object.prototype.hasOwnProperty.call( + result.event.metadata || {}, 'claudeUpdatedToolOutput'); + if (hasUpdatedOutput) { return JSON.stringify({ + ...(denied ? { decision: 'block', reason } : {}), hookSpecificOutput: { hookEventName: 'PostToolUse', updatedToolOutput: result.event.metadata?.claudeUpdatedToolOutput, @@ -755,7 +851,8 @@ function formatClaudeHookResult(result: ProtectResult): string | null { }); } return denied || decision === 'warn' - ? JSON.stringify({ systemMessage: `${reason} Output schema was not rewritten; PostToolBatch remains the continuation gate.` }) + ? JSON.stringify({ ...(denied ? { decision: 'block', reason } : {}), + systemMessage: `${reason} Output schema was not rewritten; PostToolBatch remains the continuation gate.` }) : ''; } if (event === 'PostToolBatch') { @@ -988,6 +1085,9 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { claudeBatchOnlyAgentGuardStatus: claudeBatchProjection.suppressedCount === claudeBatchProjection.originalCalls.length, } : {}), + ...(claudeBatchProjection?.unsafeSelfOutputCount ? { + claudeBatchUnsafeAgentGuardOutput: true, + } : {}), ...(claudeBatch && process.env.TOOL_INPUT ? { claudeBatchSensitive: undefined, claudeBatchVerified: false } : {}), ...(claudeConfig ? { @@ -1434,9 +1534,12 @@ function claudeBatchFacts( function projectClaudeBatchForInspection( responses: unknown, config: AgentGuardConfig, -): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number } { - if (!Array.isArray(responses)) return { originalCalls: [], inspectionCalls: [], suppressedCount: 0 }; +): { originalCalls: unknown[]; inspectionCalls: unknown[]; suppressedCount: number; unsafeSelfOutputCount: number } { + if (!Array.isArray(responses)) return { + originalCalls: [], inspectionCalls: [], suppressedCount: 0, unsafeSelfOutputCount: 0, + }; let suppressedCount = 0; + let unsafeSelfOutputCount = 0; const inspectionCalls = responses.map((call) => { if (!isPlainRecord(call)) return call; const toolName = firstString(call.tool_name, call.toolName, call.name); @@ -1445,13 +1548,29 @@ function projectClaudeBatchForInspection( || !isStandaloneAgentGuardCliCommand(pickNativeToolAuditInput('shell', toolInput))) return call; const responseKey = Object.prototype.hasOwnProperty.call(call, 'tool_response') ? 'tool_response' : Object.prototype.hasOwnProperty.call(call, 'toolResponse') ? 'toolResponse' : null; - if (!responseKey) return call; + if (!responseKey) { + const error = firstString(call.error, call.error_message); + if (redactText(error) !== error || hasUnmaskedSelfCredential(error) + || hasEncodedSensitiveSelfText(error, config)) unsafeSelfOutputCount += 1; + return call; + } const response = call[responseKey]; - const originalText = typeof response === 'string' ? response : JSON.stringify(response); // PostToolBatch carries model-visible content. Suppress only content that // is already safe. Failed or rejected PostToolUse rewrites remain visible // to the batch privacy gate. - if (redactAgentGuardSelfToolOutput(response, config) !== originalText) return call; + try { + const originalText = typeof response === 'string' ? response : JSON.stringify(response); + if (!isSupportedNativeSelfOutput(response, config) + || hasEncodedSensitiveSelfText(response, config) + || redactAgentGuardSelfToolOutput(response, config) !== originalText + || hasUnmaskedSelfCredential(response)) { + unsafeSelfOutputCount += 1; + return call; + } + } catch { + unsafeSelfOutputCount += 1; + return call; + } const projectedResponse = isPlainRecord(response) ? { exit_code: nonNegativeInteger(response.exit_code ?? response.exitCode), output: AGENTGUARD_STATUS_OUTPUT_PLACEHOLDER } @@ -1465,7 +1584,7 @@ function projectClaudeBatchForInspection( suppressedCount += 1; return projected; }); - return { originalCalls: responses, inspectionCalls, suppressedCount }; + return { originalCalls: responses, inspectionCalls, suppressedCount, unsafeSelfOutputCount }; } function hasSensitiveClaudeBatchContent(responses: unknown): boolean { diff --git a/src/runtime/self-output.ts b/src/runtime/self-output.ts index d038637..34e6fb6 100644 --- a/src/runtime/self-output.ts +++ b/src/runtime/self-output.ts @@ -14,6 +14,10 @@ export function redactAgentGuardSelfToolOutput(response: unknown, config: AgentG /** Preserve structured tool results so native hosts can accept the replacement. */ export function redactAgentGuardSelfToolValue(response: unknown, config: AgentGuardConfig): unknown { if (typeof response === 'string') return redactSelfText(response, config); + if (typeof response === 'number') { + const text = String(response); + return redactText(text) === text ? response : '[REDACTED]'; + } if (Array.isArray(response)) return response.map((item) => redactAgentGuardSelfToolValue(item, config)); if (response && typeof response === 'object') { return Object.fromEntries(Object.entries(response).map(([key, value]) => [ @@ -27,9 +31,19 @@ export function redactAgentGuardSelfToolValue(response: unknown, config: AgentGu } function redactSelfText(text: string, config: AgentGuardConfig): string { + const trimmed = text.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(text) as unknown; + if (parsed && typeof parsed === 'object') { + const safe = redactAgentGuardSelfToolValue(parsed, config); + return JSON.stringify(safe) === JSON.stringify(parsed) ? text : JSON.stringify(safe); + } + } catch { /* Plain text still receives the bounded text redactor. */ } + } const activationUrl = config.agentJwt && config.agentRegisterUrl; - const marker = `AGENTGUARDLINK${randomBytes(16).toString('hex')}END`; - const exampleMarker = `AGENTGUARDEXAMPLE${randomBytes(16).toString('hex')}END`; + const marker = randomMarker('AGENTGUARDLINK'); + const exampleMarker = randomMarker('AGENTGUARDEXAMPLE'); const withExample = text.replaceAll(PUBLIC_KEY_EXAMPLE, exampleMarker); if (!activationUrl || activationUrl.length > 2048 || /[\x00-\x1f\x7f]/.test(activationUrl)) return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); @@ -41,11 +55,37 @@ function redactSelfText(text: string, config: AgentGuardConfig): string { // The exact URL saved after registration is user-facing and must remain // clickable. A fresh per-call marker keeps every other credential subject // to normal redaction, even elsewhere in the same result. - const protectedText = withExample.replace(/https?:\/\/[^\s\"'`<>\\]+/gi, (candidate) => - candidate === activationUrl ? marker : candidate); + const protectedText = protectExactActivationUrl(withExample, activationUrl, marker); return redactText(protectedText).replaceAll(marker, activationUrl) .replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); } catch { return redactText(withExample).replaceAll(exampleMarker, PUBLIC_KEY_EXAMPLE); } } + +/** Letters cannot accidentally match numeric PII rules during redaction. */ +function randomMarker(prefix: string): string { + const letters = Array.from(randomBytes(16), (byte) => + String.fromCharCode(65 + (byte >> 4), 65 + (byte & 15))).join(''); + return `${prefix}${letters}END`; +} + +/** Protect only a complete occurrence of the saved URL, not a forged prefix. */ +function protectExactActivationUrl(text: string, url: string, marker: string): string { + let result = ''; + let cursor = 0; + while (cursor < text.length) { + const start = text.indexOf(url, cursor); + if (start < 0) break; + const end = start + url.length; + const before = text[start - 1]; + const after = text[end]; + const startsAtBoundary = before === undefined || /[\s"'`(<\[{=:]/.test(before); + const endsAtBoundary = after === undefined || /[\s"'`<>\])}]/.test(after) + || /[.,;!?]/.test(after) + && (text[end + 1] === undefined || /[\s"'`<>\])}]/.test(text[end + 1]!)); + result += text.slice(cursor, start) + (startsAtBoundary && endsAtBoundary ? marker : url); + cursor = end; + } + return result + text.slice(cursor); +} diff --git a/src/tests/claude-code-lifecycle.test.ts b/src/tests/claude-code-lifecycle.test.ts index fbd2858..d303236 100644 --- a/src/tests/claude-code-lifecycle.test.ts +++ b/src/tests/claude-code-lifecycle.test.ts @@ -192,6 +192,34 @@ describe('Claude Code native lifecycle hooks', () => { }, }); assert.equal(post, null); + const punctuated = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-punctuated-link', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, + tool_response: { stdout: `Open (${activationUrl}).\napi_key=sk-other-secret-1234567890`, exit_code: 0 }, + }); + assert.equal(punctuated.result.decision.decision, 'allow'); + assert.match(punctuated.output.hookSpecificOutput.updatedToolOutput.stdout, /Open \(https:/); + assert.ok(punctuated.output.hookSpecificOutput.updatedToolOutput.stdout.includes(`(${activationUrl}).`)); + assert.doesNotMatch(punctuated.text, /other-secret/); + + const structuredLink = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-structured-link', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, + tool_response: { agentRegisterUrl: activationUrl, stderr: 'api_key=sk-field-secret-1234567890', exit_code: 0 }, + }); + assert.equal(structuredLink.result.decision.decision, 'allow'); + assert.equal(structuredLink.output.hookSpecificOutput.updatedToolOutput.agentRegisterUrl, activationUrl); + assert.doesNotMatch(structuredLink.text, /field-secret/); + + const encodedLink = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-encoded-link', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, + tool_response: { stdout: encodeURIComponent(activationUrl), exit_code: 0 }, + }); + assert.equal(encodedLink.result.decision.decision, 'block'); + assert.equal(encodedLink.output.hookSpecificOutput, undefined); + assert.doesNotMatch(encodedLink.text, /pending-claude-link/); + const batch = await protectAction({ config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { hook_event_name: 'PostToolBatch', session_id: 'sess-pending-batch', cwd: fixture.root, @@ -209,12 +237,40 @@ describe('Claude Code native lifecycle hooks', () => { }); assert.equal(forgedBatch?.decision.decision, 'block', 'the batch must catch a result that was not rewritten before model use'); + const unknownBatch = await protectAction({ + config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { + hook_event_name: 'PostToolBatch', session_id: 'sess-unknown-batch', cwd: fixture.root, + tool_calls: [{ ...statusCall, tool_response: { + stdout: 'healthy', card: 5284917302468138, exit_code: 0, + } }], + }, + }); + assert.equal(unknownBatch?.decision.decision, 'block'); + const failedBatch = await protectAction({ + config: fixture.config, agentHost: 'claude-code', auditSafe: true, rawInput: { + hook_event_name: 'PostToolBatch', session_id: 'sess-failed-batch', cwd: fixture.root, + tool_calls: [{ tool_name: 'Bash', tool_input: { command: 'agentguard status' }, + error: 'api_key=sk-failed-batch-secret-1234567890' }], + }, + }); + assert.equal(failedBatch?.decision.decision, 'block'); const forgedPost = await runHook(fixture.config, { hook_event_name: 'PostToolUse', session_id: 'sess-forged-post', cwd: fixture.root, ...statusCall, tool_response: { stdout: statusOutput.replace(activationUrl, `${activationUrl}-forged`), exit_code: 0 }, }); assert.equal(forgedPost.result.decision.decision, 'allow'); assert.doesNotMatch(forgedPost.text, /pending-claude-link-123456-forged/); + + const encodedSavedUrl = 'https://agentguard.example/activate?token=saved%2Fsegment-123456'; + fixture.config.agentRegisterUrl = encodedSavedUrl; + const exactEncodedQuery = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-encoded-query', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard connect' }, + tool_response: { stdout: `Open (${encodedSavedUrl}).\napi_key=sk-encoded-query-secret-1234567890`, exit_code: 0 }, + }); + assert.equal(exactEncodedQuery.result.decision.decision, 'allow'); + assert.ok(exactEncodedQuery.output.hookSpecificOutput.updatedToolOutput.stdout.includes(encodedSavedUrl)); + assert.doesNotMatch(exactEncodedQuery.text, /encoded-query-secret/); }); it('rewrites simple wrappers but does not trust unrelated executable paths', async () => { @@ -271,8 +327,36 @@ describe('Claude Code native lifecycle hooks', () => { tool_name: 'Bash', tool_input: { command: 'agentguard status' }, tool_response: { stdout: 'healthy', token: 'sk-structured-secret-1234567890', count: 4 }, }); - assert.deepEqual(structured.output.hookSpecificOutput.updatedToolOutput, - { stdout: 'healthy', token: '[REDACTED]', count: 4 }); + assert.equal(structured.result.decision.decision, 'block'); + assert.equal(structured.output.decision, 'block'); + assert.equal(structured.output.hookSpecificOutput, undefined); + assert.doesNotMatch(structured.text, /structured-secret/); + + const numeric = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-status-numeric', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard status' }, + tool_response: { stdout: 'api_key=sk-numeric-secret-1234567890', card: 5284917302468138, exit_code: 0 }, + }); + assert.equal(numeric.result.decision.decision, 'block'); + assert.equal(numeric.output.decision, 'block'); + assert.doesNotMatch(numeric.text, /numeric-secret|5284917302468138/); + + const malformed = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-status-malformed', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard status' }, + tool_response: '{"token":"sk-malformed-secret-1234567890"', + }); + assert.equal(malformed.result.decision.decision, 'block'); + assert.equal(malformed.output.decision, 'block'); + assert.doesNotMatch(malformed.text, /malformed-secret/); + + const nestedUnknown = await runHook(fixture.config, { + hook_event_name: 'PostToolUse', session_id: 'sess-status-nested', cwd: fixture.root, + tool_name: 'Bash', tool_input: { command: 'agentguard status' }, + tool_response: { stdout: '{"futureField":"sk-nested-secret-1234567890"}', exit_code: 0 }, + }); + assert.equal(nestedUnknown.result.decision.decision, 'block'); + assert.doesNotMatch(nestedUnknown.text, /nested-secret/); }); it('suppresses only agentguard status output while preserving PostToolBatch protection', async () => { diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 09a2ec1..6e01078 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -434,6 +434,14 @@ describe('installed Codex native hooks', () => { assert.doesNotMatch(failedFeedback.stopReason, /failed-secret/); assert.equal(existsSync(join(fixture.home, 'audit.jsonl')), false); assert.equal(JSON.parse(run('cat ./other-tool-output', output).stdout).decision, 'block'); + const unsupported = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', session_id: 'sess-self-unknown', turn_id: 'turn-self-unknown', + cwd: fixture.project, tool_name: 'functions.exec_command', tool_use_id: 'tool-self-unknown', + tool_input: { cmd: 'agentguard status' }, + tool_response: { output: 'healthy', card: 5284917302468138, exit_code: 0 }, + }); + assert.equal(JSON.parse(unsupported.stdout).decision, 'block'); + assert.doesNotMatch(`${unsupported.stdout}\n${unsupported.stderr}`, /5284917302468138/); }); it('redacts an unexpectedly unmasked secret without blocking agentguard status output', () => {