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.
174 lines
8.0 KiB
Markdown
174 lines
8.0 KiB
Markdown
# 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
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
// 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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```sh
|
|
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](../adr/3889-process-exit-contract.md) — 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](../features/hooks-declare-their-crash-policy.md) —
|
|
the feature summary for this migration
|
|
- [Resolve unreachable-guard findings](resolve-unreachable-guard-findings.md) —
|
|
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
|