SLOPSHOPPER

kb-settings-guard

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

newguard
A shopper browsing a rack in a slop shop
Source 2 files
hooks/register.ts 206 lines
1// 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};
206
hooks/protected-paths.ts 28 lines
1// 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