create-agent-rig 0.10.1 → 1.0.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 +170 -4
- package/README.md +8 -8
- package/package.json +2 -2
- package/packages/cli/dist/commands/upgrade.js +22 -9
- package/packages/cli/dist/index.js +9 -2
- package/packages/cli/dist/integrations/doctor-guards.js +7 -3
- package/templates/agent-os/subagent-routing.json +9 -5
- package/templates/agent-os/universal/.agents/skills/diagnose/SKILL.md +43 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +65 -22
- package/templates/agent-os/universal/.agents/skills/plan-slices/SKILL.md +30 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.agents/skills/release-propose/SKILL.md +74 -0
- package/templates/agent-os/universal/.agents/skills/skill-authoring/SKILL.md +39 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +5 -1
- package/templates/agent-os/universal/.claude/agents/failure-diagnostician.md +112 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +1 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +14 -3
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +142 -14
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +5 -1
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +168 -17
- package/templates/agent-os/universal/.claude/rules/invariants.md +33 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +63 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +5 -6
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +71 -14
- package/templates/agent-os/universal/.claude/scripts/queue/propose.mjs +139 -0
- package/templates/agent-os/universal/.claude/scripts/release-evidence.mjs +188 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +4 -2
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +157 -10
- package/templates/agent-os/universal/.claude/skills/diagnose/SKILL.md +43 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +65 -22
- package/templates/agent-os/universal/.claude/skills/plan-slices/SKILL.md +30 -0
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +1 -1
- package/templates/agent-os/universal/.claude/skills/release-propose/SKILL.md +74 -0
- package/templates/agent-os/universal/.claude/skills/skill-authoring/SKILL.md +39 -0
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -2
- package/templates/agent-os/universal/.codex/agents/failure-diagnostician.toml +6 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/AGENTS.md +11 -5
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +1 -1
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +5 -3
- package/templates/agent-os/universal/docs/decisions/workflow-layer-split.md +15 -3
- package/templates/agent-os/universal/layers.json +12 -0
- package/templates/hash-history.json +118 -22
- package/templates/release-ledger.json +3 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-authoring
|
|
3
|
+
description: Use when authoring or editing a skill in this rig — a new SKILL.md under .claude/skills/, or a change to an existing one.
|
|
4
|
+
allowed-tools: Read, Grep, Glob
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Authoring a skill
|
|
8
|
+
|
|
9
|
+
No check in this rig runs on a skill you write here — the rules below are
|
|
10
|
+
applied by the author, and by a reviewer reading this file, never by a
|
|
11
|
+
mechanism. On the skills this rig itself ships, the generator's own
|
|
12
|
+
`test/template/skill-authoring.test.ts` (absent in a generated rig) checks
|
|
13
|
+
only the first rule, that a `description` is present, and that the skills and
|
|
14
|
+
roles a skill names by name exist.
|
|
15
|
+
|
|
16
|
+
- **The frontmatter `name` equals the directory name.** A skill loaded from
|
|
17
|
+
`.claude/skills/foo/` is named `foo`, not something else.
|
|
18
|
+
- **The `description` says when to use the skill**, not just what it does.
|
|
19
|
+
- **Keep it short, and point at existing rules or scripts rather than
|
|
20
|
+
restating them.** A skill that copies a rule's wording is a second copy
|
|
21
|
+
that goes stale the day the rule changes; link to `.claude/rules/` or a
|
|
22
|
+
script instead.
|
|
23
|
+
- **Every sentence describing how a mechanism behaves either points at the
|
|
24
|
+
test that proves it, or is deleted.** `.claude/rules/invariants.md`,
|
|
25
|
+
"State the limits — and test them", is the norm; the form is `see <file>
|
|
26
|
+
(absent in a generated rig) › "<exact test name>"`.
|
|
27
|
+
- **A mention of a workflow-layer skill or script in a Core document is
|
|
28
|
+
qualified "opt-in workflow layer"** — `loop` and `pr-ship` are examples,
|
|
29
|
+
not this skill's own concern.
|
|
30
|
+
- **Both copies ship and must stay identical**: the Claude skill under
|
|
31
|
+
`.claude/skills/` and the Codex repository skill under `.agents/skills/`.
|
|
32
|
+
That mirror is checked in `test/template/codex.test.ts` (absent in a
|
|
33
|
+
generated rig) › "publishes every shared skill through the Codex
|
|
34
|
+
repository skill location" — not repeated here.
|
|
35
|
+
|
|
36
|
+
## Out of scope
|
|
37
|
+
|
|
38
|
+
This is guidance to apply by reading, not an evaluator: the skill itself has
|
|
39
|
+
no procedure and checks nothing.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: code-reviewer
|
|
3
3
|
description: Reviews a completed change against the checklist before a PR is opened or merged. Use after any non-trivial implementation work, and always before opening a PR that reaches the `model` lane — everything the two cheap lanes do not claim (code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated), decided by `decision-router.mjs` where the opt-in workflow layer is installed, or by the same rule applied by hand otherwise. Blocking findings must be resolved, not argued with.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
|
-
model: claude-opus-5
|
|
5
|
+
model: claude-opus-5-5
|
|
6
6
|
effort: high
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -38,6 +38,10 @@ references, and you classify every finding as **blocking** or **advisory**.
|
|
|
38
38
|
rulebook already refuses that evidence elsewhere (`.claude/rules/autonomy.md`).
|
|
39
39
|
"Item not supplied, item 6 not checked" is a useful line in a report; a
|
|
40
40
|
guess dressed as a verdict is worse than the silence it replaces.
|
|
41
|
+
7. **Independent oracle** — a test of a security, ownership or governance
|
|
42
|
+
mechanism whose expected result is derived from the same production
|
|
43
|
+
mechanism it checks. See the independent-oracle invariant in
|
|
44
|
+
`.claude/rules/invariants.md`.
|
|
41
45
|
|
|
42
46
|
## Advisory findings
|
|
43
47
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: failure-diagnostician
|
|
3
|
+
description: Use when a check is red or a run crashed and the cause is not obvious, or to reproduce a claimed defect/historical finding on the current default branch before work is planned on it.
|
|
4
|
+
tools: Read, Grep, Glob, Bash
|
|
5
|
+
model: claude-opus-5-5
|
|
6
|
+
effort: high
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You diagnose. You take one of two input kinds — a red check or a crashed run,
|
|
10
|
+
or a claimed defect / historical finding to reproduce on the current default
|
|
11
|
+
branch — and answer with evidence, not a guess dressed as one.
|
|
12
|
+
|
|
13
|
+
## Hard limits
|
|
14
|
+
|
|
15
|
+
- **You make no repository edits.** A throwaway reproduction file goes
|
|
16
|
+
outside the repository, never inside it — you are not the Green step, and a
|
|
17
|
+
fix is not your answer. You do not commit, push, merge or open a pull
|
|
18
|
+
request.
|
|
19
|
+
- **Never re-run a check until it goes green.** A flaky-looking result is a
|
|
20
|
+
defect to report, not a thing to retry — the same stop rule that applies
|
|
21
|
+
everywhere else in this rulebook (`.claude/rules/autonomy.md`, "Stop rules —
|
|
22
|
+
by work-state, not by feelings").
|
|
23
|
+
- **On Claude Code, no hook enforces the no-edit limit above.** `tools: Read,
|
|
24
|
+
Grep, Glob, Bash` carries no Write or Edit, which stops the ordinary path,
|
|
25
|
+
but nothing refuses a shell redirect the way a guard would. On Codex the
|
|
26
|
+
equivalent profile is `sandbox_mode = "read-only"`, enforced by the runtime
|
|
27
|
+
itself. Either way, a sandbox that blocks the reproduction you need is not
|
|
28
|
+
something to work around: answer `INCONCLUSIVE` or `INSUFFICIENT_EVIDENCE`
|
|
29
|
+
and name the sandbox as the missing evidence.
|
|
30
|
+
|
|
31
|
+
## Method
|
|
32
|
+
|
|
33
|
+
1. **Reproduce.** A failure input (red check, crashed run) reproduces at the
|
|
34
|
+
commit or branch where it failed — the PR head, or the commit the caller
|
|
35
|
+
names. A claimed defect or historical finding reproduces on the current
|
|
36
|
+
default branch. No reproduction, no diagnosis.
|
|
37
|
+
2. **Isolate.** Narrow to the smallest change (input, config, code path) that
|
|
38
|
+
flips the result.
|
|
39
|
+
3. **Hypothesize.** State the mechanism you think is responsible, in one or
|
|
40
|
+
two sentences.
|
|
41
|
+
4. **Confirm with evidence.** Show the command and its output, or the
|
|
42
|
+
file:line the mechanism lives at. A hypothesis nothing confirms is
|
|
43
|
+
`INCONCLUSIVE`, not `ROOT_CAUSE`.
|
|
44
|
+
|
|
45
|
+
For a failure input that reaches `ROOT_CAUSE`, classify it: `product` (the
|
|
46
|
+
code is wrong), `test` (the test's premise or fixture is wrong),
|
|
47
|
+
`infrastructure` (CI, network, environment — not the code under test), or
|
|
48
|
+
`upstream` (a dependency or external service).
|
|
49
|
+
|
|
50
|
+
## Optional evidence (opt-in workflow layer)
|
|
51
|
+
|
|
52
|
+
Where this repository has installed the opt-in workflow layer, the run
|
|
53
|
+
journal and `run-state.mjs`'s recorded verdict may already carry evidence
|
|
54
|
+
worth reading before you reproduce anything by hand — a prior `REGRESSION`,
|
|
55
|
+
or an earlier run's own trace. Their absence is the normal Core path, not a
|
|
56
|
+
gap: read them when present, reproduce directly when not.
|
|
57
|
+
|
|
58
|
+
## The answer
|
|
59
|
+
|
|
60
|
+
End your report with **exactly one** fenced `json` block of the shared shape
|
|
61
|
+
(`.claude/scripts/lib/verdict.mjs`), and nothing after it.
|
|
62
|
+
|
|
63
|
+
- **A failure input** (red check, crash) answers `ROOT_CAUSE` or
|
|
64
|
+
`INCONCLUSIVE`.
|
|
65
|
+
- **A claim or historical finding** answers `STILL_LIVE`, `ALREADY_FIXED`,
|
|
66
|
+
`OBSOLETE` or `INSUFFICIENT_EVIDENCE`.
|
|
67
|
+
- `ROOT_CAUSE`, `INCONCLUSIVE`, `STILL_LIVE` and `INSUFFICIENT_EVIDENCE` are
|
|
68
|
+
blocking and must name at least one blocker: for `ROOT_CAUSE` and
|
|
69
|
+
`STILL_LIVE` the blocker is the cause, with `file`/`line` where there is
|
|
70
|
+
one; for `INCONCLUSIVE` and `INSUFFICIENT_EVIDENCE` it is what evidence
|
|
71
|
+
would decide the question.
|
|
72
|
+
- `ALREADY_FIXED` and `OBSOLETE` carry no blockers — the fixing commit or the
|
|
73
|
+
superseding mechanism goes in `evidence` instead.
|
|
74
|
+
- `classification` is required on `ROOT_CAUSE`, allowed but optional on
|
|
75
|
+
`STILL_LIVE`, and refused on every other word.
|
|
76
|
+
- `node .claude/scripts/verdict.mjs check <report> failure-diagnostician` is
|
|
77
|
+
what refuses a malformed answer before anyone reads it as one.
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"gate": "failure-diagnostician",
|
|
82
|
+
"verdict": "ROOT_CAUSE",
|
|
83
|
+
"blockers": [
|
|
84
|
+
{
|
|
85
|
+
"file": "src/example.ts",
|
|
86
|
+
"line": 42,
|
|
87
|
+
"rule": "reproduced failure",
|
|
88
|
+
"note": "the function reads the value before the guard that handles the missing case — reproduced on the PR head with the fixture the failing test supplies"
|
|
89
|
+
}
|
|
90
|
+
],
|
|
91
|
+
"advisories": [],
|
|
92
|
+
"evidence": ["reproduced with the failing test on the PR head", "the stack trace from that run names the file:line above"],
|
|
93
|
+
"classification": "product"
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"gate": "failure-diagnostician",
|
|
100
|
+
"verdict": "ALREADY_FIXED",
|
|
101
|
+
"blockers": [],
|
|
102
|
+
"advisories": [],
|
|
103
|
+
"evidence": ["the commit that added the missing guard fixes exactly this report", "re-ran the original repro on the current default branch; it now passes"]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Not a merge gate
|
|
108
|
+
|
|
109
|
+
You are never a routed reviewer: no `decision-router` lane names you and no
|
|
110
|
+
`pr-ship` coverage check (opt-in workflow layer) expects your answer. You
|
|
111
|
+
diagnose on request; you never implement the fix, and a report with no
|
|
112
|
+
unbacked behaviour claim or invented figure is the only kind you write.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: security-scanner
|
|
3
3
|
description: Scans a change for security issues. MUST be used when a change touches authentication, authorization, secrets or configuration, input parsing, file handling, or any new outbound call. Findings gate the PR.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
|
-
model: claude-opus-5
|
|
5
|
+
model: claude-opus-5-5
|
|
6
6
|
effort: high
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -193,9 +193,13 @@ function main() {
|
|
|
193
193
|
// repo" and waves through, running the whole Definition-of-Done suite
|
|
194
194
|
// against a tree it never managed to read.
|
|
195
195
|
//
|
|
196
|
-
// 🔴 Limit: only THIS command is sanitised
|
|
197
|
-
// below run with the environment as given,
|
|
198
|
-
// own commands and their environment is
|
|
196
|
+
// 🔴 Limit: only THIS command is sanitised beyond `RIG_RUN_DIR`. The
|
|
197
|
+
// Definition-of-Done checks below run with the environment as given,
|
|
198
|
+
// because they are the project's own commands and their environment is
|
|
199
|
+
// the project's business — except `RIG_RUN_DIR`, which names the
|
|
200
|
+
// directory of the workflow run executing this very hook and belongs to
|
|
201
|
+
// the run, not to the project's own commands.
|
|
202
|
+
// see hooks.test.ts (absent in a generated rig) › "never lets a Definition-of-Done check see RIG_RUN_DIR, even though the hook itself is given one"
|
|
199
203
|
// `timeout` below is the preamble's own leash: this is the one call that
|
|
200
204
|
// runs before the budget's clock starts. A repository slow enough to exceed
|
|
201
205
|
// it throws into the catch and the checks run anyway — the safe direction,
|
|
@@ -268,6 +272,12 @@ function main() {
|
|
|
268
272
|
// lengthen nor shorten the budget.
|
|
269
273
|
const deadline = performance.now() + budget.ms;
|
|
270
274
|
|
|
275
|
+
// Computed once, not per check: `RIG_RUN_DIR` names the workflow run
|
|
276
|
+
// directory of the run executing this hook, and it is the one variable the
|
|
277
|
+
// project's own commands never get — see the 🔴 limit above.
|
|
278
|
+
const envForChecks = { ...process.env };
|
|
279
|
+
delete envForChecks.RIG_RUN_DIR;
|
|
280
|
+
|
|
271
281
|
for (const command of usable) {
|
|
272
282
|
// A 1 ms floor rather than a branch for "the budget is already gone": the
|
|
273
283
|
// check then times out through the ordinary path, which names the command
|
|
@@ -276,6 +286,7 @@ function main() {
|
|
|
276
286
|
const result = spawnSync(command, {
|
|
277
287
|
cwd: PROJECT_ROOT,
|
|
278
288
|
shell: true,
|
|
289
|
+
env: envForChecks,
|
|
279
290
|
encoding: 'utf8',
|
|
280
291
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
281
292
|
timeout: Math.max(1, Math.ceil(deadline - performance.now())),
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// The rulebook is the set of files that decide what this session may do — hooks
|
|
6
6
|
// and settings wiring, the queue config and selector, all scripts, rules, skills
|
|
7
7
|
// and agents, the `.codex/` configuration, the integrity manifest,
|
|
8
|
-
// `.claude/doctor-exemptions.json`,
|
|
9
|
-
//
|
|
8
|
+
// `.claude/doctor-exemptions.json`, the revalidation detection contract
|
|
9
|
+
// `.rig/revalidation.json` — but not the claim records beside it under
|
|
10
|
+
// `.rig/claims/`, which a SELECT has to write — `AGENTS.md` and `CLAUDE.md`.
|
|
11
|
+
// Every one of them was editable by the run it governs: a
|
|
10
12
|
// Write to `.claude/hooks/dod-checks.json` with `[]` switched the stop gate
|
|
11
13
|
// off, and nothing refused it until the merge sweep, long after.
|
|
12
14
|
//
|
|
@@ -26,7 +28,8 @@
|
|
|
26
28
|
// names the flag; an edit outside the rulebook still passes. Refusing to
|
|
27
29
|
// inspect is not allowing (`.claude/rules/invariants.md`).
|
|
28
30
|
//
|
|
29
|
-
// Limits — each stated here and
|
|
31
|
+
// Limits — each stated here and, unless it is marked untested, measured in
|
|
32
|
+
// the generator's
|
|
30
33
|
// `test/template/guard-rulebook.test.ts` (absent in a generated rig), by the
|
|
31
34
|
// test named beside it:
|
|
32
35
|
// - it sees one edit at a time, as text, before it lands — a rulebook file
|
|
@@ -49,22 +52,57 @@
|
|
|
49
52
|
// checkout root before guarding a canonical payload path", › "blocks when
|
|
50
53
|
// the checkout root and payload use the same symlink spelling", and
|
|
51
54
|
// › "blocks an existing rulebook file when only the payload path uses a symlink spelling";
|
|
55
|
+
// - an `apply_patch` destination keeps the lexical spelling it named even when
|
|
56
|
+
// a guarded prefix (`.claude/hooks`, say) is itself a symlink/junction to
|
|
57
|
+
// somewhere else inside the checkout — `edit-input.mjs`'s `repositoryPatchPath`
|
|
58
|
+
// carries that spelling alongside the realpath-resolved one, RP-60 — ›
|
|
59
|
+
// "refuses an apply_patch through a guarded prefix junctioned to a target
|
|
60
|
+
// inside the checkout";
|
|
52
61
|
// - an `allow` prefix is a string prefix of the repo-relative path and may
|
|
53
62
|
// not widen the rulebook — an entry that is itself a prefix of a rulebook
|
|
54
63
|
// prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
|
|
55
64
|
// and the guard refuses — › "a flag whose allow-list widens the rulebook is
|
|
56
65
|
// unreadable, so `--allow .` cannot disarm it";
|
|
66
|
+
// - a miscased payload path is judged against the rulebook's CANONICAL
|
|
67
|
+
// spelling (`unattended-flag.mjs`'s `canonicalRulebookPath`), but an
|
|
68
|
+
// `allow` entry never is — a miscased entry authorizes nothing, including
|
|
69
|
+
// the one prefix deliberately withheld as an allow root
|
|
70
|
+
// (`.claude/scripts/`) and the board selector, refused regardless of case
|
|
71
|
+
// — › "guard-rulebook: an allow-list entry is judged by its literal
|
|
72
|
+
// spelling, not the one the payload folds to (RP-215 round 2)";
|
|
57
73
|
// - fail-open on its own errors and on a payload it cannot parse — › "allows
|
|
58
74
|
// an empty payload object" and › "allows non-JSON stdin" — and fail-closed
|
|
59
75
|
// on a flag it cannot read — › "blocks a rulebook edit when the flag exists
|
|
60
76
|
// but cannot be read, and names the file": the guard targets drift, not an
|
|
61
|
-
// adversary
|
|
77
|
+
// adversary;
|
|
78
|
+
// - a `//`-prefixed normalised path is refused rather than resolved while
|
|
79
|
+
// armed exactly when it does not relativise under any comparison root —
|
|
80
|
+
// a repository root spelled as a plain path can never strip a UNC admin
|
|
81
|
+
// share or a device path with no drive letter (`\\?\Volume{GUID}\…`), but
|
|
82
|
+
// a root that is itself UNC-spelled (`CLAUDE_PROJECT_DIR` as
|
|
83
|
+
// `\\server\share\repo`) strips a payload path genuinely under it, and
|
|
84
|
+
// such a path is judged normally instead — › "guard-rulebook: an
|
|
85
|
+
// unjudgeable UNC/device-namespace path is refused, not silently allowed
|
|
86
|
+
// (RP-244 round 2)" and › "guard-rulebook: a `//`-prefixed path is
|
|
87
|
+
// refused only when it resolves under no repository root (RP-244
|
|
88
|
+
// round 3)". The same refusal covers a MultiEdit past the fragment cap
|
|
89
|
+
// (the `appliesToAll` global refusal) aimed at such a path, not only the
|
|
90
|
+
// ordinary per-fragment case — › "guard-rulebook: a MultiEdit global
|
|
91
|
+
// refusal is not exempt from the `//`-prefix refusal (RP-244 round 3)";
|
|
92
|
+
// - the reverse direction is untested and left as a design limit: when the
|
|
93
|
+
// repository root is itself spelled as a UNC admin share
|
|
94
|
+
// (`\\host\X$\…`), the LOCAL DRIVE spelling of the same file (`X:\…`) is
|
|
95
|
+
// never placed against it — `relativeTo` strips a `//`-prefixed payload
|
|
96
|
+
// path from a `//`-rooted comparison root, not a drive-letter one from
|
|
97
|
+
// it — so such a payload path is judged normally rather than refused as
|
|
98
|
+
// unjudgeable, and a rulebook edit reaching the guard that way is not
|
|
99
|
+
// caught. Tracked as RP-246.
|
|
62
100
|
//
|
|
63
101
|
// The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
|
|
64
102
|
import { realpathSync } from 'node:fs';
|
|
65
103
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
66
104
|
import { editFragments } from './lib/edit-input.mjs';
|
|
67
|
-
import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
|
|
105
|
+
import { RULEBOOK_PREFIXES, canonicalRulebookPath, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
|
|
68
106
|
import { readHookInput } from './lib/hook-input.mjs';
|
|
69
107
|
|
|
70
108
|
export { RULEBOOK_PREFIXES, isRulebookPath };
|
|
@@ -119,10 +157,45 @@ export const relativeTo = (root, filePath) => {
|
|
|
119
157
|
export const isAllowed = (rel, allow) =>
|
|
120
158
|
(Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
|
|
121
159
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
160
|
+
// RP-60: `rawFilePath` is the lexical spelling `apply_patch` fragments carry
|
|
161
|
+
// alongside the realpath-resolved `filePath` (`edit-input.mjs`,
|
|
162
|
+
// `repositoryPatchPath`) — a guarded prefix that is itself a symlink/junction
|
|
163
|
+
// to somewhere else inside the checkout resolves away the rulebook spelling
|
|
164
|
+
// otherwise, the same way `canonicalRoot`/`comparisonRoots` above seed both
|
|
165
|
+
// spellings of the checkout root. Every other edit surface never sets it, so
|
|
166
|
+
// this is a no-op for them.
|
|
167
|
+
//
|
|
168
|
+
// RP-215 round 2: the return value is the CANONICAL rulebook spelling
|
|
169
|
+
// (`canonicalRulebookPath`), not the candidate that matched it. `isAllowed`
|
|
170
|
+
// and the `.claude/queue.board` carve-out below both compare this result
|
|
171
|
+
// against literal allow-list entries, so a miscased entry (`.Claude/`,
|
|
172
|
+
// `.claude/Scripts/`) never matches — only the path is canonicalised, never
|
|
173
|
+
// the allow-list. See the generator's `test/template/guard-rulebook.test.ts` (absent in a generated rig) ›
|
|
174
|
+
// "guard-rulebook: an allow-list entry is judged by its literal spelling,
|
|
175
|
+
// not the one the payload folds to (RP-215 round 2)".
|
|
176
|
+
const protectedRelative = (roots, filePath, rawFilePath) => {
|
|
177
|
+
const candidates = [...new Set([filePath, rawFilePath, canonicalPath(filePath)].filter((spelling) => typeof spelling === 'string' && spelling !== ''))]
|
|
178
|
+
.flatMap((spelling) => roots.map((root) => relativeTo(root, spelling)));
|
|
179
|
+
for (const candidate of candidates) {
|
|
180
|
+
const canonical = canonicalRulebookPath(candidate);
|
|
181
|
+
if (canonical !== undefined) return canonical;
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// RP-244 round 3: a `//`-prefixed normalised path is undecidable ONLY when it
|
|
187
|
+
// does not relativise under any comparison root — reusing `relativeTo`, the
|
|
188
|
+
// same comparison `protectedRelative` above already makes, rather than a
|
|
189
|
+
// second implementation. A UNC-spelled repository root (`CLAUDE_PROJECT_DIR`
|
|
190
|
+
// itself a `\\server\share\repo` spelling) maps to the same `//`-prefixed
|
|
191
|
+
// shape a payload path under it normalises to, so `relativeTo` CAN strip it —
|
|
192
|
+
// and a path that strips cleanly is judged normally, not refused as
|
|
193
|
+
// unjudgeable, whether or not it lands inside the rulebook.
|
|
194
|
+
const isUnjudgeablePath = (roots, filePath) => {
|
|
195
|
+
if (typeof filePath !== 'string' || !filePath.startsWith('//')) return false;
|
|
196
|
+
const file = toPosix(filePath);
|
|
197
|
+
return !roots.some((root) => relativeTo(root, filePath) !== file);
|
|
198
|
+
};
|
|
126
199
|
|
|
127
200
|
function main() {
|
|
128
201
|
const input = readHookInput();
|
|
@@ -139,8 +212,28 @@ function main() {
|
|
|
139
212
|
);
|
|
140
213
|
if (globalRefusal) {
|
|
141
214
|
if (globalRefusal.filePath) {
|
|
215
|
+
// No `rawFilePath` argument here: the only `appliesToAll` refusal that
|
|
216
|
+
// carries a non-empty `filePath` at all is the MultiEdit fragment-cap
|
|
217
|
+
// refusal (`edit-input.mjs`), and it never sets `rawFilePath` — so
|
|
218
|
+
// there is nothing a third argument would add for this call site.
|
|
142
219
|
const rel = protectedRelative(comparisonRoots, globalRefusal.filePath);
|
|
143
|
-
if (rel === undefined)
|
|
220
|
+
if (rel === undefined) {
|
|
221
|
+
// RP-244 round 3: `rel === undefined` used to read as "outside the
|
|
222
|
+
// rulebook, never judged" unconditionally — exactly the reading the
|
|
223
|
+
// per-fragment block below already proved wrong for a `//`-prefixed
|
|
224
|
+
// path that resolves under no comparison root. A global refusal
|
|
225
|
+
// carrying such a path is refused the same way, instead of exiting 0
|
|
226
|
+
// before `isUnjudgeablePath` is ever consulted.
|
|
227
|
+
if (!isUnjudgeablePath(comparisonRoots, globalRefusal.filePath)) return 0;
|
|
228
|
+
const mode = readUnattended(unattendedEnv);
|
|
229
|
+
if (!mode.on) return 0;
|
|
230
|
+
process.stderr.write(
|
|
231
|
+
`BLOCKED — "${globalRefusal.filePath}" could not be resolved against the repository root: ` +
|
|
232
|
+
'a UNC or device-namespace path is refused rather than judged while unattended. ' +
|
|
233
|
+
'Write through the repository path instead.\n',
|
|
234
|
+
);
|
|
235
|
+
return 2;
|
|
236
|
+
}
|
|
144
237
|
}
|
|
145
238
|
const mode = readUnattended(unattendedEnv);
|
|
146
239
|
if (!mode.on) return 0;
|
|
@@ -150,25 +243,60 @@ function main() {
|
|
|
150
243
|
);
|
|
151
244
|
return 2;
|
|
152
245
|
}
|
|
246
|
+
// RP-244 round 2: `normalisePath` (`edit-input.mjs`) maps a UNC or other
|
|
247
|
+
// device-namespace spelling to a `//`-prefixed result — `relativeTo` can
|
|
248
|
+
// only strip a path that starts with the literal repository root, and a
|
|
249
|
+
// `//`-prefixed path never does, on any platform or root. That is not
|
|
250
|
+
// "outside the rulebook": it is undecidable, and undecidable is refused,
|
|
251
|
+
// not allowed, while armed (`.claude/rules/invariants.md`, "The remedy
|
|
252
|
+
// belongs to the refusal"). RP-244 round 3: "does not relativise under any
|
|
253
|
+
// comparison root" replaces the plain `startsWith('//')` check — a
|
|
254
|
+
// UNC-spelled repository root makes a payload path under it decidable, so
|
|
255
|
+
// it is judged normally rather than refused as unjudgeable.
|
|
256
|
+
const unjudgeable = fragments.find(({ filePath }) => isUnjudgeablePath(comparisonRoots, filePath));
|
|
257
|
+
|
|
153
258
|
const paths = [];
|
|
154
|
-
for (const { filePath } of fragments) {
|
|
259
|
+
for (const { filePath, rawFilePath } of fragments) {
|
|
155
260
|
if (typeof filePath !== 'string' || filePath === '') continue;
|
|
156
|
-
const rel = protectedRelative(comparisonRoots, filePath);
|
|
261
|
+
const rel = protectedRelative(comparisonRoots, filePath, rawFilePath);
|
|
157
262
|
if (rel !== undefined && !paths.includes(rel)) paths.push(rel);
|
|
158
263
|
}
|
|
159
|
-
if (paths.length === 0) return 0; // nothing under the rulebook: never judged
|
|
264
|
+
if (paths.length === 0 && !unjudgeable) return 0; // nothing under the rulebook: never judged
|
|
160
265
|
|
|
161
266
|
const mode = readUnattended(unattendedEnv);
|
|
162
267
|
if (!mode.on) return 0; // attended session
|
|
163
268
|
|
|
164
269
|
if (mode.unreadable) {
|
|
270
|
+
// RP-244 round 3: `paths[0] ?? unjudgeable.filePath` used to print "is
|
|
271
|
+
// part of the rulebook" even when `paths` was empty and only an
|
|
272
|
+
// unjudgeable `//`-prefixed path put this branch on the table — a claim
|
|
273
|
+
// this guard never established. An unjudgeable path gets the same
|
|
274
|
+
// "could not be resolved" reason it gets everywhere else in this file.
|
|
275
|
+
const target = paths[0];
|
|
276
|
+
if (target === undefined) {
|
|
277
|
+
process.stderr.write(
|
|
278
|
+
`BLOCKED — "${unjudgeable.filePath}" could not be resolved against the repository root, ` +
|
|
279
|
+
`and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
|
|
280
|
+
'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',
|
|
281
|
+
);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
165
284
|
process.stderr.write(
|
|
166
|
-
`BLOCKED — "${
|
|
285
|
+
`BLOCKED — "${target}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
|
|
167
286
|
'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',
|
|
168
287
|
);
|
|
169
288
|
return 2;
|
|
170
289
|
}
|
|
171
290
|
|
|
291
|
+
if (unjudgeable) {
|
|
292
|
+
process.stderr.write(
|
|
293
|
+
`BLOCKED — "${unjudgeable.filePath}" could not be resolved against the repository root: ` +
|
|
294
|
+
'a UNC or device-namespace path is refused rather than judged while unattended. ' +
|
|
295
|
+
'Write through the repository path instead.\n',
|
|
296
|
+
);
|
|
297
|
+
return 2;
|
|
298
|
+
}
|
|
299
|
+
|
|
172
300
|
const refused = paths.filter(
|
|
173
301
|
(rel) => rel === '.claude/queue.board' || !isAllowed(rel, mode.allow),
|
|
174
302
|
);
|
|
@@ -99,7 +99,11 @@ function main() {
|
|
|
99
99
|
const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
100
100
|
if (!editTools.has(input?.tool_name)) return 0;
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
// RP-214: `editFragments` also reports a removal (a Delete File section, or
|
|
103
|
+
// the source half of a Move) as its own fragment, `removes: true` — deleting
|
|
104
|
+
// a credential file is not writing one, so this guard never judges those,
|
|
105
|
+
// including an inspection refusal attached to one.
|
|
106
|
+
const fragments = editFragments(input).filter(({ removes }) => !removes);
|
|
103
107
|
const globalRefusal = fragments.find(
|
|
104
108
|
({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
|
|
105
109
|
);
|