create-agent-rig 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +170 -34
- package/README.md +12 -6
- package/package.json +1 -1
- package/packages/cli/dist/commands/init.js +6 -3
- package/packages/cli/dist/commands/upgrade.js +2 -2
- package/packages/cli/dist/index.js +46 -8
- package/packages/cli/dist/lib/manifest.js +10 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +15 -5
- package/templates/agent-os/init/CLAUDE.md +15 -5
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +8 -1
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +1 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +8 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +18 -6
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +8 -1
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +42 -17
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +2 -1
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +187 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +72 -65
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +33 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +13 -3
- package/templates/agent-os/universal/.claude/rules/invariants.md +45 -17
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +20 -1
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +354 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +29 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +37 -8
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -1
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +62 -2
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +479 -9
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +89 -15
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +282 -19
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +395 -46
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +68 -5
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +317 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +181 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +101 -3
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +436 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -4
- package/templates/agent-os/universal/.claude/settings.json +5 -1
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +4 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +330 -16
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +64 -6
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +6 -1
- package/templates/agent-os/universal/AGENTS.md +3 -1
- package/templates/agent-os/universal/CLAUDE.md +3 -1
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +18 -6
- package/templates/agent-os/universal/layers.json +9 -0
- package/templates/hash-history.json +404 -51
- package/templates/release-ledger.json +10 -0
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- package/templates/skeleton/node-service/gitignore +2 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The run's own state — three of the four values `stopConditionOf` asks for and
|
|
3
|
-
* nothing used to answer
|
|
3
|
+
* nothing used to answer, plus one value it does not ask for: the take-up
|
|
4
|
+
* snapshot `takeUps` ({@link recordTakeUp}), which is the run's fact as much as
|
|
5
|
+
* the other three. The fourth stop input, `killSwitch`, is deliberately not here:
|
|
4
6
|
* it is already mechanical in `guard-bash` and scripted in preflight, and a
|
|
5
7
|
* second answer to "is the brake on" is the disagreement `invariants.md`
|
|
6
8
|
* forbids.
|
|
@@ -47,8 +49,8 @@
|
|
|
47
49
|
* per run is the caller's part of the contract, and the `loop` skill states it.
|
|
48
50
|
*/
|
|
49
51
|
|
|
50
|
-
import { readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
51
|
-
import { join } from 'node:path';
|
|
52
|
+
import { opendirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
53
|
+
import { basename, dirname, join } from 'node:path';
|
|
52
54
|
import { fileURLToPath } from 'node:url';
|
|
53
55
|
|
|
54
56
|
const STATE = 'state.json';
|
|
@@ -199,6 +201,102 @@ export const recordEscalation = (runDir) => {
|
|
|
199
201
|
}
|
|
200
202
|
};
|
|
201
203
|
|
|
204
|
+
/**
|
|
205
|
+
* The take-up snapshot: the selected item's `updatedAt` marker, keyed by id, as
|
|
206
|
+
* seen at SELECT. `queue/core.mjs` › revalidationOf compares the next selection
|
|
207
|
+
* against it, so a stale take-up is reported rather than silently continued.
|
|
208
|
+
*
|
|
209
|
+
* Per run, like everything else here — a snapshot from yesterday's run is not
|
|
210
|
+
* a take-up this run made. Merged by id, so a second item does not erase the
|
|
211
|
+
* first; re-recording an id moves its baseline forward. A marker that is not a
|
|
212
|
+
* string is not recorded at all: `plan-md` has none, and writing `null` would
|
|
213
|
+
* later compare equal to `null` and read as "unchanged".
|
|
214
|
+
*
|
|
215
|
+
* No run directory → nothing written, `null` back, no throw: an attended
|
|
216
|
+
* selection has no run to snapshot into. (Like {@link recordEscalation} it
|
|
217
|
+
* refuses to throw there; unlike it, there is no count to hand back, so `null`.)
|
|
218
|
+
*/
|
|
219
|
+
export const recordTakeUp = (runDir, { id, updatedAt } = {}) => {
|
|
220
|
+
if (!runDir || typeof updatedAt !== 'string' || id === undefined || id === null) return null;
|
|
221
|
+
const state = readState(runDir);
|
|
222
|
+
const takeUps = typeof state.takeUps === 'object' && state.takeUps !== null ? state.takeUps : {};
|
|
223
|
+
return updateState(runDir, { takeUps: { ...takeUps, [String(id)]: updatedAt } });
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The take-up an EARLIER run recorded for this item, or null (AR-138).
|
|
228
|
+
*
|
|
229
|
+
* {@link recordTakeUp} is per run on purpose, and that left a hole RX1
|
|
230
|
+
* measured: an item taken up yesterday and re-offered today compared against
|
|
231
|
+
* nothing and reported a first sight, however far its marker had moved. So
|
|
232
|
+
* when this run has no take-up for the item, SELECT asks the sibling run
|
|
233
|
+
* directories — newest first by name, which is the `YYYYMMDD-HHMMSS` the
|
|
234
|
+
* `loop` skill declares — and takes the first that recorded one. The answer
|
|
235
|
+
* names the run it came from, so the revalidation event can say whose
|
|
236
|
+
* baseline it compared against.
|
|
237
|
+
*
|
|
238
|
+
* 🔴 A sibling is a run only by NAME — `YYYYMMDD-HHMMSS`, the shape the `loop`
|
|
239
|
+
* skill declares — and so is the run asking. The first version took every
|
|
240
|
+
* sibling directory, and two measurements showed why that is wrong. On CI,
|
|
241
|
+
* with `RIG_RUN_DIR` under a shared temp root, a neighbouring test's scratch
|
|
242
|
+
* directory carrying a `state.json` was read as yesterday's run, and a first
|
|
243
|
+
* sight came back as a hold against a marker nobody had taken up. Locally,
|
|
244
|
+
* the same temp root held 454 000 entries, and every SELECT paid a 540 ms
|
|
245
|
+
* directory read for a question it could not answer. So a run declared under
|
|
246
|
+
* another naming looks at no siblings at all, and the limit is the mirror
|
|
247
|
+
* image: an earlier run declared under another naming is not seen here.
|
|
248
|
+
*
|
|
249
|
+
* Bounded and fail-soft: the runs root is walked through one directory handle
|
|
250
|
+
* and at most 10 000 entries are looked at, whatever is in there; at most 200
|
|
251
|
+
* candidate runs are read, an unreadable state is skipped rather than trusted,
|
|
252
|
+
* and the answer is `null` for no run directory, an unnamed one, or no runs
|
|
253
|
+
* root. Never this run's own state — that is {@link readState}'s answer, and
|
|
254
|
+
* the caller asks it first.
|
|
255
|
+
*/
|
|
256
|
+
const RUN_DIR_NAME = /^\d{8}-\d{6}$/;
|
|
257
|
+
const RUNS_ROOT_ENTRY_BUDGET = 10_000;
|
|
258
|
+
const RUNS_READ_CAP = 200;
|
|
259
|
+
|
|
260
|
+
export const previousTakeUp = (runDir, id) => {
|
|
261
|
+
if (!runDir || id === undefined || id === null) return null;
|
|
262
|
+
const root = dirname(runDir);
|
|
263
|
+
const self = basename(runDir);
|
|
264
|
+
if (!RUN_DIR_NAME.test(self)) return null;
|
|
265
|
+
const names = [];
|
|
266
|
+
let dir;
|
|
267
|
+
try {
|
|
268
|
+
dir = opendirSync(root);
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
for (let seen = 0; seen < RUNS_ROOT_ENTRY_BUDGET; seen += 1) {
|
|
274
|
+
const entry = dir.readSync();
|
|
275
|
+
if (entry === null) break;
|
|
276
|
+
if (entry.isDirectory() && entry.name !== self && RUN_DIR_NAME.test(entry.name)) {
|
|
277
|
+
names.push(entry.name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
return null;
|
|
282
|
+
} finally {
|
|
283
|
+
dir.closeSync();
|
|
284
|
+
}
|
|
285
|
+
names.sort().reverse();
|
|
286
|
+
for (const name of names.slice(0, RUNS_READ_CAP)) {
|
|
287
|
+
const candidate = join(root, name);
|
|
288
|
+
let state;
|
|
289
|
+
try {
|
|
290
|
+
state = JSON.parse(readFileSync(statePathIn(candidate), 'utf8'));
|
|
291
|
+
} catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const updatedAt = state?.takeUps?.[String(id)];
|
|
295
|
+
if (typeof updatedAt === 'string') return { updatedAt, runDir: candidate };
|
|
296
|
+
}
|
|
297
|
+
return null;
|
|
298
|
+
};
|
|
299
|
+
|
|
202
300
|
/**
|
|
203
301
|
* The stop inputs, read out of a state object that anything may have written.
|
|
204
302
|
*
|
|
@@ -21,19 +21,26 @@ import { existsSync } from 'node:fs';
|
|
|
21
21
|
import { homedir, userInfo } from 'node:os';
|
|
22
22
|
import { delimiter, join } from 'node:path';
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
/**
|
|
25
|
+
* BOTH homes, env-derived first: `$HOME` (what `.claude/settings.json` can set —
|
|
26
|
+
* pointing it at an empty directory once disarmed the brake) and the password
|
|
27
|
+
* database's, which ignores the environment, so the operator's real flag is
|
|
28
|
+
* always among the paths checked. Shared with the unattended flag (AR-51): one
|
|
29
|
+
* lookup, one place to be wrong.
|
|
30
|
+
*/
|
|
31
|
+
export const homesOf = (env = process.env) => {
|
|
32
|
+
const homes = new Set([env.HOME || homedir()]);
|
|
31
33
|
try {
|
|
32
34
|
homes.add(userInfo().homedir);
|
|
33
35
|
} catch {
|
|
34
36
|
// no password entry — the env-derived home is all there is
|
|
35
37
|
}
|
|
36
|
-
|
|
38
|
+
return [...homes];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Every path that arms the brake. The machine-level default is always first. */
|
|
42
|
+
export const stopFlags = (env = process.env) => {
|
|
43
|
+
const paths = homesOf(env).map((home) => join(home, '.claude', '__PROJECT_NAME__-loop-STOP'));
|
|
37
44
|
const extra = env.AGENT_LOOP_STOP;
|
|
38
45
|
if (extra) {
|
|
39
46
|
// Filtered and CAPPED before the spread, never after. Spreading an
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The unattended flag — how a hook learns that a loop is running, and what the
|
|
3
|
+
// current item is allowed to touch (AR-51).
|
|
4
|
+
// All upstream test pointers in this script name the generator suite, absent in a generated rig.
|
|
5
|
+
//
|
|
6
|
+
// node .claude/scripts/unattended-flag.mjs on --root <checkout> --item AR-51 --run-dir <dir> --allow <prefix> [<prefix>…]
|
|
7
|
+
// node .claude/scripts/unattended-flag.mjs off --root <checkout>
|
|
8
|
+
// node .claude/scripts/unattended-flag.mjs off --legacy --path <reported-path>
|
|
9
|
+
//
|
|
10
|
+
// It is a FILE, not an environment variable: a `PreToolUse` hook is spawned by
|
|
11
|
+
// the harness with the harness's own environment, never with a variable the
|
|
12
|
+
// session exported — the generator's `test/template/guard-rulebook.test.ts` (absent in a generated rig) ›
|
|
13
|
+
// "only a flag arms it — an exported RIG_UNATTENDED=1 with no flag changes
|
|
14
|
+
// nothing" pins that side of it — and in some harnesses an `export` does not
|
|
15
|
+
// even survive to the next Bash call. The kill switch (`stop-flag.mjs`) is a
|
|
16
|
+
// file for the same reason,
|
|
17
|
+
// and this module copies its two-home lookup. Unlike the machine-wide brake,
|
|
18
|
+
// each unattended record is scoped to the canonical checkout, so concurrent
|
|
19
|
+
// worktrees cannot overwrite or clear one another's authorization.
|
|
20
|
+
//
|
|
21
|
+
// The flag is JSON, `{ item, runDir, allow }`. `allow` is the list of
|
|
22
|
+
// repo-relative prefixes the current item may write under even though they are
|
|
23
|
+
// part of the rulebook — the loop writes it at claim time from the paths the
|
|
24
|
+
// item names, and clears the flag when the run ends. A blanket "no rulebook
|
|
25
|
+
// edits while unattended" would stall on the first queue item that touches
|
|
26
|
+
// `queue/*.mjs`, which is most of this queue; the allow-list is what makes the
|
|
27
|
+
// guard livable, and the guard is what makes the allow-list a decision rather
|
|
28
|
+
// than a default.
|
|
29
|
+
//
|
|
30
|
+
// 🔴 Three answers, and the third is not the first: `{ on: false }` when no flag
|
|
31
|
+
// exists — an attended session, the guard does nothing; `{ on: true, item,
|
|
32
|
+
// runDir, allow }` when it reads; `{ on: true, unreadable: true, why }` when a
|
|
33
|
+
// file is THERE and this module cannot read it as the shape above. A guard that
|
|
34
|
+
// treated the third as the first would be disarmed by a corrupt flag, which is
|
|
35
|
+
// the fail-open bypass `.claude/rules/invariants.md` names.
|
|
36
|
+
//
|
|
37
|
+
// 🔴 An `allow` entry may not WIDEN the rulebook: one that is a prefix of a
|
|
38
|
+
// rulebook prefix — `.`, `.claude/`, `.claude/scripts/`, `CLAUDE` — would let
|
|
39
|
+
// the flag disarm the guard for a whole tree while it reports itself as on, so
|
|
40
|
+
// the writer refuses it and a flag carrying one is unreadable. An entry outside
|
|
41
|
+
// the rulebook (`src/`) is harmless because it is never judged; a narrow entry
|
|
42
|
+
// inside it (`.claude/skills/loop/`) authorizes only that subtree. Items name
|
|
43
|
+
// both forms, so they are kept — › "an allow entry that widens the rulebook — a prefix of a rulebook prefix such as `.` — makes the flag unreadable".
|
|
44
|
+
//
|
|
45
|
+
// Bounded: the file is read up to 64 KiB, `allow` is capped at 64 entries, and
|
|
46
|
+
// both limits are refusals, never silent truncation. A candidate is opened
|
|
47
|
+
// nonblocking and must be a regular file — see the generator's
|
|
48
|
+
// `test/template/unattended-flag.test.ts` (absent in a generated rig) ›
|
|
49
|
+
// "returns promptly and fails closed when a candidate is a FIFO". An access
|
|
50
|
+
// error is unreadable, not absent — › "is on-but-unreadable when access to an
|
|
51
|
+
// existing flag fails at the stat boundary".
|
|
52
|
+
// Cleanup preserves the same distinction: an owned legacy record that cannot
|
|
53
|
+
// be inspected is an error, not evidence that nothing remains — › "exits
|
|
54
|
+
// nonzero and leaves an unreadable owned legacy flag in place".
|
|
55
|
+
import { createHash } from 'node:crypto';
|
|
56
|
+
import {
|
|
57
|
+
closeSync,
|
|
58
|
+
constants,
|
|
59
|
+
fstatSync,
|
|
60
|
+
mkdirSync,
|
|
61
|
+
openSync,
|
|
62
|
+
readSync,
|
|
63
|
+
realpathSync,
|
|
64
|
+
rmSync,
|
|
65
|
+
writeFileSync,
|
|
66
|
+
} from 'node:fs';
|
|
67
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
68
|
+
import { fileURLToPath } from 'node:url';
|
|
69
|
+
import { homesOf } from './stop-flag.mjs';
|
|
70
|
+
|
|
71
|
+
export const FLAG_BASENAME = '__PROJECT_NAME__-loop-UNATTENDED';
|
|
72
|
+
export const MAX_FLAG_BYTES = 64 * 1024;
|
|
73
|
+
export const MAX_ALLOW_ENTRIES = 64;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The files that decide what a session may do — repo-relative prefixes. Owned
|
|
77
|
+
* here because two things read them: the guard, to judge an edit, and the
|
|
78
|
+
* writer above, to refuse an allow-list that reaches outside them.
|
|
79
|
+
*/
|
|
80
|
+
export const RULEBOOK_PREFIXES = Object.freeze([
|
|
81
|
+
'.agents/',
|
|
82
|
+
'.claude/.rig-manifest.json',
|
|
83
|
+
'.claude/agents/',
|
|
84
|
+
'.claude/hooks/',
|
|
85
|
+
'.claude/settings.json',
|
|
86
|
+
'.claude/queue.json',
|
|
87
|
+
// the per-checkout board selector: picks among the boards queue.json declares,
|
|
88
|
+
// so an unattended run must not be able to re-aim itself through it either
|
|
89
|
+
'.claude/queue.board',
|
|
90
|
+
'.claude/scripts/',
|
|
91
|
+
'.claude/rules/',
|
|
92
|
+
'.claude/skills/',
|
|
93
|
+
'.codex/',
|
|
94
|
+
'AGENTS.md',
|
|
95
|
+
'CLAUDE.md',
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/** Is this repo-relative path part of the rulebook? */
|
|
99
|
+
export const isRulebookPath = (rel) =>
|
|
100
|
+
RULEBOOK_PREFIXES.some((prefix) => rel === prefix || rel.startsWith(prefix));
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Does this allow entry widen the rulebook? It is unsafe when it is an
|
|
104
|
+
* exact protected prefix deliberately unavailable as an allow root (`.agents/`,
|
|
105
|
+
* `.claude/scripts/`, `.codex/`), or when it is a proper prefix of any
|
|
106
|
+
* rulebook prefix and would therefore admit that prefix plus siblings. All
|
|
107
|
+
* protected script paths sit under `.claude/scripts/`; a narrower path such as
|
|
108
|
+
* `.claude/scripts/queue/` is an ordinary allow entry and does not widen it.
|
|
109
|
+
* `src/` also does not widen it because the guard judges nothing there.
|
|
110
|
+
*/
|
|
111
|
+
export const isWidening = (entry) =>
|
|
112
|
+
typeof entry !== 'string' ||
|
|
113
|
+
entry === '' ||
|
|
114
|
+
entry === '.agents/' ||
|
|
115
|
+
entry === '.claude/scripts/' ||
|
|
116
|
+
entry === '.codex/' ||
|
|
117
|
+
RULEBOOK_PREFIXES.some((prefix) => prefix !== entry && prefix.startsWith(entry));
|
|
118
|
+
|
|
119
|
+
const canonicalCheckout = (env) => {
|
|
120
|
+
const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' ? env.CLAUDE_PROJECT_DIR.trim() : '';
|
|
121
|
+
if (declared === '') return null;
|
|
122
|
+
try {
|
|
123
|
+
return realpathSync(declared);
|
|
124
|
+
} catch {
|
|
125
|
+
return resolve(declared);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const checkoutId = (env) => {
|
|
130
|
+
const canonical = canonicalCheckout(env);
|
|
131
|
+
if (canonical === null) return null;
|
|
132
|
+
return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const scopedBasename = (env) => {
|
|
136
|
+
const id = checkoutId(env);
|
|
137
|
+
return id === null ? FLAG_BASENAME : FLAG_BASENAME.replace('-loop-UNATTENDED', `-${id}-loop-UNATTENDED`);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** Every checkout-scoped path that arms unattended mode. The env-derived home is first. */
|
|
141
|
+
export const unattendedFlags = (env = process.env) =>
|
|
142
|
+
homesOf(env).map((home) => join(home, '.claude', scopedBasename(env)));
|
|
143
|
+
|
|
144
|
+
/** Legacy machine-wide candidates are never accepted as scoped authorization. */
|
|
145
|
+
const legacyFlags = (env) => homesOf(env).map((home) => join(home, '.claude', FLAG_BASENAME));
|
|
146
|
+
|
|
147
|
+
const isMissing = (error) => error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
|
|
148
|
+
|
|
149
|
+
const readCapped = (path) => {
|
|
150
|
+
const fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
151
|
+
try {
|
|
152
|
+
if (!fstatSync(fd).isFile()) {
|
|
153
|
+
const error = new Error('unattended flag is not a regular file');
|
|
154
|
+
error.code = 'EINVAL';
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
const buffer = Buffer.alloc(MAX_FLAG_BYTES + 1);
|
|
158
|
+
const bytes = readSync(fd, buffer, 0, buffer.length, 0);
|
|
159
|
+
return { bytes, text: buffer.toString('utf8', 0, Math.min(bytes, MAX_FLAG_BYTES)) };
|
|
160
|
+
} finally {
|
|
161
|
+
closeSync(fd);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const unreadable = (path, why) => ({ on: true, unreadable: true, path, why });
|
|
166
|
+
|
|
167
|
+
const inspectCandidates = (candidates) => {
|
|
168
|
+
const present = [];
|
|
169
|
+
for (const path of candidates) {
|
|
170
|
+
try {
|
|
171
|
+
present.push({ path, raw: readCapped(path) });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (isMissing(error)) continue;
|
|
174
|
+
return {
|
|
175
|
+
present,
|
|
176
|
+
failure: unreadable(path, `cannot be read: ${error?.code ?? 'read failed'}`),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { present, failure: null };
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** The mode the flag declares — see the header for the three answers. */
|
|
184
|
+
export const readUnattended = (env = process.env) => {
|
|
185
|
+
const scoped = checkoutId(env) !== null;
|
|
186
|
+
const inspected = inspectCandidates(unattendedFlags(env));
|
|
187
|
+
if (inspected.failure) return inspected.failure;
|
|
188
|
+
const { present } = inspected;
|
|
189
|
+
let path = present[0]?.path;
|
|
190
|
+
let raw = present[0]?.raw ?? null;
|
|
191
|
+
if (scoped && present.length > 1) {
|
|
192
|
+
const first = present[0];
|
|
193
|
+
if (
|
|
194
|
+
present.some(
|
|
195
|
+
({ raw: candidate }) =>
|
|
196
|
+
candidate.bytes !== first.raw.bytes || candidate.text !== first.raw.text,
|
|
197
|
+
)
|
|
198
|
+
) {
|
|
199
|
+
return unreadable(first.path, 'mirrored checkout-scoped unattended flags disagree');
|
|
200
|
+
}
|
|
201
|
+
raw = first.raw;
|
|
202
|
+
}
|
|
203
|
+
if (!path && scoped) {
|
|
204
|
+
const legacy = inspectCandidates(legacyFlags(env));
|
|
205
|
+
if (legacy.failure) return legacy.failure;
|
|
206
|
+
path = legacy.present[0]?.path;
|
|
207
|
+
if (path) {
|
|
208
|
+
return unreadable(
|
|
209
|
+
path,
|
|
210
|
+
'legacy machine-wide unattended flag cannot authorize a scoped checkout; migrate or remove it explicitly',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!path) return { on: false };
|
|
215
|
+
if (raw.bytes > MAX_FLAG_BYTES) return unreadable(path, `larger than ${MAX_FLAG_BYTES} bytes`);
|
|
216
|
+
let parsed;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(raw.text);
|
|
219
|
+
} catch {
|
|
220
|
+
return unreadable(path, 'not valid JSON');
|
|
221
|
+
}
|
|
222
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
223
|
+
return unreadable(path, 'not a JSON object');
|
|
224
|
+
}
|
|
225
|
+
if (!Array.isArray(parsed.allow) || parsed.allow.some((entry) => typeof entry !== 'string')) {
|
|
226
|
+
return unreadable(path, '`allow` is not an array of strings');
|
|
227
|
+
}
|
|
228
|
+
if (parsed.allow.length > MAX_ALLOW_ENTRIES) {
|
|
229
|
+
return unreadable(path, `\`allow\` carries more than ${MAX_ALLOW_ENTRIES} entries`);
|
|
230
|
+
}
|
|
231
|
+
const allow = parsed.allow.map((entry) => entry.trim()).filter(Boolean);
|
|
232
|
+
const wide = allow.find(isWidening);
|
|
233
|
+
if (wide !== undefined) {
|
|
234
|
+
return unreadable(
|
|
235
|
+
path,
|
|
236
|
+
`\`allow\` entry ${JSON.stringify(wide)} widens the rulebook (it is a prefix of a rulebook prefix) — an allow-list narrows the rulebook, never widens it`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
on: true,
|
|
241
|
+
item: typeof parsed.item === 'string' ? parsed.item : null,
|
|
242
|
+
runDir: typeof parsed.runDir === 'string' ? parsed.runDir : null,
|
|
243
|
+
allow,
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Write the flag. Scoped records are mirrored into both trusted homes, with the
|
|
249
|
+
* password-database home first, so a caller whose HOME differs still observes
|
|
250
|
+
* the target checkout's state. An unscoped legacy-compatible write keeps the
|
|
251
|
+
* historical first-home behaviour.
|
|
252
|
+
*/
|
|
253
|
+
export const writeUnattended = ({ item, runDir = null, allow = [] } = {}, env = process.env) => {
|
|
254
|
+
if (typeof item !== 'string' || item.trim() === '') {
|
|
255
|
+
throw new Error('the unattended flag needs an item id — a run without an item has nothing to allow');
|
|
256
|
+
}
|
|
257
|
+
const list = (Array.isArray(allow) ? allow : []).map((entry) => String(entry).trim()).filter(Boolean);
|
|
258
|
+
if (list.length > MAX_ALLOW_ENTRIES) {
|
|
259
|
+
throw new Error(`the allow-list is capped at ${MAX_ALLOW_ENTRIES} entries`);
|
|
260
|
+
}
|
|
261
|
+
const wide = list.find(isWidening);
|
|
262
|
+
if (wide !== undefined) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`allow entry ${JSON.stringify(wide)} widens the rulebook — it is a prefix of one of ${RULEBOOK_PREFIXES.join(', ')}; ` +
|
|
265
|
+
'an allow-list narrows the rulebook, never widens it. A directory entry needs its trailing slash ' +
|
|
266
|
+
'(`.claude/hooks/`, not `.claude/hooks`).',
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const candidates = unattendedFlags(env);
|
|
270
|
+
const targets = checkoutId(env) === null ? candidates.slice(0, 1) : [...candidates].reverse();
|
|
271
|
+
const written = [];
|
|
272
|
+
const content = `${JSON.stringify({ item: item.trim(), runDir, allow: list }, null, 2)}\n`;
|
|
273
|
+
try {
|
|
274
|
+
for (const path of targets) {
|
|
275
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
276
|
+
writeFileSync(path, content);
|
|
277
|
+
written.push(path);
|
|
278
|
+
}
|
|
279
|
+
} catch (error) {
|
|
280
|
+
for (const path of written) {
|
|
281
|
+
try {
|
|
282
|
+
rmSync(path);
|
|
283
|
+
} catch {
|
|
284
|
+
// best-effort rollback; a surviving record keeps readers fail-closed
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
return written;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const pathBelongsToCheckout = (candidate, checkout) => {
|
|
293
|
+
let resolved;
|
|
294
|
+
try {
|
|
295
|
+
resolved = realpathSync(candidate);
|
|
296
|
+
} catch {
|
|
297
|
+
resolved = resolve(candidate);
|
|
298
|
+
}
|
|
299
|
+
const rel = relative(checkout, resolved);
|
|
300
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const legacyBelongsToCheckout = (flagPath, env) => {
|
|
304
|
+
const checkout = canonicalCheckout(env);
|
|
305
|
+
if (checkout === null) return false;
|
|
306
|
+
try {
|
|
307
|
+
const raw = readCapped(flagPath);
|
|
308
|
+
if (raw.bytes > MAX_FLAG_BYTES) return false;
|
|
309
|
+
const parsed = JSON.parse(raw.text);
|
|
310
|
+
return typeof parsed?.runDir === 'string' && pathBelongsToCheckout(parsed.runDir, checkout);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (!isMissing(error)) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`legacy unattended flag at ${flagPath} cannot be read: ${error?.code ?? error?.message ?? 'read failed'}`,
|
|
315
|
+
{ cause: error },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** Remove this checkout's flags and a provably-owned legacy record. */
|
|
323
|
+
export const clearUnattended = (env = process.env) => {
|
|
324
|
+
const removed = [];
|
|
325
|
+
const failures = [];
|
|
326
|
+
const candidates = checkoutId(env) === null
|
|
327
|
+
? unattendedFlags(env)
|
|
328
|
+
: [
|
|
329
|
+
...unattendedFlags(env),
|
|
330
|
+
...legacyFlags(env).filter((path) => legacyBelongsToCheckout(path, env)),
|
|
331
|
+
];
|
|
332
|
+
for (const path of [...new Set(candidates)]) {
|
|
333
|
+
try {
|
|
334
|
+
rmSync(path);
|
|
335
|
+
removed.push(path);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
if (isMissing(error)) continue;
|
|
338
|
+
failures.push(`${path}: ${error?.code ?? error?.message ?? 'remove failed'}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (failures.length > 0) {
|
|
342
|
+
throw new Error(`failed to remove unattended flag(s): ${failures.join('; ')}`);
|
|
343
|
+
}
|
|
344
|
+
return removed;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
/** Explicit operator migration: remove exactly the inspected legacy record. */
|
|
348
|
+
export const clearLegacyUnattended = (selectedPath) => {
|
|
349
|
+
if (typeof selectedPath !== 'string' || selectedPath.trim() === '') {
|
|
350
|
+
throw new Error('off --legacy requires --path <reported-path>');
|
|
351
|
+
}
|
|
352
|
+
const path = resolve(selectedPath);
|
|
353
|
+
if (basename(path) !== FLAG_BASENAME || basename(dirname(path)) !== '.claude') {
|
|
354
|
+
throw new Error(`refusing legacy cleanup outside .claude/${FLAG_BASENAME}`);
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
rmSync(path);
|
|
358
|
+
return [path];
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (isMissing(error)) return [];
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const invokedDirectly = () => {
|
|
366
|
+
if (!process.argv[1]) return false;
|
|
367
|
+
const real = (p) => {
|
|
368
|
+
try {
|
|
369
|
+
return realpathSync(p);
|
|
370
|
+
} catch {
|
|
371
|
+
return p;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
if (invokedDirectly()) {
|
|
378
|
+
const [word, ...rest] = process.argv.slice(2);
|
|
379
|
+
const valueOf = (flag) => {
|
|
380
|
+
const index = rest.indexOf(flag);
|
|
381
|
+
return index === -1 ? null : (rest[index + 1] ?? null);
|
|
382
|
+
};
|
|
383
|
+
const root = valueOf('--root');
|
|
384
|
+
const cliEnv = root && !root.startsWith('--')
|
|
385
|
+
? { ...process.env, CLAUDE_PROJECT_DIR: root }
|
|
386
|
+
: process.env;
|
|
387
|
+
if (word === 'on') {
|
|
388
|
+
const item = valueOf('--item');
|
|
389
|
+
if (!item || item.startsWith('--')) {
|
|
390
|
+
process.stderr.write('unattended-flag on: --item <id> is required — the flag names the item whose paths are allowed\n');
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
const allowIndex = rest.indexOf('--allow');
|
|
394
|
+
const allow =
|
|
395
|
+
allowIndex === -1
|
|
396
|
+
? []
|
|
397
|
+
: rest.slice(allowIndex + 1).filter((entry, i, all) => {
|
|
398
|
+
const stop = all.findIndex((e) => e.startsWith('--'));
|
|
399
|
+
return stop === -1 || i < stop;
|
|
400
|
+
});
|
|
401
|
+
let path;
|
|
402
|
+
try {
|
|
403
|
+
[path] = writeUnattended({ item, runDir: valueOf('--run-dir'), allow }, cliEnv);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
process.stderr.write(`unattended-flag on: ${error?.message ?? error}\n`);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
process.stdout.write(`${path}\n`);
|
|
409
|
+
process.exit(0);
|
|
410
|
+
}
|
|
411
|
+
if (word === 'off') {
|
|
412
|
+
const legacy = rest.includes('--legacy');
|
|
413
|
+
let removed;
|
|
414
|
+
try {
|
|
415
|
+
removed = legacy ? clearLegacyUnattended(valueOf('--path')) : clearUnattended(cliEnv);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
process.stderr.write(`unattended-flag off: ${error?.message ?? error}\n`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
}
|
|
420
|
+
if (!legacy && root) {
|
|
421
|
+
const remaining = readUnattended(cliEnv);
|
|
422
|
+
if (remaining.on) {
|
|
423
|
+
const reason = remaining.why ?? 'an unattended flag is still armed';
|
|
424
|
+
process.stderr.write(
|
|
425
|
+
`unattended-flag off: ${reason} at ${remaining.path}. ` +
|
|
426
|
+
'Inspect that exact record; if no pre-upgrade run still uses it, remove it with `off --legacy --path <reported-path>`.\n',
|
|
427
|
+
);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
process.stdout.write(removed.length === 0 ? 'no unattended flag was set\n' : `${removed.join('\n')}\n`);
|
|
432
|
+
process.exit(0);
|
|
433
|
+
}
|
|
434
|
+
process.stderr.write(`unknown word: ${word ?? '(none)'}. This CLI has two: on, off.\n`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|