--- id: 3911 title: Hooks Declare Their Crash Policy group: v1.7.0 Features --- **Purpose:** Give every shipped enforcement hook (`hooks/*.js`, `hooks/*.sh`) a named, auditable termination vocabulary instead of a bare `process.exit(N)` scattered per file — and make a hook's fail-open/fail-closed choice a declaration a reviewer can see, rather than an inference from which literal integer follows `process.exit(` in its outer `catch`. **What changed (ADR-3889 Phase 7, #3911):** - `hooks/lib/hook-exit.js` (hand-written) exposes `allow(payload)` → exit 0, `deny(payload, stderrPayload?)` → exit 2, and `crash(onCrash, payload)`, which dispatches to `allow`/`deny` per a `HOOK_ON_CRASH` policy the caller must supply — `crash()` has no default policy, so a hook cannot fail open by omission. - Every one of the 19 enforcement hooks under `hooks/*.js` now declares `const ON_CRASH = HOOK_ON_CRASH.ALLOW` (or `DENY`) once, with a hook-specific comment naming why, and calls `crash(ON_CRASH, payload)` from its outer catch instead of a bare `process.exit(0)` / `process.exit(2)`. No hook's effective exit code changed — this is a naming-and-declaration migration, not a behavior change. - `hooks/lib/cli-exit.js` and `hooks/lib/exit-code-registry.js` are new, generated, git-tracked copies of the exit-code seam (`src/cli-exit.cts` / `gsd-core/bin/shared/exit-codes.json`), so a shipped hook can terminate correctly on a raw, unbuilt clone without depending on `gsd-core/bin/lib/` tsc output. Generated by `scripts/gen-hooks-cli-exit.cjs` and `scripts/gen-exit-code-registry.cjs`, both `--check`ed by `npm run lint:generated-sync`. - `terminateNow` gained an optional third argument, `stderrPayload`, so a deny can send a full JSON body to stdout and a distinct plain-text reason to stderr — needed because `gsd-write-guard.js` (Kimi's native hook bus reads stderr verbatim back to the model) always sent only the bare reason string on fd 2. The two streams are now written in independent try/catch blocks: previously a payload that failed to serialize on fd 1 aborted before fd 2 ever wrote, producing a deny with an empty stderr reason. - Two hooks are deliberately **not** migrated to `deny()`: `gsd-read-injection-scanner.js` (PostToolUse — its harness reads the block decision from the JSON response body, not the exit code) and `gsd-cursor-subagent-start.js` (follows Cursor's own `subagentStart` protocol, which reads `permission: "deny"` from the JSON body at exit 0). Both still use `allow()`/`crash()` for their no-op and crash paths. - `gsd-phase-boundary.sh`, `gsd-session-state.sh`, and `gsd-validate-commit.sh` gained `set -euo pipefail`, and `gsd-validate-commit.sh`'s three swallow-and-pass sites (the opt-in config read, JSON command extraction, and the `isGitSubcommand` classifier) now distinguish a genuine negative from "could not run" — on the latter they emit a stderr diagnostic and exit 0 instead of silently allowing every commit (#3838). See [Declare a hook's crash policy](../how-to/declare-a-hook-crash-policy.md) for the full how-to, and [ADR-3889](../adr/3889-process-exit-contract.md) for the exit-code registry this vocabulary is layered over.