create-agent-rig 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +66 -10
  3. package/package.json +9 -2
  4. package/packages/cli/dist/commands/init.js +73 -18
  5. package/packages/cli/dist/index.js +11 -1
  6. package/packages/cli/dist/lib/init-settings.js +52 -0
  7. package/packages/cli/dist/lib/summary.js +19 -5
  8. package/packages/cli/dist/templates.js +8 -0
  9. package/templates/agent-os/init/CLAUDE.md +133 -0
  10. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
  11. package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
  12. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
  13. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
  14. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
  15. package/templates/agent-os/universal/.claude/queue.json +3 -0
  16. package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
  17. package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
  18. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
  19. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
  20. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
  21. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
  22. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
  23. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
  24. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
  25. package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
  26. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
  27. package/templates/agent-os/universal/.claude/settings.json +4 -0
  28. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
  29. package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
  30. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
  31. package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  32. package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
  33. package/templates/agent-os/universal/CLAUDE.md +57 -7
  34. package/templates/agent-os/universal/PLAN.md +28 -2
  35. package/templates/agent-os/universal/layers.json +20 -1
  36. package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
  37. package/templates/skeleton/aws-serverless/gitignore +8 -0
  38. package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
  39. package/templates/skeleton/node-service/gitignore +8 -0
@@ -0,0 +1,808 @@
1
+ // PreToolUse hook: the part of the "Never" tier of .claude/rules/autonomy.md
2
+ // that a text scan can decide, made mechanical. A prompt-level rule is followed most of the time; a hook is
3
+ // followed every time, and these are the actions where "most of the time" is not
4
+ // good enough because they are not reversible.
5
+ //
6
+ // It is a SECOND Bash guard on purpose. `block-no-verify` owns exactly one
7
+ // invariant (the pre-commit gate may not be bypassed) and stays readable because
8
+ // of it; this one owns the irreversible actions and the kill switch.
9
+ //
10
+ // ── Why this parses instead of pattern-matching ──────────────────────────────
11
+ //
12
+ // The first version ran regexes over the command string after splitting it on
13
+ // `|&;`. An adversarial pass found 26 false negatives and 6 false positives, and
14
+ // nearly all of them had one cause: **it matched before it understood quoting.**
15
+ // - `git commit -m "cleanup; rm -rf / was possible"` → the `;` inside the
16
+ // message manufactured a segment whose first word was `rm`, and the guard
17
+ // blocked a commit. A guard that fires on prose is a guard people disable.
18
+ // - `git push --force origin "main"` → quoted text was blanked before the
19
+ // branch was read, so the branch vanished and the force-push was allowed.
20
+ //
21
+ // So it now TOKENISES first: quotes are honoured, separators inside quotes are
22
+ // just characters, and every rule reads structured arguments. That single change
23
+ // closed both directions at once.
24
+ //
25
+ // ── The limits, stated exactly — and TESTED ──────────────────────────────────
26
+ //
27
+ // This block is a credibility claim, so `test/template/guard-hardening.test.ts`
28
+ // asserts each line twice: that the limit is documented here, and that the
29
+ // command really does pass. A limits comment nothing checks drifts into fiction,
30
+ // which is what happened the first time — an earlier version of this list was
31
+ // understated in six ways.
32
+ //
33
+ // Not caught:
34
+ // - a value that only exists at runtime: `git push --force origin $BRANCH`;
35
+ // - a user-defined alias, or a wrapper script that shells out:
36
+ // `./scripts/deploy-prod.sh`;
37
+ // - a command assembled at runtime: `eval "$(printf ...)"`;
38
+ // - brace expansion: `git push --force origin mai{n..n}` really does push to
39
+ // `main`, and the guard does not expand it;
40
+ // - more than 32 heredocs in one command: past that budget the bodies are read
41
+ // as commands, so a script with 33+ heredocs can be falsely BLOCKED on its
42
+ // own data. The budget exists because each lookahead scans forward, and an
43
+ // unbounded number of them is the quadratic hazard that once killed the hook.
44
+ // Erring toward a false block past the budget is the safe direction — but it
45
+ // is a limit, so it is written here rather than discovered.
46
+ //
47
+ // That last one is here BY CHOICE, and the choice is the point. Expanding braces
48
+ // needs a cross-product, and a bound per group is not a bound on the result: the
49
+ // implementation that did it could be made to overflow the stack, which the
50
+ // fail-open catch below turned into "allow" for every rule at once. A guard that
51
+ // can be disarmed by ten characters is worse than one with a documented gap.
52
+ // See .claude/rules/invariants.md, "A guard that fails open must do provably
53
+ // bounded work".
54
+ //
55
+ // And the SCOPE of each rule, because "refuses the Never tier" reads wider than
56
+ // what is actually inspected:
57
+ // - deletes: only `rm` is examined. `find -delete`, `dd`, `shred`, `truncate`,
58
+ // `mv`, `rsync --delete` and `chmod -R 000` are not;
59
+ // - production deploys: only a workflow dispatch (`gh workflow run`, `gh api
60
+ // …/dispatches`). A deploy driven straight from an infrastructure CLI, or a
61
+ // registry publish, is not caught — and on a target whose own deploy command
62
+ // IS such a CLI, that is the ordinary spelling, not an exotic one;
63
+ // - direct pushes: only when the command NAMES the branch. Bare `git push` and
64
+ // `git push origin HEAD` depend on the checked-out branch, which this guard
65
+ // cannot know without running git. While the kill switch is on they are
66
+ // refused for that reason; the rest of the time they are not;
67
+ // - branch deletion: `git push --delete` is caught; `git branch -D main`,
68
+ // `git update-ref -d` and `gh api -X DELETE …/refs/heads/main` are not;
69
+ // - a command carried as a flag value (`find … -exec`, `env -S`) is not
70
+ // followed.
71
+ //
72
+ // The list is **not exhaustive**. The guard targets DRIFT — the ordinary spelling
73
+ // written without thinking — not an adversary, and circumventing it is itself a
74
+ // Never-tier violation. The layers behind it are review and CI.
75
+ //
76
+ // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
77
+ // stderr is shown to the agent as the reason. Fails open on anything it cannot
78
+ // parse — a crashed guard must never make the session unusable.
79
+ import { readFileSync, realpathSync } from 'node:fs';
80
+ import { fileURLToPath } from 'node:url';
81
+ import { brakeIsOn } from '../scripts/stop-flag.mjs';
82
+
83
+ /** Branches that are shared by definition. */
84
+ const PROTECTED_BRANCH = /^(main|master|develop|development|trunk)$/;
85
+ /** Command wrappers that stand between the shell and the real command. */
86
+ const WRAPPERS = new Set([
87
+ 'sudo',
88
+ 'doas',
89
+ 'env',
90
+ 'command',
91
+ 'nohup',
92
+ 'time',
93
+ 'timeout',
94
+ 'nice',
95
+ 'ionice',
96
+ 'stdbuf',
97
+ 'setsid',
98
+ 'xargs',
99
+ 'exec',
100
+ 'npx',
101
+ 'bunx',
102
+ ]);
103
+ /**
104
+ * Shell keywords that can begin a segment. Without these the word `do` or `then`
105
+ * becomes the "command name" and the segment is never inspected — so
106
+ * `for b in a; do git push --force origin main; done` was invisible.
107
+ */
108
+ /**
109
+ * Wrapper options that consume the next argument — keyed BY WRAPPER, because the
110
+ * same letter differs between them: `-n` takes a value for `xargs` and `nice`,
111
+ * but is `--non-interactive` for `sudo`. One flat set ate the real command after
112
+ * `sudo -n`, so the force-push behind it was never inspected.
113
+ */
114
+ const WRAPPER_VALUE_FLAGS = {
115
+ sudo: new Set([
116
+ '-u', '-g', '-U', '-C', '-r', '-t', '-p', '-D', '-R',
117
+ '--user', '--group', '--other-user', '--close-from', '--role', '--type',
118
+ '--prompt', '--host', '--chdir', '--chroot',
119
+ ]),
120
+ doas: new Set(['-u', '-C']),
121
+ // `-S` deliberately absent: its value is a whole command line, so skipping it
122
+ // would hide the command. Left visible, it becomes an unrecognised command name
123
+ // — a miss, but a miss that inspects rather than one that hides.
124
+ env: new Set(['-u', '-C', '-P', '--unset', '--chdir']),
125
+ xargs: new Set([
126
+ '-n', '-I', '-L', '-P', '-s', '-d', '-a', '-E', '-e',
127
+ '--max-args', '--replace', '--max-lines', '--max-procs', '--max-chars',
128
+ '--delimiter', '--arg-file', '--eof-str',
129
+ ]),
130
+ nice: new Set(['-n', '--adjustment']),
131
+ ionice: new Set(['-c', '-n']),
132
+ timeout: new Set(['-s', '-k', '--signal', '--kill-after']),
133
+ stdbuf: new Set(['-i', '-o', '-e']),
134
+ };
135
+ const KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'fi', 'done', 'in', '!', '{', '}']);
136
+ /** Shells whose `-c` argument is itself a command line, so it must be parsed too. */
137
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
138
+ /**
139
+ * Flags whose VALUE is prose or a path, never a ref. Skipping them is what keeps
140
+ * a commit message from being read as a live argument.
141
+ */
142
+ const VALUE_FLAGS = new Set([
143
+ '-m',
144
+ '--message',
145
+ '-F',
146
+ '--file',
147
+ '-C',
148
+ '--grep',
149
+ '--author',
150
+ '--date',
151
+ '--reuse-message',
152
+ '--title',
153
+ '--body',
154
+ '-t',
155
+ '-b',
156
+ ]);
157
+
158
+ /**
159
+ * Targets that make a delete unrecoverable wherever you run it. Compared after
160
+ * normalisation, so `//`, `/.`, `${HOME}` and a trailing slash all collapse onto
161
+ * these — the list stays literal and readable while the variants close.
162
+ */
163
+ const CATASTROPHIC = new Set([
164
+ '/',
165
+ '/*',
166
+ '~',
167
+ '~/*',
168
+ '$HOME',
169
+ '$HOME/*',
170
+ '/usr',
171
+ '/etc',
172
+ '/var',
173
+ '/bin',
174
+ '/lib',
175
+ '/opt',
176
+ '/home',
177
+ '/Users',
178
+ '~/.ssh',
179
+ '$HOME/.ssh',
180
+ '~/.ssh/*',
181
+ '$HOME/.ssh/*',
182
+ '/usr/*',
183
+ '/etc/*',
184
+ '/var/*',
185
+ '/bin/*',
186
+ '/lib/*',
187
+ '/opt/*',
188
+ '/home/*',
189
+ '/Users/*',
190
+ '/System',
191
+ '/Library',
192
+ '/Applications',
193
+ '/private',
194
+ '/Volumes',
195
+ '/System/*',
196
+ '/Library/*',
197
+ '/Applications/*',
198
+ ]);
199
+
200
+ /**
201
+ * The only directories whose CHILDREN are also catastrophic.
202
+ *
203
+ * Deliberately two entries, checked by a prefix test — bounded, no recursion. The
204
+ * general prefix rule that once lived here blocked `/private/tmp`, `$TMPDIR`,
205
+ * Homebrew and `/Volumes` (routine cleanup) and had to go; but `~/.ssh` was the
206
+ * case that motivated it, and nothing in a project routinely deletes a file under
207
+ * there. So it returns scoped to exactly that.
208
+ */
209
+ const CATASTROPHIC_SUBTREES = ['~/.ssh', '$HOME/.ssh'];
210
+
211
+ const isCatastrophic = (target) =>
212
+ CATASTROPHIC.has(target) ||
213
+ CATASTROPHIC_SUBTREES.some((root) => target.startsWith(`${root}/`));
214
+
215
+ /**
216
+ * While the brake is on, the network clients are refused.
217
+ *
218
+ * Pushing to a protected branch is refused with or without the brake, so the only
219
+ * thing the brake has to add is the routes that land a PR — and every one of them
220
+ * goes through a network client (`gh`, `curl`, `wget`). Denying the clients, with
221
+ * a short allowlist of read-only and PR-opening subcommands, covers `gh pr merge`,
222
+ * `gh api …/merge`, the GraphQL mutation and a raw `curl` in one rule, without
223
+ * matching text at all.
224
+ *
225
+ * The previous attempt matched the substring `merge` across every token. It denied
226
+ * 19 ordinary commands — including `git log --no-merges` and pushing a branch named
227
+ * `fix/merge-conflict-handling`, which are literally what the brake's own message
228
+ * tells the agent to do while stopping. A rule that forbids the wind-down it
229
+ * prescribes is not coarse, it is wrong.
230
+ *
231
+ * ⚠ This is NOT a complete list of ways to reach the API, and cannot be:
232
+ * `python3 -c "urllib…"` and `node -e "fetch(…)"` reach the same endpoint and are
233
+ * the "assembled at runtime" limit stated at the top of this file. The brake
234
+ * covers the clients an agent reaches for by habit, which is the drift it exists
235
+ * to stop — not an adversary who has already decided to route around it.
236
+ */
237
+ const NETWORK_CLIENTS = new Set([
238
+ 'gh',
239
+ 'hub',
240
+ 'curl',
241
+ 'curlie',
242
+ 'wget',
243
+ 'xh',
244
+ 'http',
245
+ 'https',
246
+ 'httpie',
247
+ ]);
248
+ /** `gh` subcommands that read, or open a PR — the wind-down the brake asks for. */
249
+ const BRAKE_SAFE_GH = new Set(['view', 'list', 'status', 'diff', 'checks', 'create', 'help']);
250
+
251
+ /**
252
+ * Under the brake a `git push` must name an explicit branch.
253
+ *
254
+ * The brake's premise is that pushing to a protected branch is refused anyway —
255
+ * but that is only true when the command NAMES the branch. Bare `git push`,
256
+ * `git push origin HEAD` and `git push --all` all land on the default branch when
257
+ * you are on it, and the guard cannot know which branch you are on without
258
+ * running git (it is deliberately pure). So while stopped, a push has to say
259
+ * where it is going. `git push origin feat/x` — the wind-down the brake asks for —
260
+ * is unaffected.
261
+ */
262
+ const pushWithoutExplicitRef = ({ name, args }) => {
263
+ if (name !== 'git') return false;
264
+ const operands = operandsOf(args);
265
+ if (!operands.some(({ value }) => value === 'push')) return false;
266
+ if (hasFlag(args, '--all', '--mirror')) return true;
267
+ const refs = operands.filter(({ value }) => value !== 'push').slice(1);
268
+ return refs.length === 0 || refs.some(({ value }) => /^HEAD(:|$)/.test(value));
269
+ };
270
+
271
+ const deniedByBrake = (name, args) => {
272
+ if (pushWithoutExplicitRef({ name, args })) return true;
273
+ if (!NETWORK_CLIENTS.has(name)) return false;
274
+ if (name !== 'gh' && name !== 'hub') return true;
275
+ const operands = operandsOf(args, GH_FLAGS).map(({ value }) => value);
276
+ // Nothing to do is not dangerous: `gh --version`, `gh help`.
277
+ if (operands.length === 0) return false;
278
+ // Read by POSITION. `some()` let any operand anywhere satisfy the allowlist,
279
+ // so `gh pr merge 12 --subject create` passed on the word `create`.
280
+ const [group, verb] = operands;
281
+ // `create` is only safe for a PR — `gh release create --target main` is not the
282
+ // wind-down the brake permits.
283
+ if (verb === 'create') return group !== 'pr';
284
+ return !(BRAKE_SAFE_GH.has(verb) || BRAKE_SAFE_GH.has(group));
285
+ };
286
+
287
+ // ── Tokenising ───────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Split a command line into segments of arguments, honouring quotes.
291
+ *
292
+ * Each token records whether it was quoted, because the two facts matter
293
+ * separately: a quoted argument is still an argument (so `"main"` is the branch),
294
+ * but a separator inside quotes is text (so a commit message is not a command).
295
+ *
296
+ * A subshell, a pipeline, `&&`, a command substitution and a newline all end the
297
+ * current segment — every one of them introduces a new command whose first word
298
+ * must be examined on its own.
299
+ */
300
+ export const tokenize = (raw) => {
301
+ const segments = [];
302
+ let args = [];
303
+ let value = '';
304
+ let quoted = false;
305
+ let started = false;
306
+ let heredocBudget = 32;
307
+ let pendingHeredoc = null;
308
+
309
+ const endArg = () => {
310
+ if (started) {
311
+ args.push({ value, quoted });
312
+ value = '';
313
+ quoted = false;
314
+ started = false;
315
+ }
316
+ };
317
+ const endSegment = () => {
318
+ endArg();
319
+ if (args.length > 0) segments.push(args);
320
+ args = [];
321
+ };
322
+
323
+ let i = 0;
324
+ while (i < raw.length) {
325
+ const ch = raw[i];
326
+
327
+ if (ch === '\\' && raw[i + 1] === '\n') {
328
+ i += 2; // a line continuation is whitespace, not a segment boundary
329
+ continue;
330
+ }
331
+ if (ch === '\\' && i + 1 < raw.length) {
332
+ value += raw[i + 1];
333
+ started = true;
334
+ i += 2;
335
+ continue;
336
+ }
337
+ if (ch === '$' && raw[i + 1] === "'") {
338
+ // ANSI-C quoting. `$'main'` is just `main` to the shell; leaving the `$`
339
+ // glued on hid the branch name, and the escaped `'` inside desynchronised
340
+ // the plain single-quote scanner for the rest of the line.
341
+ let j = i + 2;
342
+ while (j < raw.length && raw[j] !== "'") j += raw[j] === '\\' ? 2 : 1;
343
+ value += raw
344
+ .slice(i + 2, Math.min(j, raw.length))
345
+ .replace(/\\(.)/g, '$1');
346
+ started = true;
347
+ i = j < raw.length ? j + 1 : raw.length;
348
+ continue;
349
+ }
350
+ if (ch === "'") {
351
+ const end = raw.indexOf("'", i + 1);
352
+ value += end === -1 ? raw.slice(i + 1) : raw.slice(i + 1, end);
353
+ quoted = true;
354
+ started = true;
355
+ i = end === -1 ? raw.length : end + 1;
356
+ continue;
357
+ }
358
+ if (ch === '"') {
359
+ let j = i + 1;
360
+ while (j < raw.length && raw[j] !== '"') {
361
+ if (raw[j] === '\\' && j + 1 < raw.length) {
362
+ value += raw[j + 1];
363
+ j += 2;
364
+ } else {
365
+ value += raw[j];
366
+ j += 1;
367
+ }
368
+ }
369
+ quoted = true;
370
+ started = true;
371
+ i = j < raw.length ? j + 1 : raw.length;
372
+ continue;
373
+ }
374
+ if (ch === '#' && !started) {
375
+ const end = raw.indexOf('\n', i);
376
+ i = end === -1 ? raw.length : end; // an unquoted comment is not arguments
377
+ continue;
378
+ }
379
+ if (ch === '<' && raw[i + 1] === '<' && raw[i + 2] === '<') {
380
+ // A here-string, not a heredoc: the word after it is DATA on stdin, and no
381
+ // terminator line follows. Consumed whole — testing `raw[i+2] !== '<'` only
382
+ // skipped the FIRST `<`, so the scanner advanced one and matched `<<WORD`
383
+ // on the second, turning the here-string's word into a terminator and
384
+ // swallowing everything up to the next line equal to it. Three characters
385
+ // disarmed every rule.
386
+ i += 3;
387
+ continue;
388
+ }
389
+ if (ch === '<' && raw[i + 1] === '<' && heredocBudget > 0) {
390
+ // A heredoc body is data, not commands. Recognised HERE, inside the
391
+ // scanner, because only here is it known that the `<<` is unquoted.
392
+ //
393
+ // The marker is only NOTED — the rest of the marker line keeps tokenising,
394
+ // and the body is skipped when its newline arrives. Jumping straight past
395
+ // the terminator swallowed `cat <<EOF; rm -rf /` and merged the command
396
+ // after the terminator into this segment. Both were hide-anything shapes,
397
+ // which is precisely what moving this inside the tokenizer was meant to end.
398
+ // The marker must be CLEANLY delimited. `<<EOF"X"` concatenates to `EOFX`
399
+ // for the shell, and a guard that stops at `EOF` swallows further than the
400
+ // shell does — the hide primitive again. When the models cannot be made to
401
+ // agree, the marker is left inert and the body gets inspected.
402
+ const marker = /^<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2(?=[\s;|&<>()`]|$)/.exec(
403
+ raw.slice(i, i + 64),
404
+ );
405
+ if (marker) {
406
+ // A TOTAL budget, not a per-step one: each lookahead scans forward, so an
407
+ // input full of markers would be quadratic. Past the budget `<<` is two
408
+ // characters again, which keeps the body visible — erring toward
409
+ // inspecting more, never less.
410
+ heredocBudget -= 1;
411
+ // `<<-` strips leading TABS from the terminator line, so the terminator
412
+ // the shell accepts is not the one a plain `\nEOF\n` search finds.
413
+ pendingHeredoc = { word: marker[3], tabs: marker[1] === '-' };
414
+ i += marker[0].length;
415
+ continue;
416
+ }
417
+ i += 2;
418
+ continue;
419
+ }
420
+ if (ch === '$' && raw[i + 1] === '{') {
421
+ // A parameter expansion is part of the token, not a brace group — `${HOME}`
422
+ // must survive tokenising to be normalised into `$HOME` later.
423
+ const end = raw.indexOf('}', i + 2);
424
+ value += end === -1 ? raw.slice(i) : raw.slice(i, end + 1);
425
+ started = true;
426
+ i = end === -1 ? raw.length : end + 1;
427
+ continue;
428
+ }
429
+ if (ch === '$' && raw[i + 1] === '(' && raw[i + 2] === '(') {
430
+ // Arithmetic. `$((1<<n))` contains a LEFT SHIFT, not a heredoc — splitting
431
+ // it as a subshell left `1<<n))` to be scanned as ordinary text, where the
432
+ // marker regex matched `<<n` and swallowed the rest of the input.
433
+ const end = raw.indexOf('))', i + 3);
434
+ value += end === -1 ? raw.slice(i) : raw.slice(i, end + 2);
435
+ started = true;
436
+ i = end === -1 ? raw.length : end + 2;
437
+ continue;
438
+ }
439
+ if (ch === '$' && raw[i + 1] === '(') {
440
+ endSegment();
441
+ i += 2;
442
+ continue;
443
+ }
444
+ // `{`/`}` are NOT boundaries: a brace group is handled by the keyword skip in
445
+ // `commandOf`, and braces inside a token belong to the token.
446
+ if ('|;&\n()`'.includes(ch)) {
447
+ endSegment();
448
+ i += 1;
449
+ if (ch === '\n' && pendingHeredoc) {
450
+ // Skip the body and its terminator LINE, leaving the newline that ends
451
+ // that line to act as the next boundary. If the terminator never appears
452
+ // the body is kept and inspected rather than dropped — losing lines is
453
+ // how the pre-pass hid commands.
454
+ const { word, tabs } = pendingHeredoc;
455
+ const terminator = new RegExp(`\n${tabs ? '\t*' : ''}${word}(?=\n|$)`);
456
+ const found = terminator.exec(raw.slice(i - 1));
457
+ if (found) i = i - 1 + found.index + found[0].length;
458
+ pendingHeredoc = null;
459
+ }
460
+ continue;
461
+ }
462
+ if (/\s/.test(ch)) {
463
+ endArg();
464
+ i += 1;
465
+ continue;
466
+ }
467
+ value += ch;
468
+ started = true;
469
+ i += 1;
470
+ }
471
+ endSegment();
472
+ return segments;
473
+ };
474
+
475
+ /**
476
+ * The real command in a segment: leading `VAR=value` assignments and wrappers
477
+ * (`sudo`, `env`, …) are stepped over, and a path is reduced to its basename, so
478
+ * `FOO=1 sudo /usr/bin/git push` is recognised as `git push`.
479
+ */
480
+ export const commandOf = (args) => {
481
+ let i = 0;
482
+ let sawWrapper = false;
483
+ let lastWrapper = null;
484
+ while (i < args.length) {
485
+ const { value } = args[i];
486
+ // An assignment prefix, whether or not it was quoted. Only the UNQUOTED form
487
+ // used to be stepped over, so `GIT_SSH_COMMAND="ssh -i k" git push …` — the
488
+ // ordinary spelling for any value with a space — defeated every rule.
489
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(value)) {
490
+ i += 1;
491
+ continue;
492
+ }
493
+ if (KEYWORDS.has(value)) {
494
+ i += 1;
495
+ continue;
496
+ }
497
+ if (WRAPPERS.has(value.split('/').pop())) {
498
+ lastWrapper = value.split('/').pop();
499
+ i += 1;
500
+ sawWrapper = true;
501
+ continue;
502
+ }
503
+ // A wrapper's own options (`sudo -u root`, `env -i`, `xargs -n1`, and the
504
+ // bare duration in `timeout 60`) — step over them rather than treating `-u`
505
+ // as the command name and giving up.
506
+ if (sawWrapper && value.startsWith('-')) {
507
+ const takesValue = WRAPPER_VALUE_FLAGS[lastWrapper]?.has(value) ?? false;
508
+ i += value !== '--' && takesValue && args[i + 1] ? 2 : 1;
509
+ continue;
510
+ }
511
+ if (sawWrapper && /^\d+(\.\d+)?[smhd]?$/.test(value)) {
512
+ i += 1; // `timeout 60 …`, `nice 10 …`
513
+ continue;
514
+ }
515
+ break;
516
+ }
517
+ const name = (args[i]?.value ?? '').split('/').pop();
518
+ return { name, args: args.slice(i + 1) };
519
+ };
520
+
521
+ /**
522
+ * `gh` flags that take a value. Without these the value stayed in the operand
523
+ * list, which shifted every positional: `gh --repo o/r pr merge` no longer looked
524
+ * like a merge, and `gh workflow run --repo org/prod-release-api ci.yml` looked
525
+ * like a production deploy. One defect, both directions.
526
+ */
527
+ const GH_VALUE_FLAGS = new Set([
528
+ '--repo',
529
+ '-R',
530
+ '--ref',
531
+ '--method',
532
+ '-X',
533
+ '-f',
534
+ '-F',
535
+ '--field',
536
+ '--raw-field',
537
+ '--body-file',
538
+ '--label',
539
+ '-l',
540
+ '-H',
541
+ '--jq',
542
+ '-q',
543
+ '--template',
544
+ ]);
545
+
546
+ /** Arguments that are neither a flag nor the value of a prose/path/config flag. */
547
+ const operandsOf = (args, valueFlags = VALUE_FLAGS) => {
548
+ const operands = [];
549
+ for (let i = 0; i < args.length; i += 1) {
550
+ const { value } = args[i];
551
+ if (valueFlags.has(value)) {
552
+ i += 1; // skip the value: it is a message, a path or a config, never a ref
553
+ continue;
554
+ }
555
+ if (value.startsWith('-')) continue;
556
+ operands.push(args[i]);
557
+ }
558
+ return operands;
559
+ };
560
+
561
+ const GH_FLAGS = new Set([...VALUE_FLAGS, ...GH_VALUE_FLAGS]);
562
+
563
+ const hasFlag = (args, ...names) =>
564
+ args.some(({ value }) => names.some((name) => value === name || value.startsWith(`${name}=`)));
565
+
566
+ // ── Rules ────────────────────────────────────────────────────────────────────
567
+
568
+ /** Every branch name a refspec token designates (`+`, `src:dst`, `refs/heads/`). */
569
+ export const refNames = (token) =>
570
+ token
571
+ .replace(/^\+/, '')
572
+ .split(':')
573
+ .map((part) => part.replace(/^refs\/heads\//, ''));
574
+
575
+ const namesProtected = (token) => refNames(token).some((name) => PROTECTED_BRANCH.test(name));
576
+
577
+ function checkGit({ args }) {
578
+ const operands = operandsOf(args);
579
+ if (!operands.some(({ value }) => value === 'push')) return null;
580
+
581
+ const forced =
582
+ hasFlag(args, '-f', '--force', '--force-with-lease') ||
583
+ operands.some(({ value }) => value.startsWith('+'));
584
+ const protectedRef = operands.some(({ value }) => value !== 'push' && namesProtected(value));
585
+
586
+ if (forced && protectedRef) {
587
+ return (
588
+ 'BLOCKED — force-pushing a shared branch is a Never-tier action ' +
589
+ '(.claude/rules/autonomy.md). It rewrites history other people and other ' +
590
+ 'sessions have already built on. Push a branch and open a PR instead.'
591
+ );
592
+ }
593
+ if (hasFlag(args, '--mirror') || (forced && hasFlag(args, '--all'))) {
594
+ return (
595
+ 'BLOCKED — a --mirror/--all force-push carries every ref, including the ' +
596
+ 'shared branches, whether or not you named them (.claude/rules/autonomy.md). ' +
597
+ 'Push the one branch you mean, by name.'
598
+ );
599
+ }
600
+ if (protectedRef) {
601
+ return (
602
+ 'BLOCKED — the default branch is never written to directly, and never ' +
603
+ 'deleted: it stays releasable at all times (.claude/rules/workflow.md, ' +
604
+ '"Branches and commits"). Work reaches it through a PR.'
605
+ );
606
+ }
607
+ return null;
608
+ }
609
+
610
+ /** `-f key=value` / `--field key=value` pairs, which is where a stage actually lives. */
611
+ const fieldValues = (args) =>
612
+ args.flatMap(({ value }, index) =>
613
+ value === '-f' || value === '-F' || value === '--field' || value === '--raw-field'
614
+ ? [args[index + 1]?.value ?? '']
615
+ : value.startsWith('-f=') || value.startsWith('--field=')
616
+ ? [value.split('=').slice(1).join('=')]
617
+ : [],
618
+ );
619
+
620
+ const PROD_FIELD = /(^|\[)(stage|environment|env|target)\]?=(prod|production)$/i;
621
+ const DEPLOY_TARGET = /deploy|release|publish|ship|(^|[^a-z])cd([^a-z]|$)|(^|[^a-z])prod/i;
622
+
623
+ function checkGh({ args }) {
624
+ const operands = operandsOf(args, GH_FLAGS);
625
+ const isWorkflowRun = operands[0]?.value === 'workflow' && operands[1]?.value === 'run';
626
+ const fields = fieldValues(args);
627
+
628
+ if (isWorkflowRun) {
629
+ // The workflow being run is the operand after `run` — NOT a repo name and not
630
+ // a --ref value. `prod` in `org/prod-api` or `release/prod-hotfix` is not a
631
+ // production deploy, and blocking those is how the rule gets switched off.
632
+ const workflow = operands[2]?.value ?? '';
633
+ const prodish = /prod/i.test(workflow) || fields.some((field) => PROD_FIELD.test(field));
634
+ if (DEPLOY_TARGET.test(workflow) && prodish) {
635
+ return (
636
+ 'BLOCKED — triggering a production deploy from an agent session is a hard ' +
637
+ 'stop (.claude/rules/autonomy.md, "Never"). Escalate to the human who owns ' +
638
+ 'that release.'
639
+ );
640
+ }
641
+ }
642
+
643
+ const route = operands.map(({ value }) => value).join(' ');
644
+ if (/\/dispatches\b/.test(route)) {
645
+ // Only the WORKFLOW segment counts, never the owner or repo name: `prod` in
646
+ // `repos/o/prod-api/…` is a repository, not a production deploy.
647
+ const workflowSegment = /workflows\/([^/\s]+)\/dispatches/.exec(route)?.[1] ?? '';
648
+ const prodish =
649
+ /prod|production/i.test(workflowSegment) || fields.some((f) => PROD_FIELD.test(f));
650
+ if (prodish) {
651
+ return (
652
+ 'BLOCKED — dispatching a production workflow through the API is the same ' +
653
+ 'hard stop as running it (.claude/rules/autonomy.md, "Never"). Escalate.'
654
+ );
655
+ }
656
+ }
657
+
658
+ return null;
659
+ }
660
+
661
+ /**
662
+ * `//`, `/.`, `${HOME}` and a trailing slash all collapse onto the literal list.
663
+ *
664
+ * Single-pass on purpose. The previous version looped a NON-global `replace`, so
665
+ * it copied the whole string once per `/.` — quadratic. At 1.4MB the hook was
666
+ * killed by its own timeout, and a killed hook does not block, so every rule
667
+ * silently switched off for that command. That made the guard, for that input,
668
+ * worse than no guard at all.
669
+ */
670
+ export const normalizeTarget = (token) => {
671
+ const path = token.replace(/\$\{HOME\}/g, '$HOME');
672
+ const leading = path.startsWith('/') ? '/' : '';
673
+ const parts = path.split('/').filter((part) => part !== '' && part !== '.');
674
+ const joined = leading + parts.join('/');
675
+ return joined === '' ? (leading || path) : joined;
676
+ };
677
+
678
+ function checkRm({ args }, atCatastrophicCwd) {
679
+ for (const { value } of operandsOf(args)) {
680
+ const target = normalizeTarget(value);
681
+ if (isCatastrophic(target)) {
682
+ return (
683
+ 'BLOCKED — this deletes the filesystem root or the whole home directory, ' +
684
+ 'which no task in this project requires. If a path really needs removing, ' +
685
+ 'name it relative to the project.'
686
+ );
687
+ }
688
+ // An upward escape from root or home reaches the same place by another name.
689
+ if (/^(\/|~|\$HOME)/.test(target) && target.split('/').includes('..')) {
690
+ return (
691
+ 'BLOCKED — this path escapes upward out of the home directory or the ' +
692
+ 'filesystem root, which reaches the same place as deleting it outright.'
693
+ );
694
+ }
695
+ // `cd / && rm -rf *` is `rm -rf /*` with the target hidden in a prior segment.
696
+ if (atCatastrophicCwd && (target === '*' || target === '.' || target === './*')) {
697
+ return (
698
+ 'BLOCKED — an earlier segment changed directory to the filesystem root or ' +
699
+ 'the home directory, so this wildcard delete is a root delete.'
700
+ );
701
+ }
702
+ }
703
+ return null;
704
+ }
705
+
706
+ // ── Entry ────────────────────────────────────────────────────────────────────
707
+
708
+ /** Walk every segment of a command line, following shells and subshells inward. */
709
+ export const inspect = (raw, brake, depth = 0) => {
710
+ // Nested `eval`/`bash -c` beyond this is not drift, and following it forever is
711
+ // unbounded work. The depth is a stated limit, not an accident.
712
+ if (depth > 16) return null;
713
+ let atCatastrophicCwd = false;
714
+
715
+ for (const segment of tokenize(raw)) {
716
+ // The brake, before any per-command rule: while the flag is on, the
717
+ // network clients are refused whatever they are being asked to do.
718
+ const braked = brake && (() => {
719
+ const { name, args } = commandOf(segment);
720
+ return deniedByBrake(name, args);
721
+ })();
722
+ if (braked) {
723
+ return (
724
+ `BLOCKED — the kill switch is set (${brake}), so nothing may land on the ` +
725
+ 'default branch. Everything else stays allowed on purpose: finish the ' +
726
+ 'current task, push the branch, open the PR, write the journal entry, and ' +
727
+ `stop. "Stop cleanly" never means "lose the work". Clear it with: rm ${brake}`
728
+ );
729
+ }
730
+
731
+ const command = commandOf(segment);
732
+ if (!command.name) continue;
733
+
734
+ if (SHELLS.has(command.name) || command.name === 'eval') {
735
+ // `bash -c "<command line>"` / `eval "<command line>"` — the payload is a
736
+ // command line of its own. Taken whether or not it is quote-delimited: a
737
+ // backslash-joined payload is still a payload.
738
+ const flagIndex = command.args.findIndex(({ value }) => value === '-c');
739
+ const script =
740
+ flagIndex >= 0
741
+ ? command.args[flagIndex + 1]?.value
742
+ : command.args.map(({ value }) => value).join(' ');
743
+ if (script) {
744
+ const reason = inspect(script, brake, depth + 1);
745
+ if (reason) return reason;
746
+ }
747
+ continue;
748
+ }
749
+
750
+ if (command.name === 'cd') {
751
+ const target = normalizeTarget(operandsOf(command.args)[0]?.value ?? '');
752
+ atCatastrophicCwd = CATASTROPHIC.has(target);
753
+ continue;
754
+ }
755
+
756
+ const reason =
757
+ command.name === 'git'
758
+ ? checkGit(command)
759
+ : command.name === 'gh'
760
+ ? checkGh(command, brake)
761
+ : command.name === 'rm'
762
+ ? checkRm(command, atCatastrophicCwd)
763
+ : null;
764
+ if (reason) return reason;
765
+ }
766
+ return null;
767
+ };
768
+
769
+ function main() {
770
+ let input;
771
+ try {
772
+ input = JSON.parse(readFileSync(0, 'utf8'));
773
+ } catch {
774
+ return 0;
775
+ }
776
+ if (input.tool_name !== 'Bash') return 0;
777
+ const raw = String(input.tool_input?.command ?? '');
778
+ if (!raw.trim()) return 0;
779
+
780
+ try {
781
+ const reason = inspect(raw, brakeIsOn());
782
+ if (reason) {
783
+ process.stderr.write(`${reason}\n`);
784
+ return 2;
785
+ }
786
+ } catch {
787
+ return 0; // a guard that crashes must not block the work
788
+ }
789
+ return 0;
790
+ }
791
+
792
+ /**
793
+ * Only act when invoked as the hook. Importing this module (a test reading
794
+ * `normalizeTarget`, say) must not run the guard and exit the process.
795
+ */
796
+ const invokedDirectly = () => {
797
+ if (!process.argv[1]) return false;
798
+ const real = (p) => {
799
+ try {
800
+ return realpathSync(p);
801
+ } catch {
802
+ return p;
803
+ }
804
+ };
805
+ return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
806
+ };
807
+
808
+ if (invokedDirectly()) process.exit(main());