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