joulehook: an unrecognised hook payload fails closed #290
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/joulehook-unrecognised-payload-fails-closed"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
run()had three paths out before the gate, and they disagreed on which way to fail.malformed_input_fails_closed) pinning itThat branch sat before the
cfg.enforcing()check, so it fired while enforcing too. And"no tool action"asserts something the code does not know: what it knows is thatextract_actionrecognised none of its three shapes —tool_name,/tool_info/mcp_tool_name, andcommand//tool_info/command_line. SPEC §3.1 requires an adapter to map its host's payload ontoActionEvent"losslessly"; it does not offer allowing as the alternative when it cannot.Reachable through this crate's own documented wiring
SPEC §3.4 lists Windsurf's gate mechanism as "Cascade hooks (
pre_run_command,pre_write_code, …)" and INSTALL.md directs the operator to wire this binary into them. Apre_write_codepayload carries a file path — nottool_name, notmcp_tool_name, notcommand/command_line.So with
JOULEHOOK_TOOLSset (enforcing), the write was allowed, the process exited 0, and nothing was appended toJOULEHOOK_RECEIPT_LOG. The operator saw an enforcing gate and a clean decision log while every code write passed ungated. Any host that renames its tool field turns the whole gate into a blanket allow the same way, with no signal.The crate header claims the opposite: a tool the capability set does not hold "is denied before it executes, because the held capabilities decide, not the model."
The fix
"unrecognised hook payload shape") instead of asserting nothing was pending.ActionEventexists to seal a decision about. Flagged as a deliberate consistency choice rather than an oversight.Tests
an_unrecognised_payload_shape_fails_closed_when_enforcing— first asserts the fixture really is unmappable (extract_action(&v).is_none()) so it cannot pass vacuously; then that it denies on a body-gated host, that the reason no longer contains"no tool action", and that Windsurf gets exit 2, the blocking code.an_unrecognised_payload_shape_is_allowed_in_observe_mode— observe mode still allows and still discloses.Falsification
The fix has two independent halves, and each is load-bearing:
render(host, true, "no tool action"))..._fails_closed_when_enforcingFAILED..._is_allowed_in_observe_modeFAILEDEach mutation kills exactly one test and leaves the other nine passing, so the two tests are not redundant and neither is decoration.
Verification
On the build host (aarch64), the CI-equivalent gate for
joulehook-rs:cargo check --workspace --all-targets— exit 0cargo test --workspace— exit 0, 0 failed suites, 8 passing suitescargo test -p joulehook-hook— 10 + 4 + 3 pass, including the pre-existingthe_gate_emits_the_published_decision_for_every_host🤖 Generated with Claude Code
`run()` had three paths out before the gate, and they disagreed on which way to fail. Unreadable stdin denied. Unparseable JSON denied, with the reason spelled out: "Fail closed: an unparseable hook payload denies (the safe default §8)" — and a test, `malformed_input_fails_ closed`, pinning it. But a payload that parsed and whose action could not be extracted *allowed*, with exit 0 and no receipt sealed: None => { let (b, c) = render(host, true, "joulehook: no tool action"); return (b, c, vec![]); } That branch sat before the `cfg.enforcing()` check, so it fired while enforcing too, and "no tool action" asserts something the code does not know. What it knows is that `extract_action` recognised none of its three shapes — `tool_name`, `/tool_info/mcp_tool_name`, and `command` / `/tool_info/command_line`. SPEC §3.1 requires an adapter to map its host's payload onto `ActionEvent` "losslessly"; it does not offer allowing as the alternative when it cannot. The gap is reachable through this crate's own documented wiring, not a hypothetical host. SPEC §3.4 lists Windsurf's gate mechanism as "Cascade hooks (`pre_run_command`, `pre_write_code`, …)" and INSTALL.md directs the operator to wire this binary into them. A `pre_write_code` payload carries a file path — not `tool_name`, not `mcp_tool_name`, not `command`/`command_line` — so with `JOULEHOOK_TOOLS` set the write was allowed, the process exited 0, and nothing was appended to `JOULEHOOK_RECEIPT_LOG`. The operator saw an enforcing gate and a clean decision log while every code write passed ungated. Any host that renames its tool field turns the whole gate into a blanket allow the same way, with no signal. The crate header claims the opposite: a tool the capability set does not hold "is **denied before it executes**, because the held capabilities decide, not the model." An unrecognised shape now denies, with a reason that states the actual condition rather than asserting nothing was pending. The observe-mode check moves above the mapping, because observe mode allows by contract and so must not depend on recognising the payload — that keeps observe mode's behaviour and its §7 non-enforcement disclosure unchanged. No receipt is sealed on this path, matching the malformed-JSON path above it: both reject before an `ActionEvent` exists to seal a decision about. Tests assert the fixture really is an unmappable shape, so they cannot pass vacuously; that it denies while enforcing on both a body-gated and an exit-code-gated host; that the reason no longer claims "no tool action"; and that observe mode still allows it and still discloses. Co-Authored-By: Claude Opus 5 <[email protected]>