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 1e4be9c1..ed0feae5 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. @@ -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). 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. 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 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..810f4799 100644 --- a/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml +++ b/reference-implementations/agt/hosts/claude-code/claude-code.hookmap.yaml @@ -81,13 +81,19 @@ 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 `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. `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 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. 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 } @@ -96,6 +102,7 @@ hooks: 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 +111,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 @@ -111,15 +119,14 @@ 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 } 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/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 38cf37d3..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( { @@ -179,6 +178,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 +237,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 +263,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..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 @@ -268,6 +268,7 @@ describe("renderDecision", () => { expect(hookSpecificOutput).toEqual({ permissionDecision: "allow", permissionDecisionReason: "redaction_applied", + additionalContext: "redaction_applied", updatedInput: { command: "echo [REDACTED]" }, }); }); @@ -279,6 +280,7 @@ describe("renderDecision", () => { hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "blocked", + additionalContext: "blocked", }, }); }); @@ -299,17 +301,30 @@ 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) => { + ])("renders PreToolUse %s from the shipped hookmap", (_name, result, expected) => { 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" }], + ["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) => { + 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