create-agent-rig 0.6.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 (30) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/templates/agent-os/init/AGENTS.md +4 -2
  5. package/templates/agent-os/init/CLAUDE.md +4 -2
  6. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +42 -10
  7. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
  8. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +17 -17
  9. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
  10. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +85 -25
  11. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
  12. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
  13. package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
  14. package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
  15. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
  16. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
  17. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  18. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
  19. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  20. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
  21. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +1 -0
  22. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
  23. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
  24. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +247 -50
  25. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +42 -10
  26. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  27. package/templates/hash-history.json +132 -39
  28. package/templates/release-ledger.json +2 -1
  29. package/templates/skeleton/aws-serverless/gitignore +2 -0
  30. package/templates/skeleton/node-service/gitignore +2 -0
package/CHANGELOG.md CHANGED
@@ -11,6 +11,36 @@ Numbering is ordinary semver — **additive is a minor, a fix is a patch** — s
11
11
  that "I only take minors" remains a usable policy; 0.3.2 shipped additive
12
12
  content as a patch by the owner's call and stays recorded as one.
13
13
 
14
+ ## 0.6.1
15
+
16
+ **Security and upgrade hardening for the Agent OS shipped by 0.6.0.** This patch
17
+ closes the rulebook, unattended-run and queue-board gaps found while upgrading a
18
+ live generated repository; it adds no dependency and changes no public CLI
19
+ command.
20
+
21
+ ### Security
22
+
23
+ - **`guard-rulebook` now covers the whole shared rulebook and symlink aliases on
24
+ either side of the comparison.** `AGENTS.md` and `.codex/hooks.json` are
25
+ protected alongside the Claude files. Checkout roots and payload paths are
26
+ judged in both their selected and canonical spellings, including a
27
+ payload-only alias.
28
+ - **Queue board names containing terminal control characters are rejected before
29
+ selection, diagnostics or selector writes.** Ordinary names, including names
30
+ with spaces, remain valid; ANSI, OSC, C1 and DEL bytes from repository-owned
31
+ `queue.json` keys can no longer repaint terminal output.
32
+ - **Unattended authorization is checkout-scoped.** Concurrent worktrees derive
33
+ distinct flag paths from canonical checkout identity, legacy machine-wide
34
+ state fails closed, and migration or cleanup refuses when any armed flag cannot
35
+ be removed instead of reporting a partial disarm as success.
36
+
37
+ ### Fixed
38
+
39
+ - The generated prose-reviewer exception for upstream-only tests now applies
40
+ only to manifest-proven generator snapshots and expires on local drift. This
41
+ keeps intentional generator test references available without turning a dead
42
+ reference in an edited downstream rulebook into a pass.
43
+
14
44
  ## 0.6.0
15
45
 
16
46
  **The loop now checks its premises against the tracker at three points, and
package/README.md CHANGED
@@ -108,7 +108,7 @@ wired by both `.claude/settings.json` and `.codex/hooks.json`:
108
108
  the web talks to the backend over HTTP only;
109
109
  - **`guard-rulebook`** — in an unattended run (a flag file the `loop` skill
110
110
  writes at claim time), refuses an edit to the rulebook — hooks, wiring,
111
- `queue.json`, the queue adapters, the router, the gate sweep, the rules,
111
+ `queue.json` and its board selector, the queue adapters, the router, the gate sweep, the rules,
112
112
  `CLAUDE.md` — outside the current item's allow-list; does nothing in an
113
113
  attended session.
114
114
  - **`guard-secret-file`** — refuses an edit that writes a credential: either the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agent-rig",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Scaffold a new project with an agent operating system (rules, gates, hooks) and a runnable code skeleton",
5
5
  "keywords": [
6
6
  "create",
@@ -116,13 +116,15 @@ All four are one-liners, and all four are inert until you do them.
116
116
  no-op, and the Definition of Done is back to being a wish.
117
117
  2. **The elevated-path list below is a seed, not a survey.** It names only what
118
118
  every repo has. Everything else is yours to add.
119
- 3. **Four runtime paths need a `.gitignore` line each**, and `init` cannot add
119
+ 3. **Five runtime paths need a `.gitignore` line each**, and `init` cannot add
120
120
  them — it installs into your repository and does not edit files it did not
121
- bring. Add all four:
121
+ bring. If any are missing, add only the missing entries:
122
122
 
123
123
  ```
124
124
  # the tier the last close recorded
125
125
  .claude/queue.state.json
126
+ # the board this checkout runs on, when the config declares several
127
+ .claude/queue.board
126
128
  # gate rounds, one count per branch
127
129
  .claude/gate-rounds.json
128
130
  # task worktrees
@@ -116,13 +116,15 @@ All four are one-liners, and all four are inert until you do them.
116
116
  no-op, and the Definition of Done is back to being a wish.
117
117
  2. **The elevated-path list below is a seed, not a survey.** It names only what
118
118
  every repo has. Everything else is yours to add.
119
- 3. **Four runtime paths need a `.gitignore` line each**, and `init` cannot add
119
+ 3. **Five runtime paths need a `.gitignore` line each**, and `init` cannot add
120
120
  them — it installs into your repository and does not edit files it did not
121
- bring. Add all four:
121
+ bring. If any are missing, add only the missing entries:
122
122
 
123
123
  ```
124
124
  # the tier the last close recorded
125
125
  .claude/queue.state.json
126
+ # the board this checkout runs on, when the config declares several
127
+ .claude/queue.board
126
128
  # gate rounds, one count per branch
127
129
  .claude/gate-rounds.json
128
130
  # task worktrees
@@ -39,6 +39,28 @@ node .claude/scripts/queue/index.mjs hygiene # stale labels, link anomalies, o
39
39
  (`JIRA_BASE_URL`, `JIRA_EMAIL`, `JIRA_API_TOKEN`) and never from a file in the
40
40
  repo; the project or the JQL goes in `.claude/queue.json`.
41
41
 
42
+ A config may declare several boards (`boards: { <name>: options }` plus a
43
+ default `board`); the active one is chosen per checkout, not by editing the
44
+ composed file:
45
+
46
+ ```bash
47
+ node .claude/scripts/queue/index.mjs board # the active board and the declared ones
48
+ node .claude/scripts/queue/index.mjs board RP # switch this checkout: writes .claude/queue.board
49
+ ```
50
+
51
+ The selector is per-checkout runtime state, the same class as
52
+ `.claude/queue.state.json`: it needs its own `.gitignore` line, which a generated
53
+ project ships and an `init`-installed rig adds by hand. An undeclared name is
54
+ refused, never read as "no board" (see `test/template/queue-board.test.ts` ›
55
+ "refuses a board nobody declared instead of falling back" — in the generator,
56
+ absent in a generated rig). It is a rulebook path for `guard-rulebook`:
57
+ `.claude/queue.board` is refused even when an item allow-list names it,
58
+ and the `board` command itself refuses a switch while the checkout is unattended. This
59
+ does not prevent an arbitrary direct shell write to the selector — edit-tool
60
+ hooks cannot see one. `.claude/queue.state.json` stays per config, not per board:
61
+ the tier the last close recorded rations the next selection whichever board it
62
+ lands on.
63
+
42
64
  Adding a fourth is an adapter, not a rewrite: `core.mjs` holds every selection
43
65
  decision and each adapter only maps its tracker's records onto the neutral shape.
44
66
 
@@ -99,14 +121,15 @@ invocation. What a hook CAN see is a file, so the unattended signal is one:
99
121
  ```bash
100
122
  # at claim time, from the paths the item names (repo-relative prefixes, with
101
123
  # their trailing slash); the guard refuses every other rulebook edit while it is on
102
- node .claude/scripts/unattended-flag.mjs on --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
124
+ node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
103
125
  ```
104
126
 
105
127
  `guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
106
- on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under `.claude/hooks/`,
107
- `.claude/settings.json`, `.claude/queue.json`, `.claude/scripts/queue/`, the
108
- router, the gate sweep, `.claude/rules/` or `CLAUDE.md` is refused unless its
109
- path starts with an allowed prefix; with no flag the guard does nothing. An
128
+ on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
129
+ rulebook both harnesses' rules, skills, agents and hook wiring, plus their
130
+ scripts, queue config and integrity manifest is refused unless its
131
+ path starts with an allowed prefix; the board selector is the one always-refused
132
+ exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
110
133
  item that needs a rulebook path names it here — a decision made at claim
111
134
  time, never a default — and the stop step below turns the flag off. Pinned in
112
135
  the generator's `test/template/guard-rulebook.test.ts` — absent in a generated
@@ -718,9 +741,18 @@ next attended session would find its rulebook edits refused in the name of an
718
741
  item nobody is working:
719
742
 
720
743
  ```bash
721
- node .claude/scripts/unattended-flag.mjs off
744
+ node .claude/scripts/unattended-flag.mjs off --root "$PWD"
722
745
  ```
723
746
 
747
+ If that command reports a legacy machine-wide flag, it deliberately leaves a
748
+ foreign pre-upgrade authorization in place and the checkout stays fail-closed.
749
+ Inspect the exact reported record and confirm that no pre-upgrade run still uses
750
+ it, then remove only that record with
751
+ `node .claude/scripts/unattended-flag.mjs off --legacy --path <reported-path>`.
752
+ Run scoped `off --root "$PWD"` again to surface the next record, and repeat the
753
+ inspection one at a time; do not record the flag as off until the scoped command
754
+ succeeds.
755
+
724
756
  At every **stop** — not at a checkpoint — turn the run's findings into **at most
725
757
  three** improvement proposals. **The cap is the mechanism, not a budget:** an
726
758
  unbounded improvement list is another diary, and three forces a choice. Each names
@@ -750,7 +782,7 @@ node --input-type=module -e '
750
782
  // mechanism accepts a proposal without them; this procedure does not.
751
783
  measured: "<the paths the probe actually exercised>",
752
784
  inferred: "<the conclusion, citing only surfaces named in measured>",
753
- }, { project: "<KEY>" })); // jira only — the project key from .claude/queue.json;
785
+ }, { project: "<KEY>" })); // jira only — the ACTIVE board's key: `queue/index.mjs board --json` → options.project;
754
786
  // plan-md and github-issues take no second argument
755
787
  '
756
788
  ```
@@ -853,7 +885,7 @@ three poisons the only channel by which this project learns.
853
885
  validation and its take-up — an adapter re-records the take-up after each
854
886
  write of its own (§2, AR-140), so a comment posted after BEFORE_PR does not
855
887
  hold the close; pinned in the generator's
856
- `test/template/self-inflicted-marker.test.ts` › "continues when the run’s own
888
+ `test/template/self-inflicted-marker.test.ts` (absent in a generated rig) › "continues when the run’s own
857
889
  write moved the marker after the last validation" — and its
858
890
  state against the `in-progress` a close expects, journals one `revalidation`
859
891
  event at `point: BEFORE_CLOSE`, and lists the item's dependants with each
@@ -864,7 +896,7 @@ three poisons the only channel by which this project learns.
864
896
  and reads a hold as a stop" and › "re-reads each dependant's state, and names
865
897
  one the tracker no longer offers". On a `github-issues` queue that list is
866
898
  empty: a single `gh issue view` carries no cross-index, so `find` answers no
867
- `blocks` there (`test/template/close-transitioned.test.ts` › "github asks `gh
899
+ `blocks` there (`test/template/close-transitioned.test.ts` (absent in a generated rig) › "github asks `gh
868
900
  issue view` with the full field list and maps CLOSED to closed"). A
869
901
  hold (exit 2) stops the close: re-read the item, record the outcome with
870
902
  `node .claude/scripts/revalidate.mjs outcome --point BEFORE_CLOSE --ticket
@@ -876,7 +908,7 @@ three poisons the only channel by which this project learns.
876
908
  `true` says the close landed, because every adapter reads the item back after
877
909
  the transition — `jira` the status category after the POST, `github-issues`
878
910
  `gh issue view --json state`, `plan-md` the line being there and then gone
879
- (the generator's `test/template/close-transitioned.test.ts` › "GETs the issue
911
+ (the generator's `test/template/close-transitioned.test.ts` (absent in a generated rig) › "GETs the issue
880
912
  status after the transition POST and reports transitioned: true when the
881
913
  category is done", › "runs `issue view <id> --json state` after `issue close`
882
914
  and reports transitioned: true on CLOSED", › "reports transitioned: true once
@@ -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,
@@ -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,19 +48,19 @@
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
65
  import { readFileSync, realpathSync } from 'node:fs';
66
66
  import { fileURLToPath } from 'node:url';
@@ -81,9 +81,9 @@ import { withoutGitLocation } from '../scripts/git-env.mjs';
81
81
  // `worktree-task` worktree is measured at the main checkout, and the refusal
82
82
  // says so. Resolved inside `main()`, so a throw here reaches the backstop
83
83
  // and announces itself like every other fault of the gate's own.
84
- // see hooks.test.ts › "runs the checks in the project root, so a check reading the tree sees the session project"
85
- // see hooks.test.ts › "asks "is the tree clean?" about the project, not about the cwd"
86
- // see hooks.test.ts › "names the tree it measured in the refusal, so a foreign failure is visible at a glance"
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
87
  const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.meta.url)));
88
88
 
89
89
  // The default total budget, and the allowance for everything that happens
@@ -100,8 +100,8 @@ const projectRootOf = () => realpathSync(fileURLToPath(new URL('../..', import.m
100
100
  // So the preamble gets a number and a leash: `git status` below is given this
101
101
  // as its own timeout, which turns the allowance from a guess into a bound.
102
102
  // Both numbers are read from this file and compared against the wiring —
103
- // see hooks.test.ts › "gives the stop gate a harness timeout its own budget finishes inside"
104
- // 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"
105
105
  const DEFAULT_BUDGET_MS = 600_000;
106
106
  const PREAMBLE_MARGIN_MS = 60_000;
107
107
 
@@ -137,7 +137,7 @@ const SPAWN_NEVER_STARTED = new Set(['ENOENT', 'EACCES', 'EPERM', 'EMFILE', 'ENF
137
137
  * it. `Number.isSafeInteger` is the test rather than `isFinite`, because
138
138
  * `spawnSync` throws on a fractional `timeout` — and a throw here would land in
139
139
  * the backstop and open the gate completely.
140
- * 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"
141
141
  */
142
142
  function budgetMs(env) {
143
143
  const raw = env.RIG_DOD_BUDGET_MS;
@@ -235,13 +235,13 @@ function main() {
235
235
  // still ended green. Both were shipped here, one after the other, and both
236
236
  // are closed the same way — every usable entry runs, and a skipped entry is
237
237
  // a check with no verdict, which blocks.
238
- // see hooks.test.ts › "never lets an unusable config entry hide a failing check behind it (an empty string)"
239
- // 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"
240
240
  //
241
241
  // The predicate is exactly the three shapes `spawnSync` throws on, and no
242
242
  // wider: the shell answers every other unrunnable string with 127, which is
243
243
  // a verdict. Filtering past these three converts a real block into a skip.
244
- // 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"
245
245
  const runnable = (command) =>
246
246
  typeof command === 'string' && command !== '' && !command.includes('\0');
247
247
  const usable = checks.filter(runnable);
@@ -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
@@ -2,11 +2,12 @@
2
2
  // PreToolUse hook: in an UNATTENDED run, the rulebook is never edited outside
3
3
  // the current item's allow-list (AR-51).
4
4
  //
5
- // The rulebook is the set of files that decide what this session may do — the
6
- // hooks, their wiring, the queue config and adapters, the router and the gate
7
- // sweep, the rules, `CLAUDE.md`. Every one of them was editable by the run it
8
- // governs: a Write to `.claude/hooks/dod-checks.json` with `[]` switched the
9
- // stop gate off, and nothing refused it until the merge sweep, long after.
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.
10
11
  //
11
12
  // How it decides — and it is deliberately dumb:
12
13
  // 1. No unattended flag on disk (`.claude/scripts/unattended-flag.mjs`) → an
@@ -14,8 +15,12 @@
14
15
  // the rulebook on purpose.
15
16
  // 2. Flag present and readable → every edit fragment whose repo-relative path
16
17
  // sits under a rulebook prefix is refused (exit 2) unless it also sits
17
- // under one of the item's `allow` prefixes. Paths outside the rulebook are
18
- // never judged.
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".
19
24
  // 3. Flag present and UNREADABLE → a rulebook edit is refused and the reason
20
25
  // names the flag; an edit outside the rulebook still passes. Refusing to
21
26
  // inspect is not allowing (`.claude/rules/invariants.md`).
@@ -37,15 +42,12 @@
37
42
  // - it judges paths, not content: a README that merely mentions
38
43
  // `.claude/hooks/guard-bash.mjs` is not a rulebook edit — › "guards the
39
44
  // path, not prose that mentions a guarded path";
40
- // - it compares paths as text: the repo-relative tail is what is left after
41
- // stripping `CLAUDE_PROJECT_DIR` (falling back to the working directory
42
- // when the harness does not set it) from the front of the tool's absolute
43
- // path, so a root spelled differently from the file path a symlinked
44
- // `/tmp` versus `/private/tmp`, a case difference on a case-insensitive
45
- // disk is not stripped, and the edit is not judged. Documented and
46
- // measured, not fixed: the harness spells both from one root — › "compares
47
- // paths as text: a root spelled differently from the file path is not
48
- // judged (documented, fails open)";
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";
49
51
  // - an `allow` prefix is a string prefix of the repo-relative path and may
50
52
  // not widen the rulebook — an entry that is itself a prefix of a rulebook
51
53
  // prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
@@ -58,7 +60,8 @@
58
60
  // adversary.
59
61
  //
60
62
  // The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
61
- import { readFileSync } from 'node:fs';
63
+ import { readFileSync, realpathSync } from 'node:fs';
64
+ import { basename, dirname, join, resolve } from 'node:path';
62
65
  import { editFragments } from './lib/edit-input.mjs';
63
66
  import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
64
67
 
@@ -68,6 +71,30 @@ const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply
68
71
 
69
72
  const toPosix = (value) => String(value ?? '').replaceAll('\\', '/');
70
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
+
71
98
  /** The repo-relative tail of an absolute path, or the path itself when it is not under the root. */
72
99
  export const relativeTo = (root, filePath) => {
73
100
  const dir = toPosix(root).replace(/\/+$/, '');
@@ -79,6 +106,11 @@ export const relativeTo = (root, filePath) => {
79
106
  export const isAllowed = (rel, allow) =>
80
107
  (Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
81
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
+
82
114
  function main() {
83
115
  let input;
84
116
  try {
@@ -88,29 +120,57 @@ function main() {
88
120
  }
89
121
  if (!EDIT_TOOLS.has(input?.tool_name)) return 0;
90
122
 
91
- const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
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
+ }
92
144
  const paths = [];
93
- for (const { filePath } of editFragments(input)) {
145
+ for (const { filePath } of fragments) {
94
146
  if (typeof filePath !== 'string' || filePath === '') continue;
95
- const rel = relativeTo(root, filePath);
96
- if (isRulebookPath(rel) && !paths.includes(rel)) paths.push(rel);
97
- if (paths.length >= 64) break;
147
+ const rel = protectedRelative(comparisonRoots, filePath);
148
+ if (rel !== undefined && !paths.includes(rel)) paths.push(rel);
98
149
  }
99
150
  if (paths.length === 0) return 0; // nothing under the rulebook: never judged
100
151
 
101
- const mode = readUnattended();
152
+ const mode = readUnattended(unattendedEnv);
102
153
  if (!mode.on) return 0; // attended session
103
154
 
104
155
  if (mode.unreadable) {
105
156
  process.stderr.write(
106
157
  `BLOCKED — "${paths[0]}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
107
- 'Refusing to inspect is not allowing: fix or remove the flag (`node .claude/scripts/unattended-flag.mjs off`), then retry.\n',
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',
108
159
  );
109
160
  return 2;
110
161
  }
111
162
 
112
- const refused = paths.filter((rel) => !isAllowed(rel, mode.allow));
163
+ const refused = paths.filter(
164
+ (rel) => rel === '.claude/queue.board' || !isAllowed(rel, mode.allow),
165
+ );
113
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
+ }
114
174
  process.stderr.write(
115
175
  `BLOCKED — "${refused[0]}" is part of the rulebook, and an unattended run never edits the rulebook outside its item's allow-list ` +
116
176
  `(item ${mode.item ?? '(none)'}; allowed prefixes: ${mode.allow.length === 0 ? 'none' : mode.allow.join(', ')}). ` +