joulehook: an unrecognised hook payload fails closed #290

Merged
dcharlot merged 1 commit from fix/joulehook-unrecognised-payload-fails-closed into main 2026-10-17 09:23:52 -04:00
Owner

The defect

run() had three paths out before the gate, and they disagreed on which way to fail.

path behaviour
unreadable stdin deny
unparseable JSON deny — "Fail closed: an unparseable hook payload denies (the safe default §8)", with a test (malformed_input_fails_closed) pinning it
parses, but no action extractable allow, exit 0, 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.

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. 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 (enforcing), 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."

The fix

  • An unrecognised shape now denies, with a reason stating the actual condition ("unrecognised hook payload shape") instead of 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. Observe mode's behaviour and its §7 non-enforcement disclosure are 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. 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:

mutation result
H1 restore the fail-open (render(host, true, "no tool action")) only ..._fails_closed_when_enforcing FAILED
H2 keep the deny, move the observe check back after the mapping only ..._is_allowed_in_observe_mode FAILED

Each 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 0
  • cargo test --workspace — exit 0, 0 failed suites, 8 passing suites
  • cargo test -p joulehook-hook — 10 + 4 + 3 pass, including the pre-existing the_gate_emits_the_published_decision_for_every_host

🤖 Generated with Claude Code

## The defect `run()` had three paths out before the gate, and they disagreed on which way to fail. | path | behaviour | |---|---| | unreadable stdin | **deny** | | unparseable JSON | **deny** — *"Fail closed: an unparseable hook payload denies (the safe default §8)"*, with a test (`malformed_input_fails_closed`) pinning it | | parses, but no action extractable | **allow**, exit 0, **no receipt sealed** | ```rust 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. ### 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. 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 (enforcing), 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."* ## The fix - An unrecognised shape now **denies**, with a reason stating the actual condition (`"unrecognised hook payload shape"`) instead of 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. Observe mode's behaviour and its §7 non-enforcement disclosure are 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*. 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: | mutation | result | |---|---| | **H1** restore the fail-open (`render(host, true, "no tool action")`) | **only** `..._fails_closed_when_enforcing` FAILED | | **H2** keep the deny, move the observe check back *after* the mapping | **only** `..._is_allowed_in_observe_mode` FAILED | Each 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 **0** - `cargo test --workspace` — exit **0**, **0** failed suites, 8 passing suites - `cargo test -p joulehook-hook` — 10 + 4 + 3 pass, including the pre-existing `the_gate_emits_the_published_decision_for_every_host` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
joulehook: an unrecognised hook payload fails closed
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 11s
standards workspaces build + test / macOS energy backends (pull_request) Has been skipped
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m24s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m26s
ab930c7099
`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]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!290
No description provided.