From 5688c471ff25e923105ad327f0fed15b849c50fc Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Sat, 10 Oct 2026 11:05:48 +0800 Subject: [PATCH 1/2] fix: improve cloud audit summaries --- CHANGELOG.md | 3 + docs/claude-code.md | 4 +- docs/codex.md | 11 +- docs/dsh-runtime.md | 8 + docs/privacy-boundary.md | 24 +- src/cloud/client.ts | 93 +++++++- src/runtime/audit.ts | 157 +++++++++--- src/runtime/protect.ts | 47 +++- src/tests/codex-hooks.test.ts | 2 +- src/tests/dsh-runtime.test.ts | 31 ++- src/tests/runtime-cloud.test.ts | 410 +++++++++++++++++++++++++++++++- 11 files changed, 729 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a920dc..cf2c737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] ### Changed +- Clean runtime events (`allow`, zero risk, safe level, no findings or failure) now remain local across DSH, Codex, and Claude Code instead of being uploaded to Cloud. +- Reportable DSH events now use a Cloud-only semantic summary and bounded status metadata; raw request/response previews, headers, output, absolute paths, and evaluator reason text remain local. +- Reportable native-hook events now retain a safe action summary from the original tool input, including post-tool commands and file basenames; when raw values cannot be retained, Cloud receives a bounded tool-and-finding summary instead of a generic placeholder. - 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. diff --git a/docs/claude-code.md b/docs/claude-code.md index 866c1f9..168d302 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -34,7 +34,7 @@ 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. +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 the safe original-action summary, redacted findings, 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`. @@ -56,7 +56,7 @@ 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. +Clean `PreToolUse` events remain local; reportable `PreToolUse` events are the primary Cloud action records. Raw stdout and stderr are never uploaded from either lifecycle stage; reportable Post events carry the safe original-action summary, redacted risk summary, and necessary status fields. If the action value itself cannot be retained safely, the summary uses the tool name and a static finding title instead of a generic placeholder. 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. diff --git a/docs/codex.md b/docs/codex.md index a8cdbbd..003b5b3 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -56,7 +56,8 @@ from a repository subdirectory does not bypass them. not undone. Routine successful observations remain local; only failures, elevated risks, anomalous results, or other material new findings are uploaded as separate - Cloud events. + Cloud events. Reportable events use the original tool action for a safe + summary instead of uploading tool output. - `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. @@ -86,9 +87,11 @@ 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. +Raw stdout and stderr are never uploaded. Clean `PreToolUse` events remain +local; reportable `PreToolUse` events are the primary Cloud action records, +while only material `PostToolUse` findings are uploaded as separate events. +When an action cannot be represented safely, Cloud receives the tool name and +static finding title instead of a generic placeholder. Web-fetch Pre events may retain only a bounded destination preview; URL credentials, fragments, query values, and sensitive or opaque path segments are removed. diff --git a/docs/dsh-runtime.md b/docs/dsh-runtime.md index 42277b3..8f5341f 100644 --- a/docs/dsh-runtime.md +++ b/docs/dsh-runtime.md @@ -79,6 +79,14 @@ Events are written to `~/.agentguard/audit.jsonl` with: - the translated hook decision and disposition; - `sourceAttribution: "unknown"` when no reliable owner binding exists. +Clean DSH events remain in this local audit and are not uploaded to AgentGuard +Cloud. Events with a decision, non-zero risk, finding, failure, anomaly, or PII +summary remain Cloud-eligible. Their Cloud payload uses a bounded semantic +summary (for example, a command name plus static finding title, a file basename, +or a sanitized network destination) and bounded status metadata. Raw tool +output, request/response bodies, header values, absolute paths, and evaluator +reason text remain local. + `runtime.attribution.toolOwners` is an exact, case-sensitive map from a DSH tool name to a stable plugin or package id. It is operator-authored trust metadata, not a tool-name heuristic. Owner ids are bounded and validated, duplicate/ambiguous wildcard matching is not supported, and an unmapped tool remains `unknown`. Do not bind a name when another agent scope may shadow it with a different implementation. `runtime.ownerPolicies` applies only after a call has a matching `configured-tool-owner`. Each owner declares a `minimumDecision` of `allow`, `warn`, `require_approval`, or `block`. This is a monotonic floor: it can strengthen the shared AgentGuard decision but can never weaken it. In particular, `minimumDecision: allow` means “no additional owner restriction”; it does not bypass a warning, approval, or block produced by the shared policy. An elevation adds the bounded `DSH_OWNER_POLICY` reason code to audit and native approval text. diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 66b221a..6086ab7 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -26,6 +26,9 @@ Unknown fields are ignored rather than treated as local enforcement facts. Only redacted, Cloud-eligible runtime audit previews are uploaded by default: +- Clean events (`allow`, zero risk, `safe`, no findings, and no execution + failure) remain in the local audit and are not uploaded, regardless of host. + - `sessionId`, `agentHost`, `actionType`, `toolName` - Redacted `input` preview, capped at 2,000 characters - Native shell hooks may retain a redacted command preview so operational @@ -48,9 +51,18 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default: 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. +- Reportable native events use the original tool action for their safe summary, + including post-tool shell commands and file basenames. If no raw value can be + retained safely, the preview falls back to a bounded tool name plus static + finding title rather than an opaque placeholder. +- Reportable DSH events use the same local-first boundary through a Cloud-only + projection: command names, file basenames, sanitized network destinations, + static finding titles, and bounded status fields may be sent; raw tool output, + request/response previews, headers, absolute paths, and evaluator reason text + remain local. - 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. + Reportable post-tool events contain only the safe original-action summary, + 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 @@ -93,10 +105,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 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. +Cloud-eligible redacted audit events for later retry. Clean tool events, +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/cloud/client.ts b/src/cloud/client.ts index b367cfb..62a5fe2 100644 --- a/src/cloud/client.ts +++ b/src/cloud/client.ts @@ -8,9 +8,10 @@ import type { RuntimePiiSummary, } from '../runtime/types.js'; import { normalizeEffectiveRuntimePolicy } from '../runtime/policy.js'; -import { redactLlmMetadata, redactMetadata, redactPreview } from '../runtime/redaction.js'; +import { redactLlmMetadata, redactMetadata, redactPreview, redactText } from '../runtime/redaction.js'; import { buildAuditEvent, + cloudSafeReasons, isClaudeNativeHookAction, isCodexNativeHookAction, nativeHookSafeMetadata, @@ -323,9 +324,99 @@ export function buildCloudActionRequest(action: RuntimeAction): CloudActionReque export function buildCloudAuditEvent(event: RuntimeAuditEvent): CloudAuditEventPayload { const sanitized = buildAuditEvent(event); + if (event.agentHost === 'dsh') { + const reasons = cloudSafeReasons(event.reasons); + return { + schemaVersion: CLOUD_RUNTIME_WIRE_SCHEMA_VERSION, + ...(event.llm?.requestId ? { requestId: redactPreview(event.llm.requestId, 160) } : {}), + ...sanitized, + input: dshCloudFindingSummary(event, reasons[0]?.title), + cwd: undefined, + sourceSkill: undefined, + reasons, + metadata: nativeHookSafeMetadata(event.metadata), + }; + } return { schemaVersion: CLOUD_RUNTIME_WIRE_SCHEMA_VERSION, ...(event.llm?.requestId ? { requestId: redactPreview(event.llm.requestId, 160) } : {}), ...sanitized, }; } + +function dshCloudFindingSummary(event: RuntimeAuditEvent, findingTitle?: string): string { + const finding = findingTitle || 'Security finding'; + if (event.actionType === 'network' || event.actionType === 'browser') { + return safeDshNetworkDestination(event.input) ?? `${safeDshToolName(event)}: ${finding}`; + } + if (event.actionType === 'file_read' || event.actionType === 'file_write') { + const target = safeDshFileName(event.input); + if (target) return `${event.actionType === 'file_read' ? 'read' : 'write'} ${target}`; + } + if (event.actionType === 'shell') { + const command = safeDshCommandName(event.input) ?? safeDshToolName(event); + return `${command}: ${finding}`.slice(0, 500); + } + return `${safeDshToolName(event)}: ${finding}`.slice(0, 500); +} + +function safeDshToolName(event: RuntimeAuditEvent): string { + const tool = redactPreview(event.toolName, 160).trim(); + return tool && tool !== '[REDACTED]' ? tool : event.actionType; +} + +function safeDshCommandName(input: string): string | undefined { + const command = input.match( + /^\s*(?:(?:sudo|env)\s+)?(?:[A-Za-z_][A-Za-z0-9_]*=[^\s]+\s+)*(?:\/[^\s/]+\/)*([A-Za-z][A-Za-z0-9_-]{0,79})\b/ + )?.[1]?.toLowerCase(); + return command && redactPreview(command, 80) === command ? command : undefined; +} + +function safeDshFileName(input: string): string | undefined { + const normalized = input.trim().replace(/^['"]|['"]$/g, '').replace(/[\\/]+$/, ''); + const name = normalized.split(/[\\/]/).at(-1); + return name && name !== '.' && name !== '..' && /^[A-Za-z0-9._-]{1,128}$/.test(name) + && redactPreview(name, 128) === name + ? name + : undefined; +} + +function safeDshNetworkDestination(input: string): string | undefined { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + return undefined; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined; + const path = url.pathname.split('/').map((segment) => { + if (!segment) return ''; + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return '[REDACTED]'; + } + const tokenLike = decoded.length >= 20 && /^[A-Za-z0-9._~+/-]+$/.test(decoded); + return decoded.length <= 128 && !tokenLike && redactPreview(decoded, 128) === decoded + ? segment + : '[REDACTED]'; + }).join('/'); + const queryKeys = [...new Set(url.searchParams.keys())] + .slice(0, 20) + .map((key) => /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/.test(key) && redactText(key) === key + ? key + : 'param'); + const query = queryKeys.length > 0 + ? `?${queryKeys.map((key) => `${key}=[REDACTED]`).join('&')}` + : ''; + return `${url.protocol}//${safeDshUrlHost(url)}${path}${query}`.slice(0, 2000); +} + +function safeDshUrlHost(url: URL): string { + const hostname = url.hostname.split('.').map((label) => { + const tokenLike = label.length >= 20 && /^[A-Za-z0-9_-]+$/.test(label); + return !tokenLike && redactText(label) === label ? label : '[REDACTED]'; + }).join('.'); + return url.port ? `${hostname}:${url.port}` : hostname; +} diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index 2dfdb36..a617d00 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -1,6 +1,6 @@ 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 type { PolicyReason, RuntimeAction, RuntimeActionType, RuntimeAuditEvent, RuntimePiiSummary, RuntimePrivacyRuleEvaluation, RuntimeSeverity } from './types.js'; import { redactLlmMetadata, redactMetadata, redactPreview, redactReasons, redactText } from './redaction.js'; export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { @@ -20,7 +20,7 @@ export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { policyDecision: event.policyDecision, riskScore: clampRiskScore(event.riskScore), riskLevel: event.riskLevel, - reasons: nativeHook ? codexSafeReasons(event.reasons) : redactReasons(event.reasons), + reasons: nativeHook ? cloudSafeReasons(event.reasons) : redactReasons(event.reasons), policyVersion: redactPreview(event.policyVersion, 160), cwd: nativeHook ? undefined : event.cwd ? redactPreview(event.cwd, 500) : event.cwd, sourceSkill: event.sourceSkill ? redactPreview(event.sourceSkill, 240) : event.sourceSkill, @@ -50,7 +50,7 @@ export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { 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; + if (event.actionType !== 'llm_request') return hasMaterialAuditFinding(event); const piiDetected = hasPiiSummary(event.privacySummary); if (!piiDetected) return false; @@ -60,6 +60,20 @@ export function shouldReportAuditEventToCloud(event: RuntimeAuditEvent): boolean return !stoppedBeforeEgress; } +function hasMaterialAuditFinding(event: RuntimeAuditEvent): boolean { + if (event.metadata?.toolFailed === true || event.metadata?.responseIsError === 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 > 0 || event.riskLevel !== 'safe') return true; + if (event.enforcementStatus === 'would_block' || event.enforcementStatus === 'unsupported') return true; + if (hasPiiSummary(event.privacySummary)) return true; + return event.reasons.length > 0; +} + /** * Routine post-tool observations duplicate the already reported pre-tool * action and stay in the local audit. Upload a separate post-tool event only @@ -145,14 +159,14 @@ export function nativeHookSafeMetadata(metadata: Record | undef if (metadata.approvalOnce === true) result.approvalOnce = true; for (const key of [ 'configDiskRollback', 'modelIdIsEndpoint', 'outputReplaceable', 'displayOnly', 'transcriptModified', - 'continuationBlockedOnly', 'resumeRetransmissionGuaranteed', 'toolFailed', + 'continuationBlockedOnly', 'resumeRetransmissionGuaranteed', 'toolFailed', 'responseIsError', ]) { if (typeof metadata[key] === 'boolean') result[key] = metadata[key]; } return result; } -function codexSafeReasons(reasons: PolicyReason[]): PolicyReason[] { +export function cloudSafeReasons(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', @@ -168,29 +182,53 @@ function codexSafeReasons(reasons: PolicyReason[]): PolicyReason[] { * without exporting the raw native-hook command, absolute path, or arguments. */ function nativeHookAuditInput(event: RuntimeAuditEvent): string { - const protectedAccess = event.reasons.some((reason) => reason.code === 'SECRET_ACCESS'); + const capturedToolInput = typeof event.metadata?.nativeToolInput === 'string' + ? event.metadata.nativeToolInput + : undefined; + const capturedActionType = NATIVE_TOOL_ACTION_TYPES.has(event.metadata?.nativeToolActionType) + ? event.metadata?.nativeToolActionType as RuntimeActionType + : event.actionType; + const actionEvent = capturedToolInput || capturedActionType !== event.actionType + ? { ...event, input: capturedToolInput ?? event.input, actionType: capturedActionType } + : event; + const protectedReason = event.reasons.some((reason) => reason.code === 'SECRET_ACCESS'); + const intrinsicProtectedFile = isNativePostToolEvent(event) + && (actionEvent.actionType === 'shell' + || actionEvent.actionType === 'file_read' + || actionEvent.actionType === 'file_write') + ? safeProtectedFileReference(actionEvent, true) + : undefined; + const protectedAccess = protectedReason || intrinsicProtectedFile !== undefined; 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 (actionEvent.actionType === 'shell' && !protectedAccess) { + return nativeHookShellPreview(actionEvent, capturedToolInput === undefined) + ?? nativeHookFindingSummary(event); } - if ((event.actionType === 'file_read' || event.actionType === 'file_write') && !protectedAccess) { - return nativeHookFilePreview(event) ?? '[LOCAL_ONLY_LLM_CONTENT]'; + if ((actionEvent.actionType === 'network' || actionEvent.actionType === 'browser') && !protectedAccess) { + return nativeHookNetworkPreview(actionEvent) ?? nativeHookFindingSummary(event); + } + if ((actionEvent.actionType === 'file_read' || actionEvent.actionType === 'file_write') && !protectedAccess) { + return nativeHookFilePreview(actionEvent) ?? nativeHookFindingSummary(event); + } + if (!protectedAccess) { + const genericPreview = nativeHookGenericToolPreview(event); + if (genericPreview) return genericPreview; + return nativeHookFindingSummary(event); } - if (!protectedAccess) return '[LOCAL_ONLY_LLM_CONTENT]'; - const protectedFile = safeProtectedFileReference(event); - if (!protectedFile) return '[LOCAL_ONLY_LLM_CONTENT]'; + const protectedFile = intrinsicProtectedFile + ?? (protectedReason ? safeProtectedFileReference(actionEvent) : undefined); + if (!protectedFile) return nativeHookFindingSummary(event); - if (event.actionType === 'shell') { - const command = safeShellCommandName(event.input); + if (actionEvent.actionType === 'shell') { + const command = safeShellCommandName(actionEvent.input); return `${command ?? 'access'} ${protectedFile}`; } - if (event.actionType === 'file_read') return `read ${protectedFile}`; - if (event.actionType === 'file_write') { + if (actionEvent.actionType === 'file_read') return `read ${protectedFile}`; + if (actionEvent.actionType === 'file_write') { return event.metadata?.claudeHookEvent === 'ConfigChange' ? `config change ${protectedFile}` : `write ${protectedFile}`; @@ -198,14 +236,49 @@ function nativeHookAuditInput(event: RuntimeAuditEvent): string { return `access ${protectedFile}`; } -function nativeHookShellPreview(event: RuntimeAuditEvent): string { +function nativeHookShellPreview( + event: RuntimeAuditEvent, + findingAppliesToInput = true, +): string | undefined { const redacted = redactText(event.input); - const hasUnmaskedSensitiveFinding = event.reasons.some((reason) => ( + const hasUnmaskedSensitiveFinding = findingAppliesToInput && event.reasons.some((reason) => ( NATIVE_SENSITIVE_CONTENT_RULES.has(reason.code) )) && redacted === event.input; - if (hasUnmaskedSensitiveFinding) return '[LOCAL_ONLY_LLM_CONTENT]'; + if (hasUnmaskedSensitiveFinding) return undefined; const preview = redacted.slice(0, 2000); - return preview.trim() ? preview : '[LOCAL_ONLY_LLM_CONTENT]'; + return preview.trim() ? preview : undefined; +} + +function nativeHookFindingSummary(event: RuntimeAuditEvent): string { + const toolName = redactPreview(event.toolName, 160).trim() || 'Tool'; + const responseStatus = firstSafeInteger( + event.metadata?.responseStatusCode, + event.metadata?.statusCode, + ); + const failed = event.metadata?.claudeHookEvent === 'PostToolUseFailure' + || event.metadata?.toolFailed === true + || (responseStatus !== undefined && responseStatus >= 400); + if (failed) return `${toolName}: Tool execution failed`.slice(0, 500); + + const reason = event.reasons.find((candidate) => SAFE_RULE_ID.test(candidate.code)); + const finding = reason + ? SAFE_REASON_TITLES[reason.code] ?? 'Security finding' + : 'Security finding'; + return `${toolName}: ${finding}`.slice(0, 500); +} + +function nativeHookGenericToolPreview(event: RuntimeAuditEvent): string | undefined { + const rawKeys = event.metadata?.nativeToolArgumentKeys; + if (!Array.isArray(rawKeys)) return undefined; + const keys = rawKeys + .filter((key): key is string => ( + typeof key === 'string' + && SAFE_ARGUMENT_KEY.test(key) + && redactText(key) === key + )) + .slice(0, 20); + if (keys.length === 0) return undefined; + return `${redactPreview(event.toolName, 160)} args=${keys.join(',')}`.slice(0, 500); } /** @@ -224,11 +297,19 @@ function nativeHookNetworkPreview(event: RuntimeAuditEvent): string | 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'); + .map((key) => SAFE_URL_QUERY_KEY.test(key) && redactText(key) === key ? key : 'param'); const query = queryKeys.length > 0 ? `?${queryKeys.map((key) => `${key}=[REDACTED]`).join('&')}` : ''; - return `${url.protocol}//${url.host}${path}${query}`.slice(0, 2000); + return `${url.protocol}//${safeUrlHost(url)}${path}${query}`.slice(0, 2000); +} + +function safeUrlHost(url: URL): string { + const hostname = url.hostname.split('.').map((label) => { + const tokenLike = label.length >= 20 && /^[A-Za-z0-9_-]+$/.test(label); + return !tokenLike && redactText(label) === label ? label : '[REDACTED]'; + }).join('.'); + return url.port ? `${hostname}:${url.port}` : hostname; } function safeUrlPathSegment(segment: string): string { @@ -293,7 +374,10 @@ function safeModelSwitchLabel(value: unknown): string | undefined { return redactText(value) === value ? value : undefined; } -function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefined { +function safeProtectedFileReference( + event: RuntimeAuditEvent, + intrinsicOnly = false, +): string | undefined { const input = event.input; const ssh = input.match(/\.ssh[\\/]([A-Za-z0-9._-]{1,128})(?=$|[\s"';&|)\]},:])/i)?.[1]; if (ssh && isSafeProtectedFileName(ssh)) return `.ssh/${ssh}`; @@ -301,14 +385,14 @@ function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefine const aws = input.match(/\.aws[\\/]([A-Za-z0-9._-]{1,128})(?=$|[\s"';&|)\]},:])/i)?.[1]; if (aws && isSafeProtectedFileName(aws)) return `.aws/${aws}`; + if (/(?:^|[\\/\s"'=([{,:])\.ssh(?:[\\/]+)?(?=$|[\s"';&|)\]},:])/i.test(input)) return '.ssh'; + if (/(?:^|[\\/\s"'=([{,:])\.aws(?:[\\/]+)?(?=$|[\s"';&|)\]},:])/i.test(input)) return '.aws'; + const environment = input.match( /(?:^|[\s"'=([{,:])(?:[A-Za-z]:[\\/])?[\\/]?(?:[A-Za-z0-9_~.-]+[\\/])*(\.env(?:\.[A-Za-z0-9_-]{1,64})?)(?=$|[\s"';&|)\]},:])/i )?.[1]; if (environment && isSafeProtectedFileName(environment)) return environment; - const genericTarget = safeGenericProtectedTarget(event); - if (genericTarget) return genericTarget; - const credentials = input.match( /(?:^|[\\/\s"'=([{,:])(credentials[A-Za-z0-9._-]{0,96})(?=$|[\s"';&|)\]},:])/i )?.[1]; @@ -319,6 +403,11 @@ function safeProtectedFileReference(event: RuntimeAuditEvent): string | undefine )?.[1]; if (namedSecret && isSafeProtectedFileName(namedSecret)) return namedSecret; + if (intrinsicOnly) return undefined; + + const genericTarget = safeGenericProtectedTarget(event); + if (genericTarget) return genericTarget; + for (const reason of event.reasons) { if (reason.code !== 'SECRET_ACCESS' || !reason.evidence || /[*?\[\]]/.test(reason.evidence)) continue; const exactName = reason.evidence.split(/[\\/]/).at(-1); @@ -431,12 +520,16 @@ const MISSING_FACTS = new Set([ 'auxiliary_model_calls', 'response_source', ]); const CODEX_DECISIONS = new Set(['allow', 'warn', 'require_approval', 'block']); +const NATIVE_TOOL_ACTION_TYPES = new Set([ + 'shell', 'file_read', 'file_write', 'network', 'browser', 'web_search', 'skill_install', 'mcp_tool', 'other', +]); 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 SAFE_ARGUMENT_KEY = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/; 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> = { @@ -449,6 +542,7 @@ const SAFE_REASON_TITLES: Readonly> = { DEPLOYMENT_ACTION: 'Deployment action requires approval', DESTRUCTIVE_COMMAND: 'Dangerous command', DESTRUCTIVE_FILE_OPERATION: 'Destructive file operation', + DSH_OWNER_POLICY: 'DSH plugin owner policy', HIDDEN_NETWORK_COMMAND: 'Hidden network command', LARGE_CONTEXT_MODEL_SWITCH: 'Large-context model switch', LLM_ENDPOINT_HIJACK: 'LLM endpoint configuration change', @@ -469,6 +563,7 @@ const SAFE_REASON_TITLES: Readonly> = { RESPONSE_CONTENT_TYPE_MISMATCH: 'Response content type mismatch', RESPONSE_CREDENTIAL_ECHO: 'Credential echoed in response', RESPONSE_ERROR_DISCLOSURE: 'Server error disclosure', + RESPONSE_INSPECTION_LIMIT: 'Model response exceeded inspection limit', RESPONSE_MALICIOUS_SCRIPT: 'Malicious script in response', RESPONSE_PATH_TRAVERSAL: 'Path traversal content in response', RESPONSE_XSS_ECHO: 'Executable markup in response', @@ -479,6 +574,7 @@ const SAFE_REASON_TITLES: Readonly> = { SYSTEM_PATH_MUTATION: 'System path mutation', UNTRUSTED_PROMPT_EXPANSION: 'Untrusted prompt expansion', UNTRUSTED_LLM_ENDPOINT: 'Untrusted LLM endpoint', + UNKNOWN_TOOL: 'Unknown tool requires approval', WORKSPACE_BULK_EGRESS: 'Bulk workspace data egress', }; const NATIVE_SENSITIVE_CONTENT_RULES = new Set([ @@ -487,7 +583,7 @@ const NATIVE_SENSITIVE_CONTENT_RULES = new Set([ ]); const SAFE_FILE_COMMANDS = new Set([ 'cat', 'head', 'tail', 'less', 'more', 'grep', 'sed', 'awk', - 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'tee', + 'cp', 'mv', 'rm', 'touch', 'chmod', 'chown', 'tee', 'ls', 'find', 'stat', ]); const SINGLE_TARGET_FILE_COMMANDS = new Set([ 'cat', 'head', 'tail', 'less', 'more', 'rm', 'touch', 'chmod', 'chown', @@ -496,8 +592,7 @@ const SINGLE_TARGET_FILE_COMMANDS = new Set([ function isLlmTrafficEvent(event: RuntimeAuditEvent): boolean { return event.actionType === 'llm_request' || event.actionType === 'llm_response' - || event.metadata?.codexHookEvent === 'UserPromptSubmit' - || event.metadata?.codexHookEvent === 'PostToolUse'; + || event.metadata?.codexHookEvent === 'UserPromptSubmit'; } export function writeAuditLog(auditPath: string, event: RuntimeAuditEvent): void { diff --git a/src/runtime/protect.ts b/src/runtime/protect.ts index 4168b99..477992a 100644 --- a/src/runtime/protect.ts +++ b/src/runtime/protect.ts @@ -114,9 +114,11 @@ export async function protectAction(options: ProtectOptions): Promise 0 ? { nativeToolArgumentKeys } : {}), ...(nativeFileTargets.length > 0 ? { nativeFileTargets } : {}), ...pickNetworkMetadata(raw, toolInput), ...pickPostToolOutcomeMetadata(codexHookEvent, claudeHookEvent, raw), @@ -767,6 +784,32 @@ function buildRuntimeAction(options: ProtectOptions): RuntimeAction { }; } +function pickNativeToolAuditInput( + actionType: RuntimeActionType, + toolInput: Record | undefined, +): string { + if (!toolInput) return ''; + if (actionType === 'shell') { + return firstString(toolInput.command, toolInput.cmd, toolInput.script, toolInput.code, toolInput.input); + } + if (actionType === 'file_read' || actionType === 'file_write') { + return firstString(toolInput.file_path, toolInput.filePath, toolInput.path, toolInput.target); + } + if (actionType === 'web_search') { + return firstString(toolInput.query, toolInput.q, toolInput.search, toolInput.term); + } + if (actionType === 'network' || actionType === 'browser') { + const request = firstRecord(toolInput.request, toolInput.options); + return firstString(toolInput.url, toolInput.uri, toolInput.href, toolInput.target, request?.url, request?.uri); + } + return ''; +} + +function pickNativeToolArgumentKeys(toolInput: Record | undefined): string[] { + if (!toolInput) return []; + return Object.keys(toolInput).slice(0, 20); +} + function validateNativeCodexHook(raw: Record | null, wrapper: string | undefined): void { if (!raw || typeof raw.session_id !== 'string' || typeof raw.cwd !== 'string') throw invalidCodexPayload(); const event = pickCodexHookEvent(raw); diff --git a/src/tests/codex-hooks.test.ts b/src/tests/codex-hooks.test.ts index 5110449..27932bf 100644 --- a/src/tests/codex-hooks.test.ts +++ b/src/tests/codex-hooks.test.ts @@ -286,7 +286,7 @@ describe('installed Codex native hooks', () => { lifecycleStage?: string; metadata?: { codexHookEvent?: string; exitCode?: number }; }; - assert.equal(event.input, '[LOCAL_ONLY_LLM_CONTENT]'); + assert.equal(event.input, 'printf hello'); assert.equal(event.lifecycleStage, 'post_tool'); assert.equal(event.metadata?.codexHookEvent, 'PostToolUse'); assert.equal(event.metadata?.exitCode, 0); diff --git a/src/tests/dsh-runtime.test.ts b/src/tests/dsh-runtime.test.ts index 460bde0..3ebe9e3 100644 --- a/src/tests/dsh-runtime.test.ts +++ b/src/tests/dsh-runtime.test.ts @@ -270,6 +270,35 @@ describe('DSH runtime Phase 2A observer', () => { } }); + it('keeps clean DSH audit events local without a Cloud request or spool entry', async () => { + const cloud = await startCloudServer(); + const directory = mkdtempSync(join(tmpdir(), 'agentguard-dsh-cloud-clean-')); + const spoolPath = join(directory, 'events.jsonl'); + try { + const observed = await observeDshToolCall(execution({ + name: 'web_search', + arguments: { query: 'agent security news' }, + }), { + loadAgentGuardConfig: () => ({ + ...config, + cloudUrl: cloud.url, + apiKey: 'ag_live_dsh_cloud_test', + eventSpoolPath: spoolPath, + }), + fetchPolicyFor: () => undefined, + evaluate: async () => ({ decision: decision('allow'), policySource: 'default' }), + writeAudit() {}, + }); + + assert.ok(observed); + assert.equal(cloud.requests.length, 0); + assert.equal(existsSync(spoolPath), false); + } finally { + await cloud.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + it('spools the DSH audit event when connected Cloud ingest fails', async () => { const cloud = await startCloudServer([503]); const directory = mkdtempSync(join(tmpdir(), 'agentguard-dsh-cloud-failure-')); @@ -311,7 +340,7 @@ describe('DSH runtime Phase 2A observer', () => { eventSpoolPath: directory, }), fetchPolicyFor: () => undefined, - evaluate: async () => ({ decision: decision('allow'), policySource: 'default' }), + evaluate: async () => ({ decision: decision('warn'), policySource: 'default' }), writeAudit() {}, }); const downstream = { kind: 'allow' as const }; diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 420bcb7..968bc96 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir, tmpdir } from 'node:os'; import { __resetNetworkBehaviorForTests, evaluateLocalAction } from '../runtime/evaluator.js'; @@ -211,13 +211,176 @@ describe('Runtime Cloud bridge', () => { assert.equal(claude.input, 'ls -la ~/.agentguard && stat ~/.agentguard/config.json'); }); + it('keeps meaningful native summaries for protected directories and opaque tools', () => { + const protectedDirectory = buildAuditEvent({ + ...sampleEvent(), + agentHost: 'codex', + actionType: 'shell', + toolName: 'Bash', + input: 'ls -la ~/.ssh/ 2>/dev/null', + decision: 'require_approval', + policyDecision: 'require_approval', + riskScore: 55, + riskLevel: 'high', + reasons: [{ + code: 'SECRET_ACCESS', + severity: 'high', + title: 'Protected path access', + description: 'The command accesses a protected directory.', + }], + metadata: { codexHookEvent: 'PreToolUse' }, + }); + assert.equal(protectedDirectory.input, 'ls .ssh'); + + const opaqueTool = buildAuditEvent({ + ...sampleEvent(), + agentHost: 'claude-code', + actionType: 'mcp_tool', + toolName: 'mcp__deploy__release', + input: '{"operation":"publish","target":"private-project","token":"private-token"}', + decision: 'require_approval', + policyDecision: 'require_approval', + riskScore: 55, + riskLevel: 'high', + reasons: [{ + code: 'ACTION_TYPE_REQUIRES_APPROVAL', + severity: 'high', + title: 'Tool type requires approval', + description: 'The tool requires approval.', + }], + metadata: { + claudeHookEvent: 'PreToolUse', + nativeToolArgumentKeys: ['operation', 'target', 'token'], + }, + }); + assert.equal(opaqueTool.input, 'mcp__deploy__release args=operation,target,token'); + assert.doesNotMatch(JSON.stringify(opaqueTool), /private-project|private-token/); + + const credentialKey = 'sk-live-ABCDEF12345678901234567890'; + const unsafeArgumentKey = buildAuditEvent({ + ...opaqueTool, + input: '{"redacted":"locally"}', + metadata: { + claudeHookEvent: 'PreToolUse', + nativeToolArgumentKeys: [credentialKey], + }, + }); + assert.equal(unsafeArgumentKey.input, 'mcp__deploy__release: Tool type requires approval'); + assert.doesNotMatch(JSON.stringify(unsafeArgumentKey), new RegExp(credentialKey)); + }); + + it('uses the original native tool action for reportable post-tool summaries', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-native-post-summary-')); + 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 outputMarker = 'raw-post-output-marker'; + const secret = 'sk-reportable-post-secret-1234567890'; + + for (const agentHost of ['codex', 'claude-code'] as const) { + const hookKey = agentHost === 'codex' ? 'codexHookEvent' : 'claudeHookEvent'; + const result = await protectAction({ + config, + agentHost, + rawInput: { + hook_event_name: 'PostToolUse', + session_id: `sess_${agentHost}_post_summary`, + cwd: dir, + tool_name: 'Bash', + tool_input: { command: 'printf public-result' }, + tool_response: { output: `${outputMarker} api_key=${secret}`, exit_code: 0 }, + }, + }); + assert.ok(result); + assert.equal(result.event.metadata?.[hookKey], 'PostToolUse'); + } + + const persisted = readFileSync(config.auditPath, 'utf8'); + const events = persisted.trim().split('\n').map((line) => JSON.parse(line) as RuntimeAuditEvent); + assert.deepEqual(events.map((event) => event.input), ['printf public-result', 'printf public-result']); + assert.doesNotMatch(persisted, new RegExp(`${outputMarker}|${secret}`)); + }); + + it('keeps protected paths bounded when a native post-tool finding comes from output', () => { + const event = buildCloudAuditEvent({ + ...sampleEvent(), + agentHost: 'codex', + actionType: 'other', + toolName: 'Bash', + input: 'private output containing sensitive data', + decision: 'block', + policyDecision: 'block', + riskScore: 100, + riskLevel: 'critical', + lifecycleStage: 'post_tool', + reasons: [{ + code: 'PII_EGRESS', + severity: 'critical', + title: 'Sensitive output', + description: 'Sensitive output detected.', + }], + metadata: { + codexHookEvent: 'PostToolUse', + nativeToolActionType: 'shell', + nativeToolInput: 'cat /Users/private-user/.ssh/id_rsa', + nativeToolArgumentKeys: ['command'], + }, + }); + + assert.equal(event.input, 'cat .ssh/id_rsa'); + assert.doesNotMatch(JSON.stringify(event), /private-user|private output/); + }); + + it('keeps only the file basename in native post-tool write summaries', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agentguard-native-post-file-summary-')); + 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-post-write-content'; + const secret = 'sk-post-write-secret-1234567890'; + + const result = await protectAction({ + config, + agentHost: 'claude-code', + rawInput: { + hook_event_name: 'PostToolUse', + session_id: 'sess_claude_post_file_summary', + cwd: dir, + tool_name: 'Write', + tool_input: { + file_path: '/private/customer/src/app.ts', + content: contentMarker, + }, + tool_response: { content: `api_key=${secret}` }, + }, + }); + + assert.ok(result); + const persisted = readFileSync(config.auditPath, 'utf8'); + const event = JSON.parse(persisted.trim()) as RuntimeAuditEvent; + assert.equal(event.input, 'write app.ts'); + assert.doesNotMatch(persisted, /private\/customer|private-post-write-content|post-write-secret/); + }); + it('keeps a bounded native web-fetch destination without URL credentials or values', () => { + const queryCredential = 'sk-query-ABCDEF12345678901234567890'; + const hostCredential = 'sk-host-ABCDEF12345678901234567890'; 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', + input: `https://private-user:private-pass@${hostCredential}.example.com/models/sk-private-token-1234567890?api_key=private-key&page=2&${queryCredential}=x#private-fragment`, decision: 'warn', policyDecision: 'warn', riskScore: 20, @@ -235,9 +398,9 @@ describe('Runtime Cloud bridge', () => { const codex = buildCloudAuditEvent(event); assert.equal( codex.input, - 'https://example.com/models/[REDACTED]?api_key=[REDACTED]&page=[REDACTED]', + 'https://[REDACTED].example.com/models/[REDACTED]?api_key=[REDACTED]&page=[REDACTED]¶m=[REDACTED]', ); - assert.doesNotMatch(JSON.stringify(codex), /private-user|private-pass|private-token|private-key|private-fragment/); + assert.doesNotMatch(JSON.stringify(codex), /private-user|private-pass|private-token|private-key|private-fragment|sk-query|sk-host/); const claude = buildCloudAuditEvent({ ...event, @@ -248,7 +411,25 @@ describe('Runtime Cloud bridge', () => { 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]'); + assert.equal(nonUrl.input, 'web_fetch: Network outbound policy'); + + const post = buildCloudAuditEvent({ + ...event, + actionType: 'other', + input: 'private response body', + lifecycleStage: 'post_tool', + metadata: { + codexHookEvent: 'PostToolUse', + nativeToolActionType: 'network', + nativeToolInput: event.input, + nativeToolArgumentKeys: ['url'], + }, + }); + assert.equal( + post.input, + 'https://[REDACTED].example.com/models/[REDACTED]?api_key=[REDACTED]&page=[REDACTED]¶m=[REDACTED]', + ); + assert.doesNotMatch(JSON.stringify(post), /private-user|private-pass|private-token|private-key|private-fragment|sk-query|sk-host/); }); it('keeps only safe file targets for native reads, writes, patches, and config changes', () => { @@ -343,7 +524,7 @@ describe('Runtime Cloud bridge', () => { nativeFileTargets: ['/private/customer/private.person@invalid.test'], }, }); - assert.equal(unsafeName.input, '[LOCAL_ONLY_LLM_CONTENT]'); + assert.equal(unsafeName.input, 'Read: Path outside filesystem allowlist'); const privacyReason = buildCloudAuditEvent({ ...base, @@ -467,11 +648,11 @@ describe('Runtime Cloud bridge', () => { assert.equal(buildAuditEvent({ ...base, input: 'cat /private/customer/private.person@invalid.test', - }).input, '[LOCAL_ONLY_LLM_CONTENT]'); + }).input, 'Bash: Protected path access'); assert.equal(buildAuditEvent({ ...base, input: 'cat /private/customer/sk-never-export-1234567890', - }).input, '[LOCAL_ONLY_LLM_CONTENT]'); + }).input, 'Bash: Protected path access'); const piiShell = buildAuditEvent({ ...base, input: 'email=private.person@invalid.test', @@ -482,7 +663,7 @@ describe('Runtime Cloud bridge', () => { description: 'Sensitive content was blocked.', }], }).input; - assert.equal(piiShell, '[LOCAL_ONLY_LLM_CONTENT]'); + assert.equal(piiShell, 'Bash: Sensitive data exposure'); }); it('never mistakes shell redirects or substitutions for the protected target', () => { @@ -513,7 +694,7 @@ describe('Runtime Cloud bridge', () => { assert.equal(buildAuditEvent({ ...event, input: 'cat /private/workspace/protected.txt $(curl https://example.invalid/secret)', - }).input, '[LOCAL_ONLY_LLM_CONTENT]'); + }).input, 'Bash: Protected path access'); assert.equal(buildAuditEvent({ ...event, input: 'cat /private/workspace/protected.txt && curl https://example.invalid/secret', @@ -646,6 +827,208 @@ describe('Runtime Cloud bridge', () => { } }); + it('keeps clean DSH, Codex, and Claude Code tool events out of Cloud ingest', async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + const cleanEvents: RuntimeAuditEvent[] = [ + { + ...sampleEvent(), + actionId: 'act_clean_dsh', + agentHost: 'dsh', + toolName: 'web_search', + actionType: 'web_search', + input: 'agent security news', + }, + { + ...sampleEvent(), + actionId: 'act_clean_codex', + agentHost: 'codex', + metadata: { codexHookEvent: 'PreToolUse' }, + }, + { + ...sampleEvent(), + actionId: 'act_clean_claude', + agentHost: 'claude-code', + metadata: { claudeHookEvent: 'PreToolUse' }, + }, + ]; + + for (const event of cleanEvents) { + assert.equal(shouldReportAuditEventToCloud(event), false, event.agentHost); + } + + globalThis.fetch = (async (_input: Parameters[0], init?: RequestInit) => { + requests.push(String(init?.body ?? '')); + return jsonResponse({ success: true, data: { accepted: 0, rejected: 0 } }, 202); + }) as typeof fetch; + + try { + const client = new AgentGuardCloudClient({ + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_test_key_123456', + }); + await client.ingestEvents(cleanEvents); + assert.equal(requests.length, 0); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('uploads only a bounded DSH finding summary without raw I/O or absolute paths', async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + const outputMarker = 'RAW_PRIVATE_TOOL_OUTPUT_MARKER'; + const pathMarker = '/Users/private-user/.ssh/id_rsa'; + const credentialMarker = 'Bearer private-dsh-token'; + const queryCredential = 'sk-dsh-query-ABCDEF12345678901234567890'; + const hostCredential = 'sk-dsh-host-ABCDEF12345678901234567890'; + const event: RuntimeAuditEvent = { + ...sampleEvent(), + actionId: 'act_dsh_bounded_cloud', + agentHost: 'dsh', + actionType: 'shell', + toolName: 'bash', + input: `cat ${pathMarker}`, + cwd: '/Users/private-user/private-project', + decision: 'require_approval', + policyDecision: 'require_approval', + riskScore: 55, + riskLevel: 'high', + reasons: [{ + code: 'SECRET_ACCESS', + severity: 'high', + title: `raw title ${pathMarker}`, + description: `raw description ${outputMarker}`, + evidence: pathMarker, + }], + metadata: { + evaluation: 'local-oss', + statusCode: 403, + responseBodyPreview: outputMarker, + bodyPreview: 'private request body', + headers: { authorization: credentialMarker }, + responseHeaders: { 'set-cookie': 'private-cookie' }, + }, + }; + const networkEvent: RuntimeAuditEvent = { + ...event, + actionId: 'act_dsh_bounded_network', + actionType: 'network', + toolName: 'web_fetch', + input: `https://${hostCredential}.example.com/public?${queryCredential}=x&page=2`, + reasons: [{ + code: 'NETWORK_OUTBOUND', + severity: 'medium', + title: 'raw network title', + description: 'raw network description', + }], + }; + + globalThis.fetch = (async (_input: Parameters[0], init?: RequestInit) => { + requests.push(String(init?.body ?? '')); + return jsonResponse({ success: true, data: { accepted: 1, rejected: 0 } }, 202); + }) as typeof fetch; + + try { + const client = new AgentGuardCloudClient({ + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_test_key_123456', + }); + await client.ingestEvents([event, networkEvent]); + assert.equal(requests.length, 1); + const payload = requests[0]!; + assert.match(payload, /"input":"cat: Protected path access"/); + assert.match(payload, /https:\/\/\[REDACTED\]\.example\.com\/public\?param=\[REDACTED\]&page=\[REDACTED\]/); + assert.match(payload, /"statusCode":403/); + assert.doesNotMatch( + payload, + /RAW_PRIVATE_TOOL_OUTPUT_MARKER|private-user|private-project|private-dsh-token|private request body|private-cookie|raw title|raw description|raw network|sk-dsh-query|sk-dsh-host/, + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('keeps DSH-only findings semantically meaningful in Cloud summaries', () => { + const cases = [ + ['UNKNOWN_TOOL', 'Unknown tool requires approval'], + ['DSH_OWNER_POLICY', 'DSH plugin owner policy'], + ['RESPONSE_INSPECTION_LIMIT', 'Model response exceeded inspection limit'], + ] as const; + + for (const [code, title] of cases) { + const event = buildCloudAuditEvent({ + ...sampleEvent(), + agentHost: 'dsh', + actionType: 'other', + toolName: 'custom_tool', + decision: 'require_approval', + policyDecision: 'require_approval', + riskScore: 55, + riskLevel: 'high', + reasons: [{ + code, + severity: 'high', + title: 'unsafe raw title', + description: 'unsafe raw description', + }], + }); + assert.equal(event.input, `custom_tool: ${title}`); + assert.equal(event.reasons[0]?.title, title); + assert.doesNotMatch(JSON.stringify(event), /unsafe raw|Policy rule matched/); + } + }); + + it('keeps clean Codex and Claude Code pre-tool events in local audit only', async () => { + const originalFetch = globalThis.fetch; + const dir = mkdtempSync(join(tmpdir(), 'agentguard-clean-native-local-only-')); + const requests: string[] = []; + const policy = getDefaultEffectiveRuntimePolicy(); + + globalThis.fetch = (async (input: Parameters[0]) => { + requests.push(String(input)); + return jsonResponse({ success: true, data: { accepted: 0, rejected: 0 } }, 202); + }) as typeof fetch; + + try { + for (const agentHost of ['codex', 'claude-code'] as const) { + const hostDir = join(dir, agentHost); + const config: AgentGuardConfig = { + version: 1, + level: 'balanced', + cloudUrl: 'https://agentguard.example', + apiKey: 'ag_live_test_key_123456', + policyCachePath: join(hostDir, 'policy.json'), + auditPath: join(hostDir, 'audit.jsonl'), + eventSpoolPath: join(hostDir, 'spool.jsonl'), + }; + mkdirSync(hostDir, { recursive: true }); + writeFileSync(config.policyCachePath, JSON.stringify(policy)); + + const result = await protectAction({ + config, + agentHost, + rawInput: { + hook_event_name: 'PreToolUse', + session_id: `sess_clean_${agentHost}`, + cwd: hostDir, + tool_name: 'Bash', + tool_input: { command: 'echo hello' }, + }, + }); + + assert.ok(result, agentHost); + const event = JSON.parse(readFileSync(config.auditPath, 'utf8').trim()) as RuntimeAuditEvent; + assert.equal(event.input, 'echo hello'); + assert.equal(shouldReportAuditEventToCloud(event), false); + assert.equal(existsSync(config.eventSpoolPath), false); + } + assert.equal(requests.some((url) => url.endsWith('/api/v1/events/ingest')), false); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('reports pre-tool actions but keeps routine post-tool observations local', async () => { const originalFetch = globalThis.fetch; const requests: string[] = []; @@ -722,9 +1105,10 @@ describe('Runtime Cloud bridge', () => { 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.deepEqual( + payload.events?.filter((event) => event.lifecycleStage === 'post_tool').map((event) => event.input), + ['Bash: Tool execution failed', 'Bash: Potential data exfiltration', 'Bash: Tool execution failed'], + ); assert.doesNotMatch(requests[0]!, /private failure output|private tool output/); } finally { globalThis.fetch = originalFetch; From edeb0df4e7cdef19d054dcae6bccb9d907290ec6 Mon Sep 17 00:00:00 2001 From: Mr-Lucky Date: Sat, 10 Oct 2026 16:04:00 +0800 Subject: [PATCH 2/2] fix: preserve cloud audit reporting semantics --- src/runtime/audit.ts | 74 ++++++++++++++++++++----- src/tests/runtime-cloud.test.ts | 98 +++++++++++++++++++++++++++++++-- 2 files changed, 155 insertions(+), 17 deletions(-) diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index a617d00..ad85fd5 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -50,7 +50,9 @@ export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { 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 hasMaterialAuditFinding(event); + if (event.actionType !== 'llm_request') { + return isExplicitCleanLocalOnlyEvent(event) ? hasMaterialAuditFinding(event) : true; + } const piiDetected = hasPiiSummary(event.privacySummary); if (!piiDetected) return false; @@ -60,6 +62,12 @@ export function shouldReportAuditEventToCloud(event: RuntimeAuditEvent): boolean return !stoppedBeforeEgress; } +function isExplicitCleanLocalOnlyEvent(event: RuntimeAuditEvent): boolean { + return event.agentHost === 'dsh' + || isCodexNativeHookAction(event) + || isClaudeNativeHookAction(event); +} + function hasMaterialAuditFinding(event: RuntimeAuditEvent): boolean { if (event.metadata?.toolFailed === true || event.metadata?.responseIsError === true) return true; const responseStatus = firstSafeInteger( @@ -203,25 +211,30 @@ function nativeHookAuditInput(event: RuntimeAuditEvent): string { || event.metadata?.claudeHookEvent === 'PostModelSwitch') { return nativeHookModelSwitchPreview(event); } + if (isNativePostToolEvent(event) + && capturedToolInput === undefined + && NATIVE_STRUCTURED_PREVIEW_ACTION_TYPES.has(actionEvent.actionType)) { + return nativeHookFindingSummary(event, actionEvent.actionType); + } if (actionEvent.actionType === 'shell' && !protectedAccess) { return nativeHookShellPreview(actionEvent, capturedToolInput === undefined) - ?? nativeHookFindingSummary(event); + ?? nativeHookFindingSummary(event, actionEvent.actionType); } if ((actionEvent.actionType === 'network' || actionEvent.actionType === 'browser') && !protectedAccess) { - return nativeHookNetworkPreview(actionEvent) ?? nativeHookFindingSummary(event); + return nativeHookNetworkPreview(actionEvent) ?? nativeHookFindingSummary(event, actionEvent.actionType); } if ((actionEvent.actionType === 'file_read' || actionEvent.actionType === 'file_write') && !protectedAccess) { - return nativeHookFilePreview(actionEvent) ?? nativeHookFindingSummary(event); + return nativeHookFilePreview(actionEvent) ?? nativeHookFindingSummary(event, actionEvent.actionType); } if (!protectedAccess) { - const genericPreview = nativeHookGenericToolPreview(event); + const genericPreview = nativeHookGenericToolPreview(event, actionEvent.actionType); if (genericPreview) return genericPreview; - return nativeHookFindingSummary(event); + return nativeHookFindingSummary(event, actionEvent.actionType); } const protectedFile = intrinsicProtectedFile ?? (protectedReason ? safeProtectedFileReference(actionEvent) : undefined); - if (!protectedFile) return nativeHookFindingSummary(event); + if (!protectedFile) return nativeHookFindingSummary(event, actionEvent.actionType); if (actionEvent.actionType === 'shell') { const command = safeShellCommandName(actionEvent.input); @@ -249,8 +262,12 @@ function nativeHookShellPreview( return preview.trim() ? preview : undefined; } -function nativeHookFindingSummary(event: RuntimeAuditEvent): string { +function nativeHookFindingSummary( + event: RuntimeAuditEvent, + actionType: RuntimeActionType = event.actionType, +): string { const toolName = redactPreview(event.toolName, 160).trim() || 'Tool'; + const subject = nativeHookSummarySubject(event, actionType, toolName); const responseStatus = firstSafeInteger( event.metadata?.responseStatusCode, event.metadata?.statusCode, @@ -258,16 +275,19 @@ function nativeHookFindingSummary(event: RuntimeAuditEvent): string { const failed = event.metadata?.claudeHookEvent === 'PostToolUseFailure' || event.metadata?.toolFailed === true || (responseStatus !== undefined && responseStatus >= 400); - if (failed) return `${toolName}: Tool execution failed`.slice(0, 500); + if (failed) return `${subject}: Tool execution failed`.slice(0, 500); const reason = event.reasons.find((candidate) => SAFE_RULE_ID.test(candidate.code)); const finding = reason ? SAFE_REASON_TITLES[reason.code] ?? 'Security finding' : 'Security finding'; - return `${toolName}: ${finding}`.slice(0, 500); + return `${subject}: ${finding}`.slice(0, 500); } -function nativeHookGenericToolPreview(event: RuntimeAuditEvent): string | undefined { +function nativeHookGenericToolPreview( + event: RuntimeAuditEvent, + actionType: RuntimeActionType, +): string | undefined { const rawKeys = event.metadata?.nativeToolArgumentKeys; if (!Array.isArray(rawKeys)) return undefined; const keys = rawKeys @@ -278,7 +298,18 @@ function nativeHookGenericToolPreview(event: RuntimeAuditEvent): string | undefi )) .slice(0, 20); if (keys.length === 0) return undefined; - return `${redactPreview(event.toolName, 160)} args=${keys.join(',')}`.slice(0, 500); + const toolName = redactPreview(event.toolName, 160).trim() || 'Tool'; + const subject = nativeHookSummarySubject(event, actionType, toolName); + return `${subject} args=${keys.join(',')}`.slice(0, 500); +} + +function nativeHookSummarySubject( + event: RuntimeAuditEvent, + actionType: RuntimeActionType, + toolName: string, +): string { + if (!isNativePostToolEvent(event)) return toolName; + return `${NATIVE_ACTION_DESCRIPTORS[actionType]} via ${toolName}`; } /** @@ -521,8 +552,25 @@ const MISSING_FACTS = new Set([ ]); const CODEX_DECISIONS = new Set(['allow', 'warn', 'require_approval', 'block']); const NATIVE_TOOL_ACTION_TYPES = new Set([ - 'shell', 'file_read', 'file_write', 'network', 'browser', 'web_search', 'skill_install', 'mcp_tool', 'other', + 'shell', 'file_read', 'file_write', 'network', 'browser', 'web_search', 'skill_install', 'mcp_tool', 'deploy', 'other', +]); +const NATIVE_STRUCTURED_PREVIEW_ACTION_TYPES = new Set([ + 'shell', 'file_read', 'file_write', 'network', 'browser', ]); +const NATIVE_ACTION_DESCRIPTORS: Readonly> = { + shell: 'shell command', + file_read: 'file read', + file_write: 'file write', + network: 'network request', + web_search: 'web search', + mcp_tool: 'MCP tool', + browser: 'browser action', + skill_install: 'skill install', + deploy: 'deployment', + llm_request: 'model request', + llm_response: 'model response', + other: 'tool action', +}; 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']); diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 968bc96..1a8d22b 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -306,6 +306,57 @@ describe('Runtime Cloud bridge', () => { assert.doesNotMatch(persisted, new RegExp(`${outputMarker}|${secret}`)); }); + it('distinguishes every native post-tool action type when a safe action preview is unavailable', () => { + const rawOutput = 'RAW_PRIVATE_POST_TOOL_OUTPUT'; + const cases = [ + ['shell', 'Bash', 'shell command via Bash: Potential data exfiltration'], + ['file_read', 'Read', 'file read via Read: Potential data exfiltration'], + ['file_write', 'Write', 'file write via Write: Potential data exfiltration'], + ['network', 'WebFetch', 'network request via WebFetch: Potential data exfiltration'], + ['browser', 'Browser', 'browser action via Browser: Potential data exfiltration'], + ['web_search', 'WebSearch', 'web search via WebSearch args=query'], + ['mcp_tool', 'mcp__deploy__release', 'MCP tool via mcp__deploy__release args=operation'], + ['skill_install', 'SkillInstall', 'skill install via SkillInstall args=source'], + ['deploy', 'Deploy', 'deployment via Deploy args=target'], + ['other', 'CustomTool', 'tool action via CustomTool args=operation'], + ] as const; + + for (const [actionType, toolName, expected] of cases) { + const event = buildCloudAuditEvent({ + ...sampleEvent(), + actionId: `act_post_fallback_${actionType}`, + agentHost: 'codex', + actionType: 'other', + toolName, + input: rawOutput, + decision: 'block', + policyDecision: 'block', + riskScore: 100, + riskLevel: 'critical', + lifecycleStage: 'post_tool', + reasons: [{ + code: 'DATA_EXFILTRATION', + severity: 'high', + title: 'Raw sensitive output title', + description: 'Raw sensitive output description', + }], + metadata: { + codexHookEvent: 'PostToolUse', + nativeToolActionType: actionType, + nativeToolArgumentKeys: [ + actionType === 'web_search' ? 'query' + : actionType === 'skill_install' ? 'source' + : actionType === 'deploy' ? 'target' + : 'operation', + ], + }, + }); + + assert.equal(event.input, expected, actionType); + assert.doesNotMatch(JSON.stringify(event), /RAW_PRIVATE_POST_TOOL_OUTPUT|Raw sensitive output/); + } + }); + it('keeps protected paths bounded when a native post-tool finding comes from output', () => { const event = buildCloudAuditEvent({ ...sampleEvent(), @@ -874,6 +925,39 @@ describe('Runtime Cloud bridge', () => { } }); + it('preserves Cloud reporting for every non-LLM action type outside explicit local-only integrations', () => { + const actionTypes = [ + 'shell', 'file_read', 'file_write', 'network', 'web_search', + 'mcp_tool', 'browser', 'skill_install', 'deploy', 'other', + ] as const; + + for (const actionType of actionTypes) { + const event: RuntimeAuditEvent = { + ...sampleEvent(), + actionId: `act_openclaw_${actionType}`, + agentHost: 'openclaw', + actionType, + toolName: `tool_${actionType}`, + input: `safe ${actionType} context`, + }; + assert.equal(shouldReportAuditEventToCloud(event), true, actionType); + assert.equal(shouldReportAuditEventToCloud({ ...event, agentHost: 'dsh' }), false, `dsh:${actionType}`); + } + + const plainCodexEvent = { ...sampleEvent(), metadata: {} }; + const plainClaudeEvent = { ...sampleEvent(), agentHost: 'claude-code' as const, metadata: {} }; + assert.equal(shouldReportAuditEventToCloud(plainCodexEvent), true); + assert.equal(shouldReportAuditEventToCloud(plainClaudeEvent), true); + assert.equal(shouldReportAuditEventToCloud({ + ...plainCodexEvent, + metadata: { codexHookEvent: 'PreToolUse' }, + }), false); + assert.equal(shouldReportAuditEventToCloud({ + ...plainClaudeEvent, + metadata: { claudeHookEvent: 'PreToolUse' }, + }), false); + }); + it('uploads only a bounded DSH finding summary without raw I/O or absolute paths', async () => { const originalFetch = globalThis.fetch; const requests: string[] = []; @@ -1055,13 +1139,15 @@ describe('Runtime Cloud bridge', () => { actionType: 'other', input: 'hello', lifecycleStage: 'post_tool', - metadata: { codexHookEvent: 'PostToolUse', exitCode: 0 }, + metadata: { codexHookEvent: 'PostToolUse', nativeToolActionType: 'shell', exitCode: 0 }, }; const failedPost: RuntimeAuditEvent = { ...routinePost, actionId: 'act_post_failed', input: 'private failure output', - metadata: { codexHookEvent: 'PostToolUse', exitCode: 1, toolFailed: true }, + metadata: { + codexHookEvent: 'PostToolUse', nativeToolActionType: 'shell', exitCode: 1, toolFailed: true, + }, }; const riskyPost: RuntimeAuditEvent = { ...routinePost, @@ -1080,7 +1166,7 @@ describe('Runtime Cloud bridge', () => { ...routinePost, actionId: 'act_claude_failure', agentHost: 'claude-code', - metadata: { claudeHookEvent: 'PostToolUseFailure', toolFailed: true }, + metadata: { claudeHookEvent: 'PostToolUseFailure', nativeToolActionType: 'shell', toolFailed: true }, }; assert.equal(shouldReportAuditEventToCloud(preTool), true); @@ -1107,7 +1193,11 @@ describe('Runtime Cloud bridge', () => { ]); assert.deepEqual( payload.events?.filter((event) => event.lifecycleStage === 'post_tool').map((event) => event.input), - ['Bash: Tool execution failed', 'Bash: Potential data exfiltration', 'Bash: Tool execution failed'], + [ + 'shell command via Bash: Tool execution failed', + 'shell command via Bash: Potential data exfiltration', + 'shell command via Bash: Tool execution failed', + ], ); assert.doesNotMatch(requests[0]!, /private failure output|private tool output/); } finally {