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.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/templates/agent-os/init/AGENTS.md +4 -2
- package/templates/agent-os/init/CLAUDE.md +4 -2
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +42 -10
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +17 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +85 -25
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
- package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
- package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +247 -50
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +42 -10
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/hash-history.json +132 -39
- package/templates/release-ledger.json +2 -1
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- 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
|
|
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
|
@@ -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. **
|
|
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.
|
|
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. **
|
|
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.
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
path starts with an allowed prefix;
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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 —
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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.
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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
|
|
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
|
|
145
|
+
for (const { filePath } of fragments) {
|
|
94
146
|
if (typeof filePath !== 'string' || filePath === '') continue;
|
|
95
|
-
const rel =
|
|
96
|
-
if (
|
|
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
|
|
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(
|
|
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(', ')}). ` +
|