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.
- package/CHANGELOG.md +78 -0
- package/README.md +1 -1
- package/package.json +3 -1
- package/templates/agent-os/init/AGENTS.md +4 -2
- package/templates/agent-os/init/CLAUDE.md +4 -2
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +46 -10
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +10 -5
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +20 -23
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +6 -8
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +100 -31
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +75 -72
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +11 -1
- package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +55 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +7 -5
- package/templates/agent-os/universal/.claude/rules/invariants.md +14 -16
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +4 -1
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +144 -4
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +21 -8
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +1 -0
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +269 -58
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +46 -10
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +1 -1
- package/templates/agent-os/universal/.codex/hooks.json +16 -16
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
- package/templates/agent-os/universal/layers.json +1 -0
- package/templates/hash-history.json +158 -43
- package/templates/release-ledger.json +3 -1
- package/templates/skeleton/aws-serverless/gitignore +2 -0
- 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 —
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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.
|
|
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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 {
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
154
|
+
for (const { filePath } of fragments) {
|
|
94
155
|
if (typeof filePath !== 'string' || filePath === '') continue;
|
|
95
|
-
const rel =
|
|
96
|
-
if (
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
|
96
|
-
if (
|
|
99
|
+
const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
100
|
+
if (!editTools.has(input?.tool_name)) return 0;
|
|
97
101
|
|
|
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;
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
138
|
-
// when the project directory is given
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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 —
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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`,
|
|
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
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
are
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
specs — arrive 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
|
-
**
|
|
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
|
|
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
|