create-agent-rig 0.7.1 → 0.9.0
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 +184 -2
- package/README.md +93 -4
- package/package.json +4 -3
- package/packages/cli/dist/commands/memory.js +123 -0
- package/packages/cli/dist/commands/setup.js +45 -0
- package/packages/cli/dist/index.js +107 -3
- package/packages/cli/dist/lib/subsystems.js +269 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/packages/cli/dist/policy/benchmark/corpus.js +165 -0
- package/packages/cli/dist/policy/core/adapter.js +18 -0
- package/packages/cli/dist/policy/core/coverage.js +253 -0
- package/packages/cli/dist/policy/core/decision-record.js +287 -0
- package/packages/cli/dist/policy/core/declaration.js +127 -0
- package/packages/cli/dist/policy/core/evidence-matrix.js +94 -0
- package/packages/cli/dist/policy/core/probe.js +442 -0
- package/packages/cli/dist/policy/core/registry.js +115 -0
- package/packages/cli/dist/policy/core/validation.js +275 -0
- package/packages/cli/dist/policy/core/vocabulary.js +123 -0
- package/packages/cli/dist/policy/harness/claude.js +47 -0
- package/packages/cli/dist/policy/harness/codex.js +87 -0
- package/packages/cli/dist/policy/harness/index.js +15 -0
- package/packages/cli/dist/policy/harness/shared-hooks.js +28 -0
- package/packages/cli/dist/policy/index.js +17 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +2 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +2 -0
- package/templates/agent-os/subagent-routing.json +32 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +48 -7
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +2 -0
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +2 -0
- package/templates/agent-os/universal/.claude/agents/test-writer.md +2 -0
- package/templates/agent-os/universal/.claude/hooks/guard-subagent-model.mjs +234 -0
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +75 -32
- package/templates/agent-os/universal/.claude/hooks/warn-subagent-routing.mjs +120 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +17 -7
- package/templates/agent-os/universal/.claude/rules/workflow.md +5 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +27 -3
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +70 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +12 -4
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +64 -1
- package/templates/agent-os/universal/.claude/settings.json +16 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +48 -7
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +2 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +2 -0
- package/templates/agent-os/universal/.codex/config.toml +3 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +31 -5
- package/templates/agent-os/universal/docs/decisions/subagent-routing.md +142 -0
- package/templates/agent-os/universal/layers.json +4 -0
- package/templates/hash-history.json +15 -7
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/node-service/services/api/test/artifact.test.ts +3 -4
- package/templates/skeleton/node-service/services/api/test/package-manager.test.ts +40 -0
- package/templates/skeleton/node-service/services/api/test/package-manager.ts +51 -0
- package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +9 -8
|
@@ -79,16 +79,23 @@ state-vs-queue split exists to prevent.
|
|
|
79
79
|
node .claude/scripts/preflight.mjs
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
default
|
|
82
|
+
Scripted checks cover the kill switch, inherited `RIG_RUN_DIR`, the versioned
|
|
83
|
+
revalidation detection contract, queue readability through its configured adapter,
|
|
84
|
+
default-branch freshness, and the last deploy result,
|
|
85
85
|
and the script **prints the ones it did not check, every time**. Paste the block into the journal: a checklist that
|
|
86
86
|
leaves no record cannot tell you it was skipped.
|
|
87
87
|
|
|
88
88
|
Verdicts: **STOP** → do not start, deal with the cause. **CAUTION** → start,
|
|
89
|
-
knowing which ground is soft. **GO** → the scripted
|
|
89
|
+
knowing which ground is soft. **GO** → the scripted checks are clean; the rest are
|
|
90
90
|
still yours.
|
|
91
91
|
|
|
92
|
+
The queue probe reads one adapter listing without selecting or claiming an item.
|
|
93
|
+
A readable empty queue passes this probe; a configuration, adapter, or queue-read
|
|
94
|
+
failure produces **STOP**. See the generator's `test/template/preflight-queue.test.ts`
|
|
95
|
+
(absent in a generated rig) › "reads exactly one adapter listing without selecting, claiming, or writing queue and run files",
|
|
96
|
+
› "passes a readable empty queue without changing the other preflight verdict or queue state",
|
|
97
|
+
and › "stops when %s cannot be read".
|
|
98
|
+
|
|
92
99
|
**An `unknown` never becomes a `pass`.** A probe that could not run tells you
|
|
93
100
|
nothing.
|
|
94
101
|
|
|
@@ -149,13 +156,47 @@ What a hook CAN see is a file, so the unattended signal is one:
|
|
|
149
156
|
# at claim time, from the paths the item names (repo-relative prefixes, with
|
|
150
157
|
# their trailing slash); the guard refuses every other rulebook edit while it is on
|
|
151
158
|
node .claude/scripts/unattended-flag.mjs on --root "$PWD" --item <item-id> --run-dir "$RIG_RUN_DIR" --allow <prefix> [<prefix>…]
|
|
159
|
+
|
|
160
|
+
# 🔴 THEN READ IT BACK, and stop the run if it is not armed. Not optional.
|
|
161
|
+
node .claude/scripts/unattended-flag.mjs verify --root "$PWD" --item <item-id>
|
|
152
162
|
```
|
|
153
163
|
|
|
164
|
+
🔴 **The second command is the one that makes the first one's failure
|
|
165
|
+
visible, and skipping it inverts the whole mechanism.** `on` refuses an allow
|
|
166
|
+
entry that *widens* the rulebook — and the refusal leaves **no flag on disk**
|
|
167
|
+
(pinned in the generator's `test/template/unattended-flag.test.ts`, absent in a
|
|
168
|
+
generated rig, › "does not change `on`: a widening --allow still exits 1 and
|
|
169
|
+
still writes no flag"), while `guard-rulebook` reads an absent flag as an
|
|
170
|
+
attended session and refuses nothing. So a run that armed with a widening entry and did not check is the
|
|
171
|
+
**least** constrained run this project can produce: every rule, hook, skill and
|
|
172
|
+
settings path editable, with nothing downstream saying so. The failure is loud
|
|
173
|
+
for one line at claim time and silent for the rest of the session.
|
|
174
|
+
|
|
175
|
+
`verify` exits non-zero when no usable flag is armed for this item — absent,
|
|
176
|
+
unreadable, or naming a different item — and its message says the run is
|
|
177
|
+
unguarded rather than merely that a file is missing. **A non-zero exit here ends
|
|
178
|
+
the run**; it does not get retried with a wider allow-list. The natural way to
|
|
179
|
+
hit this is not exotic: an item touching the queue adapter invites `--allow
|
|
180
|
+
.claude/scripts/`, and that entry is refused outright. Pinned in the
|
|
181
|
+
generator's `test/template/unattended-flag.test.ts` (absent in a generated rig)
|
|
182
|
+
› "refuses when no flag is armed, naming the item and the unguarded rulebook"
|
|
183
|
+
and › "refuses when the armed flag names a different item, naming both".
|
|
184
|
+
|
|
185
|
+
⚠ **What this does not close.** `verify` is mechanical where it runs; that it
|
|
186
|
+
runs is this sentence. It removes the silence, not the possibility that a run
|
|
187
|
+
ignores an exit status — and the hook-enforced version is not available, because
|
|
188
|
+
`guard-rulebook` cannot tell "attended" from "unattended but unarmed": absence of
|
|
189
|
+
a flag is all it sees.
|
|
190
|
+
|
|
154
191
|
`guard-rulebook` reads it (`.claude/rules/autonomy.md`, "Never"): with the flag
|
|
155
192
|
on, a Write/Edit/MultiEdit/NotebookEdit/`apply_patch` under the generated
|
|
156
|
-
rulebook
|
|
157
|
-
|
|
158
|
-
|
|
193
|
+
rulebook is refused unless its
|
|
194
|
+
path starts with an allowed prefix. 🔴 **Which paths that covers is
|
|
195
|
+
`RULEBOOK_PREFIXES` in `.claude/scripts/unattended-flag.mjs`** — read it before
|
|
196
|
+
composing an allow-list, rather than working from a summary here. A summary is a
|
|
197
|
+
second copy, and the one that used to sit in this sentence had gone stale against
|
|
198
|
+
the set it described. One fact the set cannot carry, so it is stated: the board
|
|
199
|
+
selector is the one always-refused
|
|
159
200
|
exception and cannot be admitted by an allow-list. With no flag the guard does nothing. An
|
|
160
201
|
item that needs a rulebook path names it here — a decision made at claim
|
|
161
202
|
time, never a default — and the stop step below turns the flag off. Pinned in
|
|
@@ -2,6 +2,8 @@
|
|
|
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 the decision-router puts on its `model` lane, which is everything its two cheap lanes did not claim — code, a rulebook document, an unclassifiable path, a derived artifact git does not report as drift, or anything a risk flag escalated. Blocking findings must be resolved, not argued with.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
|
+
model: claude-opus-5
|
|
6
|
+
effort: high
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
You review changes. You do not fix them — you report, with file:line
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
name: prose-reviewer
|
|
3
3
|
description: Reviews the documents that instruct agents — rule files, skills, agent specs, CLAUDE.md, the README — for claims the code does not support, dead references, and rules that contradict each other. Use when a change touches any of them, before the PR.
|
|
4
4
|
tools: Read, Grep, Glob, Bash
|
|
5
|
+
model: claude-sonnet-5
|
|
6
|
+
effort: high
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
In this project the prose **is** the implementation. A rule file is what an agent
|
|
@@ -2,6 +2,8 @@
|
|
|
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
|
|
6
|
+
effort: high
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
You are the security gate. You run on changes in sensitive territory and your
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
name: test-writer
|
|
3
3
|
description: Writes the failing test BEFORE any implementation exists. Use at the start of every feature, bug fix, or behavior change — the Red step of TDD. Also use to reproduce a reported bug as a test.
|
|
4
4
|
tools: Read, Grep, Glob, Write, Edit, Bash
|
|
5
|
+
model: claude-sonnet-5
|
|
6
|
+
effort: high
|
|
5
7
|
---
|
|
6
8
|
|
|
7
9
|
You write tests that define behavior which does not exist yet. You are the Red
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// PreToolUse hook (Claude Code's Agent tool): a subagent whose project
|
|
2
|
+
// definition pins its model is never dispatched with a call-site `model`.
|
|
3
|
+
//
|
|
4
|
+
// Why it exists. Claude Code takes a subagent's model from the per-invocation
|
|
5
|
+
// `model` parameter FIRST, and only then from the definition's `model:` line
|
|
6
|
+
// (Claude Code's sub-agents documentation, "model resolution"). A pin is
|
|
7
|
+
// therefore only as strong as every call site: the generator's
|
|
8
|
+
// docs/capability-evidence.json (absent in a generated rig) records a pinned
|
|
9
|
+
// agent that ran the call-site model instead — mechanism `subagent-model-pin`,
|
|
10
|
+
// surface `Agent tool call-site model, without guard-subagent-model`. Which
|
|
11
|
+
// model a role reads with is the routing policy's decision
|
|
12
|
+
// (docs/decisions/subagent-routing.md), not one dispatch's.
|
|
13
|
+
//
|
|
14
|
+
// What "pinned" means here: `<project>/.claude/agents/<subagent_type>.md` is a
|
|
15
|
+
// regular file whose leading frontmatter carries a `model:` line with a value
|
|
16
|
+
// other than `inherit`. The project root is CLAUDE_PROJECT_DIR, else the working
|
|
17
|
+
// directory. One leading byte-order mark is not content and is skipped.
|
|
18
|
+
//
|
|
19
|
+
// The three outcomes (.claude/rules/invariants.md):
|
|
20
|
+
// - allow — nothing it can read, another event, no call-site model, or an
|
|
21
|
+
// ad-hoc subagent: no `subagent_type`, a built-in, a name that is not a plain
|
|
22
|
+
// file name (`plugin:agent`, `../x`), no such file, a path that is not a
|
|
23
|
+
// regular file (a directory, a FIFO — opened without waiting, never read), or
|
|
24
|
+
// no pin in it;
|
|
25
|
+
// - block — a call-site model for a pinned agent;
|
|
26
|
+
// - refuse to inspect — `model` or `subagent_type` PRESENT in a shape other
|
|
27
|
+
// than a string (resend it as one), or a frontmatter that does not close
|
|
28
|
+
// within MAX_HEAD_BYTES (a bound crossed).
|
|
29
|
+
// An error opening or reading the agent file fails open, like every guard here.
|
|
30
|
+
//
|
|
31
|
+
// Bounded work: one non-blocking open, one fstat, one read of at most
|
|
32
|
+
// MAX_HEAD_BYTES + 1 bytes, one frontmatter match, and one pass over its lines
|
|
33
|
+
// with a prefix test per line. Nothing recurses or rescans.
|
|
34
|
+
//
|
|
35
|
+
// Limits, stated so nobody relies on cover that is not here:
|
|
36
|
+
// - project agents only. A user-level or plugin agent that pins a model is not
|
|
37
|
+
// a role of this project, and its call-site model is allowed;
|
|
38
|
+
// - the file is found by the dispatched name, so a project agent whose
|
|
39
|
+
// frontmatter `name` differs from its file name is not matched;
|
|
40
|
+
// - `model:` is read as a frontmatter line, not through a YAML parser, so a pin
|
|
41
|
+
// spelled another YAML way (a quoted key) is not seen.
|
|
42
|
+
// Pinned in the generator's test/template/subagent-routing-hooks.test.ts
|
|
43
|
+
// (absent in a generated rig) › "blocks a call-site model on a project agent
|
|
44
|
+
// that pins one, and says to re-dispatch without it", › "never resolves %s to a
|
|
45
|
+
// project agent file, so the call is allowed", › "refuses an agent file whose
|
|
46
|
+
// frontmatter does not close within the read bound, and names the bound", ›
|
|
47
|
+
// "allows a call-site model without waiting when the agent path is not a regular
|
|
48
|
+
// file", › "reads a pin in an agent file that starts with a byte-order mark", ›
|
|
49
|
+
// "echoes a model pinned in the agent file bounded and escaped" (the bound) and
|
|
50
|
+
// › "escapes a control byte inside the echoed part of both model names".
|
|
51
|
+
import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from 'node:fs';
|
|
52
|
+
import path from 'node:path';
|
|
53
|
+
import { fileURLToPath } from 'node:url';
|
|
54
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
55
|
+
|
|
56
|
+
/** How much of an agent file is read to find its frontmatter. */
|
|
57
|
+
export const MAX_HEAD_BYTES = 64 * 1024;
|
|
58
|
+
|
|
59
|
+
/** How much of a model name a refusal repeats, from either side of the comparison. */
|
|
60
|
+
const MAX_ECHOED_MODEL = 64;
|
|
61
|
+
|
|
62
|
+
/** A name that can only ever be a file directly under `.claude/agents/`. */
|
|
63
|
+
const AGENT_FILE_NAME = /^[A-Za-z0-9_-]{1,128}$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Open without blocking: a FIFO at the agent path would otherwise hold the open
|
|
67
|
+
* until a writer appears. Windows has no such flag and no such file there.
|
|
68
|
+
*/
|
|
69
|
+
const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
70
|
+
|
|
71
|
+
const ALLOW = Object.freeze({ outcome: 'allow' });
|
|
72
|
+
|
|
73
|
+
/** The shape word for a value, bounded: never the value itself. */
|
|
74
|
+
const shapeOf = (value) => {
|
|
75
|
+
if (Array.isArray(value)) return 'an array';
|
|
76
|
+
const type = typeof value;
|
|
77
|
+
return type === 'object' ? 'an object' : `a ${type}`;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A model name as a refusal may print it: at most MAX_ECHOED_MODEL characters,
|
|
82
|
+
* through JSON.stringify — which escapes C0 control bytes such as ESC, and
|
|
83
|
+
* leaves other non-ASCII characters (C1 controls, bidi overrides) as they are.
|
|
84
|
+
*/
|
|
85
|
+
const echoed = (model) => JSON.stringify(model.slice(0, MAX_ECHOED_MODEL));
|
|
86
|
+
|
|
87
|
+
const unreadable = (field, value, expected) => ({
|
|
88
|
+
outcome: 'refuse',
|
|
89
|
+
message:
|
|
90
|
+
`BLOCKED — ${field} is present as ${shapeOf(value)}, and this guard reads ${expected}. ` +
|
|
91
|
+
'An input it cannot read is refused, never allowed: whether this dispatch overrides a ' +
|
|
92
|
+
'pinned model is decided by reading it (.claude/rules/invariants.md, "Refusing to ' +
|
|
93
|
+
`inspect is a third outcome"). Resend the Agent call with ${field} as ${expected}.`,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The first MAX_HEAD_BYTES of a regular file, and whether it went on past them;
|
|
98
|
+
* null when the path is absent, is not a regular file, or cannot be read.
|
|
99
|
+
*/
|
|
100
|
+
function readHead(file) {
|
|
101
|
+
let fd;
|
|
102
|
+
try {
|
|
103
|
+
fd = openSync(file, OPEN_FLAGS);
|
|
104
|
+
if (!fstatSync(fd).isFile()) return null;
|
|
105
|
+
const buffer = Buffer.alloc(MAX_HEAD_BYTES + 1);
|
|
106
|
+
let filled = 0;
|
|
107
|
+
while (filled < buffer.length) {
|
|
108
|
+
const read = readSync(fd, buffer, filled, buffer.length - filled, null);
|
|
109
|
+
if (read === 0) break;
|
|
110
|
+
filled += read;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
text: buffer.subarray(0, Math.min(filled, MAX_HEAD_BYTES)).toString('utf8'),
|
|
114
|
+
cut: filled > MAX_HEAD_BYTES,
|
|
115
|
+
};
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
} finally {
|
|
119
|
+
if (fd !== undefined) {
|
|
120
|
+
try {
|
|
121
|
+
closeSync(fd);
|
|
122
|
+
} catch {
|
|
123
|
+
// nothing left to release
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `{ kind: 'pinned', model }`, `{ kind: 'none' }`, or `{ kind: 'unbounded' }`
|
|
131
|
+
* when a frontmatter opens and does not close inside the bytes that were read.
|
|
132
|
+
*/
|
|
133
|
+
function pinOf({ text: raw, cut }) {
|
|
134
|
+
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
135
|
+
if (!/^---\r?\n/.test(text)) return { kind: 'none' };
|
|
136
|
+
// A head that was cut may end in the middle of a line, so only a closer
|
|
137
|
+
// followed by a newline counts there; an uncut head may end at the closer.
|
|
138
|
+
const closed = (
|
|
139
|
+
cut
|
|
140
|
+
? /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/
|
|
141
|
+
: /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/
|
|
142
|
+
).exec(text);
|
|
143
|
+
if (!closed) return cut ? { kind: 'unbounded' } : { kind: 'none' };
|
|
144
|
+
for (const line of closed[1].split(/\r?\n/)) {
|
|
145
|
+
if (!line.startsWith('model:')) continue;
|
|
146
|
+
const value = line.slice('model:'.length).trim();
|
|
147
|
+
const quoted =
|
|
148
|
+
value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0];
|
|
149
|
+
const model = quoted ? value.slice(1, -1) : value;
|
|
150
|
+
return model !== '' && model !== 'inherit' ? { kind: 'pinned', model } : { kind: 'none' };
|
|
151
|
+
}
|
|
152
|
+
return { kind: 'none' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The verdict for one hook payload, judged against the project at `root`. */
|
|
156
|
+
export function judge(input, root) {
|
|
157
|
+
if (input === null || typeof input !== 'object' || input.hook_event_name !== 'PreToolUse') {
|
|
158
|
+
return ALLOW;
|
|
159
|
+
}
|
|
160
|
+
const toolInput = input.tool_input;
|
|
161
|
+
if (toolInput === undefined || toolInput === null) return ALLOW;
|
|
162
|
+
if (typeof toolInput !== 'object' || Array.isArray(toolInput)) {
|
|
163
|
+
return unreadable('tool_input', toolInput, 'an object');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const model = toolInput.model;
|
|
167
|
+
if (model === undefined || model === null) return ALLOW;
|
|
168
|
+
if (typeof model !== 'string') return unreadable('tool_input.model', model, 'a string');
|
|
169
|
+
if (model.trim() === '') return ALLOW;
|
|
170
|
+
|
|
171
|
+
const name = toolInput.subagent_type;
|
|
172
|
+
if (name === undefined || name === null) return ALLOW;
|
|
173
|
+
if (typeof name !== 'string') return unreadable('tool_input.subagent_type', name, 'a string');
|
|
174
|
+
if (!AGENT_FILE_NAME.test(name)) return ALLOW;
|
|
175
|
+
|
|
176
|
+
const relative = `.claude/agents/${name}.md`;
|
|
177
|
+
const head = readHead(path.join(root, '.claude', 'agents', `${name}.md`));
|
|
178
|
+
if (head === null) return ALLOW;
|
|
179
|
+
const pin = pinOf(head);
|
|
180
|
+
if (pin.kind === 'unbounded') {
|
|
181
|
+
return {
|
|
182
|
+
outcome: 'refuse',
|
|
183
|
+
message:
|
|
184
|
+
`BLOCKED — ${relative} opens a frontmatter that does not close within the first ` +
|
|
185
|
+
`${MAX_HEAD_BYTES} bytes, the limit this guard reads, so it cannot tell whether ` +
|
|
186
|
+
`\`${name}\` pins its model. Close the frontmatter near the top of the file, or ` +
|
|
187
|
+
're-dispatch without `model`.',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (pin.kind !== 'pinned') return ALLOW;
|
|
191
|
+
return {
|
|
192
|
+
outcome: 'block',
|
|
193
|
+
message:
|
|
194
|
+
`BLOCKED — \`${name}\` pins its model in ${relative} (${echoed(pin.model)}), and this ` +
|
|
195
|
+
`dispatch passes model ${echoed(model)}, which Claude Code would run instead.\n` +
|
|
196
|
+
'Re-dispatch without `model`: the agent definition decides which model a role runs on. ' +
|
|
197
|
+
"Changing a role's model is a policy change — make it in the agent definition, in a " +
|
|
198
|
+
'reviewed change, never in one call (docs/decisions/subagent-routing.md).',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether this file is being run as a script rather than imported — the realpath
|
|
204
|
+
* on both sides, as `inject-rules.mjs` explains, so a symlinked checkout still runs.
|
|
205
|
+
*/
|
|
206
|
+
function invokedDirectly() {
|
|
207
|
+
if (!process.argv[1]) return false;
|
|
208
|
+
const real = (p) => {
|
|
209
|
+
try {
|
|
210
|
+
return realpathSync(p);
|
|
211
|
+
} catch {
|
|
212
|
+
return p;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function main() {
|
|
219
|
+
let verdict;
|
|
220
|
+
try {
|
|
221
|
+
const input = readHookInput();
|
|
222
|
+
if (input === null) return 0;
|
|
223
|
+
verdict = judge(input, process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
224
|
+
} catch {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
if (verdict.outcome === 'allow') return 0;
|
|
228
|
+
process.stderr.write(`${verdict.message}\n`);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (invokedDirectly()) {
|
|
233
|
+
process.exit(main());
|
|
234
|
+
}
|
|
@@ -49,9 +49,73 @@ const MAX_PATCH_SECTIONS = 128;
|
|
|
49
49
|
const MAX_MULTI_EDITS = 256;
|
|
50
50
|
const MAX_PATCH_PATH_COMPONENTS = 512;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The surfaces this normaliser answers for. A tool outside the set is one the
|
|
54
|
+
* hook does not understand, and the refusal below must not reach it: a guard
|
|
55
|
+
* that blocks payloads it was never asked about is a guard that gets deleted.
|
|
56
|
+
*/
|
|
57
|
+
const EDIT_TOOL_NAMES = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The one refusal for a container this file cannot read, on every surface it
|
|
61
|
+
* owns. `apply_patch` keeps its own wording because the shape it expects is a
|
|
62
|
+
* different shape — a patch string — and a remedy naming an object would be
|
|
63
|
+
* advice its caller cannot act on.
|
|
64
|
+
*/
|
|
65
|
+
const unreadableToolInput = (toolName) =>
|
|
66
|
+
toolName === 'apply_patch'
|
|
67
|
+
? {
|
|
68
|
+
filePath: '',
|
|
69
|
+
fragment: '',
|
|
70
|
+
inspectionRefusal:
|
|
71
|
+
'the apply_patch command arrived in a shape this guard cannot read — it is a ' +
|
|
72
|
+
'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
|
|
73
|
+
'nothing about this patch is vouched for.',
|
|
74
|
+
remedy: 'Send the command as a patch string, or a list of strings.',
|
|
75
|
+
appliesToAll: true,
|
|
76
|
+
}
|
|
77
|
+
: {
|
|
78
|
+
filePath: '',
|
|
79
|
+
fragment: '',
|
|
80
|
+
inspectionRefusal:
|
|
81
|
+
'the tool_input arrived in a shape this guard cannot read — it is an object ' +
|
|
82
|
+
'carrying the edit fields, and nothing else. Nothing was inspected, so nothing ' +
|
|
83
|
+
'about this edit is vouched for.',
|
|
84
|
+
remedy: 'Send the tool_input as an object carrying the edit fields.',
|
|
85
|
+
appliesToAll: true,
|
|
86
|
+
};
|
|
87
|
+
|
|
52
88
|
export function editFragments(input) {
|
|
53
89
|
const toolName = input?.tool_name;
|
|
54
|
-
const
|
|
90
|
+
const rawToolInput = input?.tool_input;
|
|
91
|
+
// 🔴 **"I could not look" is not "there was nothing to look at" — and this
|
|
92
|
+
// file used to answer both ways depending on which arm you reached.**
|
|
93
|
+
// `?? {}` substitutes only for null/undefined, so a `tool_input` PRESENT as a
|
|
94
|
+
// string, a number or an array flowed on as that value: every field read came
|
|
95
|
+
// back `undefined`, and the Write/Edit/NotebookEdit arms returned a fragment
|
|
96
|
+
// with an empty path and empty text — which every consuming guard reads as a
|
|
97
|
+
// clean edit. Measured on master `6589db36`: a `tool_input` that is a string
|
|
98
|
+
// carrying a credential exited 0 through Write, Edit, MultiEdit and
|
|
99
|
+
// NotebookEdit, while the SAME shape through `apply_patch` exited 2. The
|
|
100
|
+
// divergence was between arms of this one file, which is why the contract
|
|
101
|
+
// belongs here rather than in each guard (RP-85, applying RP-80's ruling).
|
|
102
|
+
//
|
|
103
|
+
// ⚠ **ABSENT stays fail-open, and the difference is the whole rule.**
|
|
104
|
+
// `.claude/rules/invariants.md`: a field that is simply absent is the case the
|
|
105
|
+
// guard has nothing to judge — it must allow, exactly as a `Write` carrying no
|
|
106
|
+
// content does. A field PRESENT in a shape the guard does not accept is the
|
|
107
|
+
// case where it was handed something and could tell it could not read it,
|
|
108
|
+
// which is the one thing reporting is for. Getting these backwards costs a
|
|
109
|
+
// credential in either direction.
|
|
110
|
+
if (
|
|
111
|
+
EDIT_TOOL_NAMES.has(toolName) &&
|
|
112
|
+
rawToolInput !== null &&
|
|
113
|
+
rawToolInput !== undefined &&
|
|
114
|
+
(typeof rawToolInput !== 'object' || Array.isArray(rawToolInput))
|
|
115
|
+
) {
|
|
116
|
+
return [unreadableToolInput(toolName)];
|
|
117
|
+
}
|
|
118
|
+
const toolInput = rawToolInput ?? {};
|
|
55
119
|
if (toolName === 'Write' || toolName === 'Edit') {
|
|
56
120
|
return [
|
|
57
121
|
{
|
|
@@ -107,21 +171,10 @@ export function editFragments(input) {
|
|
|
107
171
|
// they exited 1 with a stack trace, which neither harness treats as blocking, so
|
|
108
172
|
// a crash here was an ALLOW. A `tool_input` that is not an object is the same
|
|
109
173
|
// case as a `command` whose container this guard cannot read: detected, not
|
|
110
|
-
// readable, refused.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
filePath: '',
|
|
115
|
-
fragment: '',
|
|
116
|
-
inspectionRefusal:
|
|
117
|
-
'the apply_patch command arrived in a shape this guard cannot read — it is a ' +
|
|
118
|
-
'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
|
|
119
|
-
'nothing about this patch is vouched for.',
|
|
120
|
-
remedy: 'Send the command as a patch string, or a list of strings.',
|
|
121
|
-
appliesToAll: true,
|
|
122
|
-
},
|
|
123
|
-
];
|
|
124
|
-
}
|
|
174
|
+
// readable, refused. That check used to sit HERE, guarding this one arm; it is
|
|
175
|
+
// now the first thing `editFragments` does, for every surface in
|
|
176
|
+
// `EDIT_TOOL_NAMES`, so `toolInput` is an object by the time this line runs and
|
|
177
|
+
// `in` cannot throw (RP-85). One mechanism, one implementation.
|
|
125
178
|
if (!('command' in toolInput)) return [];
|
|
126
179
|
if (
|
|
127
180
|
typeof rawCommand !== 'string' &&
|
|
@@ -140,22 +193,12 @@ export function editFragments(input) {
|
|
|
140
193
|
// "a crashed guard that blocks everything gets deleted within the hour" —
|
|
141
194
|
// and this branch is neither. Two opposite answers to one question, ten
|
|
142
195
|
// lines apart, was the real defect.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
'string, or a list of strings, and nothing else. Nothing was inspected, so ' +
|
|
150
|
-
'nothing about this patch is vouched for.',
|
|
151
|
-
// 🔴 The remedy travels WITH the refusal that earns it. It was chosen by
|
|
152
|
-
// `/shape/i.test(reason)` in six copies — correct only by coincidence of
|
|
153
|
-
// wording, so rewording the reason silently restored the retry loop this
|
|
154
|
-
// remedy exists to replace. One field, one place.
|
|
155
|
-
remedy: 'Send the command as a patch string, or a list of strings.',
|
|
156
|
-
appliesToAll: true,
|
|
157
|
-
},
|
|
158
|
-
];
|
|
196
|
+
// 🔴 The remedy travels WITH the refusal that earns it. It was chosen by
|
|
197
|
+
// `/shape/i.test(reason)` in six copies — correct only by coincidence of
|
|
198
|
+
// wording, so rewording the reason silently restored the retry loop this
|
|
199
|
+
// remedy exists to replace. One field, one place — and since RP-85 that
|
|
200
|
+
// place is `unreadableToolInput`, shared with the four edit surfaces.
|
|
201
|
+
return [unreadableToolInput(toolName)];
|
|
159
202
|
}
|
|
160
203
|
const command = typeof rawCommand === 'string' ? rawCommand : rawCommand.join('\n');
|
|
161
204
|
if (command.length > MAX_PATCH_CHARACTERS) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// SessionStart hook (Claude Code): says out loud when this session's
|
|
2
|
+
// environment may keep the pinned subagent routing from holding.
|
|
3
|
+
//
|
|
4
|
+
// The pins live in each agent definition (`model:`, `effort:`) and in the
|
|
5
|
+
// shipped settings (`env.CLAUDE_CODE_SUBAGENT_MODEL`). Four conditions are
|
|
6
|
+
// invisible from inside the rulebook, and each is reported:
|
|
7
|
+
// - CLAUDE_CODE_SUBAGENT_MODEL_FORCE is set: Claude Code applies one model to
|
|
8
|
+
// every subagent and ignores each definition's `model:` (its 2.1.257
|
|
9
|
+
// changelog);
|
|
10
|
+
// - CLAUDE_CODE_EFFORT_LEVEL is set: its level replaced a definition's
|
|
11
|
+
// `effort:` in a live run — the generator's docs/capability-evidence.json
|
|
12
|
+
// (absent in a generated rig), mechanism `subagent-effort-pin`, surface
|
|
13
|
+
// `environment: CLAUDE_CODE_EFFORT_LEVEL=low`;
|
|
14
|
+
// - Claude Code is older than MINIMUM_CLAUDE_CODE_VERSION: until 2.1.251,
|
|
15
|
+
// CLAUDE_CODE_SUBAGENT_MODEL overrode an agent definition's `model:` (its
|
|
16
|
+
// 2.1.251 changelog), so the shipped unnamed default replaced every model pin;
|
|
17
|
+
// - the version cannot be read: then whether the pins hold cannot be confirmed.
|
|
18
|
+
//
|
|
19
|
+
// The version is read from AI_AGENT, which Claude Code sets for its subprocesses
|
|
20
|
+
// (its 2.1.120 changelog) and which a SessionStart hook's environment was
|
|
21
|
+
// observed to carry (the same evidence file, mechanism
|
|
22
|
+
// `claude-code-version-signal`). Its shape,
|
|
23
|
+
// `claude-code_<major>-<minor>-<patch>_<role>`, is observed and not documented —
|
|
24
|
+
// so a value this hook cannot parse is reported as an unknown version, never
|
|
25
|
+
// taken for a supported one.
|
|
26
|
+
//
|
|
27
|
+
// It warns and never blocks: exit 0 always, the warning on stdout, which Claude
|
|
28
|
+
// Code adds to the session's context at SessionStart. An empty variable counts
|
|
29
|
+
// as unset. The rationale is docs/decisions/subagent-routing.md.
|
|
30
|
+
//
|
|
31
|
+
// Pinned in the generator's test/template/subagent-routing-hooks.test.ts
|
|
32
|
+
// (absent in a generated rig) › "warns when %s is set, and never blocks the
|
|
33
|
+
// session", › "warns on a Claude Code older than 2.1.251 and names the minimum"
|
|
34
|
+
// and › "warns that it could not determine the version when %s".
|
|
35
|
+
import { realpathSync } from 'node:fs';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
38
|
+
|
|
39
|
+
/** The first Claude Code release in which an agent definition's `model:` outranks the env default. */
|
|
40
|
+
export const MINIMUM_CLAUDE_CODE_VERSION = '2.1.251';
|
|
41
|
+
|
|
42
|
+
const PIN_REPLACING_VARIABLES = [
|
|
43
|
+
[
|
|
44
|
+
'CLAUDE_CODE_SUBAGENT_MODEL_FORCE',
|
|
45
|
+
"replaces the `model:` every agent definition pins with one model for all subagents",
|
|
46
|
+
],
|
|
47
|
+
['CLAUDE_CODE_EFFORT_LEVEL', "replaces the `effort:` every agent definition pins with its own level"],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const VERSION = /^claude-code_(\d{1,6})-(\d{1,6})-(\d{1,6})(?:_|$)/;
|
|
51
|
+
|
|
52
|
+
const numbers = (version) => version.split('.').map(Number);
|
|
53
|
+
|
|
54
|
+
const olderThan = (found, minimum) => {
|
|
55
|
+
for (let index = 0; index < minimum.length; index += 1) {
|
|
56
|
+
if (found[index] !== minimum[index]) return found[index] < minimum[index];
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Every routing problem this environment has, as sentences; empty when there is none. */
|
|
62
|
+
export function routingProblems(env) {
|
|
63
|
+
const problems = [];
|
|
64
|
+
for (const [variable, effect] of PIN_REPLACING_VARIABLES) {
|
|
65
|
+
const value = env[variable];
|
|
66
|
+
if (typeof value === 'string' && value !== '') {
|
|
67
|
+
problems.push(`${variable} is set: it ${effect}. Unset it for those pins to hold.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const agent = typeof env.AI_AGENT === 'string' ? VERSION.exec(env.AI_AGENT) : null;
|
|
71
|
+
if (agent === null) {
|
|
72
|
+
problems.push(
|
|
73
|
+
`could not determine the Claude Code version from AI_AGENT, so it cannot be confirmed ` +
|
|
74
|
+
`that the model pins hold: they need Claude Code ${MINIMUM_CLAUDE_CODE_VERSION} or later.`,
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
const found = [Number(agent[1]), Number(agent[2]), Number(agent[3])];
|
|
78
|
+
if (olderThan(found, numbers(MINIMUM_CLAUDE_CODE_VERSION))) {
|
|
79
|
+
problems.push(
|
|
80
|
+
`Claude Code ${found.join('.')} is older than ${MINIMUM_CLAUDE_CODE_VERSION}: before it, ` +
|
|
81
|
+
"CLAUDE_CODE_SUBAGENT_MODEL overrides every agent definition's `model:`. Upgrade Claude Code.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return problems;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function invokedDirectly() {
|
|
89
|
+
if (!process.argv[1]) return false;
|
|
90
|
+
const real = (p) => {
|
|
91
|
+
try {
|
|
92
|
+
return realpathSync(p);
|
|
93
|
+
} catch {
|
|
94
|
+
return p;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function main() {
|
|
101
|
+
try {
|
|
102
|
+
const input = readHookInput();
|
|
103
|
+
if (input === null || input.hook_event_name !== 'SessionStart') return 0;
|
|
104
|
+
const problems = routingProblems(process.env);
|
|
105
|
+
if (problems.length === 0) return 0;
|
|
106
|
+
process.stdout.write(
|
|
107
|
+
'[agent-os] WARNING — the pinned subagent routing may not hold in this session:\n' +
|
|
108
|
+
problems.map((problem) => `- ${problem}`).join('\n') +
|
|
109
|
+
'\nA gate may run on a model or effort the routing policy did not choose ' +
|
|
110
|
+
'(docs/decisions/subagent-routing.md).\n',
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
// a broken warning must never make the session unusable
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (invokedDirectly()) {
|
|
119
|
+
process.exit(main());
|
|
120
|
+
}
|
|
@@ -99,7 +99,7 @@ own cost figures are read next to the lane they do not cover.
|
|
|
99
99
|
`MultiEdit`, `NotebookEdit`, or `apply_patch` that names a credential file or carries a credential value,
|
|
100
100
|
reading its vocabulary from `.claude/scripts/lib/secrets.mjs`. ⚠ **Only that
|
|
101
101
|
part.** The hook sees what an agent writes through those five tools and
|
|
102
|
-
nothing else — its own header states
|
|
102
|
+
nothing else — its own header states its blind spots — so whether a
|
|
103
103
|
credential typed by a human, or committed from disk, is also refused depends
|
|
104
104
|
on whether this project has a commit-time check. Look at `.husky/` and the CI
|
|
105
105
|
workflow; this file cannot tell you, and a
|
|
@@ -108,15 +108,25 @@ own cost figures are read next to the lane they do not cover.
|
|
|
108
108
|
at runtime instead of writing it out, or the check reports its own test data as
|
|
109
109
|
a leak.
|
|
110
110
|
- touch production data outside a reviewed migration
|
|
111
|
-
- edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
- edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it,
|
|
112
|
+
and the checkout board selector is refused **even when the item's allow-list
|
|
113
|
+
names it**; that one carve-out does not follow from the set below.
|
|
114
|
+
The rulebook is both harnesses' instruction, rule, agent, skill, script and
|
|
115
|
+
hook trees, plus the settings, queue, exemption and integrity files that decide
|
|
116
|
+
what a session may do. **Which paths exactly is not restated here**: the
|
|
117
|
+
mechanism reads them from `RULEBOOK_PREFIXES` in
|
|
118
|
+
`.claude/scripts/unattended-flag.mjs`, and so should you — the guard judges an
|
|
119
|
+
edit against it, and the flag writer refuses an allow entry that *widens* it.
|
|
120
|
+
Which entries those are is `isWidening`'s answer in that same module, not a
|
|
121
|
+
paraphrase here: pinned in the generator's `unattended-flag.test.ts` — absent
|
|
122
|
+
in a generated rig — › "explains exact protected-prefix refusal separately
|
|
123
|
+
from proper-prefix widening". An entry
|
|
124
|
+
outside the set — ordinary source — is not widening and is accepted. A second
|
|
125
|
+
copy in prose is a copy that goes stale, and this one did. Mechanical:
|
|
116
126
|
the hook refuses the edit while the unattended flag the `loop` skill writes
|
|
117
127
|
at claim time is on disk (`.claude/scripts/unattended-flag.mjs`), and does
|
|
118
128
|
nothing in an attended session. ⚠ It sees edit tool calls only — a
|
|
119
|
-
shell redirect into
|
|
129
|
+
shell redirect into a protected file is not one — and the flag, not
|
|
120
130
|
the run, is what arms it; its header states the rest of its limits.
|
|
121
131
|
|
|
122
132
|
## Stop rules — by work-state, not by feelings
|
|
@@ -38,6 +38,11 @@ subagent with a fresh context, and why the `pr-ship` gate fans reviewers out
|
|
|
38
38
|
instead of self-checking. This isolation is load-bearing, not ceremony — do
|
|
39
39
|
not "optimise" it away by reviewing in the authoring session.
|
|
40
40
|
|
|
41
|
+
A reviewer whose definition pins its model is never dispatched with a call-site
|
|
42
|
+
`model`: which model reads a change is the routing policy's decision, not the
|
|
43
|
+
dispatching session's, and `guard-subagent-model` refuses the override
|
|
44
|
+
(`docs/decisions/subagent-routing.md`).
|
|
45
|
+
|
|
41
46
|
## PR flow
|
|
42
47
|
|
|
43
48
|
This applies **once the project has a remote and CI checks** — a freshly
|