Skip to content
Open
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
2 changes: 1 addition & 1 deletion reference-implementations/agt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion reference-implementations/agt/hosts/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<session_id>.json`, so the next subprocess for that session finds it and skips the handshake.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand All @@ -104,22 +111,22 @@ 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
# `modifications` here instead would hand Claude Code the raw ACS
# 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down Expand Up @@ -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",
},
});
});
Expand Down Expand Up @@ -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",
},
});
});
Expand All @@ -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]" },
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ describe("renderDecision", () => {
expect(hookSpecificOutput).toEqual({
permissionDecision: "allow",
permissionDecisionReason: "redaction_applied",
additionalContext: "redaction_applied",
updatedInput: { command: "echo [REDACTED]" },
});
});
Expand All @@ -279,6 +280,7 @@ describe("renderDecision", () => {
hookSpecificOutput: {
permissionDecision: "deny",
permissionDecisionReason: "blocked",
additionalContext: "blocked",
},
});
});
Expand All @@ -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
Expand Down
Loading