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

2026-10-17

Transaction Science
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
`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]>
2026-10-17 04:41:54 -04:00