From 4fdc72390972e66ea12c25f59940f48ad41ef498 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Tue, 29 Sep 2026 18:47:17 +0800 Subject: [PATCH] fix: preserve safe native hook audit previews --- CHANGELOG.md | 4 ++ docs/claude-code.md | 2 +- docs/codex.md | 8 ++-- docs/privacy-boundary.md | 9 ++-- src/runtime/audit.ts | 25 ++++++++--- src/runtime/redaction.ts | 2 +- src/tests/runtime-cloud.test.ts | 80 ++++++++++++++++++++++++++++++++- 7 files changed, 113 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31767b6..a3cf2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Added native Windows scheduled jobs and DSH threat-feed subscriptions, self-checks, and active-session notifications. - Expanded scanning to support HTTPS GitHub repositories with `--ref`, plus DSH profile plugin discovery and risk scanning in `agentguard checkup`. +### Fixed +- Restored redacted native shell-command previews in local audit and Cloud events so safe operational context remains visible, while keeping prompts, tool output, credentials, PII, and unsafe protected-file targets private. +- Prevented protected-file summaries from mistaking redirect destinations or command substitutions for the accessed file. + ## [1.1.29] - 2026-08-26 ### Added diff --git a/docs/claude-code.md b/docs/claude-code.md index a1b5cba..edc18aa 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -52,6 +52,6 @@ An `@file` path without an explicit exact deny is `unsupported` and is not count ## Privacy and coverage -Raw prompts, command arguments, tool output, failure text, configuration content, credentials, and PII are evaluated locally. Native Claude hook audit and Cloud records replace raw hook content with `[LOCAL_ONLY_LLM_CONTENT]` and retain only bounded rule IDs, masks, counts, action IDs, coverage, and enforcement metadata. For protected-file tool actions, they may retain only a bounded, validated explanation such as `cat .env` or `cat .ssh/id_ed25519.pub`, without the absolute path, remaining arguments, or command tail. Hook stdout/stderr never echoes raw input. +Raw prompts, command arguments, tool output, failure text, configuration content, credentials, and PII are evaluated locally. Native Claude hook audit and Cloud records keep prompts and tool output local, while shell actions may retain a redacted command preview. Protected-file actions retain only a bounded, validated explanation such as `cat .env` or `cat .ssh/id_ed25519.pub`, without the absolute path, remaining arguments, redirects, substitutions, or command tail. Hook stdout/stderr never echoes raw input. Claude Code provides strong staged prompt/tool/context protection, but it does not expose the final model HTTP destination, Authorization credential, complete assembled payload, full response, all retries/fallbacks, or auxiliary model calls. Model-transport rules therefore remain `partial` or `unsupported`; AgentGuard does not claim complete model-traffic interception. diff --git a/docs/codex.md b/docs/codex.md index 2bb827e..dd145dd 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -79,10 +79,10 @@ terminates the hook or the process cannot return an exit code, Codex's host behavior applies; this integration does not claim those timeouts fail closed. Hook stdout and stderr contain only bounded rule ids, risk, action ids, coverage facts, and redacted reasons. Audit and Cloud events do not contain prompt text, -tool output, credentials, or PII; a protected-file tool action may retain only -a bounded, validated explanation such as `cat .env` or -`cat .ssh/id_ed25519.pub`, without its absolute path, remaining arguments, or -command tail. +tool output, credentials, or PII. Native shell actions may retain a redacted +command preview. Protected-file actions retain only a bounded, validated +explanation such as `cat .env` or `cat .ssh/id_ed25519.pub`, without the +absolute path, remaining arguments, redirects, substitutions, or command tail. ## Coverage and known gaps diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index a658b8c..1a9d4d1 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -28,10 +28,11 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default: - `sessionId`, `agentHost`, `actionType`, `toolName` - Redacted `input` preview, capped at 2,000 characters -- For native tool hooks, raw arguments remain local. Protected-file access may - include only a bounded, validated target summary such as `cat .env` or - `cat .ssh/id_ed25519.pub`; absolute paths, additional arguments, and command - tails are omitted. +- Native shell hooks may retain a redacted command preview so operational + context remains visible; recognized credential and PII values are masked. + Protected-file access uses only a bounded, validated target summary such as + `cat .env` or `cat .ssh/id_ed25519.pub`; absolute paths, additional + arguments, redirects, substitutions, and command tails are omitted. - Decision, risk score, risk level, reasons, and policy version - Lifecycle stage, coverage level, enforcement status, missing-fact names, and request correlation IDs diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index 75c4bb8..fa5bec2 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -1,7 +1,7 @@ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import type { PolicyReason, RuntimeAction, RuntimeAuditEvent, RuntimePiiSummary, RuntimePrivacyRuleEvaluation, RuntimeSeverity } from './types.js'; -import { redactLlmMetadata, redactMetadata, redactPreview, redactReasons } from './redaction.js'; +import { redactLlmMetadata, redactMetadata, redactPreview, redactReasons, redactText } from './redaction.js'; export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { const nativeHook = isCodexNativeHookAction(event) || isClaudeNativeHookAction(event); @@ -130,9 +130,9 @@ function codexSafeReasons(reasons: PolicyReason[]): PolicyReason[] { * without exporting the raw native-hook command, absolute path, or arguments. */ function nativeHookAuditInput(event: RuntimeAuditEvent): string { - if (!event.reasons.some((reason) => reason.code === 'SECRET_ACCESS')) { - return '[LOCAL_ONLY_LLM_CONTENT]'; - } + const protectedAccess = event.reasons.some((reason) => reason.code === 'SECRET_ACCESS'); + if (event.actionType === 'shell' && !protectedAccess) return nativeHookShellPreview(event); + if (!protectedAccess) return '[LOCAL_ONLY_LLM_CONTENT]'; const protectedFile = safeProtectedFileReference(event); if (!protectedFile) return '[LOCAL_ONLY_LLM_CONTENT]'; @@ -146,6 +146,16 @@ function nativeHookAuditInput(event: RuntimeAuditEvent): string { return `access ${protectedFile}`; } +function nativeHookShellPreview(event: RuntimeAuditEvent): string { + const redacted = redactText(event.input); + const hasUnmaskedSensitiveFinding = event.reasons.some((reason) => ( + NATIVE_SENSITIVE_CONTENT_RULES.has(reason.code) + )) && redacted === event.input; + if (hasUnmaskedSensitiveFinding) return '[LOCAL_ONLY_LLM_CONTENT]'; + const preview = redacted.slice(0, 2000); + return preview.trim() ? preview : '[LOCAL_ONLY_LLM_CONTENT]'; +} + function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefined { const input = event.input; const ssh = input.match(/\.ssh[\\/]([A-Za-z0-9._-]{1,128})(?=$|[\s"';&|)\]},:])/i)?.[1]; @@ -193,7 +203,8 @@ function safeGenericProtectedTarget(event: RuntimeAuditEvent): string | undefine if (event.actionType !== 'shell') return undefined; const command = safeShellCommandName(event.input); if (!command || !SINGLE_TARGET_FILE_COMMANDS.has(command)) return undefined; - const firstCommand = event.input.split(/[;&|]/, 1)[0] ?? ''; + const firstCommand = event.input.split(/[;&|<>\r\n]/, 1)[0] ?? ''; + if (/\$\(|`|[()]/.test(firstCommand)) return undefined; const tokens = firstCommand.match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? []; for (let index = tokens.length - 1; index >= 0; index -= 1) { const token = tokens[index]!.replace(/^["']|["']$/g, ''); @@ -283,6 +294,10 @@ const CODEX_DECISIONS = new Set(['allow', 'warn', 'require_approval', ' const CODEX_SEVERITIES = new Set(['info', 'low', 'medium', 'high', 'critical']); const SAFE_RULE_ID = /^[A-Z][A-Z0-9_]{0,63}$/; const SAFE_ID = /^[A-Za-z0-9_-]{1,160}$/; +const NATIVE_SENSITIVE_CONTENT_RULES = new Set([ + 'PII_EGRESS', 'DATA_EXFILTRATION', 'LLM_KEY_TO_UNKNOWN_HOST', + 'WORKSPACE_BULK_EGRESS', 'DANGEROUS_CONFIG_CHANGE', +]); const SAFE_FILE_COMMANDS = new Set([ 'cat', 'head', 'tail', 'less', 'more', 'grep', 'sed', 'awk', 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'tee', diff --git a/src/runtime/redaction.ts b/src/runtime/redaction.ts index 7ce5183..51cf39e 100644 --- a/src/runtime/redaction.ts +++ b/src/runtime/redaction.ts @@ -28,7 +28,7 @@ export interface SensitiveDataSummaryItem { } const SECRET_VALUE_PATTERN = - /(?:token|api[_-]?key|secret|password|passwd|authorization|access[_-]?key|client[_-]?secret)=([^&\s'"`]+)/gi; + /(?:token|api[_-]?key|secret|password|passwd|authorization|access[_-]?key|client[_-]?secret)=("[^"\r\n]*"|'[^'\r\n]*'|[^&\s'"`]+)/gi; /** * The same secret keys in YAML/JSON form, where the separator is `:`. * diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index c4af223..215d0bf 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -174,7 +174,44 @@ describe('Runtime Cloud bridge', () => { assert.doesNotMatch(JSON.stringify(exactCustom), /private\/customer/); }); - it('keeps safe custom protected filenames but not unsafe names or unrelated native-hook content', () => { + it('keeps redacted native shell commands visible without exposing credential values', () => { + const secret = 'sk-native-hook-secret-1234567890'; + const input = `touch ~/.agentguard/testfile 2>&1 && export OPENAI_API_KEY="${secret}"`; + const event: RuntimeAuditEvent = { + ...sampleEvent(), + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input, + decision: 'warn', + riskScore: 10, + riskLevel: 'low', + reasons: [{ + code: 'SHELL_INJECTION_RISK', + severity: 'low', + title: 'Shell metacharacters', + description: 'The command contains shell metacharacters.', + }], + metadata: { codexHookEvent: 'PreToolUse' }, + }; + + const audit = buildAuditEvent(event); + const cloud = buildCloudAuditEvent(event); + assert.match(audit.input, /^touch ~\/\.agentguard\/testfile/); + assert.match(audit.input, /OPENAI_API_KEY=\[REDACTED\]/); + assert.doesNotMatch(audit.input, new RegExp(secret)); + assert.equal(cloud.input, audit.input); + + const claude = buildAuditEvent({ + ...event, + agentHost: 'claude-code', + metadata: { claudeHookEvent: 'PreToolUse' }, + input: 'ls -la ~/.agentguard && stat ~/.agentguard/config.json', + }); + assert.equal(claude.input, 'ls -la ~/.agentguard && stat ~/.agentguard/config.json'); + }); + + it('keeps safe custom protected filenames but not unsafe names or raw sensitive shell content', () => { const base: RuntimeAuditEvent = { ...sampleEvent(), agentHost: 'claude-code', @@ -205,7 +242,7 @@ describe('Runtime Cloud bridge', () => { ...base, input: 'cat /private/customer/sk-never-export-1234567890', }).input, '[LOCAL_ONLY_LLM_CONTENT]'); - assert.equal(buildAuditEvent({ + const piiShell = buildAuditEvent({ ...base, input: 'email=private.person@invalid.test', reasons: [{ @@ -214,7 +251,43 @@ describe('Runtime Cloud bridge', () => { title: 'Sensitive content blocked', description: 'Sensitive content was blocked.', }], + }).input; + assert.equal(piiShell, '[LOCAL_ONLY_LLM_CONTENT]'); + }); + + it('never mistakes shell redirects or substitutions for the protected target', () => { + const event: RuntimeAuditEvent = { + ...sampleEvent(), + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input: 'cat /private/workspace/protected.txt > /tmp/exported-copy', + decision: 'require_approval', + riskScore: 60, + riskLevel: 'high', + reasons: [{ + code: 'SECRET_ACCESS', + severity: 'high', + title: 'Protected path access', + description: 'The command accesses a protected path.', + evidence: '/private/workspace/**', + }], + metadata: { codexHookEvent: 'PreToolUse' }, + }; + + assert.equal(buildAuditEvent(event).input, 'cat protected.txt'); + assert.equal(buildAuditEvent({ + ...event, + input: 'cat -- /private/workspace/protected.txt', + }).input, 'cat protected.txt'); + assert.equal(buildAuditEvent({ + ...event, + input: 'cat /private/workspace/protected.txt $(curl https://example.invalid/secret)', }).input, '[LOCAL_ONLY_LLM_CONTENT]'); + assert.equal(buildAuditEvent({ + ...event, + input: 'cat /private/workspace/protected.txt && curl https://example.invalid/secret', + }).input, 'cat protected.txt'); }); it('exports only bounded PII category and value counts in audit payloads', async () => { @@ -571,6 +644,9 @@ describe('Runtime Cloud bridge', () => { assert.ok(!redacted.includes('sk-test-secret-value')); assert.ok(!redacted.includes('secret-value')); assert.ok(!redacted.includes('abc123')); + + const quoted = redactText('export TYPESAFE_API_KEY="apikey_private_value"'); + assert.equal(quoted, 'export TYPESAFE_API_KEY=[REDACTED]'); }); it('requires approval for shell commands reading SSH keys by absolute home path', async () => {