From f306616e91266c840f6aad23ff044fdf6c2bacfd Mon Sep 17 00:00:00 2001 From: Eli Sherman Date: Tue, 3 Nov 2026 14:07:09 -0500 Subject: [PATCH 1/4] Carry the Guardian's reasoning in additionalContext at the Claude Code request gate When several PreToolUse hooks deny one call, Claude Code shows the model the denial reason of only the hook that finishes last, by JSON or by exit 2, and drops the others. A project hook that answers after the shim therefore hides the Guardian's reasoning, though the call stays blocked (issue #109). An allow's permissionDecisionReason reaches only the debug log, so a fail-open proceed never explained itself to the model either. Claude Code keeps additionalContext from every hook. The hookmap now also writes reasoning there on allow, deny, defer and modify. Measured with the real shim and the reference Guardian under Claude Code 2.1.285 and 2.1.311, beside a project hook that denies by exit 2 and finishes last: before, the policy's reason never reached the model; after, it arrives as hook additional context. The result gate needs no change, because Claude Code passes every hook's block reason to the model. Refs #109 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Eli Sherman --- .../agt/hosts/claude-code/README.md | 6 ++++ .../claude-code/claude-code.hookmap.yaml | 29 +++++++++++++++---- .../hosts/claude-code/test/wire-shape.test.ts | 4 +++ .../host-adapter/test/render-decision.test.ts | 7 +++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/reference-implementations/agt/hosts/claude-code/README.md b/reference-implementations/agt/hosts/claude-code/README.md index 1e4be9c1..9e1c39ba 100644 --- a/reference-implementations/agt/hosts/claude-code/README.md +++ b/reference-implementations/agt/hosts/claude-code/README.md @@ -54,6 +54,12 @@ Claude Code reads the exit code before it reads stdout, so the shim uses exactly Exit `1` never appears. Claude Code reads it as "the hook did not fire" and proceeds ungoverned. +## When another hook answers the same call + +Claude Code runs every matching hook in parallel. A project that already has its own `PreToolUse` hook on `Bash` runs it beside this shim. A deny from either one blocks the call. But when several hooks deny, the model reads the denial reason of only the hook that finishes last, whether it denied by JSON or by exit 2 (issue #109, measured on Claude Code 2.1.285 and 2.1.311). An allow's `permissionDecisionReason` reaches only Claude Code's debug log. + +So the request gate also writes the Guardian's `reasoning` to `additionalContext` on `allow`, `deny`, `defer` and `modify`. Claude Code keeps that field from every hook, and the model reads it as `PreToolUse:Bash hook additional context: …`, so the Guardian's reasoning arrives even when another hook's denial is the one in the tool result. When the shim is the only hook, a deny's reasoning arrives twice. The result gate needs no such step: Claude Code passes every hook's `block` reason to the model. + ## The result gate At `PostToolUse` the tool has already run, and Claude Code's `block` alone withholds nothing. So a `deny`, an `ask` or a `defer` at the result gate replaces the output, and a `modify` applies the redaction to the output the model will see. A modification the host cannot apply, or a `modified_content` replacement, is refused, and the refusal withholds. A `defer` renders as `deny` at both gates, because Claude Code has no deferral state. diff --git a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml index e77ea83c..8aa8fa95 100644 --- a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml +++ b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml @@ -81,21 +81,36 @@ hooks: # posture. Nothing upstream of this hookmap produces an ACS `defer` # decision today, so this entry is a guard against a Guardian that emits # one rather than a path this deployment exercises. + # + # WHY THE REASONING ALSO GOES IN `additionalContext` (issue #109). Measured + # against Claude Code 2.1.285 and 2.1.311, `claude -p`, two PreToolUse + # hooks on one Bash call: + # - When several hooks deny, the model reads the `permissionDecisionReason` + # (or exit-2 stderr) of whichever hook FINISHED LAST, and nothing from + # the others. The exit code does not change this; finish order does. A + # project hook that answers after this shim drops the Guardian's + # reasoning entirely, though the call stays blocked. + # - The `permissionDecisionReason` of an allow reaches only the debug log, + # never the model. + # - `additionalContext` is kept from every hook that returns it, and the + # model reads it as "PreToolUse:Bash hook additional context: ...". + # So `additionalContext` is the one field that carries the Guardian's + # reasoning to the model whatever else answers: a deny's explanation, a + # rewrite's, and the "no decision arrived..." a fail-open proceed gets + # from `applyFailurePosture`. When this shim is the only responder, a deny's + # reasoning reaches the model twice, as the denial and as context. `ask` is + # left without it: its reason is shown to the person who answers. decisions: - # A genuine Guardian allow only rarely carries a `reasoning` string, but - # `applyFailurePosture` always sets one -- a fail-open proceed renders as - # a plain allow, and this reason field is the only way its explanation - # ("no decision arrived...") reaches the transcript a human reads. An - # audited bypass that says nothing to the operator is the thing this - # avoids. allow: output: hookSpecificOutput.permissionDecision: { value: allow } hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + hookSpecificOutput.additionalContext: { from: reasoning, type: string } deny: output: hookSpecificOutput.permissionDecision: { value: deny } hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + hookSpecificOutput.additionalContext: { from: reasoning, type: string } ask: output: hookSpecificOutput.permissionDecision: { value: ask } @@ -104,6 +119,7 @@ hooks: output: hookSpecificOutput.permissionDecision: { value: deny } hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + hookSpecificOutput.additionalContext: { from: reasoning, type: string } # `renderDecision` receives a decision that has already been validated, # which applies the ACS specification's modification rules to the # original tool arguments and puts the result in `applied_input`. Naming @@ -120,6 +136,7 @@ hooks: output: hookSpecificOutput.permissionDecision: { value: allow } hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } + hookSpecificOutput.additionalContext: { from: reasoning, type: string } hookSpecificOutput.updatedInput: { from: applied_input } # The result gate. `outputs.from` is the leaf that becomes the ACS payload's # outputs[0].value; `outputs.within` is the object a `modify` decision is diff --git a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts index 38cf37d3..9cd927c5 100644 --- a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts @@ -167,6 +167,7 @@ describe("the wire shape this host writes to stdout, pinned decision by decision hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "drift_score 0.9 reached threshold 0.5", + additionalContext: "drift_score 0.9 reached threshold 0.5", }, }); }); @@ -179,6 +180,7 @@ describe("the wire shape this host writes to stdout, pinned decision by decision hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "blocked by policy", + additionalContext: "blocked by policy", }, }); }); @@ -237,6 +239,7 @@ describe("the wire shape this host writes to stdout, pinned decision by decision hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "waiting on an out-of-band approval", + additionalContext: "waiting on an out-of-band approval", }, }); }); @@ -262,6 +265,7 @@ describe("the wire shape this host writes to stdout, pinned decision by decision hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "redaction_applied", + additionalContext: "redaction_applied", updatedInput: { command: "echo [REDACTED]" }, }, }); diff --git a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts index 04c2a07a..8f9c1513 100644 --- a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts @@ -226,6 +226,7 @@ describe("renderDecision", () => { hookSpecificOutput: { permissionDecision: "allow", permissionDecisionReason: "drift_score 0.9 reached threshold 0.5", + additionalContext: "drift_score 0.9 reached threshold 0.5", }, }); }); @@ -268,6 +269,7 @@ describe("renderDecision", () => { expect(hookSpecificOutput).toEqual({ permissionDecision: "allow", permissionDecisionReason: "redaction_applied", + additionalContext: "redaction_applied", updatedInput: { command: "echo [REDACTED]" }, }); }); @@ -279,6 +281,7 @@ describe("renderDecision", () => { hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "blocked", + additionalContext: "blocked", }, }); }); @@ -299,12 +302,12 @@ describe("renderDecision against the shipped hookmap, per hook", () => { it.each([ ["allow", { decision: "allow" }, { permissionDecision: "allow" }], - ["deny", { decision: "deny", reasoning: "nope" }, { permissionDecision: "deny", permissionDecisionReason: "nope" }], + ["deny", { decision: "deny", reasoning: "nope" }, { permissionDecision: "deny", permissionDecisionReason: "nope", additionalContext: "nope" }], ["ask", { decision: "ask", reasoning: "confirm?" }, { permissionDecision: "ask", permissionDecisionReason: "confirm?" }], [ "modify", { decision: "modify", reasoning: "rewritten", applied_input: { command: "echo [REDACTED]" } }, - { permissionDecision: "allow", permissionDecisionReason: "rewritten", updatedInput: { command: "echo [REDACTED]" } }, + { permissionDecision: "allow", permissionDecisionReason: "rewritten", additionalContext: "rewritten", updatedInput: { command: "echo [REDACTED]" } }, ], ])("renders PreToolUse %s exactly as before", (_name, result, expected) => { expect(renderDecision("PreToolUse", result as never, real)).toEqual({ hookSpecificOutput: expected }); From 1b4d328ab01be456df973e45a5de2a410bd6ae6c Mon Sep 17 00:00:00 2001 From: Eli Sherman Date: Tue, 3 Nov 2026 14:25:04 -0500 Subject: [PATCH 2/4] Pin the reason fields' type guard, and trim the #109 comments A test now renders allow, deny, defer and modify from the shipped hookmap with a non-string reasoning and expects neither reason field, so dropping `type: string` from an additionalContext rule fails it. The README's sample shim output shows the new field. The hookmap comment points to the README for the measurements, and the modify comment no longer implies its permissionDecisionReason reaches the model. Refs #109 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Eli Sherman --- .../agt/hosts/claude-code/README.md | 2 +- .../claude-code/claude-code.hookmap.yaml | 34 ++++++------------- .../host-adapter/test/render-decision.test.ts | 12 +++++++ 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/reference-implementations/agt/hosts/claude-code/README.md b/reference-implementations/agt/hosts/claude-code/README.md index 9e1c39ba..0f01dcf4 100644 --- a/reference-implementations/agt/hosts/claude-code/README.md +++ b/reference-implementations/agt/hosts/claude-code/README.md @@ -29,7 +29,7 @@ echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","to ``` ```json -{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}} +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.","additionalContext":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}} ``` The first hook of a session sends `handshake/hello` and stores the Guardian's answer under `.acs/sessions/.json`, so the next subprocess for that session finds it and skips the handshake. diff --git a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml index 8aa8fa95..22733dda 100644 --- a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml +++ b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml @@ -82,24 +82,14 @@ hooks: # decision today, so this entry is a guard against a Guardian that emits # one rather than a path this deployment exercises. # - # WHY THE REASONING ALSO GOES IN `additionalContext` (issue #109). Measured - # against Claude Code 2.1.285 and 2.1.311, `claude -p`, two PreToolUse - # hooks on one Bash call: - # - When several hooks deny, the model reads the `permissionDecisionReason` - # (or exit-2 stderr) of whichever hook FINISHED LAST, and nothing from - # the others. The exit code does not change this; finish order does. A - # project hook that answers after this shim drops the Guardian's - # reasoning entirely, though the call stays blocked. - # - The `permissionDecisionReason` of an allow reaches only the debug log, - # never the model. - # - `additionalContext` is kept from every hook that returns it, and the - # model reads it as "PreToolUse:Bash hook additional context: ...". - # So `additionalContext` is the one field that carries the Guardian's - # reasoning to the model whatever else answers: a deny's explanation, a - # rewrite's, and the "no decision arrived..." a fail-open proceed gets - # from `applyFailurePosture`. When this shim is the only responder, a deny's - # reasoning reaches the model twice, as the denial and as context. `ask` is - # left without it: its reason is shown to the person who answers. + # WHY THE REASONING ALSO GOES IN `additionalContext` (issue #109). When + # several hooks deny one call, Claude Code gives the model the denial + # reason of only the hook that finishes last, and an allow's + # `permissionDecisionReason` reaches only the debug log. Claude Code keeps + # `additionalContext` from every hook, so it is the field that carries the + # Guardian's reasoning to the model whatever else answers. `ask` is left + # without it: its reason is shown to the person who answers. The README's + # "When another hook answers the same call" has the measurements. decisions: allow: output: @@ -127,11 +117,9 @@ hooks: # modifications object -- a shape its `updatedInput` does not accept, so # the rewrite would be reported and never take effect. # - # A reason path was the one thing this entry was missing. A policy-ordered - # rewrite is the only decision that changes what runs without the transcript - # saying anything: the command silently differs from the one that was asked - # for, and every sibling entry above carries an explanation while this one - # did not. + # A policy-ordered rewrite changes what runs, so the model has to be told + # why. That reaches it through `additionalContext`: like any allow's, the + # `permissionDecisionReason` here reaches only Claude Code's debug log. modify: output: hookSpecificOutput.permissionDecision: { value: allow } diff --git a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts index 8f9c1513..63e89945 100644 --- a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts @@ -313,6 +313,18 @@ describe("renderDecision against the shipped hookmap, per hook", () => { expect(renderDecision("PreToolUse", result as never, real)).toEqual({ hookSpecificOutput: expected }); }); + // Both reason fields carry `type: string`, so a Guardian that puts a + // non-string in `reasoning` gets the decision rendered with no reason at all, + // rather than an output Claude Code cannot read. + it.each([ + ["allow", { decision: "allow" }, { permissionDecision: "allow" }], + ["deny", { decision: "deny" }, { permissionDecision: "deny" }], + ["defer", { decision: "defer" }, { permissionDecision: "deny" }], + ["modify", { decision: "modify", applied_input: { command: "echo [REDACTED]" } }, { permissionDecision: "allow", updatedInput: { command: "echo [REDACTED]" } }], + ])("renders PreToolUse %s with a non-string reasoning and no reason field", (_name, result, expected) => { + expect(renderDecision("PreToolUse", { ...result, reasoning: { text: "not a string" } } as never, real)).toEqual({ hookSpecificOutput: expected }); + }); + // The reason field is the second half: a rewrite is the only decision that // changes what runs while the transcript says nothing, and the same rule // applies to PreToolUse's `modify`. It is worse at this gate, because what From 6254fb93ea29c8678688b6c01f50cd12bb0999d7 Mon Sep 17 00:00:00 2001 From: Eli Sherman Date: Wed, 4 Nov 2026 09:39:19 -0500 Subject: [PATCH 3/4] Keep a fail-open allow's reasoning out of the model's context Review on #475: under on_decision_failure: proceed, an unreachable Guardian becomes an allow whose reasoning says the Guardian is unreachable and the step went through. As additionalContext, the model would read that before the step runs, and an injected agent could time its next calls by it. The spec points an ALLOW's reasoning at the user, and the audit log and trace export already record every allow. So allow no longer writes additionalContext; deny, defer and modify still do. The fail-open posture test now asserts the reasoning is not in additionalContext. The README links Claude Code's hooks documentation, and the tree README's sample deny output shows the new field. Refs #109 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Eli Sherman --- reference-implementations/agt/README.md | 2 +- .../agt/hosts/claude-code/README.md | 4 ++-- .../hosts/claude-code/claude-code.hookmap.yaml | 18 ++++++++++-------- .../agt/hosts/claude-code/test/posture.test.ts | 4 ++++ .../hosts/claude-code/test/wire-shape.test.ts | 1 - .../host-adapter/test/render-decision.test.ts | 1 - 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/reference-implementations/agt/README.md b/reference-implementations/agt/README.md index df8ae404..5b7e6b55 100644 --- a/reference-implementations/agt/README.md +++ b/reference-implementations/agt/README.md @@ -190,7 +190,7 @@ echo '{"session_id":"demo","hook_event_name":"PreToolUse","tool_name":"Bash","to ``` ```json -{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}} +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0.","additionalContext":"This command was blocked because it matches a destructive-shell-command pattern. Policy: destructive_shell_command_blocked, from AGT's stock bundle (agt_stock). Matched at offset 0."}} ``` The shim never runs the command. It only asks the Guardian. diff --git a/reference-implementations/agt/hosts/claude-code/README.md b/reference-implementations/agt/hosts/claude-code/README.md index 0f01dcf4..cb67799f 100644 --- a/reference-implementations/agt/hosts/claude-code/README.md +++ b/reference-implementations/agt/hosts/claude-code/README.md @@ -56,9 +56,9 @@ Exit `1` never appears. Claude Code reads it as "the hook did not fire" and proc ## When another hook answers the same call -Claude Code runs every matching hook in parallel. A project that already has its own `PreToolUse` hook on `Bash` runs it beside this shim. A deny from either one blocks the call. But when several hooks deny, the model reads the denial reason of only the hook that finishes last, whether it denied by JSON or by exit 2 (issue #109, measured on Claude Code 2.1.285 and 2.1.311). An allow's `permissionDecisionReason` reaches only Claude Code's debug log. +Claude Code runs every matching hook in parallel. A project that already has its own `PreToolUse` hook on `Bash` runs it beside this shim. A deny from either one blocks the call. But when several hooks deny, the model reads the denial reason of only the hook that finishes last, whether it denied by JSON or by exit 2 (issue #109, measured on Claude Code 2.1.285 and 2.1.311). Claude Code's [hooks documentation](https://code.claude.com/docs/en/hooks) agrees: an allow's `permissionDecisionReason` goes only to the debug log, and Claude receives every hook's `additionalContext`. -So the request gate also writes the Guardian's `reasoning` to `additionalContext` on `allow`, `deny`, `defer` and `modify`. Claude Code keeps that field from every hook, and the model reads it as `PreToolUse:Bash hook additional context: …`, so the Guardian's reasoning arrives even when another hook's denial is the one in the tool result. When the shim is the only hook, a deny's reasoning arrives twice. The result gate needs no such step: Claude Code passes every hook's `block` reason to the model. +So the request gate also writes the Guardian's `reasoning` to `additionalContext` on `deny`, `defer` and `modify`. Claude Code keeps that field from every hook, and the model reads it as `PreToolUse:Bash hook additional context: …`, so the Guardian's reasoning arrives even when another hook's denial is the one in the tool result. When the shim is the only hook, a deny's reasoning arrives twice. An `allow` does not write it: under `on_decision_failure: proceed`, a fail-open proceed is an allow whose reasoning says the Guardian is unreachable, and the model must not read that before the step runs. The audit log and the trace export still record every allow. The result gate needs no such step: Claude Code passes every hook's `block` reason to the model. ## The result gate diff --git a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml index 22733dda..a183da06 100644 --- a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml +++ b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml @@ -82,20 +82,22 @@ hooks: # decision today, so this entry is a guard against a Guardian that emits # one rather than a path this deployment exercises. # - # WHY THE REASONING ALSO GOES IN `additionalContext` (issue #109). When - # several hooks deny one call, Claude Code gives the model the denial - # reason of only the hook that finishes last, and an allow's - # `permissionDecisionReason` reaches only the debug log. Claude Code keeps + # WHY `deny`, `defer` AND `modify` ALSO WRITE `additionalContext` (issue + # #109). When several hooks deny one call, Claude Code gives the model the + # denial reason of only the hook that finishes last. Claude Code keeps # `additionalContext` from every hook, so it is the field that carries the - # Guardian's reasoning to the model whatever else answers. `ask` is left - # without it: its reason is shown to the person who answers. The README's - # "When another hook answers the same call" has the measurements. + # Guardian's reasoning to the model whatever else answers. `allow` is left + # without it: under `on_decision_failure: proceed` its reasoning says the + # Guardian is unreachable, and the model must not learn that before the + # step runs. The audit log and the trace export record every allow. `ask` + # is left without it too: its reason is shown to the person who answers. + # The README's "When another hook answers the same call" has the + # measurements. decisions: allow: output: hookSpecificOutput.permissionDecision: { value: allow } hookSpecificOutput.permissionDecisionReason: { from: reasoning, type: string } - hookSpecificOutput.additionalContext: { from: reasoning, type: string } deny: output: hookSpecificOutput.permissionDecision: { value: deny } diff --git a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts index 3b8b5971..708c2d4a 100644 --- a/reference-implementations/agt/hosts/claude-code/test/posture.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/posture.test.ts @@ -177,6 +177,10 @@ describe("acs-hook — the negotiated posture, end to end", () => { const out = await runShim(payload("rm -rf /"), env); const hook = expectQuietDecision(out); expect(hook.permissionDecision).toBe("allow"); + // The reason says the Guardian is unreachable and steps go through + // unchecked. It must not reach the model as additional context, where an + // injected agent could read it and time its next calls (PR #475 review). + expect(hook.additionalContext).toBeUndefined(); // Byte-for-byte what the v3 runbook of the standalone repository // (github.com/afogel/ACS_reference_implementation, docs/demos/v3-runbook.md) captures // for this step. The diff --git a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts index 9cd927c5..f8e5bfea 100644 --- a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts @@ -167,7 +167,6 @@ describe("the wire shape this host writes to stdout, pinned decision by decision hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: "drift_score 0.9 reached threshold 0.5", - additionalContext: "drift_score 0.9 reached threshold 0.5", }, }); }); diff --git a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts index 63e89945..e8a38fa6 100644 --- a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts @@ -226,7 +226,6 @@ describe("renderDecision", () => { hookSpecificOutput: { permissionDecision: "allow", permissionDecisionReason: "drift_score 0.9 reached threshold 0.5", - additionalContext: "drift_score 0.9 reached threshold 0.5", }, }); }); From 5576d2ed836292b28ce64b1413e4f5a2dc0f09b3 Mon Sep 17 00:00:00 2001 From: Eli Sherman Date: Wed, 4 Nov 2026 10:20:18 -0500 Subject: [PATCH 4/4] Say what records a fail-open proceed, and correct stale test wording The host's audit log records a fail-open proceed; the Guardian's trace cannot, so the README and hookmap no longer claim both record every allow. A wire-shape comment no longer says an allow's reason reaches the transcript, the per-hook render test is no longer named "exactly as before", and the non-string reasoning matrix covers ask too. Refs #109 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Eli Sherman --- .../agt/hosts/claude-code/README.md | 2 +- .../agt/hosts/claude-code/claude-code.hookmap.yaml | 2 +- .../agt/hosts/claude-code/test/wire-shape.test.ts | 9 ++++----- .../packages/host-adapter/test/render-decision.test.ts | 3 ++- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/reference-implementations/agt/hosts/claude-code/README.md b/reference-implementations/agt/hosts/claude-code/README.md index cb67799f..ed0feae5 100644 --- a/reference-implementations/agt/hosts/claude-code/README.md +++ b/reference-implementations/agt/hosts/claude-code/README.md @@ -58,7 +58,7 @@ Exit `1` never appears. Claude Code reads it as "the hook did not fire" and proc Claude Code runs every matching hook in parallel. A project that already has its own `PreToolUse` hook on `Bash` runs it beside this shim. A deny from either one blocks the call. But when several hooks deny, the model reads the denial reason of only the hook that finishes last, whether it denied by JSON or by exit 2 (issue #109, measured on Claude Code 2.1.285 and 2.1.311). Claude Code's [hooks documentation](https://code.claude.com/docs/en/hooks) agrees: an allow's `permissionDecisionReason` goes only to the debug log, and Claude receives every hook's `additionalContext`. -So the request gate also writes the Guardian's `reasoning` to `additionalContext` on `deny`, `defer` and `modify`. Claude Code keeps that field from every hook, and the model reads it as `PreToolUse:Bash hook additional context: …`, so the Guardian's reasoning arrives even when another hook's denial is the one in the tool result. When the shim is the only hook, a deny's reasoning arrives twice. An `allow` does not write it: under `on_decision_failure: proceed`, a fail-open proceed is an allow whose reasoning says the Guardian is unreachable, and the model must not read that before the step runs. The audit log and the trace export still record every allow. The result gate needs no such step: Claude Code passes every hook's `block` reason to the model. +So the request gate also writes the Guardian's `reasoning` to `additionalContext` on `deny`, `defer` and `modify`. Claude Code keeps that field from every hook, and the model reads it as `PreToolUse:Bash hook additional context: …`, so the Guardian's reasoning arrives even when another hook's denial is the one in the tool result. When the shim is the only hook, a deny's reasoning arrives twice. An `allow` does not write it: under `on_decision_failure: proceed`, a fail-open proceed is an allow whose reasoning says the Guardian is unreachable, and the model must not read that before the step runs. A fail-open proceed is still recorded in the host's audit log. The result gate needs no such step: Claude Code passes every hook's `block` reason to the model. ## The result gate diff --git a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml index a183da06..810f4799 100644 --- a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml +++ b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml @@ -89,7 +89,7 @@ hooks: # Guardian's reasoning to the model whatever else answers. `allow` is left # without it: under `on_decision_failure: proceed` its reasoning says the # Guardian is unreachable, and the model must not learn that before the - # step runs. The audit log and the trace export record every allow. `ask` + # step runs. The host's audit log still records a fail-open proceed. `ask` # is left without it too: its reason is shown to the person who answers. # The README's "When another hook answers the same call" has the # measurements. diff --git a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts index f8e5bfea..a4c2069c 100644 --- a/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts +++ b/reference-implementations/agt/hosts/claude-code/test/wire-shape.test.ts @@ -147,11 +147,10 @@ describe("the wire shape this host writes to stdout, pinned decision by decision it("renders an allow that carries reasoning with that reasoning attached", async () => { // The shape an observe-only upstream signal produces: an ACS allow with a // synthesized explanation and non-empty policy_references. The - // explanation travels to the host; the references do not, and the - // explanation is the only thing distinguishing this from a plain allow - // in the transcript a human reads. The case that actually pays for this - // is a fail-open proceed, whose reason is written by applyFailurePosture - // and needs somewhere to go. + // explanation travels to the host; the references do not. Claude Code + // writes an allow's permissionDecisionReason only to its debug log, and + // the request gate puts no allow reasoning in additionalContext: a + // fail-open proceed's reasoning says the Guardian is unreachable. expect( await renderedBy( { diff --git a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts index e8a38fa6..032bf533 100644 --- a/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts +++ b/reference-implementations/agt/packages/host-adapter/test/render-decision.test.ts @@ -308,7 +308,7 @@ describe("renderDecision against the shipped hookmap, per hook", () => { { decision: "modify", reasoning: "rewritten", applied_input: { command: "echo [REDACTED]" } }, { permissionDecision: "allow", permissionDecisionReason: "rewritten", additionalContext: "rewritten", updatedInput: { command: "echo [REDACTED]" } }, ], - ])("renders PreToolUse %s exactly as before", (_name, result, expected) => { + ])("renders PreToolUse %s from the shipped hookmap", (_name, result, expected) => { expect(renderDecision("PreToolUse", result as never, real)).toEqual({ hookSpecificOutput: expected }); }); @@ -318,6 +318,7 @@ describe("renderDecision against the shipped hookmap, per hook", () => { it.each([ ["allow", { decision: "allow" }, { permissionDecision: "allow" }], ["deny", { decision: "deny" }, { permissionDecision: "deny" }], + ["ask", { decision: "ask" }, { permissionDecision: "ask" }], ["defer", { decision: "defer" }, { permissionDecision: "deny" }], ["modify", { decision: "modify", applied_input: { command: "echo [REDACTED]" } }, { permissionDecision: "allow", updatedInput: { command: "echo [REDACTED]" } }], ])("renders PreToolUse %s with a non-string reasoning and no reason field", (_name, result, expected) => {