A view of a file path, matched against prefixes by 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.
touchesunder 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:
false for every relative prefix. Not a
bias, the truth: a repo-relative prefix names nothing in another checkout.
This is the resolveRef lesson — the suffix match it replaced handed
/home/other-project/package.json a grant meant for this repo's.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 CommandView.touches exists precisely for that direction and takes the
opposite bias from the SAME rule (prefixVerdict), which is why the
resolver matchesPrefix makes each caller name its own. PathView has
no such counterpart; a file-side denylist inherits the allowlist bias.
Reported, not papered over.
the file_path the tool event carried, any spelling.
Optionalroot: stringthe 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.
vigiles/hook— the closed vocabulary for authoring a compiled hook.A hook today is opaque shell (
bash guard.sh): un-analyzable, and the author hand-writes the fragile parts (exit code, JSON field, agrepmatcher) that cause the #1 verified hook pain — FALSE CONFIDENCE (a guardrail that looks like it blocks but silently doesn't). Invert it: author a pure typed function(event) => Decisionagainst THIS surface, andvigiles compileemits the harness protocol for you. The whole false-confidence class becomes UNREPRESENTABLE — you never write the exit code or the field.The roles, each with its own output type so a category mistake is a
tscerror, not a silent no-op:experimental_defineHook/experimental_defineFileGate— a gate returns aDecision(allow/deny/ask);denyis the only thing that blocks.experimental_definePromptGate— a prompt gate (UserPromptSubmit) sees the prompt TEXT and maydenyto block it (a security filter).experimental_defineStopGate— a stop gate (Stop/SubagentStop) maydenyto keep the agent going (gate-until-tests-pass).experimental_defineInject— an inject returns anInjection(context text); it has nodeny, so "block on a SessionStart hook" won't compile.experimental_defineReact— a react (PostToolUse) returns aReaction; it sees the tool RESPONSE, itsrun(cmd)is effect-classified at construction, and it can't block (the tool already ran).Every gate takes a
mode:enforce(default, blocks) orobserve(the shadow/rollout mode — records what it WOULD block, never blocks).observeis harness-neutral (it just exits 0 + writes a local record).Matching is AST-backed (
command.runs("git push", { force })), so it catchescd x && git push -fthat the nativeBash(git:*)glob misses.A gate may also decide on EXTERNAL STATE by declaring
needs(e.g.needs: ["git.branch"]): the trusted runtime gathers those read-only facts and hands them in ase.ctx— the hook still does zero I/O, and reading an undeclared fact is atscerror. Built-ins:git.branch/git.isDirty/git.root/cwd/os.platform/env.isCI. For a one-off off-catalog fact, the lightweight opt-out is an inlineprovide(name, cmd)(read-only) ordangerously(name, cmd)(the loud escape) right inneeds. Seeresearch/hook-context-providers.md.⚠️ Honest scope: compile/verify fix the hook's AUTHORING + LOGIC, not the harness's DELIVERY. The delivery floor MOVED — #34692 (a subagent's tool calls never reaching PreToolUse) is FIXED as of Claude Code 2.1.241, measured against a stock registry install and pinned by src/subagent-delivery.test.ts, which goes red if it regresses. SCOPE of that measurement: headless
claude -ponly — interactive is unmeasured, and depth-2 subagent nesting does not occur there at all. What has NOT changed: a model can still route around a tool entirely (#45427 / #32376 — a Bash heredoc instead ofWrite), so a gate is a strong default and is NEVER an unbypassable wall. Seedocs/compiled-hooks.md.