diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d2f47e..4cf6a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Changed +- Shell metacharacter-only runtime findings now stay below the approval threshold: benign commands with redirects or simple shell metacharacters are scored as low risk, auto-allowed locally, and no longer generate audit events, Cloud sync, or pending approvals on that signal alone. +- Runtime approval prompts and AgentGuard skill guidance now require agents to show the exact `agentguard approve --action-id ... --once` command and wait for explicit user approval for that exact action before approving and retrying. + +### Fixed +- OpenClaw runtime protection now recognizes alternate tool name fields such as `tool_name`, `name`, and `id`, and classifies `exec`/`execute` tools as shell actions before policy evaluation. +- Repeated matching protected actions now reuse the existing pending approval id instead of creating duplicate pending approvals. +- AgentGuard approval/self commands wrapped through simple shell launchers such as `/bin/zsh -lc` are now treated as self-commands and skipped by runtime protection. + ## [1.1.25] - 2026-05-28 ### Added diff --git a/docs/codex.md b/docs/codex.md index 23876ee..8f9def8 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -44,7 +44,8 @@ approval and includes an approval command: agentguard approve --action-id act_local_... --once ``` -Run that command only after the user explicitly approves, then retry the -original action once. If the action id was not visible, inspect +Show that command to the user before running it. Run it only after the user +explicitly approves that exact action; do not let the agent approve its own +blocked command proactively. Then retry the original action once. If the action id was not visible, inspect `agentguard approvals list --json`; use `agentguard approve --last --once` only when there is exactly one relevant unexpired pending approval. diff --git a/docs/openclaw.md b/docs/openclaw.md index ca353a6..43a913a 100644 --- a/docs/openclaw.md +++ b/docs/openclaw.md @@ -56,8 +56,9 @@ approval. The block reason includes: agentguard approve --action-id act_local_... --once ``` -Run that command only after the user explicitly approves, then retry the -original action once. If the action id was not visible in the OpenClaw message, +Show that command to the user before running it. Run it only after the user +explicitly approves that exact action; do not let the agent approve its own +blocked command proactively. Then retry the original action once. If the action id was not visible in the OpenClaw message, inspect pending approvals first: ```bash diff --git a/skills/agentguard/SKILL.md b/skills/agentguard/SKILL.md index 67d7c43..defb3be 100644 --- a/skills/agentguard/SKILL.md +++ b/skills/agentguard/SKILL.md @@ -119,7 +119,7 @@ Connect behavior: If the user writes `/agentguard cli `, execute `agentguard ` directly. -When AgentGuard returns `confirm` or a block reason that includes `Approve once: agentguard approve --action-id ... --once`, do not retry the protected action until the user explicitly approves. Treat user replies such as "yes", "approve", "approved", "confirm", "confirmed", "continue", "go ahead", "execute", "run it", "同意", "确认", "批准", "继续", or "执行" as explicit approval for the most recent protected action. After approval, run exactly the provided `agentguard approve --action-id ... --once` command, then retry the original action once. If the action id is unavailable, use `agentguard approvals list --json`; only use `agentguard approve --last --once` when there is exactly one relevant unexpired pending approval. If multiple pending approvals exist, ask the user to choose a specific action id. +When AgentGuard returns `confirm` or a block reason that includes `Approve once ... agentguard approve --action-id ... --once`, do not retry the protected action until the user explicitly approves. Show the exact approval command to the user before running it. Never run an approval command proactively, and never infer approval from context or from the agent's own plan. Treat user replies such as "yes", "approve", "approved", "confirm", "confirmed", "continue", "go ahead", "execute", "run it", "同意", "确认", "批准", "继续", or "执行" as explicit approval for the most recent protected action only after the user has seen the command and understands which action is being approved. After approval, run exactly the provided `agentguard approve --action-id ... --once` command, then retry the original action once. If the action id is unavailable, use `agentguard approvals list --json`; only use `agentguard approve --last --once` when there is exactly one relevant unexpired pending approval. If multiple pending approvals exist, ask the user to choose a specific action id. Do **not** route plain `/agentguard scan`, `/agentguard action`, `/agentguard patrol`, `/agentguard trust`, `/agentguard report`, `/agentguard config`, `/agentguard checkup`, `/agentguard checkup --json`, or natural-language requests like "run agentguard checkup" through the packaged CLI. Those are this skill's higher-level workflows. Only use the packaged CLI checkup path when the user includes `--against-advisory ` or explicitly writes `/agentguard cli checkup ...`. diff --git a/src/action/detectors/exec.ts b/src/action/detectors/exec.ts index ba84bb3..d042c92 100644 --- a/src/action/detectors/exec.ts +++ b/src/action/detectors/exec.ts @@ -52,9 +52,11 @@ const AUDIT_COMMAND_PREFIXES = [ ]; /** - * Shell metacharacters that disqualify a command from the safe list + * Shell metacharacters that disqualify a command from the safe list. + * Runtime policy scores these as low-risk signals so benign commands with + * metacharacters do not enter an approval flow on this signal alone. */ -const SHELL_METACHAR_PATTERN = /[;|&`$(){}<>!#\n\t]/; +const SHELL_METACHAR_PATTERN = /\$\(|&&|\|\||>>|[|&;^!`%$<>"'*?\\\n\r\t]/; /** * Fork bomb patterns (regex-based for variants with spaces) @@ -275,27 +277,15 @@ export function analyzeExecCommand( } } - // Check for shell injection patterns - const shellInjectionPatterns = [ - /;\s*\w+/, // ; command - /\|\s*\w+/, // | command - /`[^`]+`/, // `command` - /\$\([^)]+\)/, // $(command) - /&&\s*\w+/, // && command - /\|\|\s*\w+/, // || command - ]; - - for (const pattern of shellInjectionPatterns) { - if (pattern.test(fullCommand)) { - riskTags.push('SHELL_INJECTION_RISK'); - evidence.push({ - type: 'shell_injection', - field: 'command', - description: 'Command contains shell metacharacters', - }); - if (riskLevel === 'low') riskLevel = 'medium'; - break; - } + // Check for shell metacharacters. These are advisory by themselves; combined + // with dangerous commands or sensitive access, the stronger finding controls. + if (SHELL_METACHAR_PATTERN.test(fullCommand)) { + riskTags.push('SHELL_INJECTION_RISK'); + evidence.push({ + type: 'shell_injection', + field: 'command', + description: 'Command contains shell metacharacters', + }); } // Check environment variables for secrets diff --git a/src/adapters/openclaw-plugin.ts b/src/adapters/openclaw-plugin.ts index 37ed975..f70ec72 100644 --- a/src/adapters/openclaw-plugin.ts +++ b/src/adapters/openclaw-plugin.ts @@ -429,8 +429,8 @@ export function registerOpenClawPlugin( api.on('before_tool_call', async (event: unknown, ctx?: unknown) => { try { // Try to infer plugin from tool name - const toolEvent = event as { toolName?: string }; - const pluginId = toolEvent.toolName ? getPluginIdFromTool(toolEvent.toolName) : null; + const toolName = readOpenClawToolName(event); + const pluginId = toolName ? getPluginIdFromTool(toolName) : null; // Check if plugin is untrusted if (pluginId) { @@ -444,14 +444,14 @@ export function registerOpenClawPlugin( } if (runtimeProtectionEnabled) { - const runtimeActionType = mapOpenClawToolToRuntimeAction(toolEvent.toolName, event); + const runtimeActionType = mapOpenClawToolToRuntimeAction(toolName, event); try { const runtimeResult = await runProtectAction({ config, rawInput: event, agentHost: 'openclaw', actionType: runtimeActionType, - toolName: toolEvent.toolName, + toolName, sessionId: readOpenClawSessionId(event, ctx), decisionMode: options.decisionMode ?? 'local-first', }); @@ -509,8 +509,8 @@ export function registerOpenClawPlugin( api.on('after_tool_call', async (event: unknown) => { try { const input = adapter.parseInput(event); - const toolEvent = event as { toolName?: string }; - const pluginId = toolEvent.toolName ? getPluginIdFromTool(toolEvent.toolName) : null; + const toolName = readOpenClawToolName(event); + const pluginId = toolName ? getPluginIdFromTool(toolName) : null; writeAuditLog(input, null, pluginId); } catch { // Non-critical @@ -544,6 +544,8 @@ function mapOpenClawToolToRuntimeAction( normalized === 'command' || normalized === 'terminal' || normalized === 'run' || + normalized.includes('exec') || + normalized.includes('execute') || normalized.includes('shell') || normalized.includes('terminal') || normalized.includes('command') || @@ -619,6 +621,12 @@ function mapOpenClawToolToRuntimeAction( return 'other'; } +function readOpenClawToolName(event: unknown): string | undefined { + const record = isRecord(event) ? event : undefined; + const value = record?.toolName ?? record?.tool_name ?? record?.name ?? record?.id; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + function readOpenClawParams(event: unknown): Record | undefined { const record = isRecord(event) ? event : undefined; const params = firstRecord( @@ -689,7 +697,7 @@ function runtimeResultToBeforeToolCallResult( ? ' OpenClaw cannot safely resume this call after an external approval, so AgentGuard blocked it locally.' : '') + (reasonSummary ? ` Reasons: ${reasonSummary}.` : '') + - (result.pendingApproval ? ` Approve once: agentguard approve --action-id ${result.pendingApproval.actionId} --once` : ''); + (result.pendingApproval ? ` ${approvalInstruction(result.pendingApproval.actionId)}` : ''); if (decision === 'require_approval') { return { block: true, blockReason: reason }; @@ -716,6 +724,13 @@ function normalizeRuntimePolicyDecision(decision: ProtectResult['decision']['dec return decision === 'require_approve' ? 'require_approval' : decision as ProtectResult['decision']['decision']; } +function approvalInstruction(actionId: string): string { + return ( + `Approve once (only after explicit user approval): agentguard approve --action-id ${actionId} --once.` + + ' Do not run this approval command yourself unless the user explicitly approves this exact action.' + ); +} + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } diff --git a/src/installers.ts b/src/installers.ts index e813ab3..d7be7c1 100644 --- a/src/installers.ts +++ b/src/installers.ts @@ -178,11 +178,13 @@ Expected decisions: - \`confirm\`: ask for approval in the agent channel before continuing - \`block\`: stop the action -When a response includes \`Approve once: agentguard approve --action-id ... --once\`, -ask the user before running that approval command. Treat replies such as +When a response includes \`Approve once ... agentguard approve --action-id ... --once\`, +show the exact approval command to the user and ask before running it. Do not +run an approval command proactively or infer approval from context. Treat replies such as "yes", "approve", "confirm", "continue", "go ahead", "execute", "run it", "同意", "确认", "批准", "继续", or "执行" as explicit approval for the most -recent protected action. After approval, run the exact +recent protected action only after the user has seen the command and understood +which action is being approved. After approval, run the exact \`agentguard approve --action-id ... --once\` command and retry the original action once. If the id is unavailable, inspect \`agentguard approvals list --json\`; use \`agentguard approve --last --once\` only when there is exactly one relevant diff --git a/src/runtime/approvals.ts b/src/runtime/approvals.ts index 31a5064..23d0222 100644 --- a/src/runtime/approvals.ts +++ b/src/runtime/approvals.ts @@ -55,12 +55,19 @@ export function writePendingApproval( now = new Date() ): ApprovalRecord { const store = readApprovalStore(storePath, now); + const fingerprint = actionFingerprint(action); + const existing = store.records.find((record) => + record.status === 'pending' && + record.actionFingerprint === fingerprint + ); + if (existing) return existing; + const expiresAt = new Date(now.getTime() + DEFAULT_PENDING_APPROVAL_TTL_MS).toISOString(); const record: ApprovalRecord = { actionId: decision.actionId, status: 'pending', once: true, - actionFingerprint: actionFingerprint(action), + actionFingerprint: fingerprint, sessionId: redactPreview(action.sessionId, 160), agentHost: action.agentHost, actionType: action.actionType, diff --git a/src/runtime/evaluator.ts b/src/runtime/evaluator.ts index 9b0317b..5968c60 100644 --- a/src/runtime/evaluator.ts +++ b/src/runtime/evaluator.ts @@ -52,7 +52,9 @@ export async function evaluateLocalAction( const reasons = redactReasons([...customReasons, ...ossReasons]); const riskScore = riskScoreFor(reasons, ossDecision?.risk_level || 'safe'); const riskLevel = riskLevelFor(riskScore); - const decision = decisionFor(policy, reasons, riskLevel, ossDecision?.decision); + const decision = shouldAutoAllowRuntimeDecision(riskScore, riskLevel) + ? 'allow' + : decisionFor(policy, reasons, riskLevel, ossDecision?.decision); return { actionId: `act_local_${Date.now()}_${process.pid}`, @@ -211,7 +213,7 @@ function normalizeOssReason(tag: string, evidence: ActionEvidence | undefined, a return reason('NETWORK_RISK', 'medium', 'Network action', 'The local OSS runtime detected network activity.', evidenceText); } if (tag === 'SHELL_INJECTION_RISK') { - return reason('SHELL_INJECTION_RISK', 'medium', 'Shell metacharacters', 'The local OSS runtime detected shell metacharacters.', evidenceText); + return reason('SHELL_INJECTION_RISK', 'low', 'Shell metacharacters', 'The local OSS runtime detected shell metacharacters.', evidenceText); } return reason(tag, 'medium', tag.replace(/_/g, ' ').toLowerCase(), 'The local OSS runtime detected a risky action.', evidenceText); } @@ -245,7 +247,7 @@ function riskScoreFor(reasons: PolicyReason[], ossRiskLevel: RuntimeRiskLevel): if (reasons.some((item) => item.severity === 'critical') || ossRiskLevel === 'critical') return 95; if (reasons.some((item) => item.severity === 'high') || ossRiskLevel === 'high') return 55; if (reasons.some((item) => item.severity === 'medium') || ossRiskLevel === 'medium') return 20; - if (reasons.length > 0 || ossRiskLevel === 'low') return reasons.length > 0 ? 5 : 0; + if (reasons.length > 0 || ossRiskLevel === 'low') return reasons.length > 0 ? 10 : 0; return 0; } @@ -257,6 +259,10 @@ function riskLevelFor(score: number): RuntimeRiskLevel { return 'safe'; } +function shouldAutoAllowRuntimeDecision(riskScore: number, riskLevel: RuntimeRiskLevel): boolean { + return riskScore < 20 || riskLevel === 'safe'; +} + function matchesPattern(input: string, pattern: string): boolean { if (!pattern) return false; if (input.includes(pattern)) return true; diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 8dbada8..e000e99 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -59,7 +59,7 @@ export async function protectAction(options: ProtectOptions): Promise`\n\r\t]|\$\(/; +const SHELL_EXECUTABLES = new Set(['sh', 'bash', 'zsh', 'dash', 'fish']); export function isAgentGuardCliCommand(command: string): boolean { + return isAgentGuardCliCommandInner(command, 0); +} + +function isAgentGuardCliCommandInner(command: string, depth: number): boolean { + if (depth > 2) return false; const trimmed = command.trim(); if (!trimmed || SHELL_CONTROL_RE.test(trimmed)) return false; @@ -33,7 +39,12 @@ export function isAgentGuardCliCommand(command: string): boolean { index += 1; } - return SUPPORTED_AGENT_COMMANDS.some((command) => matchesCommand(tokens, index, command)); + if (SUPPORTED_AGENT_COMMANDS.some((command) => matchesCommand(tokens, index, command))) { + return true; + } + + const wrappedCommand = shellWrapperCommand(tokens, index); + return wrappedCommand ? isAgentGuardCliCommandInner(wrappedCommand, depth + 1) : false; } function matchesCommand(tokens: string[], start: number, command: string): boolean { @@ -50,6 +61,18 @@ function skipAssignments(tokens: string[], start: number): number { return index; } +function shellWrapperCommand(tokens: string[], start: number): string | null { + if (!SHELL_EXECUTABLES.has(basename(tokens[start] || ''))) return null; + + for (let index = start + 1; index < tokens.length; index += 1) { + const token = tokens[index] || ''; + if (!token.startsWith('-') || token === '-') return null; + if (token.includes('c')) return tokens[index + 1] || null; + } + + return null; +} + function basename(value: string): string { return value.replace(/\\/g, '/').split('/').pop() || value; } diff --git a/src/tests/action.test.ts b/src/tests/action.test.ts index cdc3bc9..ba46153 100644 --- a/src/tests/action.test.ts +++ b/src/tests/action.test.ts @@ -46,6 +46,15 @@ describe('Exec Command Detector', () => { assert.ok(result.risk_tags.includes('SHELL_INJECTION_RISK') || result.risk_tags.includes('DANGEROUS_COMMAND')); }); + it('should treat shell metacharacters alone as low risk', () => { + for (const command of ['echo a>b', 'echo a&b', 'echo test!', 'echo a^b']) { + const result = analyzeExecCommand({ command }, true); + assert.equal(result.risk_level, 'low', command); + assert.ok(result.risk_tags.includes('SHELL_INJECTION_RISK'), command); + assert.ok(!result.should_block, command); + } + }); + it('should allow safe commands even when exec not allowed', () => { const result = analyzeExecCommand({ command: 'ls -la' }, false); assert.equal(result.risk_level, 'low'); diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts index 9cb600c..0bf9de9 100644 --- a/src/tests/integration.test.ts +++ b/src/tests/integration.test.ts @@ -348,6 +348,29 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { ]); }); + it('should classify alternate OpenClaw tool name fields before runtime protection', async () => { + ctx = createTestContext(); + const { api, handlers } = createMockApi(); + const calls: unknown[] = []; + registerOpenClawPlugin(api as never, { + skipAutoScan: true, + registry: ctx.agentguard.registry as never, + protectAction: async (options) => { + calls.push({ toolName: options.toolName, actionType: options.actionType }); + return null; + }, + }); + + await handlers['before_tool_call']({ + tool_name: 'execute_code', + params: { command: 'cat ~/.ssh/id_ed25519.pub' }, + }); + + assert.deepEqual(calls, [ + { toolName: 'execute_code', actionType: 'shell' }, + ]); + }); + it('should fail closed for security-sensitive OpenClaw actions when runtime protection fails', async () => { ctx = createTestContext(); const { api, handlers } = createMockApi(); @@ -588,6 +611,8 @@ describe('Integration: OpenClaw registerOpenClawPlugin', () => { assert.equal(result?.block, true, 'Should block before writing .env'); assert.ok(result?.blockReason?.includes('requires approval')); + assert.ok(result?.blockReason?.includes('explicit user approval')); + assert.ok(result?.blockReason?.includes('Do not run this approval command yourself')); }); it('should handle after_tool_call without error', async () => { diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 6f097f0..6d60902 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -383,6 +383,25 @@ describe('Runtime Cloud bridge', () => { assert.equal(multiline.decision, 'block'); }); + it('scores shell metacharacters below the approval threshold', async () => { + const policy = getDefaultEffectiveRuntimePolicy(); + + for (const command of ['echo a>b', 'echo a&b', 'echo test!', 'echo a^b']) { + const decision = await evaluateLocalAction(policy, { + sessionId: 'sess_metachar_score', + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input: command, + }); + + assert.equal(decision.decision, 'allow', command); + assert.equal(decision.riskScore, 10, command); + assert.equal(decision.riskLevel, 'low', command); + assert.ok(decision.reasons.some((reason) => reason.code === 'SHELL_INJECTION_RISK'), command); + } + }); + it('skips supported agent CLI commands before local audit or Cloud reporting', async () => { const originalFetch = globalThis.fetch; const dir = mkdtempSync(join(tmpdir(), 'agentguard-agent-cli-')); @@ -589,6 +608,59 @@ describe('Runtime Cloud bridge', () => { } }); + it('does not audit, sync, or request approval for low-risk metacharacter-only commands', async () => { + const originalFetch = globalThis.fetch; + const dir = mkdtempSync(join(tmpdir(), 'agentguard-metachar-low-')); + const policy = getDefaultEffectiveRuntimePolicy(); + const requests: string[] = []; + + globalThis.fetch = (async (input: Parameters[0]) => { + const url = String(input); + requests.push(url); + if (url.endsWith('/api/v1/policies/effective')) { + return jsonResponse({ success: true, data: policy }); + } + if (url.endsWith('/api/v1/events/ingest')) { + throw new Error('low-risk metacharacter decisions should not be synced'); + } + return jsonResponse({ success: false, error: { message: 'not found' } }, 404); + }) as typeof fetch; + + try { + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_test_key_123456', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + approvalStorePath: join(dir, 'approvals.json'), + }; + + for (const command of ['echo a>b', 'echo a&b', 'echo test!', 'echo a^b']) { + const result = await protectAction({ + config, + agentHost: 'codex', + stdinText: JSON.stringify({ + tool_name: 'Bash', + tool_input: { command }, + session_id: 'sess_metachar_low', + }), + }); + + assert.equal(result, null, command); + } + + assert.deepEqual(requests, Array(4).fill('https://agentguard.example/api/v1/policies/effective')); + assert.equal(existsSync(config.auditPath), false); + assert.equal(existsSync(config.eventSpoolPath), false); + assert.equal(existsSync(config.approvalStorePath!), false); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('does not intercept empty safe Cloud require_approval decisions', async () => { const originalFetch = globalThis.fetch; const dir = mkdtempSync(join(tmpdir(), 'agentguard-cloud-safe-noop-')); @@ -732,6 +804,36 @@ describe('Runtime Cloud bridge', () => { assert.equal(listPendingApprovals(config.approvalStorePath!).length, 0); }); + it('reuses pending approval ids for repeated matching actions', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-approval-dedupe-')); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + approvalStorePath: join(dir, 'approvals.json'), + }; + const firstStdinText = JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'cat ~/.ssh/id_rsa.pub' }, + session_id: 'sess_approval_dedupe_first', + }); + const retryStdinText = JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'cat ~/.ssh/id_rsa.pub' }, + session_id: 'sess_approval_dedupe_first', + }); + + const first = await protectAction({ config, agentHost: 'codex', stdinText: firstStdinText }); + const retry = await protectAction({ config, agentHost: 'codex', stdinText: retryStdinText }); + + assert.equal(first?.decision.decision, 'require_approval'); + assert.equal(retry?.decision.decision, 'require_approval'); + assert.equal(retry?.pendingApproval?.actionId, first?.pendingApproval?.actionId); + assert.equal(listPendingApprovals(config.approvalStorePath!).length, 1); + }); + it('approves one pending action once and consumes the grant on retry', async () => { const dir = mkdtempSync(join(tmpdir(), 'agentguard-approval-once-')); const config: AgentGuardConfig = { @@ -747,6 +849,11 @@ describe('Runtime Cloud bridge', () => { tool_input: { command: 'cat ~/.ssh/id_rsa.pub' }, session_id: 'sess_approval_once', }); + const retryStdinText = JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'cat ~/.ssh/id_rsa.pub' }, + session_id: 'sess_approval_once', + }); const blocked = await protectAction({ config, agentHost: 'codex', stdinText }); assert.equal(blocked?.decision.decision, 'require_approval'); @@ -757,16 +864,40 @@ describe('Runtime Cloud bridge', () => { }); assert.equal(approved.status, 'approved'); - const allowedRetry = await protectAction({ config, agentHost: 'codex', stdinText }); + const allowedRetry = await protectAction({ config, agentHost: 'codex', stdinText: retryStdinText }); assert.equal(allowedRetry?.decision.decision, 'allow'); assert.equal(allowedRetry?.event.decision, 'allow'); assert.equal(allowedRetry?.event.metadata?.approvedByLocalGrant, true); assert.match(readFileSync(config.auditPath, 'utf8'), /approvedByLocalGrant/); - const blockedAgain = await protectAction({ config, agentHost: 'codex', stdinText }); + const blockedAgain = await protectAction({ config, agentHost: 'codex', stdinText: retryStdinText }); assert.equal(blockedAgain?.decision.decision, 'require_approval'); }); + it('does not protect AgentGuard approval commands wrapped by a shell', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-approval-self-command-')); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + approvalStorePath: join(dir, 'approvals.json'), + }; + + const result = await protectAction({ + config, + agentHost: 'codex', + stdinText: JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: "/bin/zsh -lc 'agentguard approve --action-id act_local_1 --once'" }, + session_id: 'sess_approval_self_command', + }), + }); + + assert.equal(result, null); + }); + it('does not collapse internal whitespace when fingerprinting approved actions', () => { const base = { sessionId: 'sess_fingerprint', @@ -782,11 +913,44 @@ describe('Runtime Cloud bridge', () => { ); }); + it('scopes approval fingerprints to the runtime session', () => { + const base = { + agentHost: 'codex' as const, + actionType: 'shell' as const, + toolName: 'Bash', + input: 'cat ~/.ssh/id_rsa.pub', + cwd: '/workspace', + }; + + assert.notEqual( + actionFingerprint({ ...base, sessionId: 'sess_first' }), + actionFingerprint({ ...base, sessionId: 'sess_retry' }) + ); + }); + it('formats Claude Code agent approval as a PreToolUse ask response', () => { const result: ProtectResult = { policySource: 'cloud', approvalChannel: 'agent', event: { ...sampleEvent(), agentHost: 'claude-code' as const }, + pendingApproval: { + actionId: 'act_confirm', + status: 'pending', + once: true, + actionFingerprint: 'fingerprint', + sessionId: 'sess_test', + agentHost: 'claude-code', + actionType: 'shell', + toolName: 'Bash', + inputPreview: 'cat ~/.ssh/id_rsa.pub', + cwd: '/tmp/project', + reasonTitles: ['Protected path'], + riskScore: 70, + riskLevel: 'high', + policyVersion: 'runtime-test', + createdAt: new Date(0).toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, decision: { actionId: 'act_confirm', decision: 'require_approval' as const, @@ -807,6 +971,8 @@ describe('Runtime Cloud bridge', () => { const formatted = JSON.parse(formatProtectResult(result, false)); assert.equal(formatted.hookSpecificOutput.permissionDecision, 'ask'); assert.match(formatted.hookSpecificOutput.permissionDecisionReason, /Protected path/); + assert.match(formatted.hookSpecificOutput.permissionDecisionReason, /explicit user approval/); + assert.match(formatted.hookSpecificOutput.permissionDecisionReason, /Do not run this approval command yourself/); }); });