From 85ac3698e8d3fe3e0f4af11ac0197ef33127a966 Mon Sep 17 00:00:00 2001 From: 0xJeff Date: Mon, 12 Oct 2026 10:21:26 +0800 Subject: [PATCH] fix: report blocked post-tool batches to cloud --- CHANGELOG.md | 3 ++ docs/claude-code.md | 2 +- docs/privacy-boundary.md | 4 +- src/runtime/audit.ts | 20 ++++++--- src/tests/runtime-cloud.test.ts | 77 +++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2c737..2471c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - 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. +### Fixed +- Blocked Claude Code `PostToolBatch` findings now reach Cloud ingest with a bounded semantic summary and batch counts, while raw tool results, sensitive values, and absolute paths remain local. + ## [1.2.1] - 2026-09-29 ### Added diff --git a/docs/claude-code.md b/docs/claude-code.md index 168d302..8b43efb 100644 --- a/docs/claude-code.md +++ b/docs/claude-code.md @@ -38,7 +38,7 @@ Routine successful `PostToolUse` observations remain in the local audit and are `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. +`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. Material findings are uploaded even when continuation is blocked, using only a bounded tool-and-finding summary, static policy titles, and aggregate counts; raw tool results, sensitive values, and absolute paths remain local. Tool reads and side effects have already occurred. Existing transcript content is not removed, and a resumed session is not guaranteed to suppress retransmission. `ConfigChange` reads the event source and, when `file_path` is present, at most the first 256 KiB of new local file content to detect endpoint changes, key forwarding, and dangerous permissions. Source-only events remain metadata-only partial coverage. A block prevents the current session from applying the change; it does not roll back the file on disk. diff --git a/docs/privacy-boundary.md b/docs/privacy-boundary.md index 6086ab7..e772c36 100644 --- a/docs/privacy-boundary.md +++ b/docs/privacy-boundary.md @@ -50,7 +50,9 @@ Only redacted, Cloud-eligible runtime audit previews are uploaded by default: - 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. + anomalous result, PII summary, or another material security finding. A + material Claude Code `PostToolBatch` event remains reportable when it blocks + model continuation, using only a static finding summary and aggregate counts. - 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 diff --git a/src/runtime/audit.ts b/src/runtime/audit.ts index ad85fd5..3280d8e 100644 --- a/src/runtime/audit.ts +++ b/src/runtime/audit.ts @@ -11,11 +11,13 @@ export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { agentHost: event.agentHost, actionType: event.actionType, toolName: redactPreview(event.toolName, 160), - input: isLlmTrafficEvent(event) - ? '[LOCAL_ONLY_LLM_CONTENT]' - : nativeHook - ? nativeHookAuditInput(event) - : redactPreview(event.input), + input: isClaudePostToolBatchEvent(event) + ? nativeHookAuditInput(event) + : isLlmTrafficEvent(event) + ? '[LOCAL_ONLY_LLM_CONTENT]' + : nativeHook + ? nativeHookAuditInput(event) + : redactPreview(event.input), decision: event.decision, policyDecision: event.policyDecision, riskScore: clampRiskScore(event.riskScore), @@ -49,6 +51,7 @@ export function buildAuditEvent(event: RuntimeAuditEvent): RuntimeAuditEvent { */ export function shouldReportAuditEventToCloud(event: RuntimeAuditEvent): boolean { if (isNativePostToolEvent(event)) return shouldReportNativePostToolEvent(event); + if (isClaudePostToolBatchEvent(event)) return hasMaterialAuditFinding(event); if (event.actionType === 'llm_response') return false; if (event.actionType !== 'llm_request') { return isExplicitCleanLocalOnlyEvent(event) ? hasMaterialAuditFinding(event) : true; @@ -113,6 +116,12 @@ function isNativePostToolEvent(event: RuntimeAuditEvent): boolean { || event.metadata?.claudeHookEvent === 'PostToolUseFailure'; } +function isClaudePostToolBatchEvent( + event: Pick, +): boolean { + return event.agentHost === 'claude-code' && event.metadata?.claudeHookEvent === 'PostToolBatch'; +} + function hasPiiSummary(summary: RuntimePiiSummary | undefined): boolean { return Boolean(summary && Number.isSafeInteger(summary.valueCount) @@ -308,6 +317,7 @@ function nativeHookSummarySubject( actionType: RuntimeActionType, toolName: string, ): string { + if (isClaudePostToolBatchEvent(event)) return 'PostToolBatch'; if (!isNativePostToolEvent(event)) return toolName; return `${NATIVE_ACTION_DESCRIPTORS[actionType]} via ${toolName}`; } diff --git a/src/tests/runtime-cloud.test.ts b/src/tests/runtime-cloud.test.ts index 1a8d22b..e979e6f 100644 --- a/src/tests/runtime-cloud.test.ts +++ b/src/tests/runtime-cloud.test.ts @@ -878,6 +878,83 @@ describe('Runtime Cloud bridge', () => { } }); + it('reports blocked PostToolBatch findings with a useful bounded Cloud summary', async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + const rawResult = 'personal_email=private.person@corp.invalid'; + const absolutePath = '/Users/private/workspace/customer-records.txt'; + const blockedBatch: RuntimeAuditEvent = { + ...sampleEvent(), + actionId: 'act_blocked_post_tool_batch', + agentHost: 'claude-code', + actionType: 'llm_request', + toolName: 'Tool', + input: JSON.stringify({ tool_response: rawResult, file_path: absolutePath }), + decision: 'block', + policyDecision: 'block', + riskScore: 100, + riskLevel: 'critical', + lifecycleStage: 'post_tool_batch', + canBlockCurrentAction: true, + coverageLevel: 'partial', + enforcementStatus: 'enforced', + reasons: [{ + code: 'PII_EGRESS', + severity: 'critical', + title: 'Raw sensitive batch title', + description: 'Raw sensitive batch description', + }], + privacySummary: { + categories: [{ category: 'email_address', count: 1 }], + valueCount: 1, + }, + metadata: { + claudeHookEvent: 'PostToolBatch', + continuationBlockedOnly: true, + resumeRetransmissionGuaranteed: false, + filePathCount: 1, + serializedResultBytes: 4096, + redactedBytes: 64, + }, + }; + + assert.equal(shouldReportAuditEventToCloud(blockedBatch), true); + + 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([blockedBatch]); + assert.equal(requests.length, 1); + + const payload = JSON.parse(requests[0]!) as { events?: RuntimeAuditEvent[] }; + const uploaded = payload.events?.[0]; + assert.ok(uploaded); + assert.equal(uploaded.input, 'PostToolBatch: Sensitive data exposure'); + assert.equal(uploaded.decision, 'block'); + assert.equal(uploaded.lifecycleStage, 'post_tool_batch'); + assert.deepEqual(uploaded.privacySummary, blockedBatch.privacySummary); + assert.deepEqual(uploaded.metadata, { + claudeHookEvent: 'PostToolBatch', + filePathCount: 1, + serializedResultBytes: 4096, + redactedBytes: 64, + continuationBlockedOnly: true, + resumeRetransmissionGuaranteed: false, + }); + assert.equal(uploaded.reasons[0]?.title, 'Sensitive data exposure'); + assert.doesNotMatch(requests[0]!, /private\.person|customer-records|Raw sensitive batch/); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('keeps clean DSH, Codex, and Claude Code tool events out of Cloud ingest', async () => { const originalFetch = globalThis.fetch; const requests: string[] = [];