reads every Bash call, fork spawn and Write before it runs: a Write over a tracked file the session never read, a floor command, a system, overwrite, prune or…

🎞️ the frame: a place where most of the machine's setup lives as data.
<a href="https://github.com/dvakatsiienko/frame/actions/workflows/ci.yml"><img src="https://raw.githubusercontent.com/dvakatsiienko/frame/badges/ci.svg" alt="ci"></a> <img src="assets/badges/tests.svg" alt="tests"> <img src="assets/badges/renovate.svg" alt="renovate"> <img src="assets/badges/node.svg" alt="node"> <img src="assets/badges/pnpm.svg" alt="pnpm"> <img src="assets/badges/skills.svg" alt="skills"> <img src="assets/badges/mirrored.svg" alt="mirrored">
<img src="assets/banner.svg" align="left" width="100%" alt="frame — the inventory of one mac"> <img src="assets/mac.svg" align="right" width="36%" alt="an 80s mac showing ~ frame">
home/ is ~home/.claude/ is the claude code setup every session reads: memories, rules, skills (plugin-x), hooks, output styles, and sline, the statusline. cclio/ is the coordinator's home, the session that plans and routes work to background coders. x/ is x, the agent-first cli in go + charm: one verb registry, json for agents and boards for humans (x lane commits and pushes from a sandboxed worktree). hotkeys/ maps every keyboard chord on the machine and serves chords, the map's app. x-speak (a schedule/ daemon) reads the selected text aloud on F4 (with nothing selected it pauses / resumes, as ⇧F4 always does) and stops on F5, rewriting ids, versions, paths and code into something a voice can say; speak/ is its voice admin (pnpm speak:admin).
<img src="home/.claude/sline/showcase/sline.svg" width="100%" alt="sline, the statusline, as a session climbs from fresh to heavy">
chords is a keyboard map app: every key binding on every modifier layer, how often each one fires, and which keys are still free. powered by launchd daemon that counts key presses.
<img src="hotkeys/chords/showcase.png" width="100%" alt="chords: the hyper layer on a NuPhy Air75, each bound key with its app and press count">
the mirror links dotfiles and configs. a path under home/ is the same path under ~. the link map is derived by walking the tree, so adding a file to home/ auto-tracks it.
pnpm frame:link # status
pnpm frame:link apply # link everything not linked yet
pnpm frame:link register ~/.foo # move a file into the mirror and link it back
pnpm frame:link untrack ~/.gitconfig # hand a file back to ~
<img src="assets/frame-link.gif" width="720" alt="pnpm frame:link, ending on everything mirrored">
the mac's setup is kept as data, because data does not rot and scripts do: the Brewfile, the macos defaults, the duti file bindings, and the launchd jobs under schedule/.
pnpm macos:setup # brew bundle, macos defaults, duti, vim-plug
on a fresh machine, install the command line tools first — the clone itself needs git, and macos ships only a shim that opens the install dialog. then clone to ~/frame and run the seed, or hand it to an agent («seed this mac from frame»):
xcode-select --install # stop 0: the dialog, ~2 min
git clone https://github.com/dvakatsiienko/frame ~/frame && cd ~/frame
script/seed.sh # command line tools → brew → fnm, pnpm, node → pnpm i → macos:setup → sline → claude cli → frame:link apply
script/seed.sh --claude # the same, with ~/.claude linked
script/seed.sh --without-appstore # skip the app store apps and their sign-in
script/seed.sh --dry-run # what it would do, nothing changed
one status line per step, safe to re-run. it stops with needs your hands: … (exit 2) where only a human can act — the command line tools dialog, the homebrew password, files in the way of a link, the app store and 1password sign-ins — and the next run picks up from there. when it ends, open a new terminal: a shell opened before the seed never sources the zsh stubs it wrote.
hooks/register.ts 502 lines1import type { EngineInterface, Register } from 'claude-code';
2
3import { briefPaths } from './rules/brief.ts';
4import type { Brief, Context } from './rules/command.ts';
5import { overwrittenPaths, writtenPaths } from './rules/overwrite.ts';
6import { rewrite } from './rules/rewrite.ts';
7import {
8 addedPaths,
9 check,
10 message,
11 namesWhole,
12 removedTrees,
13} from './rules.ts';
14
15// x-mod-guard: every Bash call is read before it runs; a floor command or a hazard shape is refused with its door.
16// each refusal and each escape is kept in $.store as one `event:` key, with per-day counts a halt reads.
17
18export type GuardEvent = {
19 at: number;
20 sid: string;
21 name?: string;
22 command: string;
23 kind: 'refused' | 'escaped';
24 door: string;
25 target: string;
26 // why it was refused, the line x-mod-stash shows unfolded; an escape names every rule it stepped past
27 why: string;
28 // the rule that fired; an escape names every rule it stepped past, comma-joined
29 rule: string;
30};
31
32type Tally = { refused: number; escaped: number };
33// rules: the same tally split by the rule that fired, so noise and real catches separate
34export type DayCount = Tally & { rules?: Record<string, Tally> };
35
36const EVENT = 'event:';
37const KEEP = 50;
38const DAY = 'day:';
39const DAYS = 30;
40const SHOWN = 160;
41// a cclio session's own code edits: the 8th gets one note that a bigger job belongs to a helper
42const CODE_FILE = /\.(ts|tsx|go|sh|py|swift)$/;
43const EDITS = { key: 'edits', plugin: 'x-mod-guard' } as const;
44const EDIT_LIMIT = 8;
45const DELEGATE_NOTE =
46 "x-mod-guard: 8 code edits in this cclio session — a bigger job goes to a helper (~137k base) instead of this thread's context";
47const FAILED =
48 'x-mod-guard: the check failed or ran out of time, so this call is refused (fail closed). retry it once; if it repeats, tell cclio';
49// a fork carries the whole parent context; the line says what of it the fork needs
50const WHY_FORK = /^\s*why-fork:\s*\S/m;
51const FORK_DOOR =
52 'a fresh agent with a self-contained brief, or helper for a mechanical job';
53const FORK_WHY = 'a fork carries the whole parent context (~220k tokens)';
54const FORK_REFUSED = `x-mod-guard stopped this fork. instead: ${FORK_DOOR}. why: ${FORK_WHY}. a fork that truly needs that context says so in a prompt line: why-fork: <what parent context it needs>`;
55
56// what x brief check stamps: sha256 over the file's raw bytes, under $X_STATE or ~/.local/state/x (x/go/brief.go)
57async function readBrief(
58 $: EngineInterface,
59 path: string,
60 home: string | undefined,
61): Promise<Brief> {
62 const { base64 } = await $.fs.read(path, { as: 'bytes' });
63 const bytes = Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0));
64 const sum = [
65 ...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)),
66 ]
67 .map((b) => b.toString(16).padStart(2, '0'))
68 .join('');
69 const state = (await $.env.get('X_STATE')) || `${home}/.local/state/x`;
70 return {
71 isCoder: new TextDecoder().decode(bytes).includes('/x:crew-coder'),
72 isStamped: await $.fs.exists(`${state}/briefs/${sum}.json`),
73 };
74}
75
76// the name ListAgents shows, from cc's session registry
77async function sessionName($: EngineInterface, sid: string) {
78 const dir = `${await $.env.get('HOME')}/.claude/sessions`;
79 for (const f of await $.fs.list(dir).catch(() => [])) {
80 if (!f.name.endsWith('.json')) continue;
81 const raw = await $.fs.read(`${dir}/${f.name}`).catch(() => '');
82 if (!raw.includes(sid)) continue;
83 try {
84 const v = JSON.parse(raw) as {
85 sessionId?: unknown;
86 name?: unknown;
87 };
88 if (v.sessionId === sid && typeof v.name === 'string')
89 return v.name;
90 } catch {}
91 }
92 return undefined;
93}
94
95// one key per event, so parallel sessions never overwrite each other; the oldest past KEEP are dropped
96async function record(
97 $: EngineInterface,
98 event: Omit<GuardEvent, 'at' | 'sid' | 'name'>,
99) {
100 const sid = await $.session.id();
101 const at = await $.clock.now();
102 // one line on the band, whatever the command's shape
103 const line = event.command.replace(/\s+/g, ' ').trim();
104 const value: GuardEvent = {
105 ...event,
106 at,
107 command: line.length > SHOWN ? `${line.slice(0, SHOWN)}…` : line,
108 name: await sessionName($, sid),
109 sid,
110 };
111 // the band's record only: a store failure never turns a verdict into a refusal
112 try {
113 await $.store.set(`${EVENT}${at}:${sid}`, value);
114 const keys = await $.store.keys();
115 const events = keys.filter((k) => k.startsWith(EVENT));
116 for (const old of events.slice(0, Math.max(0, events.length - KEEP)))
117 await $.store.delete(old);
118 await count($, keys, at, sid, event).catch(() => undefined);
119 } catch (err) {
120 $.ui.log(`x-mod-guard: the event was not kept: ${err}`);
121 }
122}
123
124const tally = (was: Tally | undefined, kind: GuardEvent['kind']): Tally => ({
125 escaped: (was?.escaped ?? 0) + (kind === 'escaped' ? 1 : 0),
126 refused: (was?.refused ?? 0) + (kind === 'refused' ? 1 : 0),
127});
128
129// the local day, `yyyy-mm-dd`
130function day(at: number) {
131 const d = new Date(at);
132 const two = (n: number) => String(n).padStart(2, '0');
133 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
134}
135
136// one `day:<yyyy-mm-dd>:<session>` key per session a day, so a halt counts the whole day past the KEEP kept events; a count past DAYS is dropped
137async function count(
138 $: EngineInterface,
139 keys: string[],
140 at: number,
141 sid: string,
142 { kind, rule }: Pick<GuardEvent, 'kind' | 'rule'>,
143) {
144 const key = `${DAY}${day(at)}:${sid}`;
145 const was = (await $.store.get(key)) as DayCount | undefined;
146 const rules = { ...was?.rules };
147 for (const r of new Set(rule.split(', '))) rules[r] = tally(rules[r], kind);
148 await $.store.set(key, {
149 ...tally(was, kind),
150 rules,
151 } satisfies DayCount);
152 const oldest = `${DAY}${day(at - DAYS * 86_400_000)}`;
153 for (const old of keys)
154 if (old.startsWith(DAY) && old < oldest) await $.store.delete(old);
155}
156
157// x-mod-holds' store file: a session's first edit of a file holds it, and a Bash write by another session is refused here,
158// by the one Bash parser. the release checks are holds' own: a holder idle 30 min, a dead pid, a landed hold clean in git
159const HOLDS_FILE = /^x-mod-holds_.*\.json$/;
160const HOLD = 'hold:';
161const HOLD_IDLE_MS = 30 * 60_000;
162type Hold = { at: number; file: string; landed?: boolean };
163type Holder = { pid?: number; start?: string; idleSince: number | null };
164
165async function heldBy(
166 $: EngineInterface,
167 command: string,
168 cwd: string,
169 ctx: Context,
170): Promise<string | undefined> {
171 const paths = writtenPaths(command, cwd, ctx);
172 if (!paths.length) return undefined;
173 const dir = `${ctx.home}/.claude/plugins/store`;
174 const held: Record<string, unknown> = {};
175 for (const f of await $.fs.list(dir).catch(() => [])) {
176 if (!HOLDS_FILE.test(f.name)) continue;
177 try {
178 Object.assign(
179 held,
180 JSON.parse(await $.fs.read(`${dir}/${f.name}`)),
181 );
182 } catch {}
183 }
184 const sid = await $.session.id();
185 const now = await $.clock.now();
186 for (const path of paths) {
187 const real = await $.fs.stat(path, { resolve: true }).then(
188 (s) => s.realPath ?? path,
189 () => path,
190 );
191 for (const [key, value] of Object.entries(held)) {
192 if (!key.startsWith(HOLD)) continue;
193 const cut = key.indexOf(':', HOLD.length);
194 const holder = key.slice(HOLD.length, cut);
195 if (key.slice(cut + 1) !== real.toLowerCase() || holder === sid)
196 continue;
197 const hold = value as Hold;
198 if (
199 await isReleased(
200 $,
201 held[`holder:${holder}`] as Holder | undefined,
202 hold,
203 now,
204 )
205 )
206 continue;
207 const mins = Math.round((now - hold.at) / 60000);
208 return `x-mod-holds: ${path} is held by session ${holder.slice(0, 8)}, which took it ${mins} min ago. wait, or ask it to commit the file.`;
209 }
210 }
211 return undefined;
212}
213
214async function isReleased(
215 $: EngineInterface,
216 holder: Holder | undefined,
217 hold: Hold,
218 now: number,
219) {
220 if (!holder) return true;
221 if (holder.idleSince !== null && now - holder.idleSince >= HOLD_IDLE_MS)
222 return true;
223 if (holder.pid && holder.start) {
224 const ps = await $.process.run([
225 'ps',
226 '-o',
227 'lstart=',
228 '-p',
229 String(holder.pid),
230 ]);
231 // a reused pid starts at another time
232 if (ps.stdout.trim() !== holder.start) return true;
233 }
234 if (!hold.landed) return false;
235 const git = await $.process
236 .run(['git', 'status', '--porcelain', '--', hold.file], {
237 cwd: hold.file.slice(0, hold.file.lastIndexOf('/')) || '/',
238 })
239 .catch(() => null);
240 return !git || git.exitCode !== 0 || git.stdout.trim() === '';
241}
242
243const isUnder = (path: string, dir: string) =>
244 path === dir || path.startsWith(`${dir}/`);
245
246const SEEN = { key: 'seen', plugin: 'x-mod-guard' } as const;
247// dima's last prompt typed at the composer or sent over the bridge: a peer's message, a notification, a plugin's or an
248// sdk turn never lands here, and only this plugin writes $.state, so no tool call can forge it
249const PROMPT = { key: 'prompt', plugin: 'x-mod-guard' } as const;
250const TYPED_BY_DIMA = new Set(['composer', 'bridge']);
251const UNPROVEN_DOOR =
252 'ask dima; his own next prompt naming each target as a word lets the # dima-ok marker through (a bare symbol like . or & only as «dima-ok: <target>»). a bg session: ask cclio, who asks dima';
253const UNPROVEN_WHY =
254 "a # dima-ok marker counts only when dima's last typed prompt names its target";
255const UNREAD_DOOR = 'Read the file first, then Write';
256const UNREAD_WHY = 'a Write replaces a tracked file this session never read';
257
258async function isTracked($: EngineInterface, path: string) {
259 if (!(await $.fs.exists(path))) return false;
260 const cut = path.lastIndexOf('/');
261 // a slow or locked repo fails open on this lookup only: the Write runs as cc would run it
262 const ls = await $.process
263 .run(
264 ['git', 'ls-files', '--error-unmatch', '--', path.slice(cut + 1)],
265 { cwd: path.slice(0, cut) || '/' },
266 )
267 .catch(() => null);
268 return ls?.exitCode === 0;
269}
270
271// a tree cclio may drop unasked: no `.scratch/` plan inside, and every commit its HEAD reaches is on a branch;
272// a lookup that fails counts as unclean, so the remove asks
273async function isCleanTree($: EngineInterface, tree: string) {
274 if (await $.fs.exists(`${tree}/.scratch`)) return false;
275 const orphans = await $.process
276 .run(
277 [
278 'git',
279 'rev-list',
280 '-n',
281 '1',
282 'HEAD',
283 '--not',
284 '--branches',
285 '--remotes',
286 ],
287 { cwd: tree },
288 )
289 .catch(() => null);
290 return !!orphans && orphans.exitCode === 0 && orphans.stdout.trim() === '';
291}
292
293// parallel Reads in one step each add their path: a write that lost the race reads again
294async function markSeen($: EngineInterface, path: string) {
295 for (let tries = 0; tries < 3; tries++) {
296 const { value = [], version } = await $.state.get(SEEN);
297 if (value.includes(path)) return;
298 const { isSet } = await $.state.set(SEEN, [...value, path], {
299 ifVersion: version,
300 });
301 if (isSet) return;
302 }
303}
304
305export const register: Register = (on) => {
306 // a Monitor's command is a shell command too
307 on('tool.call', { tool: /^(Bash|Monitor)$/ }, async ($, e, next) => {
308 // the input is the model's: a command that is not a string is the tool's to refuse
309 if (e.tool !== 'Bash' && e.tool !== 'Monitor') return next(e);
310 const typed = e.command;
311 if (typeof typed !== 'string') return next(e);
312 // a shape with one right spelling is fixed, then the fixed command is what every rule reads
313 const { command, notes } = rewrite(typed);
314 const cwd = await $.session.cwd();
315 const home = await $.env.get('HOME');
316 const root = await $.session.root().catch(() => cwd);
317 const ctx: Context = {
318 home,
319 isCclio: isUnder(root, `${home}/frame/cclio`),
320 jobDir: await $.env.get('CLAUDE_JOB_DIR'),
321 };
322 const missing = new Set<string>();
323 for (const path of addedPaths(command, cwd, ctx))
324 if (!(await $.fs.exists(path))) missing.add(path);
325 const kinds: NonNullable<Context['kinds']> = new Map();
326 for (const path of overwrittenPaths(command, cwd, ctx))
327 if (await $.fs.exists(path))
328 kinds.set(path, (await $.fs.stat(path)).kind);
329 const cleanTrees = new Set<string>();
330 for (const tree of removedTrees(command, cwd, ctx))
331 if (await isCleanTree($, tree)) cleanTrees.add(tree);
332 const briefs = new Map<string, Brief>();
333 for (const path of briefPaths(command, cwd, ctx))
334 if (await $.fs.exists(path))
335 briefs.set(path, await readBrief($, path, ctx.home));
336 const verdict = check(command, cwd, {
337 ...ctx,
338 briefs,
339 cleanTrees,
340 kinds,
341 missing,
342 });
343 // a worktree session whose shell `cd`ed out (`cd ~/frame && …`) is refused for the wrong tree; the root still names its own
344 const drift =
345 root.includes('/.claude/worktrees/') &&
346 cwd !== root &&
347 !cwd.startsWith(`${root}/`)
348 ? `this worktree session's shell left its tree for ${cwd}: cd ${root}, or EnterWorktree(path: "${root}")`
349 : undefined;
350 const deny = (text: string) => ({
351 deny: drift ? `${text} — ${drift}` : text,
352 });
353 // the model typed the old command: it is told what changed, or it reads a surprise
354 const go = async () => {
355 const result = await next(notes.length ? { ...e, command } : e);
356 if (result.deny !== undefined) return deny(result.deny);
357 const context = [
358 ...(notes.length
359 ? [
360 `x-mod-guard rewrote this call before it ran: ${notes.join('; ')}. it ran: ${command}`,
361 ]
362 : []),
363 ...(drift && result.isError ? [drift] : []),
364 ];
365 if (!context.length) return result;
366 return {
367 ...result,
368 context: [...(result.context ?? []), ...context],
369 };
370 };
371 // a file another live session holds (x-mod-holds) is refused before any other rule; holds has no escape
372 const held = await heldBy($, command, cwd, ctx).catch(() => undefined);
373 if (held) return deny(held);
374 if (verdict.kind === 'run') return go();
375 if (verdict.kind === 'refused') {
376 const { refusal } = verdict;
377 await record($, {
378 command,
379 door: refusal.door,
380 kind: 'refused',
381 rule: refusal.rule,
382 target: refusal.targets[0] ?? '',
383 why: refusal.why,
384 });
385 return deny(message(refusal));
386 }
387 // the marker is only a claim: dima's own last prompt must name every target the refusals named
388 const { value: said = '' } = await $.state.get(PROMPT);
389 const unproven = [
390 ...new Set(verdict.refusals.flatMap((r) => r.targets)),
391 ].filter((t) => !namesWhole(said, t));
392 if (unproven.length) {
393 await record($, {
394 command,
395 door: UNPROVEN_DOOR,
396 kind: 'refused',
397 rule: 'dima-ok-unproven',
398 target: unproven[0] ?? '',
399 why: UNPROVEN_WHY,
400 });
401 return deny(
402 `nothing in this command ran — x-mod-guard stopped it. instead: ${UNPROVEN_DOOR}. why: ${UNPROVEN_WHY}; it does not name: ${unproven.join(' ')}`,
403 );
404 }
405 await record($, {
406 command,
407 door: verdict.refusals.map((r) => r.door).join('; '),
408 kind: 'escaped',
409 rule: [...new Set(verdict.refusals.map((r) => r.rule))].join(', '),
410 target: verdict.targets.join(', '),
411 why: verdict.refusals.map((r) => r.why).join('; '),
412 });
413 $.ui.log(`x-mod-guard: ran on dima-ok: ${verdict.targets.join(', ')}`);
414 return go();
415 }).catch(() => ({ deny: FAILED }));
416
417 on(
418 'tool.call',
419 { tool: /^(Edit|Write|MultiEdit)$/ },
420 async ($, e, next) => {
421 const result = await next(e);
422 const path = 'file_path' in e ? e.file_path : undefined;
423 if (
424 result.deny !== undefined ||
425 typeof path !== 'string' ||
426 !CODE_FILE.test(path)
427 )
428 return result;
429 try {
430 const home = await $.env.get('HOME');
431 const cwd = await $.session.cwd();
432 if (!isUnder(cwd, `${home}/frame/cclio`)) return result;
433 const n = ((await $.state.get(EDITS)).value ?? 0) + 1;
434 await $.state.set(EDITS, n);
435 if (n !== EDIT_LIMIT) return result;
436 return {
437 ...result,
438 context: [...(result.context ?? []), DELEGATE_NOTE],
439 };
440 } catch {
441 return result;
442 }
443 },
444 );
445
446 // a Write over a tracked file this session never saw replaces what it never read; a Read, Edit or Write marks it seen
447 on(
448 'tool.call',
449 { tool: /^(Read|Edit|MultiEdit|Write)$/ },
450 async ($, e, next) => {
451 const path = 'file_path' in e ? e.file_path : undefined;
452 if (typeof path !== 'string') return next(e);
453 const real = await $.fs.stat(path, { resolve: true }).then(
454 (s) => s.realPath ?? path,
455 () => path,
456 );
457 const { value: seen = [] } = await $.state.get(SEEN);
458 if (
459 e.tool === 'Write' &&
460 !seen.includes(real) &&
461 (await isTracked($, real))
462 ) {
463 await record($, {
464 command: `Write ${path}`,
465 door: UNREAD_DOOR,
466 kind: 'refused',
467 rule: 'write-unread',
468 target: path,
469 why: UNREAD_WHY,
470 });
471 return {
472 deny: `x-mod-guard stopped this Write. instead: ${UNREAD_DOOR}. why: ${UNREAD_WHY}: ${path}`,
473 };
474 }
475 const result = await next(e);
476 if (result.deny === undefined && !result.isError)
477 // bookkeeping only: the call already ran, so a failed mark never turns it into a refusal
478 await markSeen($, real).catch(() => undefined);
479 return result;
480 },
481 ).catch(() => ({ deny: FAILED }));
482
483 on('prompt.submit', async ($, e, next) => {
484 if (TYPED_BY_DIMA.has(e.origin.kind))
485 await $.state.set(PROMPT, e.text).catch(() => undefined);
486 return next(e);
487 });
488
489 on('agent.spawn', async ($, e, next) => {
490 if (!e.fork || WHY_FORK.test(e.prompt)) return next(e);
491 await record($, {
492 command: `fork: ${e.description}`,
493 door: FORK_DOOR,
494 kind: 'refused',
495 rule: 'fork',
496 target: 'why-fork',
497 why: FORK_WHY,
498 });
499 return { deny: FORK_REFUSED };
500 }).catch(() => ({ deny: FAILED }));
501};
502hooks/rules/brief.ts 60 lines1import { parse } from '../shell.ts';
2import {
3 type Command,
4 type Context,
5 type Refusal,
6 commands,
7 operands,
8 resolve,
9} from './command.ts';
10
11const CODER = '/x:crew-coder';
12const INLINE = 'inline-brief';
13// a `claude --bg` call's brief file: the one `$(cat <path>)`, `cat <path> |` or `< <path>` reads
14function spawnBrief(c: Command, ctx: Context) {
15 if (c.name !== 'claude' || !c.args.some((w) => w.text === '--bg'))
16 return undefined;
17 const dir = resolve(c.dir, '/', ctx);
18 const text =
19 c.feeders.flatMap((f) =>
20 f.name === 'cat' ? operands(f.args) : [],
21 )[0] ?? c.reads[0];
22 return {
23 dir,
24 file: text ? { path: resolve(text, dir, ctx), text } : undefined,
25 };
26}
27// the brief files every coder spawn in the command reads, for the caller to hash and look up
28export function briefPaths(command: string, cwd: string, ctx: Context = {}) {
29 return commands(parse(command), cwd).flatMap((c) => {
30 const path = spawnBrief(c, ctx)?.file?.path;
31 return path ? [path] : [];
32 });
33}
34// a coder spawn runs only on a brief whose bytes passed x brief check
35export function brief(c: Command, ctx: Context): Refusal | undefined {
36 const spawn = spawnBrief(c, ctx);
37 if (!spawn) return undefined;
38 const { dir, file } = spawn;
39 const isNamed = c.args.some((w) => w.text.includes(CODER));
40 if (!file)
41 return isNamed
42 ? {
43 door: `write the brief to a file, run x brief check <path> --repo ${dir}, then spawn with "$(cat <path>)"`,
44 rule: 'brief',
45 targets: [INLINE],
46 why: 'x-mod-guard can check a brief only in a file, and a coder brief must pass x brief check',
47 }
48 : undefined;
49 const found = ctx.briefs?.get(file.path);
50 if (!(isNamed || found?.isCoder) || found?.isStamped) return undefined;
51 return {
52 door: `x brief check ${file.path} --repo ${dir}, fix what it names, then spawn again`,
53 rule: 'brief',
54 targets: [file.text],
55 why: found
56 ? 'the brief has no clean x brief check stamp for its current bytes'
57 : 'the brief file is missing',
58 };
59}
60hooks/rules/command.ts 252 lines1import type { Parsed, Sep, Word } from '../shell.ts';
2
3// a refusal names its door; an escape marker must name every one of its targets
4export type Refusal = {
5 rule: string;
6 why: string;
7 door: string;
8 targets: string[];
9};
10// what the rules read off the machine: the job's own dir, home, and the added paths found missing
11// kinds: what each written-over path found on disk is
12export type Context = {
13 jobDir?: string;
14 home?: string;
15 missing?: Set<string>;
16 kinds?: Map<string, 'file' | 'dir' | 'other'>;
17 briefs?: Map<string, Brief>;
18 // the session is cclio's: it cleans its fleet's scratch trees and branches on its own
19 isCclio?: boolean;
20 // the trees looked up and found safe to drop: no `.scratch/`, no commit only their HEAD reaches
21 cleanTrees?: Set<string>;
22};
23// a spawn's brief file as found on disk: whether it names the coder skill, and whether x brief check stamped its bytes
24export type Brief = { isCoder: boolean; isStamped: boolean };
25export type Verdict =
26 | { kind: 'run' }
27 | { kind: 'refused'; refusal: Refusal }
28 | { kind: 'escaped'; refusals: Refusal[]; targets: string[] };
29// wrappers: the peeled heads (`sudo`, `env`); writes: the targets of its `>` redirects; reads: of its `<`
30type Peeled = {
31 name: string;
32 args: Word[];
33 assigns: string[];
34 wrappers: string[];
35 writes: string[];
36 appends: string[];
37 reads: string[];
38};
39// feeders: the commands whose output reaches this one, through `$( … )` or a pipe
40export type Command = Peeled & { sep: Sep; dir: string; feeders: Peeled[] };
41export const VAULT = 'iCloud~md~obsidian';
42// each wrapper, and its options that take the next word as their value
43const WRAPPERS = new Map<string, string[]>([
44 ['builtin', []],
45 ['command', []],
46 ['doas', ['-u']],
47 ['env', ['-u', '-C', '-S']],
48 ['exec', ['-a']],
49 ['nice', ['-n']],
50 ['nohup', []],
51 ['sudo', ['-u', '-g', '-U', '-C', '-h', '-p', '-r', '-t', '-D']],
52 ['time', []],
53 ['timeout', ['-s', '-k', '--signal', '--kill-after']],
54 ['xargs', ['-I', '-n', '-P', '-L', '-s', '-d', '-E', '-J', '-R']],
55]);
56const KEYWORDS = new Set([
57 'if',
58 'then',
59 'else',
60 'elif',
61 'do',
62 'while',
63 'until',
64 '!',
65 '{',
66 '}',
67]);
68export const SD_VALUE = new Set(['-n', '--max-replacements', '-f', '--flags']);
69const REDIRECT = /^\d*(>>?|<|&>>?)&?$/;
70const REDIRECT_JOINED = /^\d*(>>?|<|&>>?)&?\S/;
71export const basename = (p: string) => p.split('/').filter(Boolean).pop() ?? p;
72export const isFlag = (w: string) => w.startsWith('-') && w !== '-';
73// `-rf` holds f; a long flag is matched whole
74export function hasFlag(args: Word[], long: string[], short = '') {
75 return args.some(
76 ({ text: t }) =>
77 long.includes(t) ||
78 long.some((l) => t.startsWith(`${l}=`)) ||
79 (!!short &&
80 /^-[A-Za-z]+$/.test(t) &&
81 [...short].some((c) => t.includes(c))),
82 );
83}
84export function operands(args: Word[]) {
85 const out: string[] = [];
86 let isRest = false;
87 for (const { text } of args) {
88 if (isRest) out.push(text);
89 else if (text === '--') isRest = true;
90 else if (!isFlag(text)) out.push(text);
91 }
92 return out;
93}
94// a `>` that truncates its target; `>>` appends and `>&` names a descriptor
95const TRUNCATE = /^\d*&?>$/;
96const TRUNCATE_JOINED = /^\d*&?>([^>&].*)$/;
97const READ = /^\d*<$/;
98const READ_JOINED = /^\d*<([^<&>].*)$/;
99// the paths a redirect names, apart (`> f`) or joined (`>f`)
100function redirected(words: Word[], apart: RegExp, joined: RegExp) {
101 return words.flatMap((w, i) => {
102 const path = apart.test(w.text)
103 ? words[i + 1]?.text
104 : w.text.match(joined)?.[1];
105 return path ? [path] : [];
106 });
107}
108const writes = (words: Word[]) =>
109 redirected(words, TRUNCATE, TRUNCATE_JOINED).filter(
110 (to) => !to.startsWith('/dev/'),
111 );
112export const reads = (words: Word[]) => redirected(words, READ, READ_JOINED);
113function dropRedirects(words: Word[]) {
114 const out: Word[] = [];
115 for (let i = 0; i < words.length; i++) {
116 const t = words[i]?.text ?? '';
117 if (REDIRECT.test(t)) i++;
118 else if (!REDIRECT_JOINED.test(t)) out.push(words[i] as Word);
119 }
120 return out;
121}
122// a command with its assignments, shell keywords and wrappers peeled off
123export function peel(raw: Word[]): Peeled {
124 let words = dropRedirects(raw);
125 const assigns: string[] = [];
126 const wrappers: string[] = [];
127 for (;;) {
128 const head = words[0]?.text ?? '';
129 if (KEYWORDS.has(head)) {
130 words = words.slice(1);
131 continue;
132 }
133 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(head)) {
134 assigns.push(head);
135 words = words.slice(1);
136 continue;
137 }
138 const values = WRAPPERS.get(basename(head));
139 if (!values) break;
140 wrappers.push(basename(head));
141 words = words.slice(1);
142 while (isFlag(words[0]?.text ?? '')) {
143 const flag = words[0]?.text ?? '';
144 words = words.slice(values.includes(flag) ? 2 : 1);
145 }
146 // timeout's duration
147 if (basename(head) === 'timeout') words = words.slice(1);
148 }
149 const [first, ...args] = words;
150 return {
151 appends: redirected(raw, APPEND, APPEND_JOINED),
152 args,
153 assigns,
154 name: first ? basename(first.text) : '',
155 reads: reads(raw),
156 wrappers,
157 writes: writes(raw),
158 };
159}
160// each segment as the command it runs, `cd` followed
161export function commands(parsed: Parsed, cwd: string): Command[] {
162 const out: Command[] = [];
163 let dir = cwd;
164 const peeled = parsed.segments.map((s) => ({ ...peel(s.words), s }));
165 peeled.forEach(({ s, ...c }, k) => {
166 if (!c.name) return;
167 const piped: Peeled[] = [];
168 for (let j = k - 1; j >= 0 && peeled[j]?.s.sep === '|'; j--)
169 piped.push(peeled[j] as Peeled);
170 const feeders = [...s.subst.map((x) => peel(x.words)), ...piped];
171 if (c.name === 'cd') {
172 const to = c.args[0]?.text ?? '~';
173 dir = /^[/~$]/.test(to) ? to : `${dir}/${to}`;
174 }
175 out.push({ ...c, dir, feeders, sep: s.sep });
176 });
177 return out;
178}
179// git's own options before the subcommand; `-c` values kept, since two of them are floor
180export function gitParts(args: Word[]) {
181 const configs: string[] = [];
182 let at: string | undefined;
183 let i = 0;
184 for (; i < args.length; i++) {
185 const t = args[i]?.text ?? '';
186 if (t === '-c') configs.push(args[++i]?.text ?? '');
187 else if (t === '-C') at = args[++i]?.text;
188 else if (t === '--git-dir' || t === '--work-tree') i++;
189 else if (!isFlag(t)) break;
190 }
191 return {
192 at,
193 configs,
194 sub: args[i]?.text ?? '',
195 subArgs: args.slice(i + 1),
196 };
197}
198export const or = (list: string[], fallback: string) =>
199 list.length ? list : [fallback];
200export const TRASH = 'trash <path> — recoverable from the macos trash';
201export const STASH =
202 'git stash -u — it sets the work aside and keeps it recoverable';
203// the one door that is a person: nothing safe does the job
204export const ASK = 'no safe door here — ask cclio, naming the target';
205export const NEVER =
206 'fix what the hook or the signer refused, then run it without the flag';
207export const LANE = 'x lane commit — it commits named paths only';
208// a path as the shell would land it: the job dir and ~ filled in, `.` and `..` walked; a path still holding a `$` stays unresolved
209export function resolve(path: string, dir: string, ctx: Context) {
210 const filled = path
211 .replace(
212 /^\$\{?CLAUDE_JOB_DIR\}?(?=\/|$)/,
213 ctx.jobDir ?? '$CLAUDE_JOB_DIR',
214 )
215 .replace(/^(~|\$\{?HOME\}?)(?=\/|$)/, ctx.home ?? '~');
216 const whole = filled.startsWith('/') ? filled : `${dir}/${filled}`;
217 const out: string[] = [];
218 for (const part of whole.split('/')) {
219 if (!part || part === '.') continue;
220 if (part === '..') out.pop();
221 else out.push(part);
222 }
223 return `/${out.join('/')}`;
224}
225// the dir a git command works in: its `-C`, else the dir its `cd`s left
226export function gitDir(c: Command, ctx: Context) {
227 const dir = resolve(c.dir, '/', ctx);
228 const { at } = gitParts(c.args);
229 return at === undefined ? dir : resolve(at, dir, ctx);
230}
231// a git add's pathspecs as typed and where they land; globs, magic and unexpanded words are git's to read
232export function adds(c: Command, ctx: Context) {
233 if (c.name !== 'git') return [];
234 const { sub, subArgs } = gitParts(c.args);
235 if (sub !== 'add') return [];
236 const dir = gitDir(c, ctx);
237 return operands(subArgs)
238 .filter((o) => !/[*?[$]/.test(o) && !o.startsWith(':'))
239 .map((text) => ({ path: resolve(text, dir, ctx), text }));
240}
241// a resolved path under the session's own `$CLAUDE_JOB_DIR/tmp`
242export function isJobTmp(path: string, ctx: Context) {
243 return (
244 !!ctx.jobDir &&
245 path.startsWith(`${resolve(`${ctx.jobDir}/tmp`, '/', ctx)}/`)
246 );
247}
248// every file a command writes, resolved: `>` and `>>` targets, `tee`, `sd` and `sed -i` files, python's `open(…, 'w')`.
249// x-mod-holds' files are checked against it; a path held in a variable is not read
250const APPEND = /^\d*&?>>$/;
251const APPEND_JOINED = /^\d*&?>>([^&].*)$/;
252hooks/rules/overwrite.ts 133 lines1import { type Word, parse } from '../shell.ts';
2import {
3 type Command,
4 type Context,
5 type Refusal,
6 SD_VALUE,
7 basename,
8 commands,
9 hasFlag,
10 isFlag,
11 isJobTmp,
12 operands,
13 or,
14 resolve,
15} from './command.ts';
16
17// an mv or cp's dest, then the file each source lands on when that dest is a dir
18export function landings(c: Command, ctx: Context) {
19 const ops = operands(c.args);
20 const dest = ops.at(-1);
21 if (ops.length < 2 || dest === undefined) return [];
22 const to = resolve(dest, resolve(c.dir, '/', ctx), ctx);
23 return [
24 { path: to, text: dest },
25 ...ops
26 .slice(0, -1)
27 .map((src) => ({ path: `${to}/${basename(src)}`, text: dest })),
28 ];
29}
30// the paths a command may write over, for the caller to look up on disk
31export function overwrittenPaths(
32 command: string,
33 cwd: string,
34 ctx: Context = {},
35) {
36 return commands(parse(command), cwd).flatMap((c) => {
37 const dir = resolve(c.dir, '/', ctx);
38 const moved =
39 c.name === 'mv' || c.name === 'cp'
40 ? landings(c, ctx).map((l) => l.path)
41 : [];
42 return [...c.writes.map((w) => resolve(w, dir, ctx)), ...moved];
43 });
44}
45export function writtenPaths(command: string, cwd: string, ctx: Context = {}) {
46 const out: string[] = [];
47 for (const m of command.matchAll(
48 /open\(\s*['"]([^'"]+)['"]\s*,\s*['"][wax]/g,
49 ))
50 if (m[1]) out.push(resolve(m[1], cwd, ctx));
51 const parsed = parse(command);
52 for (const c of commands(parsed, cwd)) {
53 const dir = resolve(c.dir, '/', ctx);
54 const named = [...c.writes, ...c.appends];
55 const ops = operands(c.args);
56 if (c.name === 'tee') named.push(...ops);
57 if (c.name === 'sd') named.push(...sdFiles(c.args));
58 if (c.name === 'sed') named.push(...sedFiles(c.args));
59 for (const n of named)
60 if (n && !n.startsWith('/dev/') && !n.includes('$'))
61 out.push(resolve(n, dir, ctx));
62 }
63 return [...new Set(out)];
64}
65// sd's files follow its find and replace; a flag in SD_VALUE eats the word after it
66function sdFiles(args: Word[]) {
67 const out: string[] = [];
68 let isRest = false;
69 for (let i = 0; i < args.length; i++) {
70 const t = args[i]?.text ?? '';
71 if (isRest) out.push(t);
72 else if (t === '--') isRest = true;
73 else if (SD_VALUE.has(t)) i++;
74 else if (!isFlag(t)) out.push(t);
75 }
76 return out.slice(2);
77}
78// sed edits in place only with -i; macOS spells it `-i ''`, and -e or -f carries the script
79function sedFiles(args: Word[]) {
80 if (!args.some((a) => /^(-i|--in-place)/.test(a.text))) return [];
81 const kept = args.filter(
82 (a, i) => !(a.text === '' && args[i - 1]?.text === '-i'),
83 );
84 const isScripted = kept.some((a) => /^-[ef]$/.test(a.text));
85 const files: string[] = [];
86 for (let i = 0; i < kept.length; i++) {
87 const t = kept[i]?.text ?? '';
88 if (/^-[ef]$/.test(t)) i++;
89 else if (!isFlag(t)) files.push(t);
90 }
91 return isScripted ? files : files.slice(1);
92}
93// the file a `>`, an mv, a cp -f or a cp /dev/null would empty or replace; the job's own tmp is its scratch to write over
94export function overwrite(c: Command, ctx: Context): Refusal | undefined {
95 const dir = resolve(c.dir, '/', ctx);
96 const isFile = (p: string) =>
97 ctx.kinds?.get(p) === 'file' && !isJobTmp(p, ctx);
98 const written = c.writes.filter((w) => isFile(resolve(w, dir, ctx)));
99 if (written.length)
100 return {
101 door: '>> to append, a new path, or the Write tool after reading the file',
102 rule: 'overwrite',
103 targets: written,
104 why: 'a > redirect empties the file before the command runs',
105 };
106 const isNull = c.name === 'cp' && operands(c.args)[0] === '/dev/null';
107 const isForced =
108 c.name === 'mv' ||
109 (c.name === 'cp' && hasFlag(c.args, ['--force'], 'f'));
110 const isKept = hasFlag(c.args, ['--no-clobber'], 'ni');
111 if (!(isNull || isForced) || isKept) return undefined;
112 const [to, ...into] = landings(c, ctx);
113 if (!to) return undefined;
114 if (
115 !isFile(to.path) &&
116 !(ctx.kinds?.get(to.path) === 'dir' && into.some((l) => isFile(l.path)))
117 )
118 return undefined;
119 if (isNull)
120 return {
121 door: 'the Write tool after reading the file',
122 rule: 'overwrite',
123 targets: [to.text],
124 why: 'cp /dev/null empties the file',
125 };
126 return {
127 door: `${c.name} -n, or trash the old file first`,
128 rule: 'overwrite',
129 targets: [to.text],
130 why: `${c.name} replaces the file already there, silently`,
131 };
132}
133hooks/rules/rewrite.ts 73 lines1import { type Segment, parse } from '../shell.ts';
2import { peel } from './command.ts';
3
4// zsh reads `[[ a == b ]]`'s operator as syntax, never as a path
5const TEST_OPERATORS = new Set(['=', '==', '=~']);
6type Edit = { at: number; from: string; to: string; why: string };
7// a shape with one right spelling, fixed in place: `pnpm -s` and an unquoted `=`-led word
8export function edits(segments: Segment[]): Edit[] {
9 return segments.flatMap((s) => {
10 const isTest = s.words[0]?.text === '[[';
11 const peeled = peel(s.words);
12 const end = peeled.args.findIndex((w) => w.text === '--');
13 const pnpmFlags =
14 peeled.name === 'pnpm'
15 ? peeled.args.slice(0, end < 0 ? undefined : end)
16 : [];
17 const own = s.words.flatMap((w): Edit[] => {
18 if (w.at === undefined) return [];
19 if (w.text === '-s' && pnpmFlags.includes(w))
20 return [
21 {
22 at: w.at,
23 from: w.text,
24 to: '--silent',
25 why: 'pnpm 12 refuses -s',
26 },
27 ];
28 // `--include=*.ts` matches no file, so zsh's NOMATCH aborts the command before grep sees it
29 if (/^--?[A-Za-z][\w-]*=.*[*?[]/.test(w.text))
30 return [
31 {
32 at: w.at,
33 from: w.text,
34 to: `'${w.text}'`,
35 why: 'zsh aborts on an option glob that matches no file',
36 },
37 ];
38 if (
39 w.text.startsWith('=') &&
40 w.text !== '=' &&
41 !(isTest && TEST_OPERATORS.has(w.text))
42 )
43 return [
44 {
45 at: w.at,
46 from: w.text,
47 to: `'${w.text}'`,
48 why: 'zsh reads an unquoted =word as a command path',
49 },
50 ];
51 return [];
52 });
53 return [...own, ...edits(s.subst)];
54 });
55}
56export function rewrite(command: string) {
57 // a `$( … )`'s commands are segments of their own and of the word's subst: one edit per offset
58 const found = [
59 ...new Map(
60 edits(parse(command).segments).map((e) => [e.at, e]),
61 ).values(),
62 ].sort((a, b) => b.at - a.at);
63 let out = command;
64 for (const e of found)
65 out = out.slice(0, e.at) + e.to + out.slice(e.at + e.from.length);
66 return {
67 command: out,
68 notes: found
69 .reverse()
70 .map((e) => `\`${e.from}\` → \`${e.to}\` (${e.why})`),
71 };
72}
73hooks/rules.ts 194 lines1import { brief } from './rules/brief.ts';
2import {
3 type Command,
4 type Context,
5 type Refusal,
6 type Verdict,
7 adds,
8 commands,
9 gitDir,
10 gitParts,
11 hasFlag,
12 isJobTmp,
13 operands,
14 resolve,
15} from './rules/command.ts';
16import { floor } from './rules/floor.ts';
17import { hazard } from './rules/hazard.ts';
18import { background, lint, unbraced } from './rules/lint.ts';
19import { landings, overwrite } from './rules/overwrite.ts';
20import { parse } from './shell.ts';
21
22const SHELLS = new Set(['sh', 'bash', 'zsh']);
23// the paths every git add in the command names, for the caller to look up on disk; a path an earlier part of the
24// same command makes (a > redirect, touch, mkdir, a cp or mv dest) is there by the time the add runs
25export function addedPaths(command: string, cwd: string, ctx: Context = {}) {
26 const made = new Set<string>();
27 return commands(parse(command), cwd).flatMap((c) => {
28 const added = adds(c, ctx)
29 .map((a) => a.path)
30 .filter((p) => !made.has(p));
31 const dir = resolve(c.dir, '/', ctx);
32 for (const w of c.writes) made.add(resolve(w, dir, ctx));
33 if (c.name === 'touch' || c.name === 'mkdir')
34 for (const o of operands(c.args)) made.add(resolve(o, dir, ctx));
35 if (c.name === 'cp' || c.name === 'mv')
36 for (const l of landings(c, ctx)) made.add(l.path);
37 return added;
38 });
39}
40function missingAdd(c: Command, ctx: Context): Refusal | undefined {
41 const gone = adds(c, ctx).filter((a) => ctx.missing?.has(a.path));
42 if (!gone.length) return undefined;
43 return {
44 door: 'stage only paths that exist; a deleted file stages with git rm <path>',
45 rule: 'git-add-missing',
46 targets: gone.map((a) => a.text),
47 why: 'git add of a missing path stages nothing and the commit lands partial',
48 };
49}
50// the os temp roots: a throwaway repo from `mktemp -d` lands under /var/folders, a hand-made fixture under /tmp
51const TEMP_ROOTS = [
52 '/tmp/',
53 '/private/tmp/',
54 '/var/folders/',
55 '/private/var/folders/',
56];
57// a scratch clone under the job's own tmp or an os temp root: everything its local git does stays there; a push or a
58// skipped hook does not
59const LOCAL_GIT = new Set(['git-discard', 'git-rewrite', 'git-sweep']);
60function isScratchGit(c: Command, r: Refusal, ctx: Context) {
61 if (c.name !== 'git' || !LOCAL_GIT.has(r.rule)) return false;
62 const { sub, subArgs } = gitParts(c.args);
63 if (sub === 'push') return false;
64 const dir = gitDir(c, ctx);
65 const paths = [dir, ...operands(subArgs).map((o) => resolve(o, dir, ctx))];
66 return paths.every(
67 (p) =>
68 isJobTmp(p, ctx) || TEMP_ROOTS.some((root) => p.startsWith(root)),
69 );
70}
71// cclio removes a scratch tree (a clean one: --force still asks) and deletes a scratch/* branch without dima's word;
72// a merged branch's `git branch -d` already runs for everyone, since git itself refuses an unmerged one
73const SCRATCH_TREE = /\/\.claude\/(worktrees\/[^/]+|jobs\/[^/]+\/tmp\/.+)$/;
74// the scratch trees a cclio `git worktree remove` names, for the caller to look up; a tree elsewhere asks anyway
75export function removedTrees(command: string, cwd: string, ctx: Context = {}) {
76 if (!ctx.isCclio) return [];
77 return commands(parse(command), cwd).flatMap((c) => {
78 if (c.name !== 'git') return [];
79 const { sub, subArgs } = gitParts(c.args);
80 const ops = operands(subArgs);
81 if (sub !== 'worktree' || ops[0] !== 'remove') return [];
82 const dir = gitDir(c, ctx);
83 return ops
84 .slice(1)
85 .map((t) => resolve(t, dir, ctx))
86 .filter((t) => SCRATCH_TREE.test(t));
87 });
88}
89function isCleanup(c: Command, r: Refusal, ctx: Context) {
90 if (!ctx.isCclio || c.name !== 'git' || r.rule !== 'git-rewrite')
91 return false;
92 const { sub, subArgs } = gitParts(c.args);
93 const ops = operands(subArgs);
94 if (sub === 'branch')
95 return ops.length > 0 && ops.every((o) => o.startsWith('scratch/'));
96 if (sub !== 'worktree' || ops[0] !== 'remove') return false;
97 if (hasFlag(subArgs, ['--force'], 'f')) return false;
98 const dir = gitDir(c, ctx);
99 const trees = ops.slice(1).map((t) => resolve(t, dir, ctx));
100 // a tree never looked up, or holding a `.scratch/` plan or commits only its HEAD reaches, asks
101 return (
102 trees.length > 0 &&
103 trees.every((t) => SCRATCH_TREE.test(t) && ctx.cleanTrees?.has(t))
104 );
105}
106export function refusals(
107 command: string,
108 cwd: string,
109 ctx: Context = {},
110): Refusal[] {
111 const parsed = parse(command);
112 const list = commands(parsed, cwd);
113 const out: Refusal[] = [];
114 list.forEach((c, i) => {
115 const found =
116 floor(c, ctx) ??
117 hazard(c, ctx) ??
118 overwrite(c, ctx) ??
119 lint(c, list[i + 1]) ??
120 missingAdd(c, ctx) ??
121 brief(c, ctx);
122 if (found && !isScratchGit(c, found, ctx) && !isCleanup(c, found, ctx))
123 out.push(found);
124 // a nested shell's script is a command too
125 // `-c` alone or in a cluster: `bash -lc`
126 const dashC = SHELLS.has(c.name)
127 ? c.args.findIndex((w) => /^-[A-Za-z]*c[A-Za-z]*$/.test(w.text))
128 : -1;
129 const script = dashC >= 0 ? c.args[dashC + 1] : undefined;
130 if (script) out.push(...refusals(script.text, c.dir, ctx));
131 if (c.name === 'eval')
132 out.push(
133 ...refusals(c.args.map((w) => w.text).join(' '), c.dir, ctx),
134 );
135 });
136 for (const r of [background(list), unbraced(parsed)]) if (r) out.push(r);
137 // one line per rule and target: a lint seen on every command of a pipeline is one finding
138 return out.filter(
139 (r, i) =>
140 out.findIndex(
141 (o) =>
142 o.rule === r.rule && o.targets.join() === r.targets.join(),
143 ) === i,
144 );
145}
146// a target dima named as a whole word: each edge is the prompt's end, a space, a quote (his «», ios “” included),
147// punctuation or a dash; a `.` or `/` closes it only before a space or the end (`build.`, `/x/build/`).
148// a target of bare symbols (`.`, `&`) reads in any prose, so it counts only inside the marker phrase he pastes
149const EDGE = /[\s"'`,;:!?()[\]<>«»“”‘’—–]/;
150const CLOSER = /[./]/;
151export function namesWhole(said: string, target: string) {
152 if (!/[\p{L}\p{N}]/u.test(target))
153 return namesWhole(said, `dima-ok: ${target}`);
154 for (
155 let i = said.indexOf(target);
156 i >= 0;
157 i = said.indexOf(target, i + 1)
158 ) {
159 const before = said[i - 1];
160 const end = i + target.length;
161 const after = said[end];
162 const isStart = before === undefined || EDGE.test(before);
163 const isEnd =
164 after === undefined ||
165 EDGE.test(after) ||
166 (CLOSER.test(after) &&
167 (end + 1 === said.length || /\s/.test(said[end + 1] ?? '')));
168 if (isStart && isEnd) return true;
169 }
170 return false;
171}
172export function markers(command: string) {
173 return parse(command)
174 .comments.map((c) => c.match(/^\s*dima-ok:\s*(.+?)\s*$/)?.[1])
175 .filter((t): t is string => !!t);
176}
177export function check(
178 command: string,
179 cwd: string,
180 ctx: Context = {},
181): Verdict {
182 const found = refusals(command, cwd, ctx);
183 if (!found.length) return { kind: 'run' };
184 const marked = markers(command);
185 // a marker names one target, or several split by spaces or commas; every target of a refusal must be named
186 const ok = new Set(marked.flatMap((m) => [m, ...m.split(/[\s,]+/)]));
187 const open = found.find((r) => !r.targets.every((t) => ok.has(t)));
188 if (open) return { kind: 'refused', refusal: open };
189 return { kind: 'escaped', refusals: found, targets: marked };
190}
191export function message(r: Refusal) {
192 return `nothing in this command ran — x-mod-guard stopped this one command. instead: ${r.door}. why: ${r.why}. only after dima's word, end the command with # dima-ok: ${r.targets.join(' ')}`;
193}
194hooks/shell.ts 303 lines1// a small reading of a shell command: enough to name each simple command, its words and what joins them.
2// quoted text, heredoc bodies and comments are never read as commands; a `$( … )` or a backtick pair is read as a command of its own.
3
4// at: the word's offset in the command, kept only for a bare word — plain characters, no quote, escape or expansion
5// hasQuote: some of the word sat in quotes or behind a backslash, so the shell neither splits nor globs that part
6export type Word = {
7 text: string;
8 isExpanding: boolean;
9 at?: number;
10 hasQuote?: boolean;
11};
12export type Sep = ';' | '&&' | '||' | '|' | '&' | '(' | ')';
13// subst: the commands inside this command's `$( … )` and backticks
14export type Segment = { words: Word[]; sep: Sep; subst: Segment[] };
15export type Parsed = {
16 segments: Segment[];
17 comments: string[];
18 unbraced: string[];
19};
20
21type Frame = {
22 kind: 'dq' | 'subst' | 'group' | 'tick';
23 words: Word[];
24 subst: Segment[];
25 text: string;
26 isWord: boolean;
27 isExpanding: boolean;
28 isBare: boolean;
29 hasQuote: boolean;
30 at: number;
31 from: number;
32};
33
34// a `$` the shell expands: a name, a digit, `{`, `(` or a special parameter
35const EXPANDS = /[A-Za-z0-9_{(@*#?!$-]/;
36const NAME = /[A-Za-z_][A-Za-z0-9_]*/y;
37
38export function parse(command: string): Parsed {
39 const segments: Segment[] = [];
40 const comments: string[] = [];
41 const unbraced: string[] = [];
42 const heredocs: string[] = [];
43 const stack: Frame[] = [];
44 let words: Word[] = [];
45 let subst: Segment[] = [];
46 let text = '';
47 let isWord = false;
48 let isExpanding = false;
49 let isQuoted = false;
50 let isBare = false;
51 let hasQuote = false;
52 let at = 0;
53 let i = 0;
54
55 const endWord = () => {
56 if (isWord)
57 words.push({
58 isExpanding,
59 text,
60 ...(isBare && { at }),
61 ...(hasQuote && { hasQuote }),
62 });
63 text = '';
64 isWord = false;
65 isExpanding = false;
66 isBare = false;
67 hasQuote = false;
68 };
69 const endSegment = (sep: Sep) => {
70 endWord();
71 if (words.length) segments.push({ sep, subst, words });
72 else {
73 // `(a) | b`: the pipe joins the group's last command to what follows
74 const last = segments.at(-1);
75 if (last?.sep === ')' && sep !== ')') last.sep = sep;
76 }
77 words = [];
78 subst = [];
79 };
80 // plain: one literal character read as itself; anything else ends the word's bareness
81 const add = (s: string, isPlain = false) => {
82 if (!isWord) at = i;
83 isBare = isPlain && (isBare || !isWord);
84 text += s;
85 isWord = true;
86 };
87 const open = (kind: Frame['kind']) => {
88 stack.push({
89 at,
90 from: segments.length,
91 hasQuote,
92 isBare,
93 isExpanding,
94 isWord,
95 kind,
96 subst,
97 text,
98 words,
99 });
100 words = [];
101 subst = [];
102 text = '';
103 isWord = false;
104 isExpanding = false;
105 isQuoted = false;
106 };
107 // the inner commands end; the outer one goes on where it stopped, the substitution standing in its word
108 const close = () => {
109 endSegment(')');
110 const frame = stack.pop();
111 if (!frame) return;
112 const inner = segments.slice(frame.from);
113 ({ words, text, isWord, isExpanding, isBare, at, hasQuote } = frame);
114 subst = [...frame.subst, ...inner];
115 if (frame.kind === 'group') return;
116 add('$(…)');
117 isExpanding = true;
118 };
119 // a substitution that sat inside double quotes resumes them
120 const resume = () => {
121 if (stack.at(-1)?.kind !== 'dq') return;
122 stack.pop();
123 isQuoted = true;
124 };
125 // a quote around a substitution is a marker frame: closing the substitution resumes the quote
126 const quoted = (kind: Frame['kind']) => {
127 if (isQuoted)
128 stack.push({
129 at: 0,
130 from: segments.length,
131 hasQuote: false,
132 isBare: false,
133 isExpanding: false,
134 isWord: false,
135 kind: 'dq',
136 subst: [],
137 text: '',
138 words: [],
139 });
140 open(kind);
141 };
142 const dollar = () => {
143 const next = command[i + 1] ?? '';
144 if (EXPANDS.test(next)) isExpanding = true;
145 if (next === '(' && command[i + 2] === '(') {
146 // arithmetic: no command and no heredoc inside
147 const end = command.indexOf('))', i + 3);
148 add(command.slice(i, end < 0 ? undefined : end + 2));
149 i = end < 0 ? command.length : end + 2;
150 return;
151 }
152 if (next === '(') {
153 i += 2;
154 quoted('subst');
155 return;
156 }
157 NAME.lastIndex = i + 1;
158 const name = NAME.exec(command)?.[0];
159 if (!name) {
160 add('$');
161 i++;
162 return;
163 }
164 // zsh reads `$name:x` as a modifier when x is a letter; bash reads a non-ascii character as part of the name
165 const after = command[i + 1 + name.length] ?? '';
166 const modifier = command[i + 2 + name.length] ?? '';
167 if (
168 (after === ':' && /[A-Za-z]/.test(modifier)) ||
169 after.charCodeAt(0) > 127
170 )
171 unbraced.push(`$${name}${after}`);
172 add(`$${name}`);
173 i += name.length + 1;
174 };
175 const skipHeredocs = () => {
176 while (heredocs.length) {
177 const delimiter = heredocs.shift();
178 while (i < command.length) {
179 const end = command.indexOf('\n', i);
180 const line = command.slice(i, end < 0 ? undefined : end);
181 i = end < 0 ? command.length : end + 1;
182 if (line.trim() === delimiter) break;
183 }
184 }
185 };
186 const heredoc = () => {
187 endWord();
188 i += 2;
189 if (command[i] === '-') i++;
190 while (command[i] === ' ' || command[i] === '\t') i++;
191 const quote = command[i];
192 let delimiter = '';
193 if (quote === "'" || quote === '"') {
194 const end = command.indexOf(quote, i + 1);
195 delimiter = command.slice(i + 1, end < 0 ? undefined : end);
196 i = end < 0 ? command.length : end + 1;
197 } else {
198 while (
199 i < command.length &&
200 !/[\s;&|<>()]/.test(command[i] ?? '')
201 ) {
202 delimiter += command[i] === '\\' ? '' : command[i];
203 i++;
204 }
205 }
206 if (delimiter) heredocs.push(delimiter);
207 };
208
209 while (i < command.length) {
210 const c = command[i] ?? '';
211 const next = command[i + 1] ?? '';
212 if (isQuoted) {
213 if (c === '"') {
214 isQuoted = false;
215 i++;
216 } else if (c === '\\') {
217 add(next);
218 i += 2;
219 } else if (c === '$') dollar();
220 else if (c === '`') {
221 i++;
222 quoted('tick');
223 } else {
224 add(c);
225 i++;
226 }
227 } else if (c === '\n') {
228 i++;
229 endSegment(';');
230 skipHeredocs();
231 } else if (c === ' ' || c === '\t') {
232 endWord();
233 i++;
234 } else if (c === '#' && !isWord) {
235 const end = command.indexOf('\n', i);
236 comments.push(command.slice(i + 1, end < 0 ? undefined : end));
237 i = end < 0 ? command.length : end;
238 } else if (c === '\\') {
239 add(next === '\n' ? '' : next);
240 hasQuote = true;
241 i += 2;
242 } else if (c === "'") {
243 const end = command.indexOf("'", i + 1);
244 add(command.slice(i + 1, end < 0 ? undefined : end));
245 hasQuote = true;
246 i = end < 0 ? command.length : end + 1;
247 } else if (c === '"') {
248 isWord = true;
249 isBare = false;
250 hasQuote = true;
251 isQuoted = true;
252 i++;
253 } else if (c === '$') {
254 dollar();
255 } else if (c === '`') {
256 i++;
257 if (stack.at(-1)?.kind === 'tick') {
258 close();
259 resume();
260 } else open('tick');
261 } else if (c === '(') {
262 i++;
263 open('group');
264 } else if (c === ')') {
265 i++;
266 close();
267 resume();
268 } else if (c === ';') {
269 i += next === ';' ? 2 : 1;
270 endSegment(';');
271 } else if (c === '&') {
272 if (next === '&') {
273 i += 2;
274 endSegment('&&');
275 } else if (next === '>' || /[<>]$/.test(text)) {
276 add('&');
277 i++;
278 } else {
279 i++;
280 endSegment('&');
281 }
282 } else if (c === '|') {
283 if (next === '|') {
284 i += 2;
285 endSegment('||');
286 } else {
287 i += next === '&' ? 2 : 1;
288 endSegment('|');
289 }
290 } else if (c === '<' && next === '<' && command[i + 2] !== '<') {
291 heredoc();
292 } else {
293 add(c, true);
294 i++;
295 }
296 }
297 while (stack.length)
298 if (stack.at(-1)?.kind === 'dq') stack.pop();
299 else close();
300 endSegment(';');
301 return { comments, segments, unbraced };
302}
303hooks/rules/floor.ts 233 lines1import {
2 ASK,
3 type Command,
4 type Context,
5 NEVER,
6 type Refusal,
7 STASH,
8 TRASH,
9 VAULT,
10 gitDir,
11 gitParts,
12 hasFlag,
13 operands,
14 or,
15 peel,
16} from './command.ts';
17import { isOwnRepo } from './hazard.ts';
18import { rewrite } from './rewrite.ts';
19
20// what reads process ids by name
21const FINDERS = new Set(['pgrep', 'ps', 'pidof', 'lsof', 'grep', 'rg', 'awk']);
22export function floor(c: Command, ctx: Context): Refusal | undefined {
23 const ops = operands(c.args);
24 if (c.name === 'rm' || c.name === 'unlink')
25 return {
26 door: TRASH,
27 rule: 'rm',
28 targets: or(ops, 'rm'),
29 why: 'rm deletes for good',
30 };
31 if (c.name === 'find') {
32 const exec = c.args.findIndex((w) =>
33 /^-(exec|execdir|ok)$/.test(w.text),
34 );
35 const execRm = exec >= 0 && peel(c.args.slice(exec + 1)).name === 'rm';
36 if (execRm || hasFlag(c.args, ['-delete'])) {
37 const roots = c.args.findIndex((w) => /^[-(!]/.test(w.text));
38 const paths = c.args
39 .slice(0, roots < 0 ? undefined : roots)
40 .map((w) => w.text);
41 return {
42 door: TRASH,
43 rule: 'rm',
44 targets: or(paths, '.'),
45 why: 'find deletes for good',
46 };
47 }
48 }
49 const pgrep = c.feeders.find((f) => f.name === 'pgrep');
50 if (
51 c.name === 'pkill' ||
52 c.name === 'killall' ||
53 (c.name === 'kill' && c.feeders.some((f) => FINDERS.has(f.name)))
54 )
55 return {
56 door: 'claude stop <id>, or kill the pid you captured at spawn',
57 rule: 'kill',
58 targets: or(
59 c.name === 'kill' ? operands(pgrep?.args ?? []) : ops,
60 c.name,
61 ),
62 why: 'a kill by pattern hits your own process too',
63 };
64 if (
65 c.name === 'mv' &&
66 (c.dir.includes(VAULT) || ops.some((o) => o.includes(VAULT)))
67 )
68 return {
69 door: 'obsidian rename / obsidian move — they rewrite the wikilinks',
70 rule: 'vault-mv',
71 targets: ops,
72 why: 'a plain mv in the vault breaks every wikilink to the note',
73 };
74 if (
75 c.assigns.some((a) => a.startsWith('HOME=')) ||
76 (c.name === 'export' && ops.some((o) => o.startsWith('HOME=')))
77 )
78 return {
79 door: 'run it without the HOME override',
80 rule: 'home',
81 targets: ['HOME'],
82 why: 'a HOME override points every tool at the wrong config',
83 };
84 if (
85 c.name === 'npm' &&
86 hasFlag(c.args, ['--global', '--location=global'], 'g')
87 )
88 return {
89 door: 'brew first, else pnpm add -g',
90 rule: 'npm-global',
91 targets: ['npm'],
92 why: 'npm -g is not used here',
93 };
94 const isPip = /^pip\d*(\.\d+)?$/.test(c.name) && ops[0] === 'install';
95 const pipAt = c.args.findIndex(
96 (w, i) => w.text === '-m' && c.args[i + 1]?.text === 'pip',
97 );
98 const isPyPip =
99 /^python\d*(\.\d+)?$/.test(c.name) &&
100 pipAt >= 0 &&
101 operands(c.args.slice(pipAt + 2))[0] === 'install';
102 if (isPip || isPyPip)
103 return {
104 door: 'uv pip install',
105 rule: 'pip',
106 targets: ['pip'],
107 why: 'pip is not used here; uv is',
108 };
109 const bypass = c.args.find(
110 (w) => w.text === '--no-verify' || w.text === '--no-gpg-sign',
111 );
112 if (bypass && (c.name === 'git' || c.name === 'x'))
113 return {
114 door: NEVER,
115 rule: 'bypass',
116 targets: [bypass.text],
117 why: `${bypass.text} skips a gate`,
118 };
119 if (c.name !== 'git') return undefined;
120
121 const { configs, sub, subArgs } = gitParts(c.args);
122 const subOps = operands(subArgs);
123 const config = configs.find(
124 (k) =>
125 /^commit\.gpgsign=false$/i.test(k) ||
126 /^user\.(name|email)=/i.test(k),
127 );
128 if (config) {
129 const key = config.split('=')[0] ?? config;
130 return {
131 door: NEVER,
132 rule: 'bypass',
133 targets: [key],
134 why: `-c ${key} overrides signing or identity`,
135 };
136 }
137 const discard = (targets: string[], what: string): Refusal => ({
138 door: STASH,
139 rule: 'git-discard',
140 targets,
141 why: `${what} throws away uncommitted work`,
142 });
143 if (sub === 'commit' && hasFlag(subArgs, [], 'n'))
144 return {
145 door: NEVER,
146 rule: 'bypass',
147 targets: ['-n'],
148 why: 'git commit -n skips the commit hooks',
149 };
150 if (
151 sub === 'config' &&
152 /^user\.(name|email)$/i.test(subOps[0] ?? '') &&
153 subOps.length > 1
154 )
155 return {
156 door: NEVER,
157 rule: 'bypass',
158 targets: [subOps[0] ?? ''],
159 why: 'git config changes the commit identity',
160 };
161 if (sub === 'reset' && hasFlag(subArgs, ['--hard']))
162 return discard(or(subOps, 'HEAD'), 'git reset --hard');
163 if (
164 sub === 'checkout' &&
165 (subArgs.some((w) => w.text === '--') ||
166 subOps.includes('.') ||
167 hasFlag(subArgs, ['--force'], 'f'))
168 )
169 return discard(or(subOps, '.'), 'git checkout over files');
170 if (
171 sub === 'restore' &&
172 !(
173 hasFlag(subArgs, ['--staged'], 'S') &&
174 !hasFlag(subArgs, ['--worktree'], 'W')
175 )
176 )
177 return discard(or(subOps, '.'), 'git restore');
178 if (sub === 'clean' && hasFlag(subArgs, ['--force'], 'f'))
179 return discard(or(subOps, '.'), 'git clean');
180 if (sub === 'stash' && (subOps[0] === 'drop' || subOps[0] === 'clear'))
181 return {
182 door: ASK,
183 rule: 'git-discard',
184 targets: or(
185 subOps.slice(1),
186 subOps[0] === 'drop' ? 'stash@{0}' : 'clear',
187 ),
188 why: `git stash ${subOps[0]} throws away set-aside work`,
189 };
190 const rewrite = (targets: string[], what: string): Refusal => ({
191 door: ASK,
192 rule: 'git-rewrite',
193 targets,
194 why: `${what} cannot be undone`,
195 });
196 if (
197 sub === 'push' &&
198 (hasFlag(
199 subArgs,
200 ['--force', '--force-with-lease', '--delete'],
201 'fd',
202 ) ||
203 subOps.slice(1).some((o) => /^[+:]/.test(o)))
204 )
205 return rewrite(or(subOps, 'push'), 'a force-push');
206 if (
207 sub === 'branch' &&
208 (hasFlag(subArgs, [], 'D') ||
209 (hasFlag(subArgs, ['--delete'], 'd') &&
210 hasFlag(subArgs, ['--force'], 'f')))
211 )
212 return rewrite(subOps, 'git branch -D');
213 if (sub === 'filter-repo' || sub === 'filter-branch')
214 return rewrite([sub], `git ${sub}`);
215 if (sub === 'gc' && hasFlag(subArgs, ['--prune=now']))
216 return rewrite(['--prune=now'], 'git gc --prune=now');
217 if (sub === 'worktree' && subOps[0] === 'prune')
218 return rewrite(['prune'], 'git worktree prune');
219 if (sub === 'worktree' && subOps[0] === 'remove')
220 return rewrite(or(subOps.slice(1), 'remove'), 'git worktree remove');
221 const toMain = subOps
222 .slice(1)
223 .find((o) => /^[^+:][^:]*:(refs\/heads\/)?main$/.test(o));
224 if (sub === 'push' && toMain && isOwnRepo(gitDir(c, ctx), ctx))
225 return {
226 door: "x lane push — it pushes HEAD's sha and reads the remote back",
227 rule: 'push-lane',
228 targets: [toMain],
229 why: 'a hand-typed sha or ref pushed to main skips the read-back',
230 };
231 return undefined;
232}
233hooks/rules/hazard.ts 162 lines1import {
2 ASK,
3 type Command,
4 type Context,
5 type Refusal,
6 VAULT,
7 hasFlag,
8 operands,
9 or,
10 resolve,
11} from './command.ts';
12
13// frame and bytes: the two repos `x lane push` serves
14export function isOwnRepo(dir: string, ctx: Context) {
15 if (!ctx.home) return false;
16 return [`${ctx.home}/frame`, `${ctx.home}/projects/bytes`].some(
17 (r) => dir === r || dir.startsWith(`${r}/`),
18 );
19}
20const ROOT_STEP = 'hand dima the step — these are his, in System Settings';
21const PRUNE = 'leave the prune to dima — name it in your report';
22const LINEAR = 'linear closes, never deletes — cancel it (state Canceled)';
23const DELETE = new Set(['delete', 'rm', 'remove']);
24// `~`, a dir above it, a dir right under it, or the obsidian vault root and above
25function isTopDir(path: string, ctx: Context) {
26 const home = ctx.home;
27 if (path === '/' || !home) return path === '/';
28 if (path === home || home.startsWith(`${path}/`)) return true;
29 if (path.slice(0, path.lastIndexOf('/')) === home) return true;
30 const parts = path.split('/');
31 const at = parts.indexOf(VAULT);
32 return at >= 0 && parts.length - at <= 3;
33}
34// past the floor: system state, prunes, remote deletes, a top dir or a whole defaults domain, and sudo
35export function hazard(c: Command, ctx: Context): Refusal | undefined {
36 const ops = operands(c.args);
37 const dir = resolve(c.dir, '/', ctx);
38 const system = (what: string, targets: string[], door = ASK): Refusal => ({
39 door,
40 rule: 'system',
41 targets,
42 why: `${what} changes the machine past undoing`,
43 });
44 if (c.name === 'diskutil' && /^erase/i.test(ops[0] ?? ''))
45 return system(`diskutil ${ops[0]}`, or(ops.slice(1), 'diskutil'));
46 const device = c.args.find(
47 (w) =>
48 w.text.startsWith('of=/dev/') &&
49 !/^of=\/dev\/(null|std)/.test(w.text),
50 );
51 if (c.name === 'dd' && device)
52 return system('dd onto a device', [device.text.slice(3)]);
53 if (/^(mkfs|newfs)/.test(c.name)) return system(c.name, or(ops, c.name));
54 if (
55 (c.name === 'chmod' || c.name === 'chown') &&
56 hasFlag(c.args, ['--recursive'], 'R')
57 ) {
58 const home = ops.filter((o) => {
59 const p = resolve(o, dir, ctx);
60 return (
61 p === '/' || p === ctx.home || !!ctx.home?.startsWith(`${p}/`)
62 );
63 });
64 if (home.length)
65 return system(
66 `${c.name} -R over home`,
67 home,
68 `${c.name} the one path you mean, never -R over ~`,
69 );
70 }
71 if (c.name === 'csrutil' && ops[0] && ops[0] !== 'status')
72 return system(`csrutil ${ops[0]}`, [ops[0]], ROOT_STEP);
73 const gatekeeper = c.args.find(
74 (w) => w.text === '--master-disable' || w.text === '--global-disable',
75 );
76 if (c.name === 'spctl' && gatekeeper)
77 return system('spctl', [gatekeeper.text], ROOT_STEP);
78 if (c.name === 'tccutil' && ops[0] === 'reset')
79 return system('tccutil reset', or(ops.slice(1), 'reset'), ROOT_STEP);
80
81 const prune = (what: string, target: string): Refusal => ({
82 door: PRUNE,
83 rule: 'prune',
84 targets: [target],
85 why: `${what} drops what dima may still want`,
86 });
87 if (c.name === 'brew' && ops[0] === 'cleanup')
88 return prune('brew cleanup', 'cleanup');
89 if (c.name === 'pnpm' && ops[0] === 'store' && ops[1] === 'prune')
90 return prune('pnpm store prune', 'prune');
91 if (c.name === 'docker' && ops[0] === 'system' && ops[1] === 'prune')
92 return prune('docker system prune', 'prune');
93 if (c.name === 'crontab' && hasFlag(c.args, [], 'r'))
94 return prune('crontab -r', '-r');
95
96 const remote = (what: string, targets: string[], door = ASK): Refusal => ({
97 door,
98 rule: 'remote-delete',
99 targets,
100 why: `${what} deletes on the remote, past any trash`,
101 });
102 if (
103 c.name === 'gh' &&
104 (ops[0] === 'repo' || ops[0] === 'release') &&
105 /^delete/.test(ops[1] ?? '')
106 )
107 return remote(`gh ${ops[0]} ${ops[1]}`, or(ops.slice(2), ops[0]));
108 const vercelAt = ops.slice(0, 2).findIndex((o) => DELETE.has(o));
109 if (c.name === 'vercel' && vercelAt >= 0)
110 return remote(
111 `vercel ${ops.slice(0, vercelAt + 1).join(' ')}`,
112 or(ops.slice(vercelAt + 1), 'vercel'),
113 );
114 if (c.name === 'op' && DELETE.has(ops[1] ?? ''))
115 return remote(`op ${ops[0]} ${ops[1]}`, or(ops.slice(2), 'op'));
116 if (c.name === 'security' && /^delete-/.test(ops[0] ?? ''))
117 return remote(`security ${ops[0]}`, [ops[0] ?? 'security']);
118 if (c.name === 'linear' && ops[0] === 'issue' && DELETE.has(ops[1] ?? ''))
119 return remote('linear issue delete', or(ops.slice(2), 'issue'), LINEAR);
120 if (
121 ['curl', 'linear', 'xh', 'http'].includes(c.name) &&
122 c.args.some((w) => /\bissueDelete\b/.test(w.text))
123 )
124 return remote('an issueDelete mutation', ['issueDelete'], LINEAR);
125
126 const top =
127 c.name === 'trash'
128 ? ops.filter(
129 (o) =>
130 !o.includes('$') && isTopDir(resolve(o, dir, ctx), ctx),
131 )
132 : [];
133 if (top.length)
134 return {
135 door: 'trash the files inside it, by name',
136 rule: 'top-dir',
137 targets: top,
138 why: 'trash of a top dir takes everything under it at once',
139 };
140 const isGlobal = hasFlag(c.args, ['-g', '-globalDomain']);
141 if (
142 c.name === 'defaults' &&
143 ops[0] === 'delete' &&
144 ops.length - 1 < (isGlobal ? 1 : 2)
145 )
146 return {
147 door: 'defaults delete <domain> <key> — one key, never the whole domain',
148 rule: 'top-dir',
149 targets: or(ops.slice(1), isGlobal ? '-g' : 'delete'),
150 why: 'defaults delete of a domain drops every preference the app has',
151 };
152 const root = c.wrappers.find((w) => w === 'sudo' || w === 'doas');
153 if (root)
154 return {
155 door: 'run it without sudo, or hand dima the command in a copy fence',
156 rule: 'sudo',
157 targets: [root],
158 why: `${root} changes system state as root`,
159 };
160 return undefined;
161}
162hooks/rules/lint.ts 228 lines1import type { Parsed, Word } from '../shell.ts';
2import {
3 type Command,
4 LANE,
5 type Refusal,
6 SD_VALUE,
7 gitParts,
8 hasFlag,
9 isFlag,
10 operands,
11 or,
12 reads,
13} from './command.ts';
14import { edits } from './rewrite.ts';
15
16const GREPS = new Set([
17 'grep',
18 'egrep',
19 'fgrep',
20 'rg',
21 'ugrep',
22 'head',
23 'tail',
24]);
25// a script named for a gate, a family member included: `typecheck`, `test:unit`, `mods:test`, `x-go:gate`
26const GATE = /(^|:)(typecheck|test|check|tsc|vitest|gate)(:|$)/;
27// a gate tool run to list or print, which checks nothing
28const LISTING = [
29 '--help',
30 '-h',
31 '--version',
32 '-v',
33 '--listFiles',
34 '--listFilesOnly',
35 '--showConfig',
36];
37const SD_FLAGS = new Set([
38 '-p',
39 '--preview',
40 '-F',
41 '--fixed-strings',
42 '-s',
43 '--string-mode',
44 '-n',
45 '--max-replacements',
46 '-f',
47 '--flags',
48 '-h',
49 '--help',
50 '-V',
51 '--version',
52]);
53export function lint(
54 c: Command,
55 next: Command | undefined,
56): Refusal | undefined {
57 const ops = operands(c.args);
58 const pipedTo =
59 c.sep === '|' && next && GREPS.has(next.name) ? next.name : undefined;
60 if (c.name === 'sd') {
61 const hasEnd = c.args.some((w) => w.text === '--');
62 const positional: Word[] = [];
63 for (let i = 0; i < c.args.length; i++) {
64 const w = c.args[i] as Word;
65 if (w.text === '--') {
66 positional.push(...c.args.slice(i + 1));
67 break;
68 }
69 if (!isFlag(w.text)) positional.push(w);
70 else if (!hasEnd && !SD_FLAGS.has(w.text))
71 return {
72 door: `sd -- '${w.text}' …`,
73 rule: 'sd-dash',
74 targets: ['sd'],
75 why: `sd reads ${w.text} as a flag and edits nothing`,
76 };
77 else if (SD_VALUE.has(w.text)) i++;
78 }
79 // a `$` in the replacement is lost either way: the shell expands it in double quotes, sd reads `$NAME` as a capture ref in single
80 const replacement = positional[1];
81 if (replacement?.isExpanding || replacement?.text.includes('$'))
82 return {
83 door: 'python or the Edit tool for a replacement holding $',
84 rule: 'sd-dollar',
85 targets: ['sd'],
86 why: replacement.isExpanding
87 ? 'the shell expands $ inside double quotes and the line ships hollow'
88 : 'sd reads $NAME in the replacement as a capture group and writes it empty',
89 };
90 if (
91 !hasFlag(c.args, ['--preview'], 'p') &&
92 ops.some((o) => o.includes('.github/workflows/'))
93 )
94 return {
95 door: 'the Edit tool',
96 rule: 'workflow-edit',
97 targets: ['sd'],
98 why: 'sd drops ${{ … }} from a workflow line',
99 };
100 }
101 // the obsidian cli runs a verb on the active note: `delete --help` deleted memory-sweep.md (2026-10-07)
102 if (c.name === 'obsidian' && ops.length > 0) {
103 if (hasFlag(c.args, ['--help'], 'h'))
104 return {
105 door: 'obsidian --help, bare: it lists every verb with its options',
106 rule: 'obsidian-help',
107 targets: ['obsidian'],
108 why: `obsidian ${ops[0]} --help runs ${ops[0]} on the active note instead of printing help`,
109 };
110 if (
111 ops[0] === 'delete' &&
112 !ops.some((o) => o.startsWith('path=') || o.startsWith('file='))
113 )
114 return {
115 door: 'obsidian delete path=<vault path>',
116 rule: 'obsidian-delete-target',
117 targets: ['obsidian'],
118 why: 'a delete with no file named deletes the active note',
119 };
120 }
121 if (
122 (c.name === 'sed' || c.name === 'gsed') &&
123 hasFlag(c.args, ['--in-place'], 'i') &&
124 ops.some((o) => o.includes('.github/workflows/'))
125 )
126 return {
127 door: 'the Edit tool',
128 rule: 'workflow-edit',
129 targets: ['sed'],
130 why: 'sed drops ${{ … }} from a workflow line',
131 };
132 if (pipedTo && c.name === 'git' && gitParts(c.args).sub === 'push')
133 return {
134 door: 'git ls-remote <remote> <branch>',
135 rule: 'push-grep',
136 targets: [pipedTo],
137 why: 'a push is read by git ls-remote, never by its output',
138 };
139 const isListing =
140 hasFlag(c.args, LISTING) || ops.includes('list') || ops.includes('ls');
141 const isXGate = c.name === 'x' && ops[0] === 'go' && ops[1] === 'gate';
142 const isGate =
143 !isListing &&
144 (isXGate ||
145 ['tsc', 'vitest'].includes(c.name) ||
146 (['pnpm', 'npm', 'yarn', 'bun'].includes(c.name) &&
147 ops.some((o) => GATE.test(o))));
148 if (pipedTo && isGate)
149 return {
150 door: isXGate
151 ? 'run it unpiped and read the GATE line'
152 : 'run the gate unpiped and read its exit code',
153 rule: 'gate-pipe',
154 targets: [pipedTo],
155 why: `| ${pipedTo} turns a red gate quiet`,
156 };
157 // zsh never splits an unquoted parameter: `set -- $PIDS` sets one arg, and a watch on `$1` dies silent
158 const dashes = c.args.findIndex((w) => w.text === '--');
159 const unsplit =
160 c.name === 'set' && dashes >= 0
161 ? c.args
162 .slice(dashes + 1)
163 .find(
164 (w) =>
165 !w.hasQuote &&
166 /^\$\{?[A-Za-z_]/.test(w.text) &&
167 !w.text.startsWith('${='),
168 )
169 : undefined;
170 if (unsplit) {
171 const name = unsplit.text.replace(/^\$\{?|\}$/g, '');
172 return {
173 door: `\${=${name}} — zsh's split — or write the items out`,
174 rule: 'set-unsplit',
175 targets: [unsplit.text],
176 why: `zsh does not split ${unsplit.text}, so set -- gets one argument`,
177 };
178 }
179 if (c.name === 'git') {
180 const { sub, subArgs } = gitParts(c.args);
181 if (sub === 'commit' && !subArgs.some((w) => w.text === '--'))
182 return {
183 door: LANE,
184 rule: 'git-sweep',
185 targets: ['commit'],
186 why: 'a commit with no -- paths sweeps whatever is staged',
187 };
188 const sweep = subArgs.find(
189 (w) => w.text === '-A' || w.text === '--all' || w.text === '.',
190 );
191 if (sub === 'add' && sweep)
192 return {
193 door: LANE,
194 rule: 'git-sweep',
195 targets: [sweep.text],
196 why: `git add ${sweep.text} stages files that are not yours`,
197 };
198 }
199 return undefined;
200}
201export function unbraced(parsed: Parsed): Refusal | undefined {
202 const [first] = parsed.unbraced;
203 if (!first) return undefined;
204 const name = first.slice(1, -1);
205 return {
206 door: `\${${name}}`,
207 rule: 'unbraced',
208 targets: [`$${name}`],
209 why: `${first} — zsh reads a modifier, bash a longer name`,
210 };
211}
212export function background(list: Command[]): Refusal | undefined {
213 const last = list.findLastIndex((c) => c.sep === '&');
214 if (
215 last < 0 ||
216 list
217 .slice(last + 1)
218 .some((c) => c.name === 'wait' || c.name === 'sleep')
219 )
220 return undefined;
221 return {
222 door: 'add wait (or a sleep) after it, or use run_in_background',
223 rule: 'background',
224 targets: ['&'],
225 why: 'the tool wrapper exits and kills a trailing & child',
226 };
227}
228types/guard.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 // a cclio session's code edits so far: one session's count, gone with it
4 // seen: the real paths this session has Read, Edited or Written, so a Write over a tracked file it never read is refused
5 // prompt: dima's last prompt typed at the composer or the bridge, the proof a # dima-ok marker needs
6 'x-mod-guard': { edits: number; seen: string[]; prompt: string };
7 }
8}
9