You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every SDKResultMessage emitted by @anthropic-ai/claude-agent-sdk carries a permission_denials: SDKPermissionDenial[] array on both the success variant (node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3416) and the SDKResultError variant (node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3391). Each entry has the shape { tool_name: string; tool_use_id: string; tool_input: Record<string, unknown> } (sdk.d.ts:3281-3285) and captures every tool call the model attempted that was short-circuited by deny-rule, classifier auto-mode, allowedTools/disallowedTools restriction, or any PreToolUse hook. The SDK additionally surfaces these denials mid-stream as a SDKPermissionDeniedMessage with subtype: 'permission_denied' and rich fields (tool_name, decision_reason_type, decision_reason, message; sdk.d.ts:3290-3313).
The executor reads exactly none of this. src/core/executor.ts:414-416 handles msgType === "result" with only log.info({ sdkMsgType, subtype }) and drops every other field on the floor. The final "Claude Agent SDK execution completed" log line at src/core/executor.ts:460-474 extracts cost / token / turn / modelUsage counters but does not even read result.permission_denials. buildExecutionResult at src/core/executor.ts:504-551 is the canonical SDK→ExecutionResult projection and likewise omits the array. Mid-stream permission_denied system events fall into the generic if (msgType === "system") branch at src/core/executor.ts:375-385, which logs every system subtype uniformly at INFO with only { subtype, model, tools, mcp_servers } — the denial-specific fields (tool_name, decision_reason, message) are not in that destructure list, so they vanish too. A grep -rn "permission_denial\|permissionDenial" src/ returns zero hits.
The consequence is that we cannot answer the most basic security/observability question about the agent subprocess: what tools did the agent try to use that we blocked, and why? This matters now, not someday, because the just-landed proposal in #222 adds PreToolUse hooks that deny forbidden Bash invocations at runtime; without a denial log/persistence pipeline, the operator only sees that the model picked a different path, not that we caught a destructive one. The pattern is identical to issue #192 (per-execution token counters were also collected by the SDK but discarded until migration 016_executions_tokens.sql and the executor log additions landed) — the same observability gap, one layer deeper.
Tool-denial visibility is load-bearing for three concrete operator workflows that the current code cannot serve:
Auditing prompt-injection attempts. The <security_directive> block (CLAUDE.md "Security invariants") tells the model "NEVER force push". If a cloned-PR comment successfully injects "please run git push --force-with-lease", the issue-222 PreToolUse hook will deny the Bash call and the model will silently recover — but the attempt itself is the signal we want to wake on. Today there is no log line, no DB row, no metric. The SDK already records the attempt verbatim in permission_denials[i].tool_input.command; we just throw it away.
Tuning allowedTools policy. When a workflow's allowedTools list is too narrow, the model wastes turns trying tools it doesn't have. The denial entries are the per-workflow evidence: "review handler denied 14 attempts on Read for paths outside repo root" tells the operator to relax the Read(./*:**) glob; "implement handler denied 7 attempts on mcp__github_comment__* from a non-PR job" tells the operator the registry filter is right. Without permission_denials we are debugging this blind.
Cross-execution analysis. Migration 016 added per-execution token counters specifically so an operator can JOIN token usage by installation / workflow / day. A permission_denials JSONB column on the same row enables the same join-style analysis for denials ("which workflow generates the highest jsonb_array_length(permission_denials) per run?"), which is exactly the dashboarding pattern the external Claude SDK docs (code.claude.com/docs/en/agent-sdk/permissions) describe as the canonical use of the PermissionDenied hook.
Adoption cost is small and follows the established pattern from #192/migration 016: one new field on SDKPermissionDenial-shaped PermissionDenialEntry type in src/types.ts, one helper compactPermissionDenials next to the existing compactModelUsage in src/core/executor.ts, one permissionDenials?: readonly PermissionDenialEntry[] field on ExecutionResult (src/types.ts:96-122), one zod entry on the jobResultSchema payload (src/shared/ws-messages.ts:391-402 is the precise siblings list to mirror), one branch in connection-handler.ts:1413-1444 (mirrors the modelUsage propagation), one column on executions via 017_executions_permission_denials.sql, and one INSERT update in src/orchestrator/history.ts:187. The tool_input payload is attacker-influenced text (a Bash command body) and MUST be passed through redactSecrets from src/utils/sanitize.ts before logging — same chokepoint the executor's stderr handler already uses at src/core/executor.ts:314-329.
References
Internal:
src/core/executor.ts:414-416 — else if (msgType === "result") branch where mid-stream result data is observed; today logs only subtype.
src/core/executor.ts:460-474 — final "Claude Agent SDK execution completed" log line; mirror site for permissionDenialCount + redacted denial summary.
src/core/executor.ts:504-551 — buildExecutionResult projection; the new compactPermissionDenials lives next to compactModelUsage at lines 487-501 with the identical "omit when empty" idiom.
src/core/executor.ts:375-385 — if (msgType === "system") branch that absorbs permission_denied subtypes today without escalating; site for the new WARN-level agent.permission.denied structured event.
src/core/executor.ts:314-329 — existing redactSecrets chokepoint for attacker-influenced stderr text; the same wrapper applies to tool_input strings before they hit pino.
src/types.ts:96-122 — ExecutionResult interface; add permissionDenials?: readonly PermissionDenialEntry[] next to the existing modelUsage field.
src/types.ts:84-91 — ModelUsageEntry interface; pattern to mirror for the new PermissionDenialEntry type.
src/shared/ws-messages.ts:375-403 — jobResultSchema payload; mirror modelUsage zod schema for permissionDenials.
src/orchestrator/connection-handler.ts:1400-1444 — execution-completed payload projection; mirror the if (payload.modelUsage !== undefined) branch at line 1436.
src/orchestrator/history.ts:170-190 — markExecutionCompleted UPDATE; add permission_denials = ${result.permissionDenials ?? null} next to model_usage.
src/db/migrations/016_executions_tokens.sql:14-24 — template for the additive nullable JSONB column on executions.
node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3281-3313 — local SDK type definitions for SDKPermissionDenial and the mid-stream SDKPermissionDeniedMessage.
node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3391, :3416 — permission_denials: SDKPermissionDenial[] on both SDKResultError and SDKResultSuccess.
CLAUDE.md "Security invariants" — the four prompt-injection-hardening contracts this finding extends with denial-attempt observability.
docs/operate/observability.md:61 — token-counter doc block; mirror for the new permissionDenialCount field on the executor log line and the new permission_denials column on the executions table.
Issue #222 — PreToolUse runtime deny hooks for destructive Bash; this finding is its observability counterpart and was deliberately scoped narrow so the two can ship independently.
Issue #192 — per-execution token persistence (migration 016); this finding follows the same wiring path.
External:
Handling Permissions — Claude Agent SDK — official SDK reference for permission flow and the canUseTool / hook decision points that produce permission_denials.
Configure permissions — Claude Code Docs — describes permission_denials as the audit source for "how tightly your policy is tuned" and lists the dashboarding use case verbatim.
PermissionDenied Hook — Developers Digest — production patterns for denial observability including "build dashboards for what is Claude trying to do that's blocked" — the exact workflow this finding enables.
Add PermissionDenialEntry to src/types.ts mirroring the SDK shape: { toolName: string; toolUseId: string; toolInput?: Record<string, unknown>; decisionReason?: string }. Add permissionDenials?: readonly PermissionDenialEntry[] to ExecutionResult at the modelUsage field's neighbour position.
Add compactPermissionDenials(result?.permission_denials) alongside compactModelUsage in src/core/executor.ts. Map each SDKPermissionDenial through redactSecrets on any string-typed tool_input value before returning; return undefined for an empty array so the field is omitted from logs/DB rather than persisted as [] (same idiom as compactModelUsage at lines 487-501).
Wire mid-stream WARN event in the system branch. When msg["subtype"] === "permission_denied", emit log.warn({ event: "agent.permission.denied", toolName, decisionReasonType, decisionReason, message }) instead of falling into the generic INFO line. Mirror the event:-keyed structured-event idiom from agent.timeout / llm_scanner_substitution_rejected.
Extend the final completed log line at src/core/executor.ts:460-474 with permissionDenialCount: result?.permission_denials?.length ?? 0 and permissionDeniedTools (deduped histogram of tool_name). Always emit the count so a zero is also a positive signal.
Extend buildExecutionResult at src/core/executor.ts:504-551 with the existing "set only when defined" idiom: const denials = compactPermissionDenials(...); if (denials !== undefined) executionResult.permissionDenials = denials;.
Add zod schema in src/shared/ws-messages.ts:391-402 mirroring modelUsage, and the matching propagation branch in src/orchestrator/connection-handler.ts:1436.
Add migration 017_executions_permission_denials.sql with ALTER TABLE executions ADD COLUMN permission_denials JSONB NULL; plus a header comment mirroring 016_executions_tokens.sql:1-13 (rationale, nullability, additivity). Update markExecutionCompleted in src/orchestrator/history.ts:187.
Update docs/operate/observability.md with the new log fields and DB column per the CLAUDE.md doc-sync rule; update the per-execution-fields table near line 61. Add a ## Permission denials subsection with the canonical SELECT query (e.g. group by tool_name).
Colocated test at src/core/executor.test.ts: stub a fake SDKResultMessage with two permission_denials entries, run buildExecutionResult, assert permissionDenials.length === 2, assert empty array is omitted, assert redactSecrets is applied to tool_input.command.
Areas Evaluated
Read src/core/executor.ts in full (551 lines) to confirm the message-stream handling (system/assistant/result branches at lines 375-416), the buildExecutionResult projection (504-551), and the final completed log line (460-474). Confirmed zero permission_denials references.
Read src/types.ts:84-141 to confirm ExecutionResult shape and ModelUsageEntry template.
Read node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3281-3424 and :3515-3547 to confirm SDKPermissionDenial, SDKPermissionDeniedMessage, and the field-presence on both SDKResultSuccess and SDKResultError.
Read src/db/migrations/016_executions_tokens.sql as the template for the additive nullable-JSONB column migration.
Read src/orchestrator/connection-handler.ts:1400-1445 and src/shared/ws-messages.ts:375-410 to confirm the daemon→orchestrator payload propagation path.
Grepped src/ and docs/ for permission_denial|permissionDenial|denials — zero hits except the proposal context in issue #222.
Checked recent commits (git log --oneline -20) and existing research issues; #222 (PreToolUse hook) is the closest cousin and is intentionally scoped to adding denials, not observing them. This finding can land before or after #222 independently — both gates compose.
WebSearched Claude Agent SDK permission-denial observability patterns; confirmed permission_denials / PermissionDenied hook are the canonical SDK-native observability primitives recommended for exactly this workflow.
Generated by the scheduled research action on 2026-06-12
Finding
Every
SDKResultMessageemitted by@anthropic-ai/claude-agent-sdkcarries apermission_denials: SDKPermissionDenial[]array on both the success variant (node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3416) and theSDKResultErrorvariant (node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3391). Each entry has the shape{ tool_name: string; tool_use_id: string; tool_input: Record<string, unknown> }(sdk.d.ts:3281-3285) and captures every tool call the model attempted that was short-circuited by deny-rule, classifier auto-mode,allowedTools/disallowedToolsrestriction, or anyPreToolUsehook. The SDK additionally surfaces these denials mid-stream as aSDKPermissionDeniedMessagewithsubtype: 'permission_denied'and rich fields (tool_name,decision_reason_type,decision_reason,message;sdk.d.ts:3290-3313).The executor reads exactly none of this.
src/core/executor.ts:414-416handlesmsgType === "result"with onlylog.info({ sdkMsgType, subtype })and drops every other field on the floor. The final"Claude Agent SDK execution completed"log line atsrc/core/executor.ts:460-474extracts cost / token / turn /modelUsagecounters but does not even readresult.permission_denials.buildExecutionResultatsrc/core/executor.ts:504-551is the canonical SDK→ExecutionResultprojection and likewise omits the array. Mid-streampermission_deniedsystem events fall into the genericif (msgType === "system")branch atsrc/core/executor.ts:375-385, which logs every system subtype uniformly at INFO with only{ subtype, model, tools, mcp_servers }— the denial-specific fields (tool_name,decision_reason,message) are not in that destructure list, so they vanish too. Agrep -rn "permission_denial\|permissionDenial" src/returns zero hits.The consequence is that we cannot answer the most basic security/observability question about the agent subprocess: what tools did the agent try to use that we blocked, and why? This matters now, not someday, because the just-landed proposal in
#222addsPreToolUsehooks that deny forbidden Bash invocations at runtime; without a denial log/persistence pipeline, the operator only sees that the model picked a different path, not that we caught a destructive one. The pattern is identical to issue#192(per-execution token counters were also collected by the SDK but discarded untilmigration 016_executions_tokens.sqland the executor log additions landed) — the same observability gap, one layer deeper.Diagram
flowchart TB subgraph Now["Current: permission_denials silently dropped"] direction TB SDK1[Claude Agent SDK<br/>query iterator]:::neutral Stream1[SDK message stream<br/>system, assistant, result]:::neutral SysEv1[system permission_denied<br/>mid-stream event]:::warn Result1[SDKResultMessage<br/>permission_denials array]:::warn Exec1[executor.ts buildExecutionResult<br/>extracts cost, tokens, turns, modelUsage]:::neutral Drop1[permission_denials field discarded<br/>not logged, not persisted]:::danger SDK1 --> Stream1 Stream1 --> SysEv1 Stream1 --> Result1 SysEv1 -. logged as generic INFO sdkMsgType=system .-> Drop1 Result1 --> Exec1 --> Drop1 end subgraph Proposed["Proposed: structured denial events plus persistence"] direction TB SDK2[Same SDK call]:::neutral Stream2[Same stream]:::neutral SysEv2[system permission_denied]:::warn Result2[SDKResultMessage permission_denials]:::warn Hook2[Executor emits WARN agent.permission.denied<br/>per mid-stream denial with tool_name + reason]:::ok Build2[buildExecutionResult: compactPermissionDenials<br/>adds ExecutionResult.permissionDenials]:::ok Final2[final completed log carries<br/>permissionDenialCount and toolName histogram]:::ok Persist2[migration 017 adds<br/>executions.permission_denials JSONB column]:::ok SDK2 --> Stream2 Stream2 --> SysEv2 --> Hook2 Stream2 --> Result2 --> Build2 --> Final2 Build2 --> Persist2 end classDef neutral fill:#1f4e79,color:#ffffff,stroke:#0d2a44,stroke-width:1px classDef warn fill:#9a6700,color:#ffffff,stroke:#5c3d00,stroke-width:1px classDef danger fill:#a40e26,color:#ffffff,stroke:#5c0815,stroke-width:1px classDef ok fill:#196f3d,color:#ffffff,stroke:#0d3a20,stroke-width:1pxRationale
Tool-denial visibility is load-bearing for three concrete operator workflows that the current code cannot serve:
<security_directive>block (CLAUDE.md "Security invariants") tells the model "NEVER force push". If a cloned-PR comment successfully injects "please rungit push --force-with-lease", the issue-222PreToolUsehook will deny the Bash call and the model will silently recover — but the attempt itself is the signal we want to wake on. Today there is no log line, no DB row, no metric. The SDK already records the attempt verbatim inpermission_denials[i].tool_input.command; we just throw it away.allowedToolspolicy. When a workflow'sallowedToolslist is too narrow, the model wastes turns trying tools it doesn't have. The denial entries are the per-workflow evidence: "review handler denied 14 attempts onReadfor paths outside repo root" tells the operator to relax theRead(./*:**)glob; "implement handler denied 7 attempts onmcp__github_comment__*from a non-PR job" tells the operator the registry filter is right. Withoutpermission_denialswe are debugging this blind.JOINtoken usage by installation / workflow / day. Apermission_denials JSONBcolumn on the same row enables the same join-style analysis for denials ("which workflow generates the highestjsonb_array_length(permission_denials)per run?"), which is exactly the dashboarding pattern the external Claude SDK docs (code.claude.com/docs/en/agent-sdk/permissions) describe as the canonical use of thePermissionDeniedhook.Adoption cost is small and follows the established pattern from
#192/migration 016: one new field onSDKPermissionDenial-shapedPermissionDenialEntrytype insrc/types.ts, one helpercompactPermissionDenialsnext to the existingcompactModelUsageinsrc/core/executor.ts, onepermissionDenials?: readonly PermissionDenialEntry[]field onExecutionResult(src/types.ts:96-122), one zod entry on thejobResultSchemapayload (src/shared/ws-messages.ts:391-402is the precise siblings list to mirror), one branch inconnection-handler.ts:1413-1444(mirrors themodelUsagepropagation), one column onexecutionsvia017_executions_permission_denials.sql, and one INSERT update insrc/orchestrator/history.ts:187. Thetool_inputpayload is attacker-influenced text (a Bash command body) and MUST be passed throughredactSecretsfromsrc/utils/sanitize.tsbefore logging — same chokepoint the executor'sstderrhandler already uses atsrc/core/executor.ts:314-329.References
Internal:
src/core/executor.ts:414-416—else if (msgType === "result")branch where mid-stream result data is observed; today logs onlysubtype.src/core/executor.ts:460-474— final"Claude Agent SDK execution completed"log line; mirror site forpermissionDenialCount+ redacted denial summary.src/core/executor.ts:504-551—buildExecutionResultprojection; the newcompactPermissionDenialslives next tocompactModelUsageat lines 487-501 with the identical "omit when empty" idiom.src/core/executor.ts:375-385—if (msgType === "system")branch that absorbspermission_deniedsubtypes today without escalating; site for the new WARN-levelagent.permission.deniedstructured event.src/core/executor.ts:314-329— existingredactSecretschokepoint for attacker-influenced stderr text; the same wrapper applies totool_inputstrings before they hit pino.src/types.ts:96-122—ExecutionResultinterface; addpermissionDenials?: readonly PermissionDenialEntry[]next to the existingmodelUsagefield.src/types.ts:84-91—ModelUsageEntryinterface; pattern to mirror for the newPermissionDenialEntrytype.src/shared/ws-messages.ts:375-403—jobResultSchemapayload; mirrormodelUsagezod schema forpermissionDenials.src/orchestrator/connection-handler.ts:1400-1444— execution-completed payload projection; mirror theif (payload.modelUsage !== undefined)branch at line 1436.src/orchestrator/history.ts:170-190—markExecutionCompletedUPDATE; addpermission_denials = ${result.permissionDenials ?? null}next tomodel_usage.src/db/migrations/016_executions_tokens.sql:14-24— template for the additive nullable JSONB column onexecutions.node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3281-3313— local SDK type definitions forSDKPermissionDenialand the mid-streamSDKPermissionDeniedMessage.node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3391,:3416—permission_denials: SDKPermissionDenial[]on bothSDKResultErrorandSDKResultSuccess.CLAUDE.md"Security invariants" — the four prompt-injection-hardening contracts this finding extends with denial-attempt observability.docs/operate/observability.md:61— token-counter doc block; mirror for the newpermissionDenialCountfield on the executor log line and the newpermission_denialscolumn on the executions table.#222—PreToolUseruntime deny hooks for destructive Bash; this finding is its observability counterpart and was deliberately scoped narrow so the two can ship independently.#192— per-execution token persistence (migration 016); this finding follows the same wiring path.External:
permission_denials.permission_denialsas the audit source for "how tightly your policy is tuned" and lists the dashboarding use case verbatim.Suggested Next Steps
PermissionDenialEntrytosrc/types.tsmirroring the SDK shape:{ toolName: string; toolUseId: string; toolInput?: Record<string, unknown>; decisionReason?: string }. AddpermissionDenials?: readonly PermissionDenialEntry[]toExecutionResultat themodelUsagefield's neighbour position.compactPermissionDenials(result?.permission_denials)alongsidecompactModelUsageinsrc/core/executor.ts. Map eachSDKPermissionDenialthroughredactSecretson any string-typedtool_inputvalue before returning; returnundefinedfor an empty array so the field is omitted from logs/DB rather than persisted as[](same idiom ascompactModelUsageat lines 487-501).systembranch. Whenmsg["subtype"] === "permission_denied", emitlog.warn({ event: "agent.permission.denied", toolName, decisionReasonType, decisionReason, message })instead of falling into the generic INFO line. Mirror theevent:-keyed structured-event idiom fromagent.timeout/llm_scanner_substitution_rejected.src/core/executor.ts:460-474withpermissionDenialCount: result?.permission_denials?.length ?? 0andpermissionDeniedTools(deduped histogram oftool_name). Always emit the count so a zero is also a positive signal.buildExecutionResultatsrc/core/executor.ts:504-551with the existing "set only when defined" idiom:const denials = compactPermissionDenials(...); if (denials !== undefined) executionResult.permissionDenials = denials;.src/shared/ws-messages.ts:391-402mirroringmodelUsage, and the matching propagation branch insrc/orchestrator/connection-handler.ts:1436.017_executions_permission_denials.sqlwithALTER TABLE executions ADD COLUMN permission_denials JSONB NULL;plus a header comment mirroring016_executions_tokens.sql:1-13(rationale, nullability, additivity). UpdatemarkExecutionCompletedinsrc/orchestrator/history.ts:187.docs/operate/observability.mdwith the new log fields and DB column per the CLAUDE.md doc-sync rule; update the per-execution-fields table near line 61. Add a## Permission denialssubsection with the canonical SELECT query (e.g. group bytool_name).src/core/executor.test.ts: stub a fakeSDKResultMessagewith twopermission_denialsentries, runbuildExecutionResult, assertpermissionDenials.length === 2, assert empty array is omitted, assertredactSecretsis applied totool_input.command.Areas Evaluated
src/core/executor.tsin full (551 lines) to confirm the message-stream handling (system/assistant/resultbranches at lines 375-416), thebuildExecutionResultprojection (504-551), and the final completed log line (460-474). Confirmed zeropermission_denialsreferences.src/types.ts:84-141to confirmExecutionResultshape andModelUsageEntrytemplate.node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts:3281-3424and:3515-3547to confirmSDKPermissionDenial,SDKPermissionDeniedMessage, and the field-presence on bothSDKResultSuccessandSDKResultError.src/db/migrations/016_executions_tokens.sqlas the template for the additive nullable-JSONB column migration.src/orchestrator/connection-handler.ts:1400-1445andsrc/shared/ws-messages.ts:375-410to confirm the daemon→orchestrator payload propagation path.src/anddocs/forpermission_denial|permissionDenial|denials— zero hits except the proposal context in issue#222.git log --oneline -20) and existing research issues;#222(PreToolUse hook) is the closest cousin and is intentionally scoped to adding denials, not observing them. This finding can land before or after#222independently — both gates compose.permission_denials/PermissionDeniedhook are the canonical SDK-native observability primitives recommended for exactly this workflow.Generated by the scheduled research action on 2026-06-12