Files
msd-core/docs/how-to/declare-a-hook-crash-policy.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

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. payload is optional; most no-op paths call allow(undefined).
  • deny(payload, stderrPayload?) — exit 2, the Claude Code hook-protocol block code. payload is written to stdout; by default the same bytes go to stderr too. See "A deny needing a distinct stderr payload" below for when you pass stderrPayload.
  • crash(onCrash, payload) — dispatches to allow/deny per a policy YOU supply. There is no default: call crash(ON_CRASH, payload) from your outer catch, never a bare crash(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 declares DENY yet; 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 Kimi's native hook bus reads 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 calls process.stdout.write(JSON.stringify(output)) directly for its verdict, and uses allow()/crash() only for its true no-op and crash paths.
  • msd-cursor-subagent-start.js (Cursor subagentStart) — 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.

  • ADR-3889 — the exit-code registry hook-exit.js is 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