SLOPSHOPPER

x-mod-guard

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…

newguardpromptprocessagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · x-mod-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by x-mod-guard: nothing in this command ran — x-mod-guard stopped this one command. instead: trash ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🎞️ 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">

🧭 toc

  • fleet — skills, memory, sline, hotkeys
  • chords — the keyboard map, live
  • mirror — home/ is ~
  • machine — brew, defaults, launchd
  • link it — a fresh mac install

🛸 fleet

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

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">

🪞 mirror

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">

💻 machine

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

🔗 link it

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.

Source 11 files
hooks/register.ts 502 lines
1import 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};
502
hooks/rules/brief.ts 60 lines
1import { 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}
60
hooks/rules/command.ts 252 lines
1import 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*&?>>([^&].*)$/;
252
hooks/rules/overwrite.ts 133 lines
1import { 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}
133
hooks/rules/rewrite.ts 73 lines
1import { 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}
73
hooks/rules.ts 194 lines
1import { 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}
194
hooks/shell.ts 303 lines
1// 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}
303
hooks/rules/floor.ts 233 lines
1import {
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}
233
hooks/rules/hazard.ts 162 lines
1import {
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}
162
hooks/rules/lint.ts 228 lines
1import 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}
228
types/guard.d.ts 9 lines
1declare 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