create-agent-rig 0.6.0 → 0.6.2

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/templates/agent-os/init/AGENTS.md +4 -2
  5. package/templates/agent-os/init/CLAUDE.md +4 -2
  6. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +46 -10
  7. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
  8. package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +3 -7
  9. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -23
  10. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +6 -8
  11. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +3 -7
  12. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +100 -31
  13. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +75 -72
  14. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +3 -7
  15. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
  16. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
  17. package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +55 -0
  18. package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
  19. package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
  20. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
  21. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
  22. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
  23. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
  24. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
  25. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
  26. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +21 -8
  27. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
  28. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
  29. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +269 -58
  30. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +46 -10
  31. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
  32. package/templates/agent-os/universal/.codex/hooks.json +16 -16
  33. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
  34. package/templates/agent-os/universal/layers.json +1 -0
  35. package/templates/hash-history.json +158 -43
  36. package/templates/release-ledger.json +3 -1
  37. package/templates/skeleton/aws-serverless/gitignore +2 -0
  38. package/templates/skeleton/node-service/gitignore +2 -0
@@ -2,11 +2,13 @@
2
2
  // PreToolUse hook: in an UNATTENDED run, the rulebook is never edited outside
3
3
  // the current item's allow-list (AR-51).
4
4
  //
5
- // The rulebook is the set of files that decide what this session may do — the
6
- // hooks, their wiring, the queue config and adapters, the router and the gate
7
- // sweep, the rules, `CLAUDE.md`. Every one of them was editable by the run it
8
- // governs: a Write to `.claude/hooks/dod-checks.json` with `[]` switched the
9
- // stop gate off, and nothing refused it until the merge sweep, long after.
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,
8
+ // `.claude/doctor-exemptions.json`, `AGENTS.md` and `CLAUDE.md`. Every one of
9
+ // them was editable by the run it governs: a
10
+ // Write to `.claude/hooks/dod-checks.json` with `[]` switched the stop gate
11
+ // off, and nothing refused it until the merge sweep, long after.
10
12
  //
11
13
  // How it decides — and it is deliberately dumb:
12
14
  // 1. No unattended flag on disk (`.claude/scripts/unattended-flag.mjs`) → an
@@ -14,8 +16,12 @@
14
16
  // the rulebook on purpose.
15
17
  // 2. Flag present and readable → every edit fragment whose repo-relative path
16
18
  // sits under a rulebook prefix is refused (exit 2) unless it also sits
17
- // under one of the item's `allow` prefixes. Paths outside the rulebook are
18
- // never judged.
19
+ // under one of the item's `allow` prefixes. A known path outside the
20
+ // rulebook is never judged — › "allows a MultiEdit beyond the fragment cap
21
+ // when its known path is outside the rulebook". A pathless global refusal
22
+ // for an oversized or unsupported `apply_patch` payload is blocked while
23
+ // armed because its scope cannot be proved — › "states the pathless
24
+ // global-refusal limit for oversized and unsupported apply_patch payloads".
19
25
  // 3. Flag present and UNREADABLE → a rulebook edit is refused and the reason
20
26
  // names the flag; an edit outside the rulebook still passes. Refusing to
21
27
  // inspect is not allowing (`.claude/rules/invariants.md`).
@@ -37,15 +43,12 @@
37
43
  // - it judges paths, not content: a README that merely mentions
38
44
  // `.claude/hooks/guard-bash.mjs` is not a rulebook edit — › "guards the
39
45
  // path, not prose that mentions a guarded path";
40
- // - it compares paths as text: the repo-relative tail is what is left after
41
- // stripping `CLAUDE_PROJECT_DIR` (falling back to the working directory
42
- // when the harness does not set it) from the front of the tool's absolute
43
- // path, so a root spelled differently from the file path a symlinked
44
- // `/tmp` versus `/private/tmp`, a case difference on a case-insensitive
45
- // disk is not stripped, and the edit is not judged. Documented and
46
- // measured, not fixed: the harness spells both from one root — › "compares
47
- // paths as text: a root spelled differently from the file path is not
48
- // judged (documented, fails open)";
46
+ // - it compares both roots and payload paths in their selected and canonical
47
+ // spellings, whether selection came from `CLAUDE_PROJECT_DIR` or the
48
+ // working-directory fallback "canonicalizes a differently spelled
49
+ // checkout root before guarding a canonical payload path", "blocks when
50
+ // the checkout root and payload use the same symlink spelling", and
51
+ // "blocks an existing rulebook file when only the payload path uses a symlink spelling";
49
52
  // - an `allow` prefix is a string prefix of the repo-relative path and may
50
53
  // not widen the rulebook — an entry that is itself a prefix of a rulebook
51
54
  // prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
@@ -58,9 +61,11 @@
58
61
  // adversary.
59
62
  //
60
63
  // The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
61
- import { readFileSync } from 'node:fs';
64
+ import { realpathSync } from 'node:fs';
65
+ import { basename, dirname, join, resolve } from 'node:path';
62
66
  import { editFragments } from './lib/edit-input.mjs';
63
67
  import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
68
+ import { readHookInput } from './lib/hook-input.mjs';
64
69
 
65
70
  export { RULEBOOK_PREFIXES, isRulebookPath };
66
71
 
@@ -68,6 +73,41 @@ const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply
68
73
 
69
74
  const toPosix = (value) => String(value ?? '').replaceAll('\\', '/');
70
75
 
76
+ // RP-54: `.native`, never plain `realpathSync`. Both resolve symlinks and
77
+ // normalise separators; only the native one expands a Windows 8.3 short name,
78
+ // so `C:\Users\RUNNER~1\…` and `C:\Users\runneradmin\…` otherwise survive as
79
+ // two spellings of one directory. The root arrives from
80
+ // `git rev-parse --show-toplevel` (long) while a payload path arrives however
81
+ // the tool spelled it (short under an 8.3 temp or home), and a root that
82
+ // matches no spelling of the payload makes this fail-open guard allow the
83
+ // rulebook edit it exists to refuse. Both sites take the same canonicaliser or
84
+ // the comparison is between two different normalisations. Measured on Windows
85
+ // by codex.test.ts (absent in a generated rig) › "anchors a nested-cwd Windows
86
+ // Codex rulebook edit to the canonical repository root".
87
+ const canonicalRoot = (root) => {
88
+ try {
89
+ return realpathSync.native(root);
90
+ } catch {
91
+ return root;
92
+ }
93
+ };
94
+
95
+ /** Resolve symlinks in the nearest existing ancestor, preserving a missing tail. */
96
+ const canonicalPath = (filePath) => {
97
+ let cursor = resolve(filePath);
98
+ const tail = [];
99
+ for (;;) {
100
+ try {
101
+ return join(realpathSync.native(cursor), ...tail);
102
+ } catch {
103
+ const parent = dirname(cursor);
104
+ if (parent === cursor) return filePath;
105
+ tail.unshift(basename(cursor));
106
+ cursor = parent;
107
+ }
108
+ }
109
+ };
110
+
71
111
  /** The repo-relative tail of an absolute path, or the path itself when it is not under the root. */
72
112
  export const relativeTo = (root, filePath) => {
73
113
  const dir = toPosix(root).replace(/\/+$/, '');
@@ -79,38 +119,67 @@ export const relativeTo = (root, filePath) => {
79
119
  export const isAllowed = (rel, allow) =>
80
120
  (Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
81
121
 
122
+ const protectedRelative = (roots, filePath) =>
123
+ [...new Set([filePath, canonicalPath(filePath)])]
124
+ .flatMap((spelling) => roots.map((root) => relativeTo(root, spelling)))
125
+ .find(isRulebookPath);
126
+
82
127
  function main() {
83
- let input;
84
- try {
85
- input = JSON.parse(readFileSync(0, 'utf8'));
86
- } catch {
87
- return 0; // unparseable payload: not ours to judge
88
- }
128
+ const input = readHookInput();
129
+ if (input === null) return 0; // unparseable payload: not ours to judge
89
130
  if (!EDIT_TOOLS.has(input?.tool_name)) return 0;
90
131
 
91
- const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
132
+ const selectedRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
133
+ const root = canonicalRoot(selectedRoot);
134
+ const comparisonRoots = [...new Set([root, selectedRoot])];
135
+ const unattendedEnv = { ...process.env, CLAUDE_PROJECT_DIR: root };
136
+ const fragments = editFragments(input);
137
+ const globalRefusal = fragments.find(
138
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
139
+ );
140
+ if (globalRefusal) {
141
+ if (globalRefusal.filePath) {
142
+ const rel = protectedRelative(comparisonRoots, globalRefusal.filePath);
143
+ if (rel === undefined) return 0;
144
+ }
145
+ const mode = readUnattended(unattendedEnv);
146
+ if (!mode.on) return 0;
147
+ process.stderr.write(
148
+ `BLOCKED — cannot safely inspect this unattended edit: ${globalRefusal.inspectionRefusal}\n` +
149
+ `${globalRefusal.remedy ?? 'Split it into a smaller edit and retry.'}\n`,
150
+ );
151
+ return 2;
152
+ }
92
153
  const paths = [];
93
- for (const { filePath } of editFragments(input)) {
154
+ for (const { filePath } of fragments) {
94
155
  if (typeof filePath !== 'string' || filePath === '') continue;
95
- const rel = relativeTo(root, filePath);
96
- if (isRulebookPath(rel) && !paths.includes(rel)) paths.push(rel);
97
- if (paths.length >= 64) break;
156
+ const rel = protectedRelative(comparisonRoots, filePath);
157
+ if (rel !== undefined && !paths.includes(rel)) paths.push(rel);
98
158
  }
99
159
  if (paths.length === 0) return 0; // nothing under the rulebook: never judged
100
160
 
101
- const mode = readUnattended();
161
+ const mode = readUnattended(unattendedEnv);
102
162
  if (!mode.on) return 0; // attended session
103
163
 
104
164
  if (mode.unreadable) {
105
165
  process.stderr.write(
106
166
  `BLOCKED — "${paths[0]}" is part of the rulebook and the unattended flag at ${mode.path} is unreadable (${mode.why}). ` +
107
- 'Refusing to inspect is not allowing: fix or remove the flag (`node .claude/scripts/unattended-flag.mjs off`), then retry.\n',
167
+ 'Refusing to inspect is not allowing: fix it, or clear this checkout with `node .claude/scripts/unattended-flag.mjs off --root "$PWD"`, then retry.\n',
108
168
  );
109
169
  return 2;
110
170
  }
111
171
 
112
- const refused = paths.filter((rel) => !isAllowed(rel, mode.allow));
172
+ const refused = paths.filter(
173
+ (rel) => rel === '.claude/queue.board' || !isAllowed(rel, mode.allow),
174
+ );
113
175
  if (refused.length === 0) return 0;
176
+ if (refused[0] === '.claude/queue.board') {
177
+ process.stderr.write(
178
+ 'BLOCKED — ".claude/queue.board" is the checkout board selector and cannot be changed while unattended, even through an item allow-list. ' +
179
+ 'Disarm unattended mode before deliberately switching queues.\n',
180
+ );
181
+ return 2;
182
+ }
114
183
  process.stderr.write(
115
184
  `BLOCKED — "${refused[0]}" is part of the rulebook, and an unattended run never edits the rulebook outside its item's allow-list ` +
116
185
  `(item ${mode.item ?? '(none)'}; allowed prefixes: ${mode.allow.length === 0 ? 'none' : mode.allow.join(', ')}). ` +
@@ -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, and the moment you edit it they are yours.
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 FOUR:
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`, and `apply_patch`; every other
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
@@ -74,10 +82,10 @@
74
82
  // by an explicit per-line candidate cap rather than running to exhaustion. Any
75
83
  // unbounded work in a fail-open guard is a total bypass of every rule at once,
76
84
  // not just of this one.
77
- import { readFileSync } from 'node:fs';
78
85
 
79
86
  import { findSecretValues, isCredentialPath } from '../scripts/lib/secrets.mjs';
80
87
  import { editFragments } from './lib/edit-input.mjs';
88
+ import { readHookInput } from './lib/hook-input.mjs';
81
89
 
82
90
  /** Where a refusal points the agent, so the block is actionable rather than a wall. */
83
91
  const WHERE_CREDENTIALS_BELONG =
@@ -85,47 +93,30 @@ const WHERE_CREDENTIALS_BELONG =
85
93
  'and reach the process through the environment — see .claude/rules/autonomy.md, "Never".';
86
94
 
87
95
  function main() {
88
- let input;
89
- try {
90
- input = JSON.parse(readFileSync(0, 'utf8'));
91
- } catch {
92
- return 0; // unparseable payload: not ours to judge
93
- }
96
+ const input = readHookInput();
97
+ if (input === null) return 0; // unparseable payload: not ours to judge
94
98
 
95
- const toolName = input?.tool_name;
96
- if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'apply_patch') return 0;
99
+ const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
100
+ if (!editTools.has(input?.tool_name)) return 0;
97
101
 
98
- if (toolName === 'apply_patch') {
99
- let refused = false;
100
- for (const { filePath, fragment, inspectionRefusal, remedy, appliesToAll } of editFragments(input)) {
101
- if (inspectionRefusal) {
102
- refused = true;
103
- process.stderr.write(
104
- `BLOCKED cannot safely inspect this edit: ${inspectionRefusal}\n` +
105
- // The remedy has to match the refusal: splitting cannot change a
106
- // container shape, and a fixed line sent the agent into a retry loop
107
- // on the one path it could not retry out of.
108
- `${remedy ?? 'Split it into a smaller patch and retry.'}\n`,
109
- );
110
- continue;
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;
102
+ const fragments = editFragments(input);
103
+ const globalRefusal = fragments.find(
104
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
105
+ );
106
+ if (globalRefusal) {
107
+ const fallbackRemedy = input?.tool_name === 'apply_patch'
108
+ ? 'Split it into a smaller patch and retry.'
109
+ : 'Split it into a smaller edit and retry.';
110
+ process.stderr.write(
111
+ `BLOCKED cannot safely inspect this edit: ${globalRefusal.inspectionRefusal}\n` +
112
+ `${globalRefusal.remedy ?? fallbackRemedy}\n`,
113
+ );
114
+ return 2;
124
115
  }
125
116
 
126
- const toolInput = input?.tool_input ?? {};
127
- const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
128
- if (filePath === '') return 0; // nothing to judge; fail open
117
+ if (fragments.length === 0 || fragments.every(({ filePath }) => filePath === '')) {
118
+ return 0; // nothing to judge; fail open
119
+ }
129
120
 
130
121
  // The tool sends an absolute path. Judge the repo-relative tail so a checkout
131
122
  // living under a directory literally called `secrets` does not make every edit
@@ -134,41 +125,53 @@ function main() {
134
125
  // below never matches, every path stays absolute, and a checkout that happens
135
126
  // to live under a directory called `secrets` has EVERY edit refused. That is
136
127
  // the "deleted within the hour" outcome `.claude/rules/invariants.md` warns
137
- // about — see guard-secret-file.test.ts "judges the repo-relative path even
138
- // when the project directory is given with a trailing slash".
128
+ // about — see guard-secret-file.test.ts (absent in a generated rig)
129
+ // "judges the repo-relative path even when the project directory is given %s".
139
130
  const projectDir = String(process.env.CLAUDE_PROJECT_DIR ?? '')
140
131
  .replaceAll('\\', '/')
141
132
  .replace(/\/+$/, '');
142
- const relativePath =
143
- projectDir !== '' && filePath.startsWith(`${projectDir}/`)
144
- ? filePath.slice(projectDir.length + 1)
145
- : filePath;
133
+ let refused = false;
134
+ for (const { filePath, fragment, inspectionRefusal } of fragments) {
135
+ const relativePath =
136
+ projectDir !== '' && filePath.startsWith(`${projectDir}/`)
137
+ ? filePath.slice(projectDir.length + 1)
138
+ : filePath;
139
+ if (relativePath === '') continue;
146
140
 
147
- if (isCredentialPath(relativePath)) {
141
+ if (inspectionRefusal) {
142
+ refused = true;
143
+ process.stderr.write(
144
+ `BLOCKED — cannot safely inspect this edit to "${relativePath}": ${inspectionRefusal}\n` +
145
+ 'Split it into a smaller edit and retry.\n',
146
+ );
147
+ continue;
148
+ }
149
+ if (isCredentialPath(relativePath)) {
150
+ refused = true;
151
+ process.stderr.write(
152
+ `BLOCKED — "${relativePath}" is a credential file, and this repository never carries one.\n` +
153
+ `${WHERE_CREDENTIALS_BELONG}\n` +
154
+ `If this file is a documented placeholder, name it .env.example — that form stays committable.\n`,
155
+ );
156
+ continue;
157
+ }
158
+
159
+ const findings = findSecretValues(fragment);
160
+ if (findings.length === 0) continue;
161
+ refused = true;
148
162
  process.stderr.write(
149
- `BLOCKED — "${relativePath}" is a credential file, and this repository never carries one.\n` +
150
- `${WHERE_CREDENTIALS_BELONG}\n` +
151
- `If this file is a documented placeholder, name it .env.example that form stays committable.\n`,
163
+ `BLOCKED — this edit writes a credential value into "${relativePath}":\n` +
164
+ findings
165
+ .map((finding) => ` - ${finding.id} on line ${finding.line} of the text being written`)
166
+ .join('\n') +
167
+ `\n${WHERE_CREDENTIALS_BELONG}\n` +
168
+ // Deliberately NOT the matched text. A guard that prints what it found has
169
+ // copied the credential into a hook transcript and a terminal scrollback —
170
+ // it has leaked the secret in the act of refusing it.
171
+ `The matched value is deliberately not shown; open the line above to see it.\n`,
152
172
  );
153
- return 2;
154
173
  }
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;
174
+ return refused ? 2 : 0;
172
175
  }
173
176
 
174
177
  let status;
@@ -8,8 +8,8 @@
8
8
  // Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
9
9
  // codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
10
10
  // projects do not carry that suite, and a downstream edit requires a local replacement test.
11
- import { readFileSync } from 'node:fs';
12
11
  import { editFragments } from './lib/edit-input.mjs';
12
+ import { readHookInput } from './lib/hook-input.mjs';
13
13
 
14
14
  const WEB_PATH = /(^|\/)apps\/web\//;
15
15
  const CODE_FILE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
@@ -20,12 +20,8 @@ const FORBIDDEN_WORKSPACE = /^@[^/]+\/(db|api|worker)$/;
20
20
  const FORBIDDEN_RELATIVE = /(^|\/)(packages\/db|services)(\/|$)/;
21
21
 
22
22
  function main() {
23
- let input;
24
- try {
25
- input = JSON.parse(readFileSync(0, 'utf8'));
26
- } catch {
27
- return 0; // unparseable payload: not ours to judge
28
- }
23
+ const input = readHookInput();
24
+ if (input === null) return 0; // unparseable payload: not ours to judge
29
25
  const violations = [];
30
26
  const importRe =
31
27
  /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"]([^'"]+)['"]/gm;
@@ -30,6 +30,7 @@
30
30
  // is why every ambiguity resolves toward injecting more.
31
31
  import { readFileSync, realpathSync } from 'node:fs';
32
32
  import { fileURLToPath } from 'node:url';
33
+ import { readHookInput } from './lib/hook-input.mjs';
33
34
 
34
35
  // Regions the rule file marks as not worth injecting. The marker is explicit
35
36
  // and lives in the rule file itself, where the person editing it can see it —
@@ -150,12 +151,8 @@ function invokedDirectly() {
150
151
  }
151
152
 
152
153
  function main() {
153
- let input;
154
- try {
155
- input = JSON.parse(readFileSync(0, 'utf8'));
156
- } catch {
157
- return 0;
158
- }
154
+ const input = readHookInput();
155
+ if (input === null) return 0;
159
156
  if (input.hook_event_name !== 'SessionStart') return 0;
160
157
 
161
158
  let rules;
@@ -70,7 +70,17 @@ export function editFragments(input) {
70
70
  if (toolName === 'MultiEdit') {
71
71
  if (!Array.isArray(toolInput.edits)) return [];
72
72
  const filePath = normalisePath(toolInput.file_path);
73
- return toolInput.edits.slice(0, MAX_MULTI_EDITS).map((edit) => ({
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) => ({
74
84
  filePath,
75
85
  fragment: String(edit?.new_string ?? ''),
76
86
  }));
@@ -0,0 +1,55 @@
1
+ // The one place a hook reads its payload — `invariants.md`, "One mechanism,
2
+ // one implementation". Eight hooks each parsing stdin is eight chances for one
3
+ // of them to keep a defect the others fixed, and the one nobody looks at is the
4
+ // one that will.
5
+ //
6
+ // 🔴 Why this module exists at all (RP-54). A hook that cannot parse its
7
+ // payload allows the tool call — that is the documented fail-open, and it is
8
+ // correct: a crashed guard must not make the session unusable. But it means an
9
+ // unreadable payload silently disarms EVERY rule the hook carries, so what
10
+ // counts as unreadable has to be as narrow as the format honestly allows.
11
+ //
12
+ // Measured on the hosted `windows-unit` runner, through the generated Codex
13
+ // `commandWindows` wrapper (CI run 33281160544): the wrapper delivers stdin and
14
+ // propagates the child's exit code, but the guard received **290 bytes for a
15
+ // 287-byte payload**. PowerShell prepends a UTF-8 BOM on that host. `JSON.parse`
16
+ // throws on a leading U+FEFF, so every guard resolved a well-formed payload to
17
+ // "not ours to judge" and allowed the edit — including `guard-bash`, which
18
+ // carries the Never tier and the kill switch. A Windows host whose PowerShell
19
+ // does not add the BOM parses the same payload fine, which is why this looked
20
+ // like a runner-only mystery for two days.
21
+ //
22
+ // A BOM is a byte-order mark, not content: stripping one leading U+FEFF is what
23
+ // the format means, not a tolerance added to get a test green.
24
+ //
25
+ // Bounded work, because a fail-open reader is a total bypass if it can throw or
26
+ // spin: one read, one character comparison, one slice, one parse. No loop, no
27
+ // recursion, no rescanning.
28
+ //
29
+ // Limits, stated rather than implied:
30
+ // - Only a BOM at position 0 is stripped. A payload with other leading bytes is
31
+ // still unreadable, and still resolves to `null`.
32
+ // - `null` means "no payload this hook can judge" and every caller keeps its own
33
+ // fail-open on it. This module does not decide policy; it only removes the
34
+ // spelling difference between two hosts.
35
+ // Pinned in hook-stdin.test.ts (absent in a generated rig) ›
36
+ // "blocks the same command when PowerShell prepends a UTF-8 BOM" and ›
37
+ // "reads stdin through the one shared reader, in every hook that reads it".
38
+
39
+ import { readFileSync } from 'node:fs';
40
+
41
+ /** The hook payload as an object, or `null` when there is none this hook can read. */
42
+ export const readHookInput = () => {
43
+ let raw;
44
+ try {
45
+ raw = readFileSync(0, 'utf8');
46
+ } catch {
47
+ return null;
48
+ }
49
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
50
+ try {
51
+ return JSON.parse(raw);
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
@@ -95,10 +95,10 @@ own cost figures are read next to the lane they do not cover.
95
95
  - bypass pre-commit (`--no-verify` is hook-blocked anyway)
96
96
  - force-push a shared branch
97
97
  - put secrets in code, config, logs, or fixtures. One part of this is
98
- **mechanical**: `guard-secret-file` refuses an edit through `Write`, `Edit`, or
99
- `apply_patch` that names a credential file or carries a credential value,
98
+ **mechanical**: `guard-secret-file` refuses an edit through `Write`, `Edit`,
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
- part.** The hook sees what an agent writes through those three tools and
101
+ part.** The hook sees what an agent writes through those five tools and
102
102
  nothing else — its own header states the four 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
@@ -109,8 +109,10 @@ own cost figures are read next to the lane they do not cover.
109
109
  a leak.
110
110
  - touch production data outside a reviewed migration
111
111
  - edit the rulebook from an **unattended** run outside the item's allow-list — `guard-rulebook` refuses it.
112
- The rulebook: the hooks, their wiring, `.claude/queue.json`, the queue
113
- adapters, the router, the gate sweep, the rules, `CLAUDE.md`. Mechanical:
112
+ The rulebook is both harnesses' instruction, agent, skill, script and hook
113
+ trees (`CLAUDE.md`, `AGENTS.md`, `.claude/{agents,hooks,rules,scripts,skills}`,
114
+ `.agents/`, `.codex/`), plus `.claude/settings.json`, the queue config and its
115
+ always-refused board selector, and the integrity manifest. Mechanical:
114
116
  the hook refuses the edit while the unattended flag the `loop` skill writes
115
117
  at claim time is on disk (`.claude/scripts/unattended-flag.mjs`), and does
116
118
  nothing in an attended session. ⚠ It sees edit tool calls only — a
@@ -216,27 +216,25 @@ are guessing, and a guessed invariant is the one that will fire on honest work.
216
216
 
217
217
  ## About the hooks you were given
218
218
 
219
- They arrive with their tests **in the generator that produced this project**, not
220
- in this repository so by the rule above, as they sit here, they are checks
221
- without tests. That is deliberate and it has a boundary: it holds only while they
222
- are untouched.
223
-
224
- This is the one narrow exception for a generator-authored hook: it may cite the
225
- generator's upstream tests **only while unchanged downstream**, and its hook
226
- header must identify the upstream generator tests as absent locally. That pointer
227
- records the evidence used to author the inherited snapshot; it does not turn the
228
- absent test into a local check.
229
-
230
- **The moment you edit one, its test is yours.** A guard whose behaviour has
231
- changed and whose test lives somewhere else is precisely the "quietly stopped
232
- matching" case this rule names, and nothing here would catch it. The same applies
233
- if you keep a hook whose invariant you have re-scoped.
219
+ Generator-authored rulebook artifacts rules, hooks, skills, scripts and agent
220
+ specsarrive with their tests **in the generator that produced this project**,
221
+ not in this repository. There is one narrow inherited-snapshot exception: such
222
+ an artifact may cite the generator's upstream tests, which are absent locally,
223
+ only when the pointer says they are absent and `.claude/.rig-manifest.json`
224
+ proves the current artifact's hash matches the installed manifest.
225
+
226
+ A manifest-backed upgrade remains an inherited, generator-owned artifact even
227
+ though the upgrade diff changes its bytes. The exception applies **only while the
228
+ manifest hash matches**. A hash mismatch, missing manifest, or no evidence ends
229
+ the exception and the local test is yours; an owned guard whose behaviour changed
230
+ while its test lives elsewhere is precisely the "quietly stopped matching" case
231
+ this rule names.
234
232
 
235
233
  If a hook matters enough to keep, it is worth ten minutes to copy the shape from
236
234
  `.claude/skills/new-invariant/guard-invariant.example.test.mjs` and pin the
237
235
  behaviour you actually rely on.
238
236
 
239
- **That boundary is audited, not remembered.** `node .claude/scripts/doctor.mjs`
237
+ **For hooks, that ownership boundary is audited, not remembered.** `node .claude/scripts/doctor.mjs`
240
238
  reads `.claude/.rig-manifest.json` and asks of every hook in `.claude/hooks/` (and
241
239
  `.husky/`, when it exists) whether the project owns it — the bytes differ from
242
240
  what the generator installed, or the manifest has no entry — and, if so, whether
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * The decision router — which gate does this change deserve, and what does that
3
3
  * cost.
4
+ * All upstream test pointers in this script name the generator suite, absent in a generated rig.
4
5
  *
5
6
  * `pr-ship` is the merge-time gate and it always runs the expensive path: the
6
7
  * full suite, then `code-reviewer` on every diff. That is right for a change
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ // All upstream test pointers in this script name the generator suite, absent in a generated rig.
2
3
  // doctor — the harness audits itself (AR-5).
3
4
  //
4
5
  // node .claude/scripts/doctor.mjs # the report, ready to paste
@@ -10,7 +11,9 @@
10
11
  // `.claude/rules/invariants.md` — a stated rule, a mechanical check, a test for
11
12
  // the check — is decoration with any part missing, and the part a rig loses
12
13
  // first is the third: the shipped hooks arrive with their tests in the generator
13
- // that produced them, and the moment one is edited its test is the rig's own.
14
+ // that produced them. A manifest-backed generator upgrade remains inherited;
15
+ // the test becomes the rig's own when the current hash no longer matches the
16
+ // manifest evidence below.
14
17
  //
15
18
  // Ownership is read from `.claude/.rig-manifest.json`, the install manifest the
16
19
  // generator writes (its `files` map is install-relative path → sha256 of the
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * The environment a child `git` should run with — one implementation, imported.
3
+ * All upstream test pointers in this script name the generator suite, absent in a generated rig.
3
4
  *
4
5
  * 🔴 **Why this is not inlined at each call site.** A process started under a
5
6
  * git hook inherits `GIT_DIR` and `GIT_INDEX_FILE`, and any child then answers