From 5abc89cd81983f1de7a045783319ebdc246513b5 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Wed, 30 Sep 2026 16:15:23 +0800 Subject: [PATCH 1/3] fix: selectively report post-tool events --- CHANGELOG.md | 5 ++ docs/claude-code.md | 4 ++ docs/codex.md | 6 +++ docs/privacy-boundary.md | 15 ++++-- src/runtime/audit.ts | 64 ++++++++++++++++++++----- src/runtime/protect.ts | 36 ++++++++++++++ src/tests/codex-hooks.test.ts | 33 +++++++++++++ src/tests/runtime-cloud.test.ts | 85 +++++++++++++++++++++++++++++++++ 8 files changed, 232 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d932499..6eca944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [Unreleased] + +### Changed +- Native `PreToolUse` actions remain the primary Cloud records, while routine successful `PostToolUse` observations now stay local and only failures, elevated risks, anomalous results, or material new findings are uploaded as separate redacted events. + ## [1.2.1] - 2026-09-29 ### Added diff --git a/docs/claude-code.md b/docs/claude-code.md index edc18aa..e3cd244 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -34,6 +34,8 @@ Hook execution is bounded to 30 seconds. Claude Code may continue the original a `PostToolUse` scans `tool_response`. AgentGuard uses `updatedToolOutput` only for verified response shapes: Read, Bash/PowerShell, WebFetch/WebSearch, and MCP content results. Redaction preserves the verified string/object/array shape. Unknown output schemas are not guessed or destructively rewritten; sensitive unknown output is marked `partial`/`would_block`, and `PostToolBatch` remains the continuation gate. +Routine successful `PostToolUse` observations remain in the local audit and are not uploaded. Failures, elevated risks, anomalous results, and other material new findings are uploaded as separate Cloud events containing only redacted summaries and bounded status metadata. + `PostToolUseFailure` records only redacted failure type/summary and coverage. It cannot replace or block a failed tool's output. Whether a particular Claude Code version includes failed results in a following `PostToolBatch.tool_calls[].tool_response` is `unverified` until tested on that version; if absent, the failure-output path is `unsupported`. `PostToolBatch` scans each official `tool_calls[].tool_response` result Claude is about to use, records bounded file-path, byte, and redaction counts, and can stop the agentic loop before the next model call. Tool reads and side effects have already occurred. Existing transcript content is not removed, and a resumed session is not guaranteed to suppress retransmission. @@ -54,4 +56,6 @@ An `@file` path without an explicit exact deny is `unsupported` and is not count 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. +`PreToolUse` is the primary Cloud action record. Raw stdout and stderr are never uploaded from either lifecycle stage; reportable Post events carry only the redacted risk summary and necessary status fields. + 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 dd145dd..607a7d1 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -54,6 +54,9 @@ from a repository subdirectory does not bypass them. result prevents the original result from continuing to the next model step. The tool has already run, so filesystem, network, and other side effects are not undone. + Routine successful observations remain local; only failures, elevated risks, + anomalous results, or other material new findings are uploaded as separate + Cloud events. - `PreCompact` and `PostCompact` record only the trigger and redacted policy metadata. AgentGuard does not open or parse `transcript_path`, and compact metadata is not sent to AgentGuard Cloud. @@ -83,6 +86,9 @@ 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. +Raw stdout and stderr are never uploaded. `PreToolUse` remains the primary +Cloud action record, while only material `PostToolUse` findings are uploaded as +separate events. ## Coverage and known gaps diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 1a9d4d1..1e22461 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -33,6 +33,13 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default: 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. +- Native `PreToolUse` events are the primary Cloud action records. Routine + successful `PostToolUse` observations stay in the local audit. A post-tool + event is uploaded separately only when it adds a failure, elevated risk, + anomalous result, PII summary, or another material security finding. +- Raw stdout and stderr are never uploaded from either lifecycle stage. + Reportable post-tool events contain only the redacted risk summary and + bounded status metadata. - Decision, risk score, risk level, reasons, and policy version - Lifecycle stage, coverage level, enforcement status, missing-fact names, and request correlation IDs @@ -75,10 +82,10 @@ Cloud endpoints also apply server-side redaction, but clients should not rely on ## Offline behavior If Cloud is unreachable, AgentGuard continues local enforcement and spools -Cloud-eligible redacted audit events for later retry. Routine model requests and -model responses are filtered again at the Cloud client boundary, including -when an older spool is drained. It must never fail open for local `block` -decisions. +Cloud-eligible redacted audit events for later retry. Routine post-tool +observations, routine model requests, and model responses are filtered again +at the Cloud client boundary, including when an older spool is drained. It +must never fail open for local `block` decisions. Local policies are normalized when loaded, so caches written before the LLM privacy fields existed inherit the bundled privacy defaults. Cloud availability diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index fa5bec2..4446ec0 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -48,19 +48,11 @@ export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { * and the request was not stopped at a blocking gate. */ export function shouldReportAuditEventToCloud(event: RuntimeAuditEvent): boolean { + if (isNativePostToolEvent(event)) return shouldReportNativePostToolEvent(event); if (event.actionType === 'llm_response') return false; if (event.actionType !== 'llm_request') return true; - const piiDetected = Boolean( - event.privacySummary - && Number.isSafeInteger(event.privacySummary.valueCount) - && event.privacySummary.valueCount > 0 - && event.privacySummary.categories.some((item) => ( - PII_CATEGORIES.has(item.category) - && Number.isSafeInteger(item.count) - && item.count > 0 - )) - ); + const piiDetected = hasPiiSummary(event.privacySummary); if (!piiDetected) return false; const stoppedBeforeEgress = event.canBlockCurrentAction !== false @@ -68,6 +60,52 @@ export function shouldReportAuditEventToCloud(event: RuntimeAuditEvent): boolean return !stoppedBeforeEgress; } +/** + * Routine post-tool observations duplicate the already reported pre-tool + * action and stay in the local audit. Upload a separate post-tool event only + * when the result adds a material security or execution signal. + */ +function shouldReportNativePostToolEvent(event: RuntimeAuditEvent): boolean { + if (event.metadata?.claudeHookEvent === 'PostToolUseFailure') return true; + if (event.metadata?.toolFailed === true) return true; + + const responseStatus = firstSafeInteger( + event.metadata?.responseStatusCode, + event.metadata?.statusCode, + ); + if (responseStatus !== undefined && responseStatus >= 400) return true; + + if (event.decision !== 'allow' || (event.policyDecision && event.policyDecision !== 'allow')) return true; + if (event.riskScore >= 20 || MATERIAL_POST_RISK_LEVELS.has(event.riskLevel)) return true; + if (event.enforcementStatus === 'would_block' || event.enforcementStatus === 'unsupported') return true; + if (hasPiiSummary(event.privacySummary)) return true; + + return event.reasons.some((reason) => ( + reason.code !== 'SHELL_INJECTION_RISK' || MATERIAL_POST_SEVERITIES.has(reason.severity) + )); +} + +function isNativePostToolEvent(event: RuntimeAuditEvent): boolean { + return event.metadata?.codexHookEvent === 'PostToolUse' + || event.metadata?.claudeHookEvent === 'PostToolUse' + || event.metadata?.claudeHookEvent === 'PostToolUseFailure'; +} + +function hasPiiSummary(summary: RuntimePiiSummary | undefined): boolean { + return Boolean(summary + && Number.isSafeInteger(summary.valueCount) + && summary.valueCount > 0 + && summary.categories.some((item) => ( + PII_CATEGORIES.has(item.category) + && Number.isSafeInteger(item.count) + && item.count > 0 + ))); +} + +function firstSafeInteger(...values: unknown[]): number | undefined { + return values.find((value): value is number => Number.isSafeInteger(value)); +} + export function isCodexNativeHookAction(action: Pick): boolean { return action.agentHost === 'codex' && CODEX_HOOK_EVENTS.has(action.metadata?.codexHookEvent); } @@ -95,7 +133,7 @@ export function nativeHookSafeMetadata(metadata: Record | undef } for (const key of [ 'responseStatusCode', 'statusCode', 'responseBodyBytes', 'responseBytes', 'contentLength', - 'filePathCount', 'serializedResultBytes', 'redactedBytes', 'contextTokens', + 'filePathCount', 'serializedResultBytes', 'redactedBytes', 'contextTokens', 'exitCode', ]) { const value = metadata[key]; if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) result[key] = value; @@ -107,7 +145,7 @@ export function nativeHookSafeMetadata(metadata: Record | undef if (metadata.approvalOnce === true) result.approvalOnce = true; for (const key of [ 'configDiskRollback', 'modelIdIsEndpoint', 'outputReplaceable', 'displayOnly', 'transcriptModified', - 'continuationBlockedOnly', 'resumeRetransmissionGuaranteed', + 'continuationBlockedOnly', 'resumeRetransmissionGuaranteed', 'toolFailed', ]) { if (typeof metadata[key] === 'boolean') result[key] = metadata[key]; } @@ -292,6 +330,8 @@ const MISSING_FACTS = new Set([ ]); const CODEX_DECISIONS = new Set(['allow', 'warn', 'require_approval', 'block']); const CODEX_SEVERITIES = new Set(['info', 'low', 'medium', 'high', 'critical']); +const MATERIAL_POST_RISK_LEVELS = new Set(['medium', 'high', 'critical']); +const MATERIAL_POST_SEVERITIES = new Set(['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([ diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index a86e1fd..ce4ac05 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -116,6 +116,8 @@ export async function protectAction(options: ProtectOptions): Promise | null, +): Record { + const postTool = codexHookEvent === 'PostToolUse' + || claudeHookEvent === 'PostToolUse' + || claudeHookEvent === 'PostToolUseFailure'; + if (!postTool || !raw) return {}; + + const response = firstRecord( + raw.tool_response, + raw.toolResponse, + raw.tool_output, + raw.toolOutput, + raw.response, + raw.result, + raw.output, + ); + const exitCode = nonNegativeInteger( + response?.exit_code ?? response?.exitCode ?? raw.exit_code ?? raw.exitCode, + ); + const failed = claudeHookEvent === 'PostToolUseFailure' + || (exitCode !== undefined && exitCode !== 0) + || response?.success === false + || response?.is_error === true + || response?.isError === true; + return { + ...(exitCode !== undefined ? { exitCode } : {}), + ...(failed ? { toolFailed: true } : {}), + }; +} + function definedMetadata(key: string, ...values: unknown[]): Record { for (const value of values) { if (value !== undefined) return { [key]: value }; diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index b89a924..5110449 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -257,6 +257,39 @@ describe('installed Codex native hooks', () => { assert.doesNotMatch(`${run.stdout}\n${run.stderr}`, new RegExp(secret)); const audit = readFileSync(join(fixture.home, 'audit.jsonl'), 'utf8'); assert.doesNotMatch(audit, new RegExp(privateOutput)); + const auditEvent = JSON.parse(audit.trim().split('\n').at(-1)!) as { + metadata?: { exitCode?: number; toolFailed?: boolean }; + }; + assert.equal(auditEvent.metadata?.exitCode, 0); + assert.equal(auditEvent.metadata?.toolFailed, undefined); + }); + + it('keeps routine post-tool output in the local audit without retaining stdout', () => { + const fixture = installFixture(); + const stdoutMarker = 'routine-private-stdout-marker'; + const run = fixture.run('agentguard-post-tool.sh', { + hook_event_name: 'PostToolUse', + session_id: 'sess_post_routine', + turn_id: 'turn_post_routine', + cwd: fixture.project, + tool_name: 'Bash', + tool_use_id: 'tool_post_routine', + tool_input: { command: 'printf hello' }, + tool_response: { output: stdoutMarker, exit_code: 0 }, + }); + + assert.equal(run.status, 0); + const audit = readFileSync(join(fixture.home, 'audit.jsonl'), 'utf8'); + assert.doesNotMatch(audit, new RegExp(stdoutMarker)); + const event = JSON.parse(audit.trim().split('\n').at(-1)!) as { + input?: string; + lifecycleStage?: string; + metadata?: { codexHookEvent?: string; exitCode?: number }; + }; + assert.equal(event.input, '[LOCAL_ONLY_LLM_CONTENT]'); + assert.equal(event.lifecycleStage, 'post_tool'); + assert.equal(event.metadata?.codexHookEvent, 'PostToolUse'); + assert.equal(event.metadata?.exitCode, 0); }); it('records compact metadata without reading transcript content', () => { diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 215d0bf..1346af2 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -416,6 +416,91 @@ describe('Runtime Cloud bridge', () => { } }); + it('reports pre-tool actions but keeps routine post-tool observations local', async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + const preTool: RuntimeAuditEvent = { + ...sampleEvent(), + actionId: 'act_pre_tool', + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input: 'printf hello', + decision: 'allow', + policyDecision: 'allow', + riskScore: 10, + riskLevel: 'low', + lifecycleStage: 'pre_tool', + reasons: [{ + code: 'SHELL_INJECTION_RISK', severity: 'low', title: 'Shell metacharacters', description: 'Low-risk shell syntax.', + }], + metadata: { codexHookEvent: 'PreToolUse' }, + }; + const routinePost: RuntimeAuditEvent = { + ...preTool, + actionId: 'act_post_routine', + actionType: 'other', + input: 'hello', + lifecycleStage: 'post_tool', + metadata: { codexHookEvent: 'PostToolUse', exitCode: 0 }, + }; + const failedPost: RuntimeAuditEvent = { + ...routinePost, + actionId: 'act_post_failed', + input: 'private failure output', + metadata: { codexHookEvent: 'PostToolUse', exitCode: 1, toolFailed: true }, + }; + const riskyPost: RuntimeAuditEvent = { + ...routinePost, + actionId: 'act_post_risky', + input: 'private tool output containing a secret', + decision: 'block', + policyDecision: 'block', + riskScore: 80, + riskLevel: 'high', + enforcementStatus: 'would_block', + reasons: [{ + code: 'DATA_EXFILTRATION', severity: 'high', title: 'Sensitive output', description: 'Sensitive output detected.', + }], + }; + const claudeFailure: RuntimeAuditEvent = { + ...routinePost, + actionId: 'act_claude_failure', + agentHost: 'claude-code', + metadata: { claudeHookEvent: 'PostToolUseFailure', toolFailed: true }, + }; + + assert.equal(shouldReportAuditEventToCloud(preTool), true); + assert.equal(shouldReportAuditEventToCloud(routinePost), false); + assert.equal(shouldReportAuditEventToCloud(failedPost), true); + assert.equal(shouldReportAuditEventToCloud(riskyPost), true); + assert.equal(shouldReportAuditEventToCloud(claudeFailure), true); + + globalThis.fetch = (async (_input: Parameters[0], init?: RequestInit) => { + requests.push(typeof init?.body === 'string' ? init.body : ''); + return jsonResponse({ success: true, data: { accepted: 4, rejected: 0 } }, 202); + }) as typeof fetch; + + try { + const client = new AgentGuardCloudClient({ + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_test_key_123456', + }); + await client.ingestEvents([preTool, routinePost, failedPost, riskyPost, claudeFailure]); + assert.equal(requests.length, 1); + const payload = JSON.parse(requests[0]!) as { events?: RuntimeAuditEvent[] }; + assert.deepEqual(payload.events?.map((event) => event.actionId), [ + 'act_pre_tool', 'act_post_failed', 'act_post_risky', 'act_claude_failure', + ]); + for (const post of payload.events?.filter((event) => event.lifecycleStage === 'post_tool') ?? []) { + assert.equal(post.input, '[LOCAL_ONLY_LLM_CONTENT]'); + } + assert.doesNotMatch(requests[0]!, /private failure output|private tool output/); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('keeps ordinary OpenClaw model observations local but uploads a PII egress summary', async () => { const originalFetch = globalThis.fetch; const dir = mkdtempSync(join(tmpdir(), 'agentguard-model-cloud-boundary-')); From 61acdaabdd2239ca2b5c88d4d3db9e1ad6286516 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Fri, 2 Oct 2026 16:19:29 +0800 Subject: [PATCH 2/3] fix: preserve safe web fetch previews --- CHANGELOG.md | 1 + docs/claude-code.md | 2 ++ docs/codex.md | 3 +++ docs/privacy-boundary.md | 3 +++ src/runtime/audit.ts | 40 +++++++++++++++++++++++++++++++++ src/tests/runtime-cloud.test.ts | 40 +++++++++++++++++++++++++++++++++ 6 files changed, 89 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eca944..20219fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Changed - Native `PreToolUse` actions remain the primary Cloud records, while routine successful `PostToolUse` observations now stay local and only failures, elevated risks, anomalous results, or material new findings are uploaded as separate redacted events. +- Native web-fetch audit events now retain a bounded sanitized destination instead of a generic placeholder, without uploading URL credentials, fragments, query values, or sensitive path segments. ## [1.2.1] - 2026-09-29 diff --git a/docs/claude-code.md b/docs/claude-code.md index e3cd244..a4d783f 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -58,4 +58,6 @@ Raw prompts, command arguments, tool output, failure text, configuration content `PreToolUse` is the primary Cloud action record. Raw stdout and stderr are never uploaded from either lifecycle stage; reportable Post events carry only the redacted risk summary and necessary status fields. +WebFetch Pre events may retain only a bounded destination preview. URL credentials, fragments, query values, and sensitive or opaque path segments are removed before Cloud sync. + 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 607a7d1..cf9b7d6 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -89,6 +89,9 @@ absolute path, remaining arguments, redirects, substitutions, or command tail. Raw stdout and stderr are never uploaded. `PreToolUse` remains the primary Cloud action record, while only material `PostToolUse` findings are uploaded as separate events. +Web-fetch Pre events may retain only a bounded destination preview; URL +credentials, fragments, query values, and sensitive or opaque path segments +are removed. ## Coverage and known gaps diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 1e22461..7307145 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -33,6 +33,9 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default: 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. +- Native web-fetch Pre events retain only a bounded `http`/`https` destination. + URL credentials, fragments, query values, sensitive path segments, and + opaque token-like path segments are removed; safe query names may remain. - Native `PreToolUse` events are the primary Cloud action records. Routine successful `PostToolUse` observations stay in the local audit. A post-tool event is uploaded separately only when it adds a failure, elevated risk, diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index 4446ec0..6ddceb2 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -170,6 +170,9 @@ function codexSafeReasons(reasons: PolicyReason[]): PolicyReason[] { function nativeHookAuditInput(event: RuntimeAuditEvent): string { const protectedAccess = event.reasons.some((reason) => reason.code === 'SECRET_ACCESS'); if (event.actionType === 'shell' && !protectedAccess) return nativeHookShellPreview(event); + if ((event.actionType === 'network' || event.actionType === 'browser') && !protectedAccess) { + return nativeHookNetworkPreview(event) ?? '[LOCAL_ONLY_LLM_CONTENT]'; + } if (!protectedAccess) return '[LOCAL_ONLY_LLM_CONTENT]'; const protectedFile = safeProtectedFileReference(event); @@ -194,6 +197,41 @@ function nativeHookShellPreview(event: RuntimeAuditEvent): string { return preview.trim() ? preview : '[LOCAL_ONLY_LLM_CONTENT]'; } +/** + * Preserve only the request destination. Credentials, fragments, query + * values, sensitive path segments, and opaque token-like segments stay local. + */ +function nativeHookNetworkPreview(event: RuntimeAuditEvent): string | undefined { + let url: URL; + try { + url = new URL(event.input.trim()); + } catch { + return undefined; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined; + + const path = url.pathname.split('/').map(safeUrlPathSegment).join('/'); + const queryKeys = [...new Set(url.searchParams.keys())] + .slice(0, 20) + .map((key) => SAFE_URL_QUERY_KEY.test(key) ? key : 'param'); + const query = queryKeys.length > 0 + ? `?${queryKeys.map((key) => `${key}=[REDACTED]`).join('&')}` + : ''; + return `${url.protocol}//${url.host}${path}${query}`.slice(0, 2000); +} + +function safeUrlPathSegment(segment: string): string { + if (!segment) return ''; + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return '[REDACTED]'; + } + if (decoded.length > 128 || OPAQUE_URL_PATH_SEGMENT.test(decoded)) return '[REDACTED]'; + return redactText(decoded) === decoded ? segment : '[REDACTED]'; +} + function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefined { const input = event.input; const ssh = input.match(/\.ssh[\\/]([A-Za-z0-9._-]{1,128})(?=$|[\s"';&|)\]},:])/i)?.[1]; @@ -334,6 +372,8 @@ const MATERIAL_POST_RISK_LEVELS = new Set(['medium', 'high', 'critical' const MATERIAL_POST_SEVERITIES = new Set(['medium', 'high', 'critical']); const SAFE_RULE_ID = /^[A-Z][A-Z0-9_]{0,63}$/; const SAFE_ID = /^[A-Za-z0-9_-]{1,160}$/; +const SAFE_URL_QUERY_KEY = /^[A-Za-z0-9_.~-]{1,64}$/; +const OPAQUE_URL_PATH_SEGMENT = /^(?=.{24,128}$)(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_-]+$/; const NATIVE_SENSITIVE_CONTENT_RULES = new Set([ 'PII_EGRESS', 'DATA_EXFILTRATION', 'LLM_KEY_TO_UNKNOWN_HOST', 'WORKSPACE_BULK_EGRESS', 'DANGEROUS_CONFIG_CHANGE', diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 1346af2..25b61c1 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -211,6 +211,46 @@ describe('Runtime Cloud bridge', () => { assert.equal(claude.input, 'ls -la ~/.agentguard && stat ~/.agentguard/config.json'); }); + it('keeps a bounded native web-fetch destination without URL credentials or values', () => { + const event: RuntimeAuditEvent = { + ...sampleEvent(), + agentHost: 'codex', + actionType: 'network', + toolName: 'web_fetch', + input: 'https://private-user:private-pass@example.com/models/sk-private-token-1234567890?api_key=private-key&page=2#private-fragment', + decision: 'warn', + policyDecision: 'warn', + riskScore: 20, + riskLevel: 'medium', + lifecycleStage: 'pre_tool', + reasons: [{ + code: 'NETWORK_OUTBOUND', + severity: 'medium', + title: 'Network outbound policy', + description: 'The action makes an outbound network request.', + }], + metadata: { codexHookEvent: 'PreToolUse', method: 'GET' }, + }; + + const codex = buildCloudAuditEvent(event); + assert.equal( + codex.input, + 'https://example.com/models/[REDACTED]?api_key=[REDACTED]&page=[REDACTED]', + ); + assert.doesNotMatch(JSON.stringify(codex), /private-user|private-pass|private-token|private-key|private-fragment/); + + const claude = buildCloudAuditEvent({ + ...event, + agentHost: 'claude-code', + input: 'https://docs.example.com/public/guide', + metadata: { claudeHookEvent: 'PreToolUse', method: 'GET' }, + }); + assert.equal(claude.input, 'https://docs.example.com/public/guide'); + + const nonUrl = buildCloudAuditEvent({ ...event, input: '{"url":"not validated"}' }); + assert.equal(nonUrl.input, '[LOCAL_ONLY_LLM_CONTENT]'); + }); + it('keeps safe custom protected filenames but not unsafe names or raw sensitive shell content', () => { const base: RuntimeAuditEvent = { ...sampleEvent(), From 49833967441ed626adfee4d8fb3fc85ac5089564 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Thu, 8 Oct 2026 11:17:57 +0800 Subject: [PATCH 3/3] fix: preserve safe native event summaries --- CHANGELOG.md | 1 + docs/claude-code.md | 2 + docs/codex.md | 2 + docs/privacy-boundary.md | 8 ++ src/runtime/audit.ts | 113 ++++++++++++++++++- src/runtime/protect.ts | 50 +++++++++ src/tests/runtime-cloud.test.ts | 190 ++++++++++++++++++++++++++++++++ 7 files changed, 363 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20219fe..4a920dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changed - Native `PreToolUse` actions remain the primary Cloud records, while routine successful `PostToolUse` observations now stay local and only failures, elevated risks, anomalous results, or material new findings are uploaded as separate redacted events. - Native web-fetch audit events now retain a bounded sanitized destination instead of a generic placeholder, without uploading URL credentials, fragments, query values, or sensitive path segments. +- Native file, configuration-change, model-switch, and policy-reason events now retain bounded allowlisted summaries instead of generic placeholders, without uploading paths, contents, patch bodies, endpoints, credentials, or raw reason text. ## [1.2.1] - 2026-09-29 diff --git a/docs/claude-code.md b/docs/claude-code.md index a4d783f..866c1f9 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -60,4 +60,6 @@ Raw prompts, command arguments, tool output, failure text, configuration content WebFetch Pre events may retain only a bounded destination preview. URL credentials, fragments, query values, and sensitive or opaque path segments are removed before Cloud sync. +File-tool and ConfigChange events may retain a validated basename or bounded file count; absolute paths and all file/configuration content stay local. Model-switch events may retain validated model labels, a bounded source label, and a non-negative context-token count. Endpoint-like and credential-like labels are replaced with `unknown`. + 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 cf9b7d6..a8cdbbd 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -92,6 +92,8 @@ separate events. Web-fetch Pre events may retain only a bounded destination preview; URL credentials, fragments, query values, and sensitive or opaque path segments are removed. +File-tool Pre events may retain a validated basename or bounded file count. +Absolute paths, file contents, edit bodies, and patch bodies remain local. ## Coverage and known gaps diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 7307145..66b221a 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -36,6 +36,14 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default: - Native web-fetch Pre events retain only a bounded `http`/`https` destination. URL credentials, fragments, query values, sensitive path segments, and opaque token-like path segments are removed; safe query names may remain. +- Native file reads and writes retain only a validated basename or bounded file + count. Absolute paths, file contents, edit bodies, and patch bodies stay + local. Configuration changes use the same boundary. +- Native model-switch events retain only validated model labels, a bounded + source label, and the non-negative context-token count. Endpoint-like or + credential-like labels are replaced with `unknown`. +- Native policy reasons use static titles selected by allowlisted rule ID; + descriptions, evidence, and remediation text remain redacted. - Native `PreToolUse` events are the primary Cloud action records. Routine successful `PostToolUse` observations stay in the local audit. A post-tool event is uploaded separately only when it adds a failure, elevated risk, diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index 6ddceb2..2dfdb36 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -156,7 +156,7 @@ function codexSafeReasons(reasons: PolicyReason[]): PolicyReason[] { return reasons.slice(0, 20).map((reason) => ({ code: SAFE_RULE_ID.test(reason.code) ? reason.code : 'POLICY', severity: CODEX_SEVERITIES.has(reason.severity) ? reason.severity as RuntimeSeverity : 'info', - title: reason.code === 'SECRET_ACCESS' ? 'Protected path access' : 'Policy rule matched', + title: SAFE_REASON_TITLES[reason.code] ?? 'Policy rule matched', description: '[REDACTED]', evidence: reason.evidence === undefined ? undefined : '[REDACTED]', remediation: reason.remediation === undefined ? undefined : '[REDACTED]', @@ -169,10 +169,17 @@ function codexSafeReasons(reasons: PolicyReason[]): PolicyReason[] { */ function nativeHookAuditInput(event: RuntimeAuditEvent): string { const protectedAccess = event.reasons.some((reason) => reason.code === 'SECRET_ACCESS'); + if (event.metadata?.claudeHookEvent === 'PreModelSwitch' + || event.metadata?.claudeHookEvent === 'PostModelSwitch') { + return nativeHookModelSwitchPreview(event); + } if (event.actionType === 'shell' && !protectedAccess) return nativeHookShellPreview(event); if ((event.actionType === 'network' || event.actionType === 'browser') && !protectedAccess) { return nativeHookNetworkPreview(event) ?? '[LOCAL_ONLY_LLM_CONTENT]'; } + if ((event.actionType === 'file_read' || event.actionType === 'file_write') && !protectedAccess) { + return nativeHookFilePreview(event) ?? '[LOCAL_ONLY_LLM_CONTENT]'; + } if (!protectedAccess) return '[LOCAL_ONLY_LLM_CONTENT]'; const protectedFile = safeProtectedFileReference(event); @@ -183,7 +190,11 @@ function nativeHookAuditInput(event: RuntimeAuditEvent): string { return `${command ?? 'access'} ${protectedFile}`; } if (event.actionType === 'file_read') return `read ${protectedFile}`; - if (event.actionType === 'file_write') return `write ${protectedFile}`; + if (event.actionType === 'file_write') { + return event.metadata?.claudeHookEvent === 'ConfigChange' + ? `config change ${protectedFile}` + : `write ${protectedFile}`; + } return `access ${protectedFile}`; } @@ -232,6 +243,56 @@ function safeUrlPathSegment(segment: string): string { return redactText(decoded) === decoded ? segment : '[REDACTED]'; } +function nativeHookFilePreview(event: RuntimeAuditEvent): string | undefined { + const configChange = event.metadata?.claudeHookEvent === 'ConfigChange'; + const operation = configChange ? 'config change' : event.actionType === 'file_read' ? 'read' : 'write'; + const targets = nativeFileTargets(event); + if (targets.length === 0) return configChange ? operation : undefined; + if (targets.length > 1) return `${operation} ${Math.min(targets.length, 100)} files`; + const target = safePathBasename(targets[0]!); + return target ? `${operation} ${target}` : undefined; +} + +function nativeFileTargets(event: RuntimeAuditEvent): string[] { + const targets = event.metadata?.nativeFileTargets; + if (Array.isArray(targets)) { + return [...new Set(targets.filter((value): value is string => ( + typeof value === 'string' && value.trim().length > 0 + )).map((value) => value.trim()))].slice(0, 100); + } + if (event.actionType === 'file_read') return [event.input]; + if (event.actionType === 'file_write') { + if (event.metadata?.claudeHookEvent === 'ConfigChange') { + return event.input === 'config change' ? [] : [event.input]; + } + const path = event.input.match( + /["'](?:file_path|filePath|path|target)["']\s*:\s*["']([^"']+)["']/ + )?.[1]; + return path ? [path] : []; + } + return []; +} + +function nativeHookModelSwitchPreview(event: RuntimeAuditEvent): string { + const fromModel = safeModelSwitchLabel(event.metadata?.fromModel) ?? 'unknown'; + const toModel = safeModelSwitchLabel(event.metadata?.toModel) ?? 'unknown'; + const source = safeModelSwitchLabel(event.metadata?.modelSwitchSource); + const rawContextTokens = firstSafeInteger(event.metadata?.contextTokens); + const contextTokens = rawContextTokens !== undefined && rawContextTokens >= 0 + ? rawContextTokens + : undefined; + return [ + `model switch ${fromModel} -> ${toModel}`, + ...(source ? [`source=${source}`] : []), + ...(contextTokens !== undefined ? [`context_tokens=${Math.min(contextTokens, 10_000_000)}`] : []), + ].join(' ').slice(0, 500); +} + +function safeModelSwitchLabel(value: unknown): string | undefined { + if (typeof value !== 'string' || value.includes('://') || !SAFE_MODEL_SWITCH_LABEL.test(value)) return undefined; + return redactText(value) === value ? value : undefined; +} + function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefined { const input = event.input; const ssh = input.match(/\.ssh[\\/]([A-Za-z0-9._-]{1,128})(?=$|[\s"';&|)\]},:])/i)?.[1]; @@ -267,7 +328,10 @@ function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefine } function safeGenericProtectedTarget(event: RuntimeAuditEvent): string | undefined { - if (event.actionType === 'file_read') return safePathBasename(event.input); + if (event.actionType === 'file_read' || event.actionType === 'file_write') { + const targets = nativeFileTargets(event); + if (targets.length === 1) return safePathBasename(targets[0]!); + } if (event.actionType === 'file_write') { const path = event.input.match( @@ -374,6 +438,49 @@ const SAFE_RULE_ID = /^[A-Z][A-Z0-9_]{0,63}$/; const SAFE_ID = /^[A-Za-z0-9_-]{1,160}$/; const SAFE_URL_QUERY_KEY = /^[A-Za-z0-9_.~-]{1,64}$/; const OPAQUE_URL_PATH_SEGMENT = /^(?=.{24,128}$)(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_-]+$/; +const SAFE_MODEL_SWITCH_LABEL = /^[A-Za-z0-9._/-]{1,160}$/; +const SAFE_REASON_TITLES: Readonly> = { + ACTION_TYPE_REQUIRES_APPROVAL: 'Tool type requires approval', + AGENT_SELF_APPROVAL: 'Agent approval command denied', + CUSTOM_BLOCKED_COMMAND: 'Custom blocked command', + CUSTOM_BLOCKED_DOMAIN: 'Custom blocked domain', + DANGEROUS_CONFIG_CHANGE: 'Dangerous configuration change', + DATA_EXFILTRATION: 'Potential data exfiltration', + DEPLOYMENT_ACTION: 'Deployment action requires approval', + DESTRUCTIVE_COMMAND: 'Dangerous command', + DESTRUCTIVE_FILE_OPERATION: 'Destructive file operation', + HIDDEN_NETWORK_COMMAND: 'Hidden network command', + LARGE_CONTEXT_MODEL_SWITCH: 'Large-context model switch', + LLM_ENDPOINT_HIJACK: 'LLM endpoint configuration change', + LLM_KEY_TO_UNKNOWN_HOST: 'Model credential routing risk', + MODEL_SWITCH_APPROVAL: 'Model switch requires approval', + NETWORK_DOS_RESPONSE: 'Repeated network service failures', + NETWORK_LARGE_RESPONSE: 'Large network response volume', + NETWORK_ODD_HOUR_ACTIVITY: 'Odd-hour network activity', + NETWORK_OUTBOUND: 'Network outbound policy', + NETWORK_RATE_LIMIT: 'High-frequency network activity', + NETWORK_REPLAY: 'Repeated identical request', + NETWORK_RISK: 'Network action', + NETWORK_TOKEN_DOMAIN_SWEEP: 'Credential used across many domains', + PATH_NOT_ALLOWED: 'Path outside filesystem allowlist', + PII_EGRESS: 'Sensitive data exposure', + PINNED_REMOTE_PACKAGE_EXECUTION: 'Pinned remote package execution', + REMOTE_CODE_EXECUTION: 'Remote code execution', + RESPONSE_CONTENT_TYPE_MISMATCH: 'Response content type mismatch', + RESPONSE_CREDENTIAL_ECHO: 'Credential echoed in response', + RESPONSE_ERROR_DISCLOSURE: 'Server error disclosure', + RESPONSE_MALICIOUS_SCRIPT: 'Malicious script in response', + RESPONSE_PATH_TRAVERSAL: 'Path traversal content in response', + RESPONSE_XSS_ECHO: 'Executable markup in response', + RELAY_RESPONSE_TAMPERING: 'Model response integrity risk', + SECRET_ACCESS: 'Protected path access', + SHELL_INJECTION_RISK: 'Shell metacharacters', + SYSTEM_PATH_ACCESS: 'System path access', + SYSTEM_PATH_MUTATION: 'System path mutation', + UNTRUSTED_PROMPT_EXPANSION: 'Untrusted prompt expansion', + UNTRUSTED_LLM_ENDPOINT: 'Untrusted LLM endpoint', + WORKSPACE_BULK_EGRESS: 'Bulk workspace data egress', +}; const NATIVE_SENSITIVE_CONTENT_RULES = new Set([ 'PII_EGRESS', 'DATA_EXFILTRATION', 'LLM_KEY_TO_UNKNOWN_HOST', 'WORKSPACE_BULK_EGRESS', 'DANGEROUS_CONFIG_CHANGE', diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index ce4ac05..4168b99 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -714,6 +714,13 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { : ''; const actionInput = process.env.TOOL_INPUT || pickInput(raw, actionType, toolInput, codexHookEvent, claudeHookEvent, claudeConfig?.input); + const nativeFileTargets = pickNativeFileTargets( + actionType, + toolName, + raw, + toolInput, + claudeHookEvent, + ); const unknownSensitiveToolOutput = claudeHookEvent === 'PostToolUse' && verifiedOutput === undefined && redactText(actionInput) !== actionInput; @@ -741,6 +748,7 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { ...(codexHookEvent ? { codexHookEvent } : {}), ...(claudeHookEvent ? { claudeHookEvent } : {}), ...(options.phase === 'post' ? { hookPhase: 'post' } : {}), + ...(nativeFileTargets.length > 0 ? { nativeFileTargets } : {}), ...pickNetworkMetadata(raw, toolInput), ...pickPostToolOutcomeMetadata(codexHookEvent, claudeHookEvent, raw), ...pickFilePathMetadata(raw), @@ -1305,6 +1313,48 @@ function pickInput( return JSON.stringify(raw); } +/** + * Capture only structured file targets for later audit summarization. The raw + * paths remain internal metadata and are removed by nativeHookSafeMetadata; + * file contents and patch bodies are never copied into this field. + */ +function pickNativeFileTargets( + actionType: RuntimeActionType, + toolName: string, + raw: Record | null, + toolInput: Record | undefined, + claudeHookEvent: ClaudeHookEvent | undefined, +): string[] { + if (actionType !== 'file_read' && actionType !== 'file_write') return []; + const targets = new Set(); + const addTarget = (value: unknown): void => { + if (typeof value === 'string' && value.trim()) targets.add(value.trim()); + }; + const addRecordTarget = (value: unknown): void => { + if (!isPlainRecord(value)) return; + addTarget(value.file_path); + addTarget(value.filePath); + addTarget(value.path); + addTarget(value.target); + }; + + addRecordTarget(toolInput); + if (Array.isArray(toolInput?.edits)) { + for (const edit of toolInput.edits.slice(0, 100)) addRecordTarget(edit); + } + if (claudeHookEvent === 'ConfigChange') addTarget(raw?.file_path); + + const lowerToolName = toolName.toLowerCase(); + if (actionType === 'file_write' && (toolName === 'apply_patch' || lowerToolName.includes('patch'))) { + const patch = firstString(toolInput?.patch, toolInput?.command); + for (const match of patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) { + addTarget(match[1]); + if (targets.size >= 100) break; + } + } + return [...targets].slice(0, 100); +} + function readClaudeConfigChange(raw: Record | null): { input: string; bytesRead: number; diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 25b61c1..420bcb7 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -251,6 +251,196 @@ describe('Runtime Cloud bridge', () => { assert.equal(nonUrl.input, '[LOCAL_ONLY_LLM_CONTENT]'); }); + it('keeps only safe file targets for native reads, writes, patches, and config changes', () => { + const base: RuntimeAuditEvent = { + ...sampleEvent(), + agentHost: 'codex', + actionType: 'file_read', + toolName: 'Read', + input: '/private/customer/reports/quarterly-report.txt', + decision: 'require_approval', + policyDecision: 'require_approval', + riskScore: 55, + riskLevel: 'high', + lifecycleStage: 'pre_tool', + reasons: [{ + code: 'PATH_NOT_ALLOWED', + severity: 'high', + title: 'Private path outside allowlist', + description: 'Raw description must not be uploaded.', + }], + metadata: { + codexHookEvent: 'PreToolUse', + nativeFileTargets: ['/private/customer/reports/quarterly-report.txt'], + }, + }; + + const read = buildCloudAuditEvent(base); + assert.equal(read.input, 'read quarterly-report.txt'); + assert.equal(read.reasons[0]?.title, 'Path outside filesystem allowlist'); + assert.doesNotMatch(JSON.stringify(read), /private\/customer|Raw description/); + + const write = buildCloudAuditEvent({ + ...base, + actionType: 'file_write', + toolName: 'Write', + input: '{"file_path":"/private/customer/src/app.ts","content":"private-write-content"}', + metadata: { + codexHookEvent: 'PreToolUse', + nativeFileTargets: ['/private/customer/src/app.ts'], + }, + }); + assert.equal(write.input, 'write app.ts'); + assert.doesNotMatch(JSON.stringify(write), /private-write-content|private\/customer/); + + const patch = buildCloudAuditEvent({ + ...base, + actionType: 'file_write', + toolName: 'apply_patch', + input: 'private patch content', + metadata: { + codexHookEvent: 'PreToolUse', + nativeFileTargets: ['/private/customer/src/a.ts', '/private/customer/src/b.ts'], + }, + }); + assert.equal(patch.input, 'write 2 files'); + assert.doesNotMatch(JSON.stringify(patch), /private patch content|private\/customer/); + + const config = buildCloudAuditEvent({ + ...base, + agentHost: 'claude-code', + actionType: 'file_write', + toolName: 'ConfigChange', + input: '/private/customer/.claude/settings.json', + reasons: [{ + code: 'DANGEROUS_CONFIG_CHANGE', + severity: 'critical', + title: 'Raw config title', + description: 'Raw config description', + }], + metadata: { + claudeHookEvent: 'ConfigChange', + nativeFileTargets: ['/private/customer/.claude/settings.json'], + configSource: 'project_settings', + }, + }); + assert.equal(config.input, 'config change settings.json'); + assert.equal(config.reasons[0]?.title, 'Dangerous configuration change'); + assert.doesNotMatch(JSON.stringify(config), /private\/customer|Raw config/); + + const sourceOnlyConfig = buildCloudAuditEvent({ + ...config, + input: 'config change', + metadata: { claudeHookEvent: 'ConfigChange', configSource: 'project_settings' }, + }); + assert.equal(sourceOnlyConfig.input, 'config change'); + + const unsafeName = buildCloudAuditEvent({ + ...base, + input: '/private/customer/private.person@invalid.test', + metadata: { + codexHookEvent: 'PreToolUse', + nativeFileTargets: ['/private/customer/private.person@invalid.test'], + }, + }); + assert.equal(unsafeName.input, '[LOCAL_ONLY_LLM_CONTENT]'); + + const privacyReason = buildCloudAuditEvent({ + ...base, + reasons: [{ + code: 'LLM_ENDPOINT_HIJACK', + severity: 'critical', + title: 'Raw endpoint title', + description: 'Raw endpoint description', + }], + }); + assert.equal(privacyReason.reasons[0]?.title, 'LLM endpoint configuration change'); + assert.doesNotMatch(JSON.stringify(privacyReason), /Raw endpoint/); + }); + + it('extracts a native file target before discarding write content from audit', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-native-file-preview-')); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + policyCachePath: join(dir, 'policy.json'), + auditPath: join(dir, 'audit.jsonl'), + eventSpoolPath: join(dir, 'spool.jsonl'), + }; + writeFileSync(config.policyCachePath, JSON.stringify(getDefaultEffectiveRuntimePolicy())); + const contentMarker = 'private-write-content-marker'; + + const result = await protectAction({ + config, + agentHost: 'codex', + rawInput: { + hook_event_name: 'PreToolUse', + session_id: 'sess_native_write_preview', + cwd: dir, + tool_name: 'Write', + tool_input: { + file_path: '/tmp/work/.ssh/id_ed25519.pub', + content: contentMarker, + }, + }, + }); + + assert.ok(result); + assert.equal(result.decision.decision, 'require_approval'); + const persisted = readFileSync(config.auditPath, 'utf8'); + const event = JSON.parse(persisted.trim()) as RuntimeAuditEvent; + assert.equal(event.input, 'write id_ed25519.pub'); + assert.doesNotMatch(persisted, /private-write-content-marker|\/tmp\/work\/\.ssh\//); + }); + + it('keeps a bounded native model-switch summary without endpoint or credential labels', () => { + const event: RuntimeAuditEvent = { + ...sampleEvent(), + agentHost: 'claude-code', + actionType: 'other', + toolName: 'PreModelSwitch', + input: 'raw model-switch input', + decision: 'require_approval', + policyDecision: 'require_approval', + riskScore: 55, + riskLevel: 'high', + lifecycleStage: 'model_switch', + reasons: [{ + code: 'MODEL_SWITCH_APPROVAL', + severity: 'high', + title: 'Raw model title', + description: 'Raw model description', + }], + metadata: { + claudeHookEvent: 'PreModelSwitch', + fromModel: 'claude-a', + toModel: 'anthropic/claude-b', + modelSwitchSource: 'user', + contextTokens: 120_000, + modelIdIsEndpoint: false, + }, + }; + + const cloud = buildCloudAuditEvent(event); + assert.equal( + cloud.input, + 'model switch claude-a -> anthropic/claude-b source=user context_tokens=120000', + ); + assert.equal(cloud.reasons[0]?.title, 'Model switch requires approval'); + assert.doesNotMatch(JSON.stringify(cloud), /Raw model/); + + const unsafe = buildCloudAuditEvent({ + ...event, + metadata: { + ...event.metadata, + toModel: 'https://private-model.example/v1', + modelSwitchSource: 'sk-private-source-1234567890', + }, + }); + assert.equal(unsafe.input, 'model switch claude-a -> unknown context_tokens=120000'); + assert.doesNotMatch(JSON.stringify(unsafe), /private-model|private-source/); + }); + it('keeps safe custom protected filenames but not unsafe names or raw sensitive shell content', () => { const base: RuntimeAuditEvent = { ...sampleEvent(),