Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes, cline, codebuddy and pi end to end: capability descriptors, installer branches and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters, hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi migrations, Kimi payload normalization in the hook guards, dead hostBehaviors vocabulary, launcher home probes, fixtures, runtime-specific tests and the prose that presented them as supported. Installer output for the six kept runtimes is byte-identical to before the prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and read-injection-scanner are left in place pending a decision.
8.0 KiB
Declare a hook's crash policy
A MSD enforcement hook (hooks/*.js, hooks/*.sh) ends every run by terminating
the process — but "terminate" is not one decision, it is at least three: pass
the tool call, block it, or explain what happens when the hook's own code
breaks. Before ADR-3889 Phase 7 (#3911), each hook answered the third question
with a bare process.exit(0) or process.exit(2) buried in an outer catch,
so a reviewer had to read every hook's catch block to know its fail-open/
fail-closed stance. hooks/lib/hook-exit.js names all three outcomes and makes
the third one a declaration you write once, near the top of the file.
This page covers terminating a hook correctly, declaring and justifying
ON_CRASH, the one case that needs a distinct stderr payload, the two hooks
that must NOT use this vocabulary at all, and what to do when a check inside
your hook cannot run rather than merely returning a negative.
The three outcomes
const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
allow(payload)— exit 0. The tool call proceeds.payloadis optional; most no-op paths callallow(undefined).deny(payload, stderrPayload?)— exit 2, the Claude Code hook-protocol block code.payloadis written to stdout; by default the same bytes go to stderr too. See "A deny needing a distinct stderr payload" below for when you passstderrPayload.crash(onCrash, payload)— dispatches toallow/denyper a policy YOU supply. There is no default: callcrash(ON_CRASH, payload)from your outer catch, never a barecrash(payload).
A real guard shape:
const ON_CRASH = HOOK_ON_CRASH.ALLOW; // declared once, near the top — see below
try {
const data = JSON.parse(input);
if (looksFine(data)) allow(undefined);
emitBlock({ decision: 'block', reason: 'why this call is refused' });
} catch {
// Hook errors must never block a legitimate tool call.
crash(ON_CRASH, undefined);
}
function emitBlock(output) {
deny(output, output.reason);
}
Choosing and declaring ON_CRASH
HOOK_ON_CRASH has exactly two values, ALLOW and DENY. Ask: if this
hook's own code throws, is it safer for the tool call to proceed, or safer to
block it?
ALLOW— the hook is advisory, or its enforcement is not the last line of defense. A crash here should not stop legitimate work. This is every migrated hook's policy today (19 of 19) — including the two hard-blocking guards (msd-write-guard.js,msd-worktree-path-guard.js): their threat model is a confused agent overwriting a file it can already reach by other means, not a determined adversary, so a hook bug is not treated as worse than the thing it protects against.DENY— a crash inside a security-critical check is itself suspicious enough that refusing is the safer failure. Nothing in this repo declaresDENYyet; if you are the first, that is a real decision, not a default — write the reason down (see below) and expect it to be scrutinized in review.
Declare it once, near your hook's imports, not inline at the call site:
// This guard's outer catch has always exited 0 (fail open — a hook error
// must never block a legitimate tool call). Declared ONCE here so the outer
// catch's crash() call states its policy explicitly rather than inheriting
// a default (#3911).
const ON_CRASH = HOOK_ON_CRASH.ALLOW;
A useful reason names the failure mode, not the mechanism. "Fails open"
restates what ALLOW already means. What the code cannot say is why this
hook, specifically: what breaks if it fires wrongly (a legitimate call
blocked) versus what breaks if it stays silent (a narrow threat model, a
check that runs again downstream, an advisory that was never load-bearing).
Write the trade-off, not the translation.
A deny needing a distinct stderr payload
Most callers never pass stderrPayload — fd 2 gets the same bytes as fd 1,
unchanged from before this migration. One shape needs it: msd-write-guard.js
guards a curated .planning/ artifact, but a host's native hook bus may read
stderr verbatim back to the model on exit 2. A full JSON object on stderr
would show the model a data structure instead of a sentence, so this call
site sends the complete decision to stdout and only the plain-text reason to
stderr:
function emitBlock(output) {
deny(output, output.reason);
}
stderrPayload is forwarded to terminateNow verbatim: a string is
written raw (no JSON.stringify), anything else is JSON-stringified like the
stdout payload. Reach for this only when a specific downstream reader needs
plain text on stderr — most hooks have no such reader and should leave
stderrPayload unset.
The exception: two hooks that must NOT call deny()
allow()/deny()/crash() assume the host reads the exit code as the
decision. Two shipped hooks have a different contract, where the decision
lives in the JSON response body and the process still exits 0:
msd-read-injection-scanner.js(PostToolUse) — Claude Code's PostToolUse protocol reads{ decision: "block", ... }from stdout, not from the exit code; the tool call already completed by the time this hook runs, so there is no exit-code channel to block it through. It callsprocess.stdout.write(JSON.stringify(output))directly for its verdict, and usesallow()/crash()only for its true no-op and crash paths.msd-cursor-subagent-start.js(CursorsubagentStart) — Cursor's own hook protocol reads{ permission: "deny", user_message, ... }from the JSON body at exit 0;"ask"is not even a supported value, and there is no exit-2 convention here at all. Same split:process.stdout.write(...)for the decision,allow()only for its stdin-timeout no-op.
If you are writing a hook against a harness that reads its verdict from the
response body, follow one of these two, not deny() — calling deny() there
would exit 2 into a harness that is not listening on the exit code, turning a
block into an unexplained crash.
When a check cannot run at all
crash() covers your hook's own bugs. It does not cover the more common
defect: a check inside a working hook that silently reads "could not run"
as if it were "ran, found nothing" — passing every case it should have
inspected. #3838 is the worked example: msd-validate-commit.sh had three
swallow-and-pass sites (the opt-in config read, JSON command extraction, and
its isGitSubcommand classifier) where a failure and a genuine negative both
fell through to the same exit 0, silently disabling commit validation on
any of them. The fix distinguishes the two outcomes and says so on stderr
before falling through:
ENABLED=$(node -e "..." 2>"$ENABLED_ERR") || CONFIG_STATUS=$?
CONFIG_STATUS=${CONFIG_STATUS:-0}
if [ "$CONFIG_STATUS" != "0" ]; then
# Could not determine the opt-in flag at all — distinct from
# ".planning/config.json exists and legitimately disables the hook".
echo "msd-validate-commit.sh: could not read .planning/config.json (opt-in check) — validator disabled for this call. $(cat "$ENABLED_ERR")" >&2
rm -f "$ENABLED_ERR"
exit 0
fi
The exit code is unchanged (still 0, still a pass) — what changed is that the
"could not run" case is no longer indistinguishable from a real negative: it
is diagnosable on stderr instead of vanishing. Apply the same discipline
inside your own hook: a try { ... } catch { /* silently pass */ } around a
check is the shape to be suspicious of, whether or not it happens to route
through hook-exit.js at all.
Related
- ADR-3889 — the exit-code registry
hook-exit.jsis layered over, and the rationale for banding codes 0/1/2 the way it does - Hooks declare their crash policy — the feature summary for this migration
- Resolve unreachable-guard findings — a sibling "the loop surfaced something, here is what to do with it" page, for the shell-guard-drift class rather than the exit-code class