diff --git a/CHANGELOG.md b/CHANGELOG.md index d932499..4a920dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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. +- 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 ### Added diff --git a/docs/claude-code.md b/docs/claude-code.md index edc18aa..866c1f9 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,10 @@ 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. + +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 dd145dd..a8cdbbd 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,14 @@ 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. +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 1a9d4d1..66b221a 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -33,6 +33,24 @@ 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 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, + 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 +93,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..2dfdb36 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]; } @@ -118,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]', @@ -131,7 +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); @@ -142,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}`; } @@ -156,6 +208,91 @@ 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 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]; @@ -191,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( @@ -292,8 +432,55 @@ 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 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 a86e1fd..4168b99 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -116,6 +116,8 @@ export async function protectAction(options: ProtectOptions): Promise 0 ? { nativeFileTargets } : {}), ...pickNetworkMetadata(raw, toolInput), + ...pickPostToolOutcomeMetadata(codexHookEvent, claudeHookEvent, raw), ...pickFilePathMetadata(raw), ...claudeEventMetadata(claudeHookEvent, raw), ...(claudeBatch?.metadata || {}), @@ -1302,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; @@ -1398,6 +1451,39 @@ function pickNetworkMetadata( }; } +function pickPostToolOutcomeMetadata( + codexHookEvent: CodexHookEvent | undefined, + claudeHookEvent: ClaudeHookEvent | undefined, + raw: Record | 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..420bcb7 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -211,6 +211,236 @@ 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 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(), @@ -416,6 +646,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-'));