create-agent-rig 0.5.0 → 0.6.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 (66) hide show
  1. package/CHANGELOG.md +170 -34
  2. package/README.md +12 -6
  3. package/package.json +1 -1
  4. package/packages/cli/dist/commands/init.js +6 -3
  5. package/packages/cli/dist/commands/upgrade.js +2 -2
  6. package/packages/cli/dist/index.js +46 -8
  7. package/packages/cli/dist/lib/manifest.js +10 -0
  8. package/scripts/prepare.mjs +1 -1
  9. package/templates/agent-os/init/AGENTS.md +15 -5
  10. package/templates/agent-os/init/CLAUDE.md +15 -5
  11. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
  12. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
  13. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
  14. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
  15. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  16. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
  17. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
  18. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
  19. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
  20. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
  21. package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
  22. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
  23. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  24. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
  25. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  26. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
  27. package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
  28. package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
  29. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
  30. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
  31. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  32. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  33. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
  34. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  35. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
  36. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
  37. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  38. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
  39. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
  40. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
  41. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
  42. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
  43. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
  44. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
  45. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
  46. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
  47. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  48. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
  49. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
  50. package/templates/agent-os/universal/.claude/settings.json +5 -1
  51. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
  52. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
  53. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
  54. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
  55. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  56. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
  57. package/templates/agent-os/universal/.codex/hooks.json +6 -1
  58. package/templates/agent-os/universal/AGENTS.md +3 -1
  59. package/templates/agent-os/universal/CLAUDE.md +3 -1
  60. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  61. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
  62. package/templates/agent-os/universal/layers.json +9 -0
  63. package/templates/hash-history.json +404 -51
  64. package/templates/release-ledger.json +10 -0
  65. package/templates/skeleton/aws-serverless/gitignore +2 -0
  66. package/templates/skeleton/node-service/gitignore +2 -0
@@ -61,11 +61,16 @@ about it here, so the two gates never file competing opinions on one paragraph.
61
61
 
62
62
  ⚠ A pointer into a test suite the reader's project does not carry is normally
63
63
  item 2, not backing. There is one narrow inherited-snapshot exception from
64
- `invariants.md`: a generator-authored hook may point to upstream generator
65
- tests that are absent locally **only while the hook is unchanged downstream**
66
- and its hook header identifies those tests as absent locally. If that hook is
67
- edited downstream or appears as changed in the current diff, the exception
68
- expires and the local test is yours; then an absent pointer is item 2 again.
64
+ `invariants.md`: a generator-authored artifact rules, hooks, skills,
65
+ scripts, or agent specs
66
+ may point to upstream generator tests that are absent locally only when the
67
+ pointer explicitly says the suite is absent locally and
68
+ `.claude/.rig-manifest.json` proves the current artifact's hash matches the
69
+ installed manifest. A manifest-backed upgrade remains an inherited,
70
+ generator-owned artifact; a changed file in the upgrade diff does not alone
71
+ make it downstream-authored. The exception applies **only while the manifest
72
+ hash matches**. A hash mismatch, missing manifest, or no evidence ends the
73
+ exception and the local test is yours; then an absent pointer is item 2 again.
69
74
 
70
75
  🔴 Three things this is not. It is not item 1: that one is about enforcement the
71
76
  mechanism does not provide, this one is about any claim with nothing behind it,
@@ -154,7 +159,8 @@ calling gate reads.
154
159
  }
155
160
  ],
156
161
  "advisories": [],
157
- "evidence": ["opened .claude/hooks/guard-bash.mjs and quoted the line"]
162
+ "evidence": ["opened .claude/hooks/guard-bash.mjs and quoted the line"],
163
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
158
164
  }
159
165
  ```
160
166
 
@@ -165,3 +171,9 @@ calling gate reads.
165
171
  `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
166
172
  them, and the gate name is what stops your answer being read as somebody
167
173
  else's.
174
+ - **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
175
+ checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
176
+ <commit>` tell "this gate answered for the commit being merged" from "it
177
+ answered two pushes ago". A verdict naming no commit is counted as neither
178
+ covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
179
+ runs that check, so a session that skips the gate skips this with it.
@@ -58,7 +58,8 @@ the human who has to fix the finding.
58
58
  }
59
59
  ],
60
60
  "advisories": [],
61
- "evidence": ["grepped for the pattern across services/"]
61
+ "evidence": ["grepped for the pattern across services/"],
62
+ "headSha": "9c1f0a7d4b3e2c5a8f6d0b9e7c4a1f2d3e5b6c70"
62
63
  }
63
64
  ```
64
65
 
@@ -71,3 +72,9 @@ the human who has to fix the finding.
71
72
  `node .claude/scripts/verdict.mjs check <report> <this gate>` is what refuses
72
73
  them, and the gate name is what stops your answer being read as somebody
73
74
  else's.
75
+ - **`headSha` is the commit you reviewed** — `git rev-parse HEAD` in the
76
+ checkout you read. It is what lets `node .claude/scripts/verdict.mjs coverage
77
+ <commit>` tell "this gate answered for the commit being merged" from "it
78
+ answered two pushes ago". A verdict naming no commit is counted as neither
79
+ covered nor missing, so `pr-ship` holds on it — and only `pr-ship`: no hook
80
+ runs that check, so a session that skips the gate skips this with it.
@@ -34,9 +34,9 @@
34
34
  // a per-step budget is how three checks quietly cost three times the wall
35
35
  // clock the wiring allows (`.claude/rules/invariants.md` — "an explicit total
36
36
  // budget rather than a per-step one").
37
- // see hooks.test.ts › "gates the stop when a check outruns the budget — unmeasured is not a pass"
38
- // see hooks.test.ts › "spends one budget across the whole suite, not a fresh one per check"
39
- // see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
37
+ // see hooks.test.ts (absent in a generated rig) › "gates the stop when a check outruns the budget — unmeasured is not a pass"
38
+ // see hooks.test.ts (absent in a generated rig) › "spends one budget across the whole suite, not a fresh one per check"
39
+ // see hooks.test.ts (absent in a generated rig) › "gives the stop gate a harness timeout its own budget finishes inside"
40
40
  //
41
41
  // ⚠ This rests on ONE assumption about the harness that nothing in this
42
42
  // repository can prove or falsify: that a hook outrunning its timeout is
@@ -48,24 +48,44 @@
48
48
  // Limits, each with the test that pins it:
49
49
  // - a check whose output outgrows the buffer is UNMEASURED, and blocks like
50
50
  // any other unmeasured check —
51
- // see hooks.test.ts › "gates the stop when a check drowns its own buffer — a pass nobody watched is not a pass"
51
+ // see hooks.test.ts (absent in a generated rig) › "gates the stop when a check drowns its own buffer — a pass nobody watched is not a pass"
52
52
  // - below that buffer, output volume is not a verdict: a chatty check that
53
53
  // passes, passes —
54
- // see hooks.test.ts › "does not read a chatty passing check as a failure (the ENOBUFS false gate)"
54
+ // see hooks.test.ts (absent in a generated rig) › "does not read a chatty passing check as a failure (the ENOBUFS false gate)"
55
55
  // - the RIG_DOD_BUDGET_MS override may only LOWER the budget, and an
56
56
  // override this hook did not honour is announced rather than ignored —
57
- // see hooks.test.ts › "clamps a budget override that would outlive the harness, and names the budget it used"
57
+ // see hooks.test.ts (absent in a generated rig) › "clamps a budget override that would outlive the harness, and names the budget it used"
58
58
  // - a fail-open is announced on stderr, because a silent exit 0 and a clean
59
59
  // pass are the same observation from outside —
60
- // see hooks.test.ts › "announces a fail-open instead of returning a silent clean pass"
60
+ // see hooks.test.ts (absent in a generated rig) › "announces a fail-open instead of returning a silent clean pass"
61
61
  // - an ABSENT config is not a failure and says nothing; only a config that
62
62
  // exists and cannot be used announces —
63
- // see hooks.test.ts › "stays silent when there is no config at all — nothing to gate is the design, not a swallowed error"
63
+ // see hooks.test.ts (absent in a generated rig) › "stays silent when there is no config at all — nothing to gate is the design, not a swallowed error"
64
64
  import { execSync, spawnSync } from 'node:child_process';
65
- import { readFileSync } from 'node:fs';
65
+ import { readFileSync, realpathSync } from 'node:fs';
66
+ import { fileURLToPath } from 'node:url';
66
67
 
67
68
  import { withoutGitLocation } from '../scripts/git-env.mjs';
68
69
 
70
+ // 🔴 The tree this gate measures is the project the hook BELONGS to — the
71
+ // directory above `.claude/hooks/` — never the directory the session happens
72
+ // to be in. Observed twice in one session (AR-119): reviewing another branch
73
+ // in a worktree under `.claude/worktrees/`, the gate ran the suite in the
74
+ // worktree the shell had last cd-ed into and reported THAT branch's failing
75
+ // test as this session's own Definition-of-Done failure. Reviewing a foreign
76
+ // branch in a worktree is a first-class motion here, so an inherited cwd is
77
+ // the wrong tree often enough to matter. `git status` and every check run
78
+ // with this as their `cwd`, and both refusals name it, so a failure that is
79
+ // still foreign is visible at a glance. It is the session's ROOT, not its
80
+ // branch: a session started at the main checkout whose own task lives in a
81
+ // `worktree-task` worktree is measured at the main checkout, and the refusal
82
+ // says so. Resolved inside `main()`, so a throw here reaches the backstop
83
+ // and announces itself like every other fault of the gate's own.
84
+ // see hooks.test.ts (absent in a generated rig) › "runs the checks in the project root, so a check reading the tree sees the session project"
85
+ // see hooks.test.ts (absent in a generated rig) › "asks "is the tree clean?" about the project, not about the cwd"
86
+ // see hooks.test.ts (absent in a generated rig) › "names the tree it measured in the refusal, so a foreign failure is visible at a glance"
87
+ const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.meta.url)));
88
+
69
89
  // The default total budget, and the allowance for everything that happens
70
90
  // OUTSIDE it.
71
91
  //
@@ -80,8 +100,8 @@ import { withoutGitLocation } from '../scripts/git-env.mjs';
80
100
  // So the preamble gets a number and a leash: `git status` below is given this
81
101
  // as its own timeout, which turns the allowance from a guess into a bound.
82
102
  // Both numbers are read from this file and compared against the wiring —
83
- // see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
84
- // see hooks.test.ts › "bounds its own preamble: the git status call carries a timeout derived from the declared margin"
103
+ // see hooks.test.ts (absent in a generated rig) › "gives the stop gate a harness timeout its own budget finishes inside"
104
+ // see hooks.test.ts (absent in a generated rig) › "bounds its own preamble: the git status call carries a timeout derived from the declared margin"
85
105
  const DEFAULT_BUDGET_MS = 600_000;
86
106
  const PREAMBLE_MARGIN_MS = 60_000;
87
107
 
@@ -117,7 +137,7 @@ const SPAWN_NEVER_STARTED = new Set(['ENOENT', 'EACCES', 'EPERM', 'EMFILE', 'ENF
117
137
  * it. `Number.isSafeInteger` is the test rather than `isFinite`, because
118
138
  * `spawnSync` throws on a fractional `timeout` — and a throw here would land in
119
139
  * the backstop and open the gate completely.
120
- * see hooks.test.ts › "runs the gate on the default budget when the override is unusable, instead of not running it"
140
+ * see hooks.test.ts (absent in a generated rig) › "runs the gate on the default budget when the override is unusable, instead of not running it"
121
141
  */
122
142
  function budgetMs(env) {
123
143
  const raw = env.RIG_DOD_BUDGET_MS;
@@ -155,6 +175,8 @@ function main() {
155
175
  if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
156
176
  if (input.stop_hook_active) return 0;
157
177
 
178
+ const PROJECT_ROOT = projectRootOf();
179
+
158
180
  try {
159
181
  // The environment loses the variables that locate a repository first. A
160
182
  // process started under a git hook inherits an absolute GIT_DIR, and this
@@ -183,6 +205,7 @@ function main() {
183
205
  // and the reason the bound can be generous. The options object stays terse
184
206
  // on purpose; a sibling test matches it by a bounded window.
185
207
  const status = execSync('git status --porcelain', {
208
+ cwd: PROJECT_ROOT,
186
209
  encoding: 'utf8',
187
210
  stdio: ['ignore', 'pipe', 'ignore'],
188
211
  env: withoutGitLocation(),
@@ -212,13 +235,13 @@ function main() {
212
235
  // still ended green. Both were shipped here, one after the other, and both
213
236
  // are closed the same way — every usable entry runs, and a skipped entry is
214
237
  // a check with no verdict, which blocks.
215
- // see hooks.test.ts › "never lets an unusable config entry hide a failing check behind it (an empty string)"
216
- // see hooks.test.ts › "refuses the stop for an empty config entry it skipped, even though every check it could run passed"
238
+ // see hooks.test.ts (absent in a generated rig) › "never lets an unusable config entry hide a failing check behind it (an empty string)"
239
+ // see hooks.test.ts (absent in a generated rig) › "refuses the stop for an empty config entry it skipped, even though every check it could run passed"
217
240
  //
218
241
  // The predicate is exactly the three shapes `spawnSync` throws on, and no
219
242
  // wider: the shell answers every other unrunnable string with 127, which is
220
243
  // a verdict. Filtering past these three converts a real block into a skip.
221
- // see hooks.test.ts › "refuses the stop for a config it could read but cannot use, and names the file to fix"
244
+ // see hooks.test.ts (absent in a generated rig) › "refuses the stop for a config it could read but cannot use, and names the file to fix"
222
245
  const runnable = (command) =>
223
246
  typeof command === 'string' && command !== '' && !command.includes('\0');
224
247
  const usable = checks.filter(runnable);
@@ -252,6 +275,7 @@ function main() {
252
275
  // that did not fit instead of blaming it for spending what an earlier one
253
276
  // spent. One path, and no branch that only a race can reach.
254
277
  const result = spawnSync(command, {
278
+ cwd: PROJECT_ROOT,
255
279
  shell: true,
256
280
  encoding: 'utf8',
257
281
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -277,7 +301,8 @@ function main() {
277
301
  // unknown, and an unmeasured Definition of Done is not a passed one.
278
302
  const timedOut = result.error.code === 'ETIMEDOUT';
279
303
  process.stderr.write(
280
- `STOP GATED — \`${command}\` produced no verdict: ${result.error.message}\n` +
304
+ `STOP GATED — \`${command}\` produced no verdict (measured in ${PROJECT_ROOT}): ` +
305
+ `${result.error.message}\n` +
281
306
  (timedOut
282
307
  ? `It did not finish inside the ${budget.ms} ms budget for the whole suite ` +
283
308
  `(RIG_DOD_BUDGET_MS lowers it; raising it means raising this hook's default ` +
@@ -297,7 +322,7 @@ function main() {
297
322
  .slice(-15)
298
323
  .join('\n');
299
324
  process.stderr.write(
300
- `STOP GATED — a Definition of Done check fails: ${command}\n` +
325
+ `STOP GATED — a Definition of Done check fails (measured in ${PROJECT_ROOT}): ${command}\n` +
301
326
  (tail.trim() ? `${tail}\n` : '') +
302
327
  `Fix the failure before ending the session. If this failure has resisted ` +
303
328
  `repeated attempts, follow the stop rules instead: end with a written ` +
@@ -24,7 +24,8 @@
24
24
  //
25
25
  // ── The limits, stated exactly — and TESTED ──────────────────────────────────
26
26
  //
27
- // This block is a credibility claim, so `test/template/guard-hardening.test.ts`
27
+ // This block is a credibility claim, so the generator's
28
+ // `test/template/guard-hardening.test.ts` (absent in a generated rig)
28
29
  // asserts each line twice: that the limit is documented here, and that the
29
30
  // command really does pass. A limits comment nothing checks drifts into fiction,
30
31
  // which is what happened the first time — an earlier version of this list was
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook: in an UNATTENDED run, the rulebook is never edited outside
3
+ // the current item's allow-list (AR-51).
4
+ //
5
+ // The rulebook is the set of files that decide what this session may do — hooks
6
+ // and settings wiring, the queue config and selector, all scripts, rules, skills
7
+ // and agents, the `.codex/` configuration, the integrity manifest, `AGENTS.md`
8
+ // and `CLAUDE.md`. Every one of them was editable by the run it governs: a
9
+ // Write to `.claude/hooks/dod-checks.json` with `[]` switched the stop gate
10
+ // off, and nothing refused it until the merge sweep, long after.
11
+ //
12
+ // How it decides — and it is deliberately dumb:
13
+ // 1. No unattended flag on disk (`.claude/scripts/unattended-flag.mjs`) → an
14
+ // attended session → exit 0 for everything. A human at the keyboard edits
15
+ // the rulebook on purpose.
16
+ // 2. Flag present and readable → every edit fragment whose repo-relative path
17
+ // sits under a rulebook prefix is refused (exit 2) unless it also sits
18
+ // under one of the item's `allow` prefixes. A known path outside the
19
+ // rulebook is never judged — › "allows a MultiEdit beyond the fragment cap
20
+ // when its known path is outside the rulebook". A pathless global refusal
21
+ // for an oversized or unsupported `apply_patch` payload is blocked while
22
+ // armed because its scope cannot be proved — › "states the pathless
23
+ // global-refusal limit for oversized and unsupported apply_patch payloads".
24
+ // 3. Flag present and UNREADABLE → a rulebook edit is refused and the reason
25
+ // names the flag; an edit outside the rulebook still passes. Refusing to
26
+ // inspect is not allowing (`.claude/rules/invariants.md`).
27
+ //
28
+ // Limits — each stated here and each measured in the generator's
29
+ // `test/template/guard-rulebook.test.ts` (absent in a generated rig), by the
30
+ // test named beside it:
31
+ // - it sees one edit at a time, as text, before it lands — a rulebook file
32
+ // rewritten through a Bash redirect (`echo … > .claude/settings.json`), a
33
+ // generated file, or `git checkout` of another branch is not an edit tool
34
+ // call and never reaches it, and `guard-bash` does not cover that either —
35
+ // › "a Bash redirect into the rulebook is not an edit tool call and passes
36
+ // — guard-bash does not cover it either";
37
+ // - the flag in either home arms it (the env-derived one and the password
38
+ // database one, like the kill switch), and ONLY a flag arms it: an
39
+ // exported variable changes nothing, and an attended session that never
40
+ // set a flag is exactly as free as before — › "only a flag arms it — an
41
+ // exported RIG_UNATTENDED=1 with no flag changes nothing";
42
+ // - it judges paths, not content: a README that merely mentions
43
+ // `.claude/hooks/guard-bash.mjs` is not a rulebook edit — › "guards the
44
+ // path, not prose that mentions a guarded path";
45
+ // - it compares both roots and payload paths in their selected and canonical
46
+ // spellings, whether selection came from `CLAUDE_PROJECT_DIR` or the
47
+ // working-directory fallback — › "canonicalizes a differently spelled
48
+ // checkout root before guarding a canonical payload path", › "blocks when
49
+ // the checkout root and payload use the same symlink spelling", and
50
+ // › "blocks an existing rulebook file when only the payload path uses a symlink spelling";
51
+ // - an `allow` prefix is a string prefix of the repo-relative path and may
52
+ // not widen the rulebook — an entry that is itself a prefix of a rulebook
53
+ // prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
54
+ // and the guard refuses — › "a flag whose allow-list widens the rulebook is
55
+ // unreadable, so `--allow .` cannot disarm it";
56
+ // - fail-open on its own errors and on a payload it cannot parse — › "allows
57
+ // an empty payload object" and › "allows non-JSON stdin" — and fail-closed
58
+ // on a flag it cannot read — › "blocks a rulebook edit when the flag exists
59
+ // but cannot be read, and names the file": the guard targets drift, not an
60
+ // adversary.
61
+ //
62
+ // The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
63
+ import { readFileSync, realpathSync } from 'node:fs';
64
+ import { basename, dirname, join, resolve } from 'node:path';
65
+ import { editFragments } from './lib/edit-input.mjs';
66
+ import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
67
+
68
+ export { RULEBOOK_PREFIXES, isRulebookPath };
69
+
70
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
71
+
72
+ const toPosix = (value) => String(value ?? '').replaceAll('\\', '/');
73
+
74
+ const canonicalRoot = (root) => {
75
+ try {
76
+ return realpathSync(root);
77
+ } catch {
78
+ return root;
79
+ }
80
+ };
81
+
82
+ /** Resolve symlinks in the nearest existing ancestor, preserving a missing tail. */
83
+ const canonicalPath = (filePath) => {
84
+ let cursor = resolve(filePath);
85
+ const tail = [];
86
+ for (;;) {
87
+ try {
88
+ return join(realpathSync(cursor), ...tail);
89
+ } catch {
90
+ const parent = dirname(cursor);
91
+ if (parent === cursor) return filePath;
92
+ tail.unshift(basename(cursor));
93
+ cursor = parent;
94
+ }
95
+ }
96
+ };
97
+
98
+ /** The repo-relative tail of an absolute path, or the path itself when it is not under the root. */
99
+ export const relativeTo = (root, filePath) => {
100
+ const dir = toPosix(root).replace(/\/+$/, '');
101
+ const file = toPosix(filePath);
102
+ if (dir !== '' && file.startsWith(`${dir}/`)) return file.slice(dir.length + 1);
103
+ return file.replace(/^\.\//, '');
104
+ };
105
+
106
+ export const isAllowed = (rel, allow) =>
107
+ (Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
108
+
109
+ const protectedRelative = (roots, filePath) =>
110
+ [...new Set([filePath, canonicalPath(filePath)])]
111
+ .flatMap((spelling) => roots.map((root) => relativeTo(root, spelling)))
112
+ .find(isRulebookPath);
113
+
114
+ function main() {
115
+ let input;
116
+ try {
117
+ input = JSON.parse(readFileSync(0, 'utf8'));
118
+ } catch {
119
+ return 0; // unparseable payload: not ours to judge
120
+ }
121
+ if (!EDIT_TOOLS.has(input?.tool_name)) return 0;
122
+
123
+ const selectedRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
124
+ const root = canonicalRoot(selectedRoot);
125
+ const comparisonRoots = [...new Set([root, selectedRoot])];
126
+ const unattendedEnv = { ...process.env, CLAUDE_PROJECT_DIR: root };
127
+ const fragments = editFragments(input);
128
+ const globalRefusal = fragments.find(
129
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
130
+ );
131
+ if (globalRefusal) {
132
+ if (globalRefusal.filePath) {
133
+ const rel = protectedRelative(comparisonRoots, globalRefusal.filePath);
134
+ if (rel === undefined) return 0;
135
+ }
136
+ const mode = readUnattended(unattendedEnv);
137
+ if (!mode.on) return 0;
138
+ process.stderr.write(
139
+ `BLOCKED — cannot safely inspect this unattended edit: ${globalRefusal.inspectionRefusal}\n` +
140
+ `${globalRefusal.remedy ?? 'Split it into a smaller edit and retry.'}\n`,
141
+ );
142
+ return 2;
143
+ }
144
+ const paths = [];
145
+ for (const { filePath } of fragments) {
146
+ if (typeof filePath !== 'string' || filePath === '') continue;
147
+ const rel = protectedRelative(comparisonRoots, filePath);
148
+ if (rel !== undefined && !paths.includes(rel)) paths.push(rel);
149
+ }
150
+ if (paths.length === 0) return 0; // nothing under the rulebook: never judged
151
+
152
+ const mode = readUnattended(unattendedEnv);
153
+ if (!mode.on) return 0; // attended session
154
+
155
+ if (mode.unreadable) {
156
+ process.stderr.write(
157
+ `BLOCKED — "${paths[0]}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
158
+ 'Refusing to inspect is not allowing: fix it, or clear this checkout with `node .claude/scripts/unattended-flag.mjs off --root "$PWD"`, then retry.\n',
159
+ );
160
+ return 2;
161
+ }
162
+
163
+ const refused = paths.filter(
164
+ (rel) => rel === '.claude/queue.board' || !isAllowed(rel, mode.allow),
165
+ );
166
+ if (refused.length === 0) return 0;
167
+ if (refused[0] === '.claude/queue.board') {
168
+ process.stderr.write(
169
+ 'BLOCKED — ".claude/queue.board" is the checkout board selector and cannot be changed while unattended, even through an item allow-list. ' +
170
+ 'Disarm unattended mode before deliberately switching queues.\n',
171
+ );
172
+ return 2;
173
+ }
174
+ process.stderr.write(
175
+ `BLOCKED — "${refused[0]}" is part of the rulebook, and an unattended run never edits the rulebook outside its item's allow-list ` +
176
+ `(item ${mode.item ?? '(none)'}; allowed prefixes: ${mode.allow.length === 0 ? 'none' : mode.allow.join(', ')}). ` +
177
+ 'If the item really needs this path, it belongs in the allow-list the loop wrote at claim time — a decision, not a default. ' +
178
+ 'See .claude/rules/autonomy.md, "Never".\n',
179
+ );
180
+ return 2;
181
+ }
182
+
183
+ try {
184
+ process.exit(main());
185
+ } catch {
186
+ process.exit(0); // a crashed guard must not block the session
187
+ }
@@ -22,18 +22,25 @@
22
22
  // so plainly where none does — a limits comment nothing checks drifts into
23
23
  // overstatement, which is the direction that gets a reader hurt. ⚠ Those tests live in the GENERATOR this rig came from, not here;
24
24
  // `.claude/rules/invariants.md` ("About the hooks you were given") says the same
25
- // of this hook's own tests, and the moment you edit it they are yours.
25
+ // of this hook's own tests. A manifest-backed generator upgrade remains inherited
26
+ // while `.claude/.rig-manifest.json` matches; once the hash differs, the local test
27
+ // is yours.
26
28
  //
27
- // There are FOUR:
29
+ // There are FIVE:
28
30
  //
29
31
  // - It sees ONE edit fragment, not the resulting file. A credential assembled
30
- // across two edits is not seen — see guard-secret-file.test.ts › "does not
32
+ // across two edits is not seen — see guard-secret-file.test.ts (absent in a generated rig) › "does not
31
33
  // see a credential split across two edits, because it is shown one fragment
32
34
  // at a time". This is the same limit every guard in this directory has,
33
35
  // stated in full in `.claude/rules/invariants.md`, "What the enforcement
34
36
  // actually is — stated exactly".
37
+ // - A `MultiEdit` is capped at 256 fragments and REFUSES before mapping a
38
+ // longer list, so the tail is never silently dropped — see
39
+ // guard-secret-file.test.ts (absent in a generated rig) › "refuses a
40
+ // MultiEdit beyond the fragment cap instead of silently dropping the tail".
35
41
  // - It sees only what the AGENT writes. The `toolName` branch in `main` below
36
- // names the complete surface: `Write`, `Edit`, and `apply_patch`; every other
42
+ // names the complete surface: `Write`, `Edit`, `MultiEdit`, `NotebookEdit`,
43
+ // and `apply_patch`; every other
37
44
  // tool returns before inspection. A human editing the file, or a
38
45
  // `git commit` of something already on disk, never reaches a PreToolUse hook
39
46
  // at all, and no test here can show that: it is a property of the harness,
@@ -51,15 +58,16 @@
51
58
  // `findSecretValues` applies by default so a fail-open guard cannot be made
52
59
  // to hang. A credential past that point is not seen. ⚠ No test here pins
53
60
  // this one: the case is pinned one layer down, on the module, by
54
- // secrets-lib.test.ts › "has a limit even when the caller names none". The CI sweep lifts the
61
+ // secrets-lib.test.ts (absent in a generated rig) › "has a limit even when the caller names none". The CI sweep lifts the
55
62
  // cap; this hook cannot, and that asymmetry is the point.
56
63
  // - It FAILS OPEN on what it cannot understand — see guard-secret-file.test.ts
64
+ // (absent in a generated rig)
57
65
  // › "allows a payload that is not JSON at all" and its neighbours. An
58
66
  // unparseable payload, a missing field, or an internal throw all allow the
59
67
  // edit; a crashed guard that blocks everything gets deleted within the hour.
60
68
  //
61
69
  // ⚠ **An `apply_patch` command that is PRESENT and is not a shape this guard
62
- // reads is the other case, and it now REFUSES** — see codex.test.ts ›
70
+ // reads is the other case, and it now REFUSES** — see codex.test.ts (absent in a generated rig)
63
71
  // "refuses, rather than failing open, when apply_patch command is supplied
64
72
  // as %s". The line between them is whether the guard can tell: an absent
65
73
  // field is a payload it does not understand, a container it detects and
@@ -92,40 +100,27 @@ function main() {
92
100
  return 0; // unparseable payload: not ours to judge
93
101
  }
94
102
 
95
- const toolName = input?.tool_name;
96
- if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'apply_patch') return 0;
103
+ const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
104
+ if (!editTools.has(input?.tool_name)) return 0;
97
105
 
98
- if (toolName === 'apply_patch') {
99
- let refused = false;
100
- for (const { filePath, fragment, inspectionRefusal, remedy, appliesToAll } of editFragments(input)) {
101
- if (inspectionRefusal) {
102
- refused = true;
103
- process.stderr.write(
104
- `BLOCKED cannot safely inspect this edit: ${inspectionRefusal}\n` +
105
- // The remedy has to match the refusal: splitting cannot change a
106
- // container shape, and a fixed line sent the agent into a retry loop
107
- // on the one path it could not retry out of.
108
- `${remedy ?? 'Split it into a smaller patch and retry.'}\n`,
109
- );
110
- continue;
111
- }
112
- if (isCredentialPath(filePath)) {
113
- refused = true;
114
- process.stderr.write(`BLOCKED — "${filePath}" is a credential file, and this repository never carries one.\n${WHERE_CREDENTIALS_BELONG}\n`);
115
- continue;
116
- }
117
- const findings = findSecretValues(fragment);
118
- if (findings.length > 0 || appliesToAll) {
119
- refused = true;
120
- if (findings.length > 0) process.stderr.write(`BLOCKED — this edit writes a credential value into "${filePath}".\n${WHERE_CREDENTIALS_BELONG}\n`);
121
- }
122
- }
123
- return refused ? 2 : 0;
106
+ const fragments = editFragments(input);
107
+ const globalRefusal = fragments.find(
108
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
109
+ );
110
+ if (globalRefusal) {
111
+ const fallbackRemedy = input?.tool_name === 'apply_patch'
112
+ ? 'Split it into a smaller patch and retry.'
113
+ : 'Split it into a smaller edit and retry.';
114
+ process.stderr.write(
115
+ `BLOCKED cannot safely inspect this edit: ${globalRefusal.inspectionRefusal}\n` +
116
+ `${globalRefusal.remedy ?? fallbackRemedy}\n`,
117
+ );
118
+ return 2;
124
119
  }
125
120
 
126
- const toolInput = input?.tool_input ?? {};
127
- const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
128
- if (filePath === '') return 0; // nothing to judge; fail open
121
+ if (fragments.length === 0 || fragments.every(({ filePath }) => filePath === '')) {
122
+ return 0; // nothing to judge; fail open
123
+ }
129
124
 
130
125
  // The tool sends an absolute path. Judge the repo-relative tail so a checkout
131
126
  // living under a directory literally called `secrets` does not make every edit
@@ -134,41 +129,53 @@ function main() {
134
129
  // below never matches, every path stays absolute, and a checkout that happens
135
130
  // to live under a directory called `secrets` has EVERY edit refused. That is
136
131
  // the "deleted within the hour" outcome `.claude/rules/invariants.md` warns
137
- // about — see guard-secret-file.test.ts "judges the repo-relative path even
138
- // when the project directory is given with a trailing slash".
132
+ // about — see guard-secret-file.test.ts (absent in a generated rig)
133
+ // "judges the repo-relative path even when the project directory is given %s".
139
134
  const projectDir = String(process.env.CLAUDE_PROJECT_DIR ?? '')
140
135
  .replaceAll('\\', '/')
141
136
  .replace(/\/+$/, '');
142
- const relativePath =
143
- projectDir !== '' && filePath.startsWith(`${projectDir}/`)
144
- ? filePath.slice(projectDir.length + 1)
145
- : filePath;
137
+ let refused = false;
138
+ for (const { filePath, fragment, inspectionRefusal } of fragments) {
139
+ const relativePath =
140
+ projectDir !== '' && filePath.startsWith(`${projectDir}/`)
141
+ ? filePath.slice(projectDir.length + 1)
142
+ : filePath;
143
+ if (relativePath === '') continue;
146
144
 
147
- if (isCredentialPath(relativePath)) {
145
+ if (inspectionRefusal) {
146
+ refused = true;
147
+ process.stderr.write(
148
+ `BLOCKED — cannot safely inspect this edit to "${relativePath}": ${inspectionRefusal}\n` +
149
+ 'Split it into a smaller edit and retry.\n',
150
+ );
151
+ continue;
152
+ }
153
+ if (isCredentialPath(relativePath)) {
154
+ refused = true;
155
+ process.stderr.write(
156
+ `BLOCKED — "${relativePath}" is a credential file, and this repository never carries one.\n` +
157
+ `${WHERE_CREDENTIALS_BELONG}\n` +
158
+ `If this file is a documented placeholder, name it .env.example — that form stays committable.\n`,
159
+ );
160
+ continue;
161
+ }
162
+
163
+ const findings = findSecretValues(fragment);
164
+ if (findings.length === 0) continue;
165
+ refused = true;
148
166
  process.stderr.write(
149
- `BLOCKED — "${relativePath}" is a credential file, and this repository never carries one.\n` +
150
- `${WHERE_CREDENTIALS_BELONG}\n` +
151
- `If this file is a documented placeholder, name it .env.example that form stays committable.\n`,
167
+ `BLOCKED — this edit writes a credential value into "${relativePath}":\n` +
168
+ findings
169
+ .map((finding) => ` - ${finding.id} on line ${finding.line} of the text being written`)
170
+ .join('\n') +
171
+ `\n${WHERE_CREDENTIALS_BELONG}\n` +
172
+ // Deliberately NOT the matched text. A guard that prints what it found has
173
+ // copied the credential into a hook transcript and a terminal scrollback —
174
+ // it has leaked the secret in the act of refusing it.
175
+ `The matched value is deliberately not shown; open the line above to see it.\n`,
152
176
  );
153
- return 2;
154
177
  }
155
-
156
- const fragment = String((toolName === 'Write' ? toolInput.content : toolInput.new_string) ?? '');
157
- const findings = findSecretValues(fragment);
158
- if (findings.length === 0) return 0;
159
-
160
- process.stderr.write(
161
- `BLOCKED — this edit writes a credential value into "${relativePath}":\n` +
162
- findings
163
- .map((finding) => ` - ${finding.id} on line ${finding.line} of the text being written`)
164
- .join('\n') +
165
- `\n${WHERE_CREDENTIALS_BELONG}\n` +
166
- // Deliberately NOT the matched text. A guard that prints what it found has
167
- // copied the credential into a hook transcript and a terminal scrollback —
168
- // it has leaked the secret in the act of refusing it.
169
- `The matched value is deliberately not shown; open the line above to see it.\n`,
170
- );
171
- return 2;
178
+ return refused ? 2 : 0;
172
179
  }
173
180
 
174
181
  let status;
@@ -45,6 +45,8 @@ const MAX_TOTAL_HUNK_LINES = 10_000;
45
45
  const MAX_OUTPUT_LINES = 20_000;
46
46
  const MAX_SPLICE_OPERATIONS = 1_000;
47
47
  const MAX_PATCH_SECTIONS = 128;
48
+ /** A MultiEdit is capped before it is mapped — bounded work, never a spread of input. */
49
+ const MAX_MULTI_EDITS = 256;
48
50
  const MAX_PATCH_PATH_COMPONENTS = 512;
49
51
 
50
52
  export function editFragments(input) {
@@ -60,6 +62,37 @@ export function editFragments(input) {
60
62
  },
61
63
  ];
62
64
  }
65
+ // Claude Code's other two edit surfaces (AR-51). `MultiEdit` carries one
66
+ // file and a list of edits — one fragment per edit, same path — and
67
+ // `NotebookEdit` carries a cell's new source. Before this, both reached every
68
+ // guard through the unanchored `Write|Edit` matcher and yielded no fragment,
69
+ // so a `Date.now()` in a MultiEdit to the core passed unchecked.
70
+ if (toolName === 'MultiEdit') {
71
+ if (!Array.isArray(toolInput.edits)) return [];
72
+ const filePath = normalisePath(toolInput.file_path);
73
+ if (toolInput.edits.length > MAX_MULTI_EDITS) {
74
+ return [
75
+ {
76
+ filePath,
77
+ fragment: '',
78
+ inspectionRefusal: `MultiEdit carries more than the ${MAX_MULTI_EDITS}-fragment inspection limit`,
79
+ appliesToAll: true,
80
+ },
81
+ ];
82
+ }
83
+ return toolInput.edits.map((edit) => ({
84
+ filePath,
85
+ fragment: String(edit?.new_string ?? ''),
86
+ }));
87
+ }
88
+ if (toolName === 'NotebookEdit') {
89
+ return [
90
+ {
91
+ filePath: normalisePath(toolInput.notebook_path),
92
+ fragment: String(toolInput.new_source ?? ''),
93
+ },
94
+ ];
95
+ }
63
96
  if (toolName !== 'apply_patch') return [];
64
97
  const rawCommand = toolInput.command;
65
98
  // ⚠ **Absent is not malformed, and the difference decides which way this fails.**