Denies a delegated agent lane any write to this repo's Claude settings files.

hooks/register.ts 206 lines1// kb-settings-guard — deny a DELEGATED LANE any write to the files that switch
2// this repo's guards off, while leaving the main thread alone, and while leaving
3// a lane working inside its OWN WORKTREE completely alone.
4//
5// Ray's rulings (2026-09-11, four /grilling rounds):
6// - a function hook, not a classic PreToolUse hook;
7// - deny when the call comes from a subagent, allow otherwise;
8// - WORKTREE-SCOPED: "codex should be able to work on any task as we want".
9// A lane has full authority inside its own worktree and is gated at merge.
10//
11// ⚠️ NOT YET REGISTERED. This plugin is in neither `extraKnownMarketplaces` nor
12// `enabledPlugins`, so nothing here runs. Registration, readiness and liveness
13// are ticket G04 (#757) — and note that since Claude Code v2.1.195 an
14// external-source plugin enabled only by PROJECT settings does not load until
15// each user runs `claude plugin install`, so a `git clone` alone never arms it.
16//
17// LOADER CONSTRAINTS, each measured on 2.1.268. Violating one makes the module
18// fail to load, and that is SILENT unless the session was started with
19// `--debug-file`:
20//
21// 1. Imports are relative paths and "claude-code" only. No node builtins. A
22// `.json` import also fails — the loader compiles every import as
23// TypeScript — while a relative `.ts` import works, which is how the
24// generated path list below arrives.
25// 2. `$` may not be READ as a value. `Object.keys($ ?? {})` fails the load.
26// The error text also says "bound, passed, spread, returned" but that
27// OVERSTATES the enforced check: passing `$` to a local function works and
28// is measured. Treat the text as the safe envelope, not the specification.
29// 3. `next.to` is always `next.to(e, "<tier>")`.
30//
31// 🔴 THE SPELLING TRAP. The lane marker on a FUNCTION-hook event is `agentId`
32// (camelCase). The CLASSIC hook — and the public docs — spell the same concept
33// `agent_id` (snake_case). A guard written from those docs reads `e.agent_id`,
34// gets `undefined` on every call, concludes "main thread", and ALLOWS
35// EVERYTHING, silently, forever. Absence is the ALLOW signal, so this guard
36// CANNOT fail closed on a renamed field; only an end-to-end arm that dispatches
37// a real lane and asserts the deny can catch it. A green unit test over this
38// file proves nothing about the live wiring.
39
40import { PROTECTED_SUFFIXES } from "./protected-paths";
41import type { Register } from "claude-code";
42
43/**
44 * Registered as one ANCHORED `RegExp` matcher per tool, never as a bare
45 * `on("tool.call", hook)`; a literal `string` fails the typed `On` (spec §3.8a).
46 *
47 * 🔴 THE REAL `anthropics/claude-code#92533` INVARIANT IS "NEVER BASH", NOT
48 * "use literal matchers". This comment said the latter until 2026-09-11, when
49 * the upstream issue was read directly rather than relayed: its minimal
50 * reproducer IS a literal matcher —
51 *
52 * on("tool.call", { tool: "Bash" }, async ($, e, next) => next(e));
53 *
54 * — a pure passthrough on one literal tool, and it still breaks EVERY Bash call
55 * inside an `Agent(isolation: "worktree")` subagent, `pwd` and `true` included,
56 * with no retry that helps. So literalness mitigates nothing on its own.
57 *
58 * This module is safe because `WRITE_TOOLS` below never names Bash. Adding
59 * "Bash" to that array would satisfy the old wording exactly and break worktree
60 * subagents repo-wide — which is why the invariant is restated as a prohibition
61 * on the TOOL rather than a recommendation about matcher shape.
62 *
63 * One literal matcher per tool is still how this registers, for the separate and
64 * One anchored RegExp per tool keeps the covered inventory reviewable, with
65 * each entry independently armable. Runtime firing is unmeasured (G04 #757).
66 * A bare `on("tool.call", hook)` would additionally see Bash, which is what
67 * makes it forbidden here.
68 * Measured on 2.1.268: `on(event, matcher, hook)` is real, the matcher is a
69 * partial of the event, and `{ tool: "Edit" }` fired on Edit ONLY while an
70 * unmatched control registration in the same module saw Bash, Read, Edit and
71 * SendUserMessage. Anchored RegExp matchers now keep the covered inventory
72 * reviewable; literal string matchers fail tsc against typed `On` (spec §3.8a).
73 */
74const WRITE_TOOLS: readonly string[] = ["Edit", "Write", "NotebookEdit"];
75
76/**
77 * Match on path SEGMENTS, never a substring of the serialized event.
78 *
79 * The substring form is the measured false positive: `tool.call` also fires for
80 * the assistant's own outgoing message, so a pattern tested against the whole
81 * event blocked Claude's reply because the reply QUOTED the path.
82 *
83 * ⚠️ Known limit: this is an exact suffix test, so a case-insensitive alias or a
84 * symlinked path reaching the same file is not matched.
85 */
86export function isProtectedPath(filePath: string): boolean {
87 if (typeof filePath !== "string" || filePath.length === 0) return false;
88 const normalized = filePath.replace(/\\/g, "/");
89 return PROTECTED_SUFFIXES.some(
90 (suffix) => normalized === suffix || normalized.endsWith("/" + suffix),
91 );
92}
93
94/**
95 * The lane marker, isolated so the spelling exists exactly once in this module.
96 */
97export function laneOf(event: { agentId?: unknown }): string | null {
98 const id = event?.agentId;
99 return typeof id === "string" && id.length > 0 ? id : null;
100}
101
102/** Every ancestor directory of a path, nearest first. */
103export function ancestorsOf(filePath: string): string[] {
104 const parts = filePath.split("/");
105 const out: string[] = [];
106 for (let i = parts.length - 1; i > 1; i--) out.push(parts.slice(0, i).join("/"));
107 return out;
108}
109
110/**
111 * Is the TARGET FILE inside a linked git worktree?
112 *
113 * 🔴 This asks about `e.file_path`, NOT about `$.session.cwd()`. An earlier
114 * version of this module derived the verdict from the session's cwd alone, so a
115 * lane whose session sat in a worktree could write an ABSOLUTE path into the
116 * main checkout and be allowed. Worktree authority belongs to the DESTINATION,
117 * not to the caller's location.
118 *
119 * A linked worktree's `.git` is a FILE (holding `gitdir: …`); a main checkout's
120 * is a DIRECTORY. Measured both ways on real repos.
121 *
122 * Returns null when no repo root is found. FAIL DIRECTION: the caller treats
123 * null as "main checkout", i.e. the guard stays ACTIVE. A false deny costs a
124 * lane one round trip; a false allow costs the guard entirely.
125 */
126async function targetInLinkedWorktree($: any, filePath: string): Promise<boolean | null> {
127 for (const dir of ancestorsOf(filePath)) {
128 try {
129 const entries = await $.fs.list(dir);
130 if (!Array.isArray(entries)) continue;
131 const dotGit = entries.find((entry: any) => entry?.name === ".git");
132 if (dotGit) return dotGit.kind === "file";
133 } catch {
134 // unreadable directory — keep walking upward
135 }
136 }
137 return null;
138}
139
140/**
141 * The hook body, declared at MODULE TOP LEVEL.
142 *
143 * 🔴 Required by the loader, measured: a hook defined as a local `const`
144 * inside `register()` is refused with `the hook "handler" is not a function
145 * declared at the top of this file (a function declaration, or a const bound
146 * to a function), nor imported from one of the module's own files`.
147 */
148async function handler($: any, e: any, next: any): Promise<any> {
149 try {
150 if (e === null || typeof e !== "object") return next(e);
151
152 const lane = laneOf(e);
153 if (lane === null) return next(e); // the main thread — Ray's own edits pass
154
155 const filePath = typeof e.file_path === "string" ? e.file_path : "";
156 if (!isProtectedPath(filePath)) return next(e);
157
158 const inWorktree = await targetInLinkedWorktree($, filePath);
159 if (inWorktree === true) {
160 // full authority inside its own worktree; gated at merge
161 try {
162 $.ui.log(`kb-settings-guard: allowing ${e.tool} on ${filePath} — target is in a linked worktree`);
163 } catch {
164 // logging must never decide the outcome
165 }
166 return next(e);
167 }
168
169 // Compute the refusal BEFORE logging: an exception thrown by $.ui.log on
170 // the critical path would return no denial at all.
171 const refusal = {
172 deny:
173 `kb-settings-guard: a delegated lane may not write ${filePath} in the main checkout. ` +
174 `This file switches the guard stack off, so a lane that can edit it can disable ` +
175 `every other guard for every future session. Two ways forward: do this work in ` +
176 `your own worktree, where you have full authority and the change is gated at ` +
177 `merge; or report the change you need and let the main session make it.`,
178 };
179 try {
180 $.ui.log(`kb-settings-guard: denied ${e.tool} on ${filePath} from lane ${lane}`);
181 } catch {
182 // logging must never decide the outcome
183 }
184 return refusal;
185 } catch (err) {
186 // Fail closed on our own error for a protected target, never open. An
187 // exception here must not read as permission to proceed.
188 try {
189 $.ui.log(`kb-settings-guard: internal error, failing closed: ${String(err)}`);
190 } catch {
191 // nothing left to do
192 }
193 return {
194 deny:
195 "kb-settings-guard: the guard errored while deciding this write and is failing " +
196 "closed. Report this rather than retrying.",
197 };
198 }
199}
200
201export const register: Register = (on) => {
202 for (const tool of WRITE_TOOLS) {
203 on("tool.call", { tool: new RegExp(`^${tool}$`) }, handler);
204 }
205};
206hooks/protected-paths.ts 28 lines1// GENERATED — do not hand-edit.
2//
3// Source: schemas/guard-policy.schema.json
4// Regenerate: mise run kb-guard-codegen
5// Drift check: mise run kb-guard-codegen-check
6//
7// A `.json` file cannot be used here: the hooks-module loader compiles every
8// import as TypeScript, so importing `./protected-paths.json` fails with
9// `does not parse: Unexpected token (1:13)`. A relative `.ts` import works,
10// which is why this file is TypeScript holding data.
11//
12// Ruled by Ray 2026-09-11 (grilling Q3): the settings pair PLUS the guard's
13// own machinery, because a lane that can edit `mise.toml` neuters every
14// `kb-*` guard task just as surely as one that edits `settings.json`.
15
16export const PROTECTED_SUFFIXES: readonly string[] = [
17 ".claude/settings.json",
18 ".claude/settings.local.json",
19 "mise.toml",
20 "hk.pkl",
21 "python/src/kb_setup/hook_guard.py",
22 "schemas/guard-policy.schema.json",
23 "python/src/kb_setup/guard_codegen.py",
24 "python/src/kb_setup/settings_guard.py",
25 ".claude/mods/kb-settings-guard/hooks/protected-paths.ts",
26 ".claude/mods/kb-settings-guard/hooks/register.ts",
27];
28