/** * Compiled hooks — a hook as a CONSTRAINED TYPED PROGRAM, not arbitrary shell. * * The pure core behind the public `vigiles/hook` surface (re-exported in * `src/hook.ts`; compiled by `vigiles compile`, run by `vigiles hook-runtime * run-program`). A hook today is opaque shell (`bash guard.sh`) — * un-analyzable (Rice), and the author hand-writes the fragile parts (exit code, * JSON field, a `grep` matcher) that the verified #1 pains come from. Invert it: * the author writes a PURE typed function `(event) => Decision` against a CLOSED * API; vigiles compiles it. The constraint ELIMINATES whole bug classes by * construction and buys testability / safety / matching / portability: * * - TESTABILITY: `decide` is a pure fn — unit-test in-process, no subprocess, no * exit-code/JSON plumbing. The false-confidence bug class (exit 1≠2, wrong field) * is UNREPRESENTABLE — the author never writes the protocol; `compile` emits it. * - SAFETY: capability = API surface. `checkHookImports` rejects any import outside * `vigiles/hook` at compile (so the hook can't reach `child_process`/`net`), and * `stampHook`/`verifyHookStamp` make the compiled artifact TAMPER-EVIDENT (the * integrity.ts pattern) — a hand-edit that smuggles a capability breaks the stamp. * - MATCHING: `command.runs("git push", { force })` is AST-backed (leafCommands), * so it catches `cd x && git push -f` that the native `Bash(git:*)` glob (#30519) * and a hand-written `grep` both miss. * - PORTABILITY: one program → each harness's protocol (CC exit-2 here; Codex / * OpenCode via the HookProtocol port later — OpenCode hooks ARE in-process TS). * * Pure core, harness-neutral. HONEST SCOPE (kept in every doc): compile/verify fix * the hook's AUTHORING + LOGIC, not the harness's DELIVERY. #34692 (a subagent's * calls never reaching PreToolUse) is FIXED as of CC 2.1.241 — measured on a stock * install, pinned by src/subagent-delivery.test.ts. SCOPE of that measurement: * headless `claude -p` only — interactive is unmeasured, and depth-2 subagent * nesting does not occur there at all. A gate is STILL a strong default * rather than an unbypassable wall, because a model can route around a tool * entirely (#45427 / #32376). Limits (buy-in, node-startup latency) + * full record in research/hook-pain-points.md. */ import { type BashEffect } from "./bash-effects.js"; import { type SHA256Hash } from "./hash.js"; /** * `@iarna/toml` is required LAZILY, at the one call site that serializes a Codex * TOML settings block — never at module load. MEASURED 2026-09-08 (Node 22.22.2, * `tools/measure-hook-startup.mjs`): the top-level import cost 56 ms of a * ~170 ms `require("dist/core/hook-program.js")`, and this module is on the hot * path of `vigiles hook-runtime run-program`, which runs on EVERY matching tool * call. A hook DECIDES; it never serializes a settings block, so it paid 56 ms * per tool call for a compile-time dependency. * * 🔴 THE LAZY REQUIRE MOVED, IT DID NOT GO AWAY. Serializing a settings block * is now `SettingsCodec.render`, and `core/settings-codec.ts` keeps exactly * this wrapper for exactly this reason — a codec is reached from strictly MORE * places than this module was, so a top-level import there would have widened * the regression rather than repeated it. `src/hook-runtime-graph.test.ts` * still fails if `@iarna/toml` reappears in a decision's module graph. */ import type { HarnessDialect } from "./dialect.js"; import type { HookProtocol } from "./hook-protocol.js"; import { type SettingsCodec } from "./settings-codec.js"; import { type EventCapabilityTable } from "./event-capability.js"; import { type ProviderName, type NeedSpec, type HookCtx } from "./hook-providers.js"; import { type StateFact, type StateWrite } from "./hook-state.js"; /** * The erased runtime shape of a gathered context. Author-facing types keep the * precise `HookCtx`; the decode functions re-narrow and cast. */ type RawCtx = Record; /** * Does `name` match a hook's declared tool list, under the SAME semantics as the * matcher the compiler emits for it? * * 🔴 IT DID NOT, AND THE DISAGREEMENT WAS SILENT. `hookRouting` joins a react's * tools with `|` and emits that as the harness matcher, and a Claude Code matcher * is a REGEX — `Edit|Write|MultiEdit` only works because it is one. The runtime * meanwhile compared with `Array.includes`, i.e. exact string equality. So a hook * declaring a tool FAMILY compiled fine, was wired up fine, was routed to by the * harness fine, and was then dropped by vigiles' own filter without a word. * * MEASURED 2026-08-12 against the real runtime, before the fix: * * $ echo '{"tool_name":"mcp__4f54037d-0499__list_events",…}' \ * | vigiles hook-runtime run-program mcp-family.hook.mjs * exit=0 # silence — react() never ran * $ echo '{"tool_name":"mcp__.*",…}' | … * FIRED on mcp__.* # fires only for a tool LITERALLY named "mcp__.*" * * That is the false-confidence class this whole subsystem exists to eliminate, * living inside the subsystem. The live evidence that the harness really does * route these: the knowledge base has shipped `"matcher": "mcp__.*"` in * `.claude/settings.json` for months and its stamp file was last written the * morning this was measured. The MCP server's id changes per session, so an exact * list cannot be written down — a family matcher is the only correct spelling. * * Anchored `^(…)$` so a pattern cannot match a longer tool name by accident, and * identical to `includes` for ordinary names, which contain no metacharacters. * An unparseable pattern is rejected at COMPILE ({@link invalidToolPatterns}), so * the fallback here is unreachable in a compiled hook and exists only so that a * hand-constructed one degrades to exact matching rather than throwing mid-event. */ export declare function matchesTool(tools: readonly string[], name: string): boolean; /** Tool patterns that are not valid regexes — rejected at compile, see {@link matchesTool}. */ export declare function invalidToolPatterns(tools: readonly string[]): string[]; export type Decision = { readonly kind: "allow"; } | { readonly kind: "deny"; readonly reason: string; } | { readonly kind: "ask"; readonly reason: string; }; export declare const allow: () => Decision; export declare const deny: (reason: string) => Decision; export declare const ask: (reason: string) => Decision; /** * Whether a gate BLOCKS on a `deny` (`enforce`, the default) or only RECORDS what * it WOULD block while letting everything through (`observe` — the shadow / rollout * mode: trust a new gate by watching it first, then promote to `enforce`). This is * the WAF "shadow mode" pattern, the one essential mode (block vs don't-block-but- * record) — not a vocabulary of on-fail actions. HARNESS-NEUTRAL by construction: * observe just exits 0 and writes a local record, so it behaves identically on * Claude Code and Codex (no harness-specific field names involved). */ export type HookMode = "enforce" | "observe"; /** A gate's runtime ACTION after applying its {@link HookMode} to its {@link Decision}. Pure. */ export type GateAction = { readonly kind: "block"; readonly reason: string; } | { readonly kind: "ask"; readonly reason: string; } | { readonly kind: "observe"; readonly would: "deny" | "ask"; readonly reason: string; } | { readonly kind: "allow"; }; /** * Map a gate's {@link Decision} + {@link HookMode} to what the runtime actually does. * `enforce`: deny→block (exit 2), ask→ask, allow→allow. `observe`: a deny/ask is * recorded as a no-op `observe` (would-have-blocked) and allowed; allow stays allow. * Pure, so a test asserts "in observe mode this deny does NOT block" with no process. */ export declare function gateAction(decision: Decision, mode?: HookMode): GateAction; /** * WHERE A REACT'S `notice` CAN ACTUALLY ARRIVE — the react-side twin of * {@link gateAction}, and pure for the same reason: the runtime and a test must * agree about delivery without either of them running a harness. * * 🔴 THE DEFECT THIS EXISTS TO CLOSE, MEASURED 2026-09-07 BY THIS REPO'S OWN * FIRST COMPILED HOOK. `notice(…)` was written to stderr and nothing else. Per * Claude Code's hooks documentation, stderr from a hook that exits 0 "goes to * the debug log only, never the transcript, and Claude never sees it" — and a * react ALWAYS exits 0, because its type has no `deny`. So a notice reached * NOBODY: not the model, not the user, not the transcript. `docs/compiled-hooks.md` * stated the MECHANIC ("notice writes to stderr") and never the CONSEQUENCE, so * the role looked healthy while delivering nowhere — precisely the * false-confidence class compiled hooks exist to eliminate, inside the * implementation of compiled hooks. * * The fix is the mechanism the shipped nudges already use: * `hookSpecificOutput.additionalContext` on STDOUT. Two candidates were * considered and one is a trap: * * - **exit 2.** The host's per-event table does show `PostToolUse` stderr to the * model on exit 2. But {@link HookProtocol.blockExitCode} IS 2 — it is the * DENY channel. Spending it on a role whose type-level guarantee is "can never * block" would make that guarantee a lie on every other event. Rejected. * - **`additionalContext`.** Already proven on this event by the shipped refs * nudge, shape shared across harnesses, and the per-harness half is exactly * {@link HookProtocol.injectableEvents} — a fact the port already carries and * the conformance kit already checks. Chosen. * * `injectableEvents` is INJECTED rather than read from a Claude Code literal, so * this stays harness-agnostic (`core ⊄ adapter`): a Codex react on `PostToolUse` * is delivered by the same code path, and an event neither harness injects is * reported `undeliverable` rather than silently dropped. */ export type NoticeDelivery = /** The notice can reach the agent as injected context on this event. */ { readonly kind: "inject"; readonly context: string; } /** * A notice on an event this harness does NOT inject. It still goes to stderr * (the debug log), but nothing surfaces it — so the CALLER must say so out * loud rather than treat this as success. Carrying the message means the * caller never has to re-derive which reaction it was talking about. */ | { readonly kind: "undeliverable"; readonly message: string; } /** Not a notice (a `run` or `nothing`) — nothing to deliver. */ | { readonly kind: "none"; }; /** * Decide how a {@link Reaction}'s notice reaches the agent on `on`, given the * events this harness injects. Pure — no I/O, no process, no harness literal. */ export declare function noticeDelivery(reaction: Reaction, on: string, injectableEvents: readonly string[]): NoticeDelivery; /** An AST-backed view of a Bash command — the author never writes a regex. */ export interface CommandView { readonly raw: string; /** True iff a leaf command runs `program` (e.g. "git push"), optionally with --force/-f. */ runs(program: string, opts?: { readonly force?: boolean; }): boolean; /** True iff the command is provably side-effecting (bash-effects classifier). */ isSideEffecting(): boolean; /** * True iff a leaf command MENTIONS a path under one of the prefixes (e.g. * `~/.ssh`, `.env`) — the secret-read / sensitive-path matcher. Sees the path * however the command is wrapped (`cd x && cat ~/.ssh/id_rsa`). * * MENTIONS, not writes. `grep -c x notes/S.md` touches `notes` — so does * `rm -rf notes`. Pairing this with {@link isSideEffecting}, which classifies * the WHOLE command line, does NOT recover the difference: a plain read whose * line happens to be side-effecting for an unrelated reason (`grep -c x * notes/S.md 2>/dev/null`) matches both and gets blocked. To gate WRITES to a * directory, use {@link writesTo}; conflating the two is the trap. * * OVER-INCLUSIVE ON PURPOSE, AND THE OPPOSITE WAY ROUND FROM {@link * PathView.under}. This is a DENYLIST primitive — every caller spells * `touches(secret) ? deny() : allow()` — so a miss is an ALLOW, not silence. An * unprovable answer is therefore reported as a MATCH: a token that could name * something under the prefix under some root or some `cd` matches, and the gate * blocks. See {@link prefixVerdict} for the three answers and {@link * matchesPrefix} for the two biases. */ touches(prefixes: readonly string[]): boolean; /** * True iff a leaf command CREATES OR MODIFIES a file under one of the prefixes * — the "don't let Bash write here" matcher, and the precise counterpart to * {@link touches}. Two sources, both AST-backed: * * - **Redirection targets** — `cmd > f`, `cmd >> f`, `cmd >| f`, `cmd &> f`. * This is the single most common write shape, and it lives on the statement, * not in any argv. * - **File-writing programs**, at the argv positions that actually name the * file written: `sed -i`, `cp`/`mv`/`install` (destination), `tee`, `dd of=`, * `truncate`, `shred`. * * A path merely READ never matches — `cat a/paper.md`, `grep x a/paper.md`, * `cp a/paper.md /tmp/x` (the source is read, `/tmp/x` is the write) are all * false for `writesTo(["a"])`. Quoting is handled by the parser, so a write * QUOTED inside another command does not match: in * `echo 'echo y > a/paper.md' > /tmp/note.txt` the only real target is * `/tmp/note.txt`. * * Deletion is a different question and is deliberately NOT reported here — * pair with `runs("rm")` if a gate cares about removal too. * * Exactly `writeTargets(prefixes).length > 0`, and implemented as that — reach * for {@link writeTargets} when the gate needs to know WHICH file. */ writesTo(prefixes: readonly string[]): boolean; /** * The write targets of this command that fall under one of the prefixes — the * "WHICH file is written" counterpart to {@link writesTo}. Same two AST-backed * sources (redirection targets + file-writing programs' argv positions), same * denylist bias (an undecidable placement is INCLUDED). * * Spelling is as-written after normalization — quote-unwrapped, * `$HOME`-canonicalized, and resolved against the leaf's own chdir wrapper * (`env -C dir sed -i x` reports `dir/x`) — in order of appearance, exact * duplicates collapsed. Filter by basename/suffix; never re-match the prefixes * by hand, because hand-rolled prefix matching is the exact source of the * trailing-slash, absolute-path and root-blindness defects this vocabulary * exists to remove. * * An empty array ⇔ `writesTo(prefixes) === false`, so the natural * `writeTargets(P).some(pred)` needs no emptiness check to behave correctly. * * `prefixes` is REQUIRED and there is no unfiltered overload: the raw list * does not cross the API boundary, because a consumer holding it has to * re-implement the matching that {@link prefixVerdict} exists to own. */ writeTargets(prefixes: readonly string[]): readonly string[]; /** * True iff the command pipes into a BARE shell interpreter (`curl … | sh`, * `… | bash -s`) — the remote-code-execution shape. High-signal: a shell leaf * WITH a script-file argument (`sh deploy.sh`) is NOT flagged; only a shell * reading from stdin is, which only happens downstream of a pipe. */ pipesToShell(): boolean; } /** * Trailing separators removed — EXCEPT the one that is the path itself. * * 🔴 THE CARVE-OUT IS THE RULE, NOT AN EDGE CASE, and skipping it fails * SILENTLY. Strip every trailing separator and the POSIX root `"/"` becomes * `""`, the Windows drive root `"C:/"` becomes `"C:"` — and `isAbsoluteRef` * reads a bare drive letter as RELATIVE. Nothing throws: an allowlist prefix of * `"C:/"` simply stops matching, so a confinement gate denies every path and a * react hook goes quiet, both looking like a correct decision. * * This lives here, in one place, because the same normalisation is applied to * two different things — the PREFIX being matched against, and the project ROOT * a path is resolved against (`run-hook.ts` imports it for the latter). Round 28 * fixed the root and left the prefix; round 29 found the prefix. Two copies of a * rule is how the second one survives the fix to the first. */ export declare function trimTrailingSeparators(path: string): string; /** * An AST-backed view of a Bash command. * * @param raw - the command line as the tool event carried it. * @param root - the project root repo-relative prefixes resolve against, from * the runtime ({@link projectRootOf}: `$CLAUDE_PROJECT_DIR`, else the event's * own `cwd`, never `process.cwd()`). Omitting it does NOT disarm the denylist * matchers — without a root an absolute token is `"undecidable"` rather than * placeable, and {@link CommandView.touches} treats that as a match. It does * cost precision: with a root, `/somewhere-else/notes/x` is provably not this * repo's `notes`; without one it can only be over-blocked. */ export declare function commandView(raw: string, root?: string): CommandView; /** * The typed event a Bash gate decides over. Generic over the declared context * `needs` (`N`) so `e.ctx` exposes ONLY the facts the hook declared — reading an * undeclared one is a `tsc` error. The default is the erased (all-providers) * shape used by the runtime/`AnyHook`. */ export interface BashToolEvent { readonly event: string; readonly tool: "Bash"; readonly command: CommandView; /** Host-gathered, DECLARED read-only facts (git branch, …) — see `needs`. */ readonly ctx: HookCtx; } /** A hook program: where it fires + the pure decision. */ export interface HookProgram { readonly on: string; /** `enforce` (default) blocks on a `deny`; `observe` records + allows. */ readonly mode?: HookMode; /** Declared context providers the trusted runtime gathers into `e.ctx`. */ readonly needs?: N; readonly decide: (e: BashToolEvent) => Decision; } /** * @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending. * Imported and CALLED as `experimental_defineHook` — do not alias the prefix away at * the import. Measured 2026-08-21: with the alias in place the marker survived * at 0 of 5 call sites in the only user-facing example, because a reader 200 * lines down sees `defineHook` without it and cannot tell it is provisional. */ export declare function experimental_defineHook(p: HookProgram): HookProgram; /** * Build the typed event from a raw PreToolUse event, then decide. * * `root` is the project root repo-relative prefixes in the hook resolve against, * threaded exactly as {@link decideFileGate} threads it: omitted, it falls back * to the event's OWN `cwd` (a payload field, so this stays pure and reads no * environment); the CLI passes {@link projectRootOf}, which prefers * `$CLAUDE_PROJECT_DIR`. Until 2026-08-12 no root reached here at all, and the * denylist matchers on {@link CommandView} were bypassable by spelling a path * absolutely — see the block above {@link PrefixVerdict} for the measurement. */ export declare function decideProgram(program: HookProgram, rawEvent: { tool_name?: string; tool_input?: { command?: unknown; }; cwd?: unknown; }, ctx?: RawCtx, root?: string | undefined): Decision; /** Map a Decision to the CC hook exit code — the protocol the author never writes. */ export declare function decisionExitCode(d: Decision): number; /** * Reject any import/require outside `vigiles/hook` + textual escape hatches * (eval / Function / dynamic import). The hook's capabilities are then EXACTLY the * sanctioned API — it cannot reach `child_process`, `fs`, or the network. Returns * the offending specifiers/constructs (empty = clean). (Probe: a regex scan; a * production impl walks the TS AST.) */ export declare function checkHookImports(source: string): string[]; export declare class HookCompileError extends Error { } /** A compiled hook program: the harness block + a tamper-evident stamp. */ export interface CompiledHookProgram { readonly hooks: Record; /** * Non-fatal findings about the compiled hook — today, a role that FIRES on its * event but cannot do what the role promises (a gate on `PostToolUse`, whose * deny feeds the model rather than vetoing). Absent when there is nothing to * say; a FATAL mismatch throws instead, so this never carries a dead hook. */ readonly warnings?: readonly string[]; /** * The rendered settings block to add to the harness's hooks config — JSON for * Claude Code (`.claude/settings.json`), TOML `[[hooks.]]` for Codex * (`config.toml`). The CLI prints this; the structured `hooks` above is the * Claude-Code-shaped intermediate. */ readonly settingsBlock: string; /** SHA-256 of the sanctioned source — the runtime refuses an artifact whose stamp differs. */ readonly stamp: SHA256Hash; } /** * Per-harness emit inputs (all optional, default to Claude Code) — the ports the * CLI threads in from the resolved adapter. Keeps the core harness-agnostic: * core depends only on these interfaces, never an adapter (core ⊄ adapter). */ export interface CompileHookOptions { /** * The command the emitted block runs. The compiler adds `|| exit 2` (a hook * that can block) or `|| exit 0` (a reminder-only hook) at the end; one that is * already there is replaced. */ readonly gateCommand?: string; /** Validate `hook.on` against this harness's hook-event catalog (a typo won't compile). */ readonly dialect?: HarnessDialect; /** Matcher style (exact vs anchored regex). Defaults to Claude Code's `"exact"`. */ readonly hookProtocol?: HookProtocol; /** * Settings ENCODING, from `PluginLayout.settings`. Absent ⇒ JSON, the * backwards-compatible default for a caller that injects no layout. * * It used to be `settingsFormat?: "json" | "toml"` and `renderSettingsBlock` * branched on it for BOTH the encoding and the entry shape. The shape comes * from `hookProtocol.registration` now, so the two can no longer be chosen * independently of each other by accident. */ readonly settings?: SettingsCodec; /** Names of registered providers (`.vigiles/providers/`) a `provider()` ref may resolve to. */ readonly registeredProviders?: readonly string[]; } /** * Every hook shape the closed vocabulary can express. `compileHookProgram` * (emit) and the runtime dispatch both range over this union. */ /** * The ERASED needs-generic for the {@link AnyHook} union. A gate's `decide` * carries `N` contravariantly (in the event param), so no single concrete * instantiation is a supertype of every authored hook (a built-in-needs hook and * an inline-needs hook have no common `HookProgram`). `any` erases the context * generic for the runtime/union view; the decode functions re-narrow + cast `ctx` * (they never trust this type). Author-facing types keep the precise `N`. */ type ErasedNeeds = readonly any[]; export type AnyHook = HookProgram | FileGateHook | PromptGateHook | StopGateHook | InjectHook | ReactHook; /** * The runtime dispatch shapes. A bare {@link HookProgram} (no `role`) is a Bash * command gate; the role-keyed hooks carry their own shape. The runtime uses this * to pick the right decode + output (a gate exits 2, an inject prints * `additionalContext`, a react runs its classified command). `prompt-gate` and * `stop-gate` are gates on non-tool events — they decode the prompt / stop signal * and emit a `Decision` like the tool gates. */ export type DispatchKind = "bash-gate" | "file-gate" | "prompt-gate" | "stop-gate" | "inject" | "react"; export declare function dispatchKind(hook: AnyHook): DispatchKind; /** * What the shell should do when a hook's command fails before the hook gives an * answer: the runtime is not installed, or it starts but cannot load the hook. * No hook code ran, so the hook cannot be asked. The answer has to be written * into the command itself, and this function is the one place that decides it. * * There are two kinds of hook and they need different answers: * * - A gate (it can block a tool call) must exit 2. A gate that cannot run must * not let calls through: a protection that silently stops working is worse * than one that is known to be missing. * - A nudge (it only adds a reminder: an inject or a react) must exit 0. If it * blocked, one missing file could stop the whole session. This happened on * 2026-08-10: merge-conflict markers in `package.json` stopped every hook from * loading, the Bash gate then refused `git merge --abort` (the command that * undoes the damage), and the session could not be repaired from inside. * * The kind is fixed by the type the author picked: a react hook has no `deny`, * and an inject hook only returns text. * * Only this function decides. The runtime used to guess a second time, from the * hook's file name ("inject" in the name meant nudge), and the two guesses * disagreed (#312). Now the runtime always exits 2 when a hook cannot load, and * the `|| exit N` ending (see {@link withRoleExit}) is the only thing that turns * that 2 into a 0. It is shell, so a different runtime version cannot change it. * * This does not rescue a gate that cannot load: it still refuses every command, * including the install that would fix it. It only stops a nudge doing the same. * * (Other tools disagree on this too: husky and lefthook skip, pre-commit fails.) */ export declare function hookRuntimeMissingExit(kind: DispatchKind): 0 | 2; /** * `command` with `|| exit 0` or `|| exit 2` (chosen by the kind of hook) as its * last words. A `|| exit N` already there is replaced, not added to: the shell * stops at the first `exit` it reaches, so a nudge wired `|| exit 2 || exit 0` * would still block. Applying it twice gives the same result as applying it once. */ export declare function withRoleExit(command: string, kind: DispatchKind): string; /** A gate's {@link HookMode} (`enforce` default); non-gate roles report `enforce` too. */ export declare function hookMode(hook: AnyHook): HookMode; /** The context providers a hook declared via `needs` ([] if none / not a gate). */ export declare function hookNeeds(hook: AnyHook): readonly NeedSpec[]; /** Where a hook fires + its tool matcher (undefined for tool-less events). */ export declare function hookRouting(hook: AnyHook): { on: string; matcher?: string; }; /** * Is this ROLE legal on this EVENT? The check six measured fixtures showed * nothing was making (2026-09-16): a prompt-gate on `PreToolUse` reads an absent * prompt as `""` and allows everything; a stop-gate on `SessionStart` exits 2 * into an event that ignores it; a file-gate on `Stop` matches a tool on an * event that carries none. All six compiled clean, `lint` and `audit` found * zero, and `tsc` — which `vigiles compile` does not run — caught one. * * THREE VERDICTS, and the third is the point: * - `dead` — the harness's own table says this cannot work. A compile error. * - `degraded` — it fires, but not as the role promises. A WARNING, never an * error: the worked case is a gate on `PostToolUse`, whose exit 2 feeds the * model instead of vetoing, and that is a channel this very repo ships a hook * on. Failing it would be the cry-wolf `lint-rule-calibration` names. * - `unknown` — the event is not in the capability table. SILENT. We record 9 * of Claude Code's 31 events; rejecting an author for using one of the other * 22 would be punishing them for our gap. */ export type RoleEventFit = { readonly kind: "ok"; } | { readonly kind: "unknown"; } | { readonly kind: "dead"; readonly message: string; } | { readonly kind: "degraded"; readonly message: string; }; export declare function checkRoleEventFit(kind: DispatchKind, on: string, hasMatcher: boolean, table: EventCapabilityTable | undefined): RoleEventFit; /** * Compile a hook program from its source. Runs the capability check FIRST (an * out-of-API import does NOT compile), validates the event against the target * harness (a typo won't compile), then stamps the source so the shipped artifact * is tamper-evident. The hook supplies the routing (event + matcher), which * differs per role — a tool gate matches its tool, a react matches its tools, an * inject (SessionStart) has no tool matcher at all. * * Harness-agnostic by injection: with no `opts` it emits the Claude Code block * (back-compatible); pass `{ dialect, hookProtocol, settingsFormat }` from a * resolved adapter and it emits that harness's block (e.g. Codex TOML + regex * matcher). Core never imports an adapter — only the port interfaces. */ export declare function compileHookProgram(source: string, hook: AnyHook, opts?: CompileHookOptions): CompiledHookProgram; /** Stamp a hook's source (the integrity.ts pattern, applied to a hook artifact). */ export declare function stampHook(source: string): SHA256Hash; /** Verify a shipped hook artifact matches its stamp (tamper → false). */ export declare function verifyHookStamp(source: string, stamp: SHA256Hash): boolean; /** An AST-free view of a file path — the matching primitive for file tools. */ export interface PathView { readonly raw: string; /** * True iff the path sits under at least one allowed prefix (e.g. "src/**"). * * A prefix is matched in ITS OWN spelling: a repo-relative prefix (`"src"`, * `"drafts/papers/"`) is compared against the path made repo-relative, an * absolute prefix (`"/etc"`) against the path made absolute. Which of those is * available depends on the project root — see {@link pathView}. */ under(prefixes: readonly string[]): boolean; /** * The path as a repo-relative reference, or `undefined` when this view cannot * produce one — an absolute path with no known root, or a path that resolves * OUTSIDE the root (`/etc/passwd`, a sibling checkout). Exposed because a hook * that wants to report or re-match the path needs the same answer `under` * used, and `raw` alone does not carry it. */ readonly rel: string | undefined; } /** * A view of a file path, matched against prefixes by {@link PathView.under}. * * 🔴 `root` IS NOT OPTIONAL IN PRACTICE — WITHOUT IT THIS IS DEAD FOR EVERY REAL * EVENT. Claude Code's Edit/Write/MultiEdit tools always send an ABSOLUTE * `file_path`, and every hook in the wild writes repo-relative prefixes * (`under(["drafts/papers/"])`). Before the root existed, the two could * never meet: the comparison ran `"/home/u/repo/drafts/papers/x.tex"` * against `"drafts/papers"` and returned `false` for all input, forever. * MEASURED 2026-08-12 on the live runtime with one compiled react hook and two * spellings of the same file — the relative one printed its checklist, the * absolute one exited 0 in silence. Three shipped hooks in a consumer repo were * dead by it, all three compiled *specifically to replace hooks that were dead*, * and none of the tests noticed because the harness helper built relative paths. * * ## Which way a miss errs, and why it is not the same answer as `touches` * * `under` is the ALLOWLIST/COVERAGE primitive: its callers spell * `under(P) ? allow() : deny()` (confinement) and `under(P) ? notice() : nothing()` * (a nudge). For both, an unprovable answer must be `false` — a confinement gate * denies (fails closed) and a nudge stays quiet. So: * * - path resolves OUTSIDE the root → `false` for every relative prefix. Not a * bias, the truth: a repo-relative prefix names nothing in another checkout. * This is the {@link resolveRef} lesson — the suffix match it replaced handed * `/home/other-project/package.json` a grant meant for this repo's. * - NO root known and the path is absolute → `false` for every relative prefix. * Here we genuinely cannot tell, and `false` is the choice: silence, never a * false grant. It is also the pre-fix behaviour, so nothing regresses. * * ⚠️ THE ASYMMETRY INVERTS FOR A DENYLIST, AND `under` DOES NOT SERVE THAT CASE. * A gate written `under(["secrets"]) ? deny() : allow()` reads an unprovable * `false` as ALLOW — a false grant. The Bash-side counterpart {@link * CommandView.touches} exists precisely for that direction and takes the * opposite bias from the SAME rule ({@link prefixVerdict}), which is why the * resolver {@link matchesPrefix} makes each caller name its own. `PathView` has * no such counterpart; a file-side denylist inherits the allowlist bias. * Reported, not papered over. * * @param raw - the `file_path` the tool event carried, any spelling. * @param root - the project root relative prefixes resolve against. Comes from * the runtime (`$CLAUDE_PROJECT_DIR`, else the event's own `cwd`), never from * `process.cwd()` — under a git worktree the process's cwd is a DIFFERENT * checkout from the one the harness resolved the hook out of, and that exact * mismatch already wedged a repo once. */ export declare function pathView(raw: string, root?: string): PathView; /** * The project root a compiled hook's repo-relative prefixes resolve against. * * Two sources, in order, and `process.cwd()` is deliberately NOT one of them: * * 1. **`$CLAUDE_PROJECT_DIR`** — what the harness itself resolved the hook's own * path against (`.claude/settings.json` spells the command * `"$CLAUDE_PROJECT_DIR/.claude/hooks/x.hook.ts"`), so it is the same root the * hook was loaded from by construction. * 2. **the event's `cwd`** — Claude Code puts the session's working directory in * every hook payload. A fallback, not a peer: it is where the session is, and * the two coincide in the ordinary case. * * `process.cwd()` is excluded because under a git worktree it can be a different * checkout than the one the harness is driving, and a root from the wrong * checkout turns every repo-relative prefix into a non-match — the same silent * death this whole function exists to end. When NEITHER source is present the * answer is `undefined` and the miss errs toward silence (see {@link pathView}). */ export declare function projectRootOf(event: { readonly cwd?: unknown; }, env?: Readonly>): string | undefined; /** * The one case {@link pathView} cannot answer and must not answer silently: an * ABSOLUTE `file_path` with no project root in sight. Every repo-relative prefix * in the hook returns `false`, so a gate waves everything through and a nudge * never fires — indistinguishable, from the outside, from a hook with nothing to * say. Returns the warning line, or `undefined` when there is nothing wrong. * * Narrow on purpose: a RELATIVE `file_path` is decidable without a root, so it * warns about nothing. That matters because a react hook's `notice` also goes to * stderr, and a warning on every event would read as the hook firing. */ export declare function undecidablePathWarning(filePath: unknown, root: string | undefined): string | undefined; /** The event a file-tool gate decides over (Edit/Write/Read carry `file_path`). */ export interface FileToolEvent { readonly event: string; readonly tool: string; readonly path: PathView; /** Host-gathered, DECLARED read-only facts — see `needs`. */ readonly ctx: HookCtx; } export interface FileGateHook { readonly role: "gate"; readonly on: string; readonly match: { readonly tools: readonly string[]; }; /** `enforce` (default) blocks on a `deny`; `observe` records + allows. */ readonly mode?: HookMode; /** Declared context providers the trusted runtime gathers into `e.ctx`. */ readonly needs?: N; readonly decide: (e: FileToolEvent) => Decision; } export declare const tools: (...names: string[]) => { tools: string[]; }; /** * @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending. * Imported and CALLED as `experimental_defineFileGate` — do not alias the prefix away at * the import. Measured 2026-08-21: with the alias in place the marker survived * at 0 of 5 call sites in the only user-facing example, because a reader 200 * lines down sees `defineFileGate` without it and cannot tell it is provisional. */ export declare function experimental_defineFileGate(p: Omit, "role">): FileGateHook; /** * Run a file-tool gate against a raw PreToolUse event (reads `file_path`). * * `root` is the project root repo-relative prefixes resolve against; omitted, it * falls back to the event's OWN `cwd` (a payload field — this stays pure and * reads no environment). The CLI passes {@link projectRootOf} instead, which * prefers `$CLAUDE_PROJECT_DIR`. Without either, an absolute `file_path` matches * no relative prefix — see {@link pathView} for why that direction was chosen. */ export declare function decideFileGate(hook: FileGateHook, raw: { tool_name?: string; tool_input?: { file_path?: unknown; }; cwd?: unknown; }, ctx?: RawCtx, root?: string | undefined): Decision; /** The event a UserPromptSubmit gate decides over — it sees the prompt TEXT. */ export interface PromptEvent { readonly event: string; /** The user's submitted prompt text (empty string if the event carried none). */ readonly prompt: string; /** Host-gathered, DECLARED read-only facts — see `needs`. */ readonly ctx: HookCtx; } export interface PromptGateHook { readonly role: "prompt-gate"; /** The event — `UserPromptSubmit`. */ readonly on: string; /** `enforce` (default) blocks on a `deny`; `observe` records + allows. */ readonly mode?: HookMode; /** Declared context providers the trusted runtime gathers into `e.ctx`. */ readonly needs?: N; /** Decide over the prompt. `deny` blocks/erases the prompt; `ask` defers to the user. */ readonly decide: (e: PromptEvent) => Decision; } /** * @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending. * Imported and CALLED as `experimental_definePromptGate` — do not alias the prefix away at * the import. Measured 2026-08-21: with the alias in place the marker survived * at 0 of 5 call sites in the only user-facing example, because a reader 200 * lines down sees `definePromptGate` without it and cannot tell it is provisional. */ export declare function experimental_definePromptGate(p: Omit, "role">): PromptGateHook; /** Run a prompt gate against a raw UserPromptSubmit event (reads `prompt`). */ export declare function decidePromptGate(hook: PromptGateHook, raw: { prompt?: unknown; }, ctx?: RawCtx): Decision; /** * The event a Stop gate decides over. `deny` BLOCKS the agent from ending its * turn (the reason is surfaced to the agent — e.g. "tests are red, keep going"). */ export interface StopEvent { readonly event: string; /** * True when this Stop is itself the consequence of a PRIOR Stop-block — the * loop guard. A gate MUST return `allow` when this is set, or it can wedge the * agent in an infinite stop→continue→stop cycle. */ readonly stopHookActive: boolean; /** Host-gathered, DECLARED read-only facts — see `needs`. */ readonly ctx: HookCtx; } export interface StopGateHook { readonly role: "stop-gate"; /** The event — `Stop` or `SubagentStop`. */ readonly on: string; /** `enforce` (default) blocks on a `deny`; `observe` records + allows. */ readonly mode?: HookMode; /** Declared context providers the trusted runtime gathers into `e.ctx`. */ readonly needs?: N; /** Decide whether the agent may stop. `deny` keeps it going; `allow` lets it stop. */ readonly decide: (e: StopEvent) => Decision; } /** * @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending. * Imported and CALLED as `experimental_defineStopGate` — do not alias the prefix away at * the import. Measured 2026-08-21: with the alias in place the marker survived * at 0 of 5 call sites in the only user-facing example, because a reader 200 * lines down sees `defineStopGate` without it and cannot tell it is provisional. */ export declare function experimental_defineStopGate(p: Omit, "role">): StopGateHook; /** Run a Stop gate against a raw Stop/SubagentStop event (reads `stop_hook_active`). */ export declare function decideStopGate(hook: StopGateHook, raw: { stop_hook_active?: unknown; }, ctx?: RawCtx): Decision; /** The output of an inject hook — text to add to context. NO allow/deny exists here. */ export interface Injection { readonly kind: "inject"; readonly context: string; /** Facts to record — a DECLARATION; the trusted runtime performs the write. */ readonly records: readonly StateWrite[]; } /** * Context to add, plus any facts that just became true: * `inject(text, record("calendar.nagged"))`. * * The writes are trailing arguments on every output builder, so there is one rule * to learn rather than a per-role spelling — and a hook that records nothing is * written exactly as it was before. */ export declare const inject: (context: string, ...records: readonly StateWrite[]) => Injection; /** * The event an inject hook produces from (no tool — SessionStart/UserPromptSubmit). * Generic over its declared `needs`, exactly like the gate events. */ export interface SessionEvent { readonly event: string; readonly source: string; /** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */ readonly ctx: HookCtx; } export interface InjectHook { readonly role: "inject"; readonly on: string; /** * Declared context providers the trusted runtime gathers into `e.ctx`. * * Injects and reacts could not declare `needs` at all until state landed, for * no reason anyone had recorded — and a fact a hook cannot read is not a * feature. `needs` is now uniform across every role. */ readonly needs?: N; /** Produces context to add. Its return type (Injection) has no `deny` — by design. */ readonly produce: (e: SessionEvent) => Injection; } /** * @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending. * Imported and CALLED as `experimental_defineInject` — do not alias the prefix away at * the import. Measured 2026-08-21: with the alias in place the marker survived * at 0 of 5 call sites in the only user-facing example, because a reader 200 * lines down sees `defineInject` without it and cannot tell it is provisional. */ export declare function experimental_defineInject(p: Omit, "role">): InjectHook; /** * Run an inject hook → the CC JSON the author never hand-writes. The compiler * targets `additionalContext` (the RIGHT field for this event), so the * wrong-JSON-field pain can't occur. */ export declare function runInject(hook: InjectHook, raw: { source?: string; }, ctx?: RawCtx): { hookSpecificOutput: { hookEventName: string; additionalContext: string; }; }; /** The full {@link Injection} — the runtime needs its `records`, which the CC JSON drops. */ export declare function injectionOf(hook: InjectHook, raw: { source?: string; }, ctx?: RawCtx): Injection; /** * A view of a tool's RESPONSE (PostToolUse) — the matching primitive a react hook * reasons over (e.g. capture/notify only when a command FAILED). The author never * parses the raw payload shape. */ export interface ResponseView { /** The response as text (an object payload is JSON-stringified). */ readonly raw: string; /** * True iff the tool reported a failure — a truthy `error`/`is_error` field on a * structured payload, or a leading `Error`/`error:` line on a text one. */ isError(): boolean; /** True iff the response text contains `needle`. */ contains(needle: string): boolean; } export declare function responseView(raw: unknown): ResponseView; /** The event a react hook reacts over — the tool, the file path, AND its response. */ export interface ReactEvent { readonly event: string; readonly tool: string; readonly path: PathView; /** The tool's response (PostToolUse) — react only on an error, capture output, … */ readonly response: ResponseView; /** Host-gathered, DECLARED facts — built-ins, inline providers, and `state()` reads. */ readonly ctx: HookCtx; } export interface RunReaction { readonly kind: "run"; readonly command: string; readonly effect: BashEffect; readonly records: readonly StateWrite[]; } export type Reaction = RunReaction | { readonly kind: "notice"; readonly message: string; readonly records: readonly StateWrite[]; } | { readonly kind: "none"; readonly records: readonly StateWrite[]; }; /** * Run a command in reaction — its effect is classified AT CONSTRUCTION * (audit/diff-able). Trailing arguments record facts. * * ⚠️ `run()` is for invoking a real TOOL. Using it to write a stamp * (`run("date +%s > .claude/.stamp")`) was the only way to remember anything * before `record()` existed; it is now the wrong tool — it spends a subprocess * and a shell on a variable assignment, and it classifies as side-effecting. */ export declare const run: (command: string, ...records: readonly StateWrite[]) => RunReaction; /** Surface a non-blocking note (no execution). Trailing arguments record facts. */ export declare const notice: (message: string, ...records: readonly StateWrite[]) => Reaction; /** * Take no action. Trailing arguments still record facts — `nothing(record("x"))` * is the shape of a hook whose entire job is to WITNESS that something happened * (an MCP call, a deploy) so a different hook can read it later. */ export declare const nothing: (...records: readonly StateWrite[]) => Reaction; export interface ReactHook { readonly role: "react"; readonly on: string; /** * Which tools to react to. OMIT IT for an event that carries no tool at all * (`Stop`, `SubagentStop`, `SessionEnd`), exactly as inject and the prompt/stop * gates already do — `hookRouting` then emits no matcher. * * 🔴 IT USED TO BE REQUIRED, WHICH MADE EVERY TOOL-LESS REACT DEAD. `runReact` * gates on `tool_name`; a `Stop` event carries none, so the name defaulted to * `""`, matched nothing, and the hook returned `nothing()` forever. MEASURED * 2026-08-12 against the real runtime: a `Stop` react printed nothing and * exited 0 — indistinguishable from a hook that decided to stay quiet, which * is the whole reason advisory hooks die unnoticed. Three of the seven hooks * this feature was built for are `Stop` nudges. */ readonly match?: { readonly tools: readonly string[]; }; /** Declared context providers the trusted runtime gathers into `e.ctx`. */ readonly needs?: N; /** Reacts to a tool that already ran. Returns a Reaction — NO `deny` exists here. */ readonly react: (e: ReactEvent) => Reaction; } /** * @experimental Compiled hooks are provisional — see docs/compiled-hooks.md#status--pending. * Imported and CALLED as `experimental_defineReact` — do not alias the prefix away at * the import. Measured 2026-08-21: with the alias in place the marker survived * at 0 of 5 call sites in the only user-facing example, because a reader 200 * lines down sees `defineReact` without it and cannot tell it is provisional. */ export declare function experimental_defineReact(p: Omit, "role">): ReactHook; /** * Run a react hook against a raw PostToolUse event → the (classified) Reaction. * * `root` behaves exactly as in {@link decideFileGate}: the event's own `cwd` by * default, the CLI's {@link projectRootOf} when the runtime supplies one. It * trails `ctx` so the argument order matches {@link decideProgram} and * {@link decideFileGate} — every decode function reads `(hook, raw, ctx, root)`. */ export declare function runReact(hook: ReactHook, raw: { tool_name?: string; tool_input?: { file_path?: unknown; }; tool_response?: unknown; cwd?: unknown; }, ctx?: RawCtx, root?: string | undefined): Reaction; /** The raw event fields the decode functions read (the union across roles). */ export interface RawHookEvent { readonly tool_name?: string; readonly tool_input?: { readonly command?: unknown; readonly file_path?: unknown; }; /** PostToolUse response (react). */ readonly tool_response?: unknown; /** SessionStart / UserPromptSubmit (inject). */ readonly source?: string; /** UserPromptSubmit (prompt-gate). */ readonly prompt?: string; /** Stop / SubagentStop (stop-gate) — the prior-block loop guard. */ readonly stop_hook_active?: boolean; /** * The session's working directory, which Claude Code puts in EVERY hook * payload. The fallback project root repo-relative path prefixes resolve * against when the runtime does not pass one — see {@link projectRootOf}. */ readonly cwd?: string; } /** * A hook event whose project root has already been RESOLVED — the shape every * consumer past the entry point takes. * * {@link RawHookEvent} is what arrives on stdin; this is what the runtime works * with. The difference is one field, and that field is the whole point: with the * root ON the event, an event and a root cannot be handed to different places * and disagree. That divergence is not hypothetical. Measured 2026-09-19: the * decision layer resolved against the payload while the tamper stamp resolved * against `process.cwd()`, and under a git worktree the stamp check did not * point at the wrong file — it returned silently and did not run at all. * * Threading the root as a second parameter beside the event fixes an instance * and keeps the shape. A field removes the shape. */ export interface HookEvent extends RawHookEvent { /** * The project root, always usable — so no consumer repeats a `?? cwd` fallback * and none can forget it. */ readonly root: string; /** * Whether {@link root} came from the payload (`$CLAUDE_PROJECT_DIR` or the * event's own `cwd`) or is the fallback standing in for a payload that * declared none. * * 🔴 THIS IS THE FIELD A POLICY DECISION READS. What to do with an undeclared * root differs by ROLE, not by call site: a gate that cannot locate the * project is a gate that cannot decide, and a gate that cannot decide must * refuse; a nudge in the same position must stay quiet, because a reminder is * never worth a wedged repository. Collapsing both into one behaviour inside * the resolver would make that choice unexpressible. */ readonly rootDeclared: boolean; } /** * Resolve a raw payload's project root ONCE, at the entry point. * * `fallback` is injected rather than read here, because this module does no IO * and holds no ambient state — the caller supplies `process.cwd()`. That is what * keeps `process.cwd()` to a single occurrence in the whole hook runtime. */ export declare function resolveHookEvent(raw: RawHookEvent, env: Readonly>, fallback: string): HookEvent; /** The normalized outcome of running a hook program — discriminated by role. */ export type HookProgramOutcome = { readonly kind: "decision"; readonly decision: Decision; } | { readonly kind: "injection"; readonly context: string; readonly records: readonly StateWrite[]; } | { readonly kind: "reaction"; readonly reaction: Reaction; }; /** * The state writes an outcome declares, filtered to the ones the runtime may * actually perform. A gate's `Decision` carries none — deliberately: a gate is * the role that must be trustworthy and runs on every tool call, so it READS * state (via `needs`) and never writes it. Adding a write there later is easy; * removing one would not be. */ export declare function outcomeWrites(outcome: HookProgramOutcome): { readonly ok: readonly StateWrite[]; readonly refused: readonly string[]; }; /** Record where a hook program was loaded from. Called by `loadHook` ONLY. */ export declare function rememberHookSource(hook: AnyHook, file: string): void; /** The file a hook was loaded from, or `undefined` for one built in-process. */ export declare function hookSource(hook: AnyHook): string | undefined; /** * Evaluate a compiled hook against a raw event, in-process, dispatching by role: * a gate → its `Decision`, an inject → the injected context text, a react → its * (effect-classified) `Reaction`. Pure — no subprocess, no model. The ergonomic * base for testing a compiled hook (see `assertHookDenies` / `assertHookAllows`). */ export declare function runHookProgram(hook: AnyHook, event: RawHookEvent, ctx?: RawCtx, root?: string | undefined): HookProgramOutcome; /** * The repository a wedged hook belongs to, as the runtime sees it. Supplied by * the caller because core takes no `node:path` and reads no disk — and because * the hook's own path does NOT determine it (see {@link isLoadPathRepairEvent}). */ export interface HookRepoPaths { /** * Absolute root that relative references resolve against. Must be the SAME * root the runtime reads the hook and its stamp with, or the write accepted * here is not the file the runtime reads. */ readonly root: string; /** * The actual files whose breakage can wedge THIS hook's module resolution, * already resolved by the caller: the `package.json` at every ancestor of the * hook, plus the repo's `.vigilesrc.json`. Node walks that chain, so a * monorepo package's own `package.json` is on it and a sibling checkout's is * not. */ readonly loadPathFiles: readonly string[]; } /** * True when this event IS the author repairing the hook — a FILE WRITE, and only * a file write. * * 🔴 THIS USED TO ADMIT A BASH COMMAND, AND THAT WAS THE WHOLE PROBLEM. The * escape accepted `vigiles compile …`, and recognising a trusted ACTION from an * untrusted STRING produced five findings in a row, each fix correct and each * followed by another: `.some()` over the leaves → the operand (`compile * /tmp/payload.spec.ts`, which `loadSpec` imports) → the executable path * (`/tmp/vigiles compile`) → the working directory (`cd /tmp/evil && vigiles * compile`). The degrees of freedom in a command string are unbounded — argv, * cwd, PATH, `node_modules` resolution, a hostile `.vigilesrc.json`, a shell * function — so no amount of parsing closes the class. Two more constraints * predict two more findings. * * ## Why the command was not needed at all — measured, not argued * * MEASURED 2026-08-12 against the real runtime, on a stale-stamp fixture: * writing `{}` into the stamp sidecar (or deleting it) makes * `verifyStampOrRefuse` return early, so the hook LOADS AND ENFORCES again — * `ls` passed and `git push --force` was still denied with the hook's own reason. * The repo is unwedged by a file write, and the gate is back on duty; the author * then runs `vigiles compile` through the NORMAL gate, needing no escape at all. * * MEASURED the same day on the load-wedge fixture: with `package.json` holding * conflict markers, `vigiles compile ` exits 1 — * `Cannot load hook …: Invalid package config` — because compile has to load the * hook through the same broken resolver the runtime just failed on. The command * the escape existed to permit CANNOT repair that wedge. It was pure liability. * * So the Bash escape kept only a git whitelist — and a later round measured that * those run `.git/hooks/*` too and deleted them as well. NO command is an escape * now; the repair is what it always should have been: a write * ({@link isLoadPathRepairEvent}). * * ## What is accepted here * * A write whose target is the hook's own source, or its stamp sidecar * (`.vigiles/hooks/.json`). Both are repo-owned paths derived from the * file the runtime was invoked with, never from the event. * * 🔴 SCOPE IS NOT THE SAME QUESTION AS EXECUTION. A previous round defended a * suffix comparison here on the ground that a write executes nothing — true, and * beside the point: `/home/another-project/.claude/hooks/guard.hook.mjs` ends * the same way as this repo's hook, so a wedge in one checkout granted a write * into another. Both sides now resolve against `repoRoot` and compare whole * ({@link resolveRef}); the absolute-vs-relative tolerance that suffix matching * was reached for is what resolution gives you properly. * * This grants nothing new: a Bash gate never gated file tools in the first place * (the 2026-08-10 incident was fixed by hand-editing JSON while every Bash * command was refused), and a file gate already let the hook's own source be * rewritten, which is strictly more powerful than clearing its stamp. */ export declare function isStampRepairEvent(event: RawHookEvent, hookFile: string, repoRoot: string): boolean; /** * True when this event is a load-path REPAIR — a file write, and only a file * write, to one of the files whose breakage takes the runtime down. * * 🔴 THE GIT ESCAPE USED TO LIVE HERE AND IT EXECUTED REPO CODE. `git merge * --abort` · `git rebase --abort` · `git checkout -- ` were admitted on the * reasoning that they "only move the tree to states git already holds; none * executes a line of repo code". MEASURED against git 2.43.0 with hooks installed * in `.git/hooks/`, and the reasoning is false for ALL THREE: * * git checkout -- f.txt → post-checkout (args ` 0`) * git merge --abort → reference-transaction (prepared, committed) * git rebase --abort → reference-transaction (×4) + post-checkout * * `reference-transaction` fires on ANY ref update, and every one of these updates * a ref. `.git/hooks/*` is writable by exactly the actor this door assumes — * whoever could wedge the repo in the first place — so the whitelist was an * arbitrary-execution path standing open precisely while the gate enforced * nothing. Same shape as the `vigiles compile` escape one layer down: a command * believed inert because of what it MEANS rather than what it DOES. * * ## Why the replacement is a write, not a safer command * * `git -c core.hooksPath=/nonexistent …` does suppress all three (measured: no * hook ran for any of them). It was rejected anyway. It puts free-form structure * back into the accepted string — the exact thing five findings on the compile * door came from — and it buys nothing, because MEASURED on the load-wedge * fixture a plain file write already restores the gate to ENFORCING: * * wedged : `ls` refused * after Edit : `ls` allowed, `git push --force` DENIED by the hook's own reason * * You do not need to finish recovering, only to stop being wedged. Once the hook * loads, the gate decides normally and `git merge --abort` is an ordinary allowed * command — through the gate, not around it. * * ## Why running an install is not allowed either (#312, considered and rejected) * * The usual reason a hook cannot load is that nothing is installed yet (a fresh * container, or a merge that brought the wiring but not the package). The cure * is `npm ci`, which is exactly what the gate refuses. Letting a lockfile * install through was proposed and rejected, for four reasons: * * - Like `git merge --abort` and `vigiles compile` above, it would be allowed for * what it is meant to do, not for what it can do. An install runs the * lifecycle scripts of every package in the lockfile, which is more than the * `.git/hooks/*` scripts that got the git commands removed. * - "The gate is not running anyway" would equally justify allowing `ls`. It * does not single out `npm ci`. * - On a fresh container the runtime itself is missing, so none of this code * runs; the command's own `|| exit 2` decides. * - `npm ci` fails when the lockfile is behind `package.json`. Fixing that needs * plain `npm install`, which fetches versions the repo has not pinned. * * So the way out stays a file write. What is left over is described in * `docs/compiled-hooks.md` ("When a hook cannot load"). Removing the cause would * need hook files that live in the same git commit as their wiring; that is a * separate decision (#312). * * ## What is accepted * * A write to the hook's own source, its stamp sidecar, or one of * {@link HARNESS_CONFIG_FILES}. That set is COMPLETE for a compiled hook rather * than a guess: `checkHookImports` rejects every import but `vigiles/hook`, so the * load path is the hook file plus the config that resolves that specifier — * nothing else can break it. * * A file write executes nothing, which is the property no command on this door * ever had. * * ## …IN THIS REPOSITORY, which is a separate question * * "A write executes nothing" answers the EXECUTION objection and says nothing * about SCOPE. Under the suffix comparison this used to do, a write to * `/home/another-project/package.json` was accepted while THIS repo was wedged — * a file that cannot repair the failure, in a checkout the wedged session has no * business touching. Every candidate is now resolved against * {@link HookRepoPaths.root} and compared whole against the paths the runtime * itself derived ({@link HookRepoPaths.loadPathFiles}, plus this root's * {@link HARNESS_CONFIG_FILES} so the floor case can never be argued away). * * ⚠️ THE HOOK'S OWN PATH DOES NOT GIVE THE ROOT, so the caller passes it. A hook * lives at `.claude/hooks/…`, `.vigiles/hooks/…`, a plugin subdirectory or the * repo root itself; there is no fixed depth to walk up, and finding a `.git` * marker would mean reading disk, which core does not do. Nor would a * git-derived root be RIGHT: `verifyStampOrRefuse` reads the hook and its stamp * sidecar via `process.cwd()`, so any other root would accept writes to files * the runtime does not read — repairs that do not repair. The root is therefore * the one the rest of the runtime already uses, and it is passed in rather than * invented here. */ export declare function isLoadPathRepairEvent(event: RawHookEvent, hookFile: string, repo: HookRepoPaths): boolean; export {}; //# sourceMappingURL=hook-program.d.ts.map