Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion docs/privacy-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions src/runtime/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -113,6 +116,12 @@ function isNativePostToolEvent(event: RuntimeAuditEvent): boolean {
|| event.metadata?.claudeHookEvent === 'PostToolUseFailure';
}

function isClaudePostToolBatchEvent(
event: Pick<RuntimeAction, 'agentHost' | 'metadata'>,
): boolean {
return event.agentHost === 'claude-code' && event.metadata?.claudeHookEvent === 'PostToolBatch';
}

function hasPiiSummary(summary: RuntimePiiSummary | undefined): boolean {
return Boolean(summary
&& Number.isSafeInteger(summary.valueCount)
Expand Down Expand Up @@ -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}`;
}
Expand Down
77 changes: 77 additions & 0 deletions src/tests/runtime-cloud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>[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[] = [];
Expand Down
Loading