create-agent-rig 0.6.1 → 0.7.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 +143 -0
- package/package.json +3 -1
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +2 -3
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +122 -81
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +19 -12
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +24 -8
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +3 -6
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +67 -12
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +5 -9
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +20 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +3 -7
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +5 -9
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +3 -6
- package/templates/agent-os/universal/.claude/hooks/lib/hook-input.mjs +164 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +19 -0
- package/templates/agent-os/universal/.claude/scripts/lib/claim-records.mjs +800 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-evidence.mjs +56 -0
- package/templates/agent-os/universal/.claude/scripts/lib/shell-tools.mjs +81 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +19 -1
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +17 -66
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +29 -7
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +159 -23
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +61 -27
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +4 -2
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +268 -48
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +32 -15
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +180 -37
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +33 -19
- package/templates/agent-os/universal/.claude/settings.json +1 -1
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +122 -81
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +19 -12
- package/templates/agent-os/universal/.codex/hooks.json +17 -17
- package/templates/agent-os/universal/.rig/revalidation.json +10 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +3 -2
- package/templates/agent-os/universal/docs/decisions/content-blind-revalidation.md +144 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +15 -0
- package/templates/agent-os/universal/layers.json +6 -0
- package/templates/hash-history.json +71 -28
- package/templates/release-ledger.json +3 -1
|
@@ -66,6 +66,7 @@ import { readFileSync, realpathSync } from 'node:fs';
|
|
|
66
66
|
import { fileURLToPath } from 'node:url';
|
|
67
67
|
|
|
68
68
|
import { withoutGitLocation } from '../scripts/git-env.mjs';
|
|
69
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
69
70
|
|
|
70
71
|
// 🔴 The tree this gate measures is the project the hook BELONGS to — the
|
|
71
72
|
// directory above `.claude/hooks/` — never the directory the session happens
|
|
@@ -166,12 +167,8 @@ function budgetMs(env) {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
function main() {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
172
|
-
} catch (error) {
|
|
173
|
-
return failOpen(`the Stop payload could not be read: ${error.message}`);
|
|
174
|
-
}
|
|
170
|
+
const input = readHookInput();
|
|
171
|
+
if (input === null) return failOpen('the Stop payload could not be read');
|
|
175
172
|
if (input.hook_event_name !== 'Stop' && input.hook_event_name !== 'SubagentStop') return 0;
|
|
176
173
|
if (input.stop_hook_active) return 0;
|
|
177
174
|
|
|
@@ -74,12 +74,48 @@
|
|
|
74
74
|
// written without thinking — not an adversary, and circumventing it is itself a
|
|
75
75
|
// Never-tier violation. The layers behind it are review and CI.
|
|
76
76
|
//
|
|
77
|
+
// ── Which SURFACES it sees, and what that does not promise (RP-65) ───────────
|
|
78
|
+
//
|
|
79
|
+
// This runs for every tool named in `.claude/scripts/lib/shell-tools.mjs`, not
|
|
80
|
+
// for `Bash` alone. It used to be wired under `Bash` only, and the measurement
|
|
81
|
+
// that changed it is in that file: the same `--no-verify` command was blocked
|
|
82
|
+
// through one tool and ran through the other, in one session.
|
|
83
|
+
//
|
|
84
|
+
// ⚠ Widening the matcher makes the same RULES run on both surfaces. It does not
|
|
85
|
+
// make the PARSING identical: the tokeniser above is POSIX, and PowerShell's
|
|
86
|
+
// quoting, escaping and separators are its own, so a command whose danger is
|
|
87
|
+
// visible only after PowerShell-specific parsing can read differently here.
|
|
88
|
+
//
|
|
89
|
+
// ⚠ And the coarse checks are narrower than "coarse" suggests. The rules
|
|
90
|
+
// match a command NAME — `git`, `gh`, `rm` — so they refuse the operation
|
|
91
|
+
// only when the operation is spelled that way.
|
|
92
|
+
// Measured with the brake armed: `gh pr merge …` is refused on both surfaces,
|
|
93
|
+
// while `gh.exe pr merge …`, `Start-Process gh -ArgumentList …` and
|
|
94
|
+
// `Remove-Item -Recurse -Force C:\` are all allowed. The first of those is
|
|
95
|
+
// allowed under `Bash` too, so this is a rule-set bound rather than anything
|
|
96
|
+
// the widened matcher introduced — but it is a bound, and an earlier draft of
|
|
97
|
+
// this block claimed the opposite. This gap is why the file keeps a name that
|
|
98
|
+
// says `bash`: a rename would promise a parity the parser does not have.
|
|
99
|
+
//
|
|
77
100
|
// Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
|
|
78
|
-
// stderr is shown to the agent as the reason.
|
|
79
|
-
//
|
|
80
|
-
|
|
101
|
+
// stderr is shown to the agent as the reason.
|
|
102
|
+
//
|
|
103
|
+
// Two different things happen to input this guard cannot act on, and collapsing
|
|
104
|
+
// them into one sentence is the mistake `.claude/rules/invariants.md`
|
|
105
|
+
// ("Refusing to inspect is a third outcome") says costs a credential either way:
|
|
106
|
+
// - NOTHING TO JUDGE -> allow. An unparseable payload, no `tool_input`, no
|
|
107
|
+
// `command`, an empty one, a tool this guard does not answer for, or a crash
|
|
108
|
+
// inside `inspect` — a guard that has nothing to look at, or that broke, must
|
|
109
|
+
// never make the session unusable.
|
|
110
|
+
// - HANDED SOMETHING IT CANNOT READ -> block. A `command` that is present in a
|
|
111
|
+
// shape this guard does not accept is refused, naming the shape expected,
|
|
112
|
+
// because allowing it would report a check that never ran.
|
|
113
|
+
// The split is decided in one place for both shell guards, `lib/hook-input.mjs`.
|
|
114
|
+
import { realpathSync } from 'node:fs';
|
|
81
115
|
import { fileURLToPath } from 'node:url';
|
|
82
116
|
import { brakeIsOn } from '../scripts/stop-flag.mjs';
|
|
117
|
+
import { SHELL_TOOLS } from '../scripts/lib/shell-tools.mjs';
|
|
118
|
+
import { readHookInput, refusalText, shellCommandOf } from './lib/hook-input.mjs';
|
|
83
119
|
|
|
84
120
|
/** Branches that are shared by definition. */
|
|
85
121
|
const PROTECTED_BRANCH = /^(main|master|develop|development|trunk)$/;
|
|
@@ -804,16 +840,35 @@ export const inspect = (raw, brake, depth = 0) => {
|
|
|
804
840
|
};
|
|
805
841
|
|
|
806
842
|
function main() {
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
843
|
+
const input = readHookInput();
|
|
844
|
+
if (input === null) return 0;
|
|
845
|
+
// The ONE list decides which surfaces this guard answers for. Comparing a
|
|
846
|
+
// literal here is what made the widened matcher in `settings.json` cosmetic:
|
|
847
|
+
// the hook was launched for every shell tool and then excused itself from all
|
|
848
|
+
// but one, so the Never tier and the kill switch stayed bypassable on the
|
|
849
|
+
// other. Two spellings of one fact, and the one that ran was the wrong one.
|
|
850
|
+
if (!SHELL_TOOLS.includes(input.tool_name)) return 0;
|
|
851
|
+
// Three outcomes, decided in one shared place (RP-80): absent → allow, a
|
|
852
|
+
// string → inspect, present-in-a-shape-this-cannot-read → REFUSE. The last
|
|
853
|
+
// one used to be an allow, and what that cost is measured rather than
|
|
854
|
+
// asserted: on `master` at `254b25c8`, with the kill switch armed, a
|
|
855
|
+
// `command` spelled as an array of argv words returned 0 here before
|
|
856
|
+
// `brakeIsOn()` was ever consulted. Pinned in hook-command-shape.test.ts
|
|
857
|
+
// (absent in a generated rig) › "refuses an unreadable command through %s
|
|
858
|
+
// while the kill switch is armed". A rule that can be stepped over by
|
|
859
|
+
// restating the same command in another container is not a rule.
|
|
860
|
+
const command = shellCommandOf(input);
|
|
861
|
+
if (command.kind === 'unreadable') {
|
|
862
|
+
process.stderr.write(`${refusalText(command)}\n`);
|
|
863
|
+
return 2;
|
|
812
864
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
865
|
+
// Every member except `string` leaves nothing to inspect. Stated as one
|
|
866
|
+
// POSITIVE test rather than a list of the others, so a member added later
|
|
867
|
+
// cannot fall through to `raw.trim()` — which sits outside the try below,
|
|
868
|
+
// where a throw exits 1 and the harness reads that as allow. That is the
|
|
869
|
+
// fail-open this change removes, re-entering by another door.
|
|
870
|
+
if (command.kind !== 'string') return 0;
|
|
871
|
+
const raw = command.command;
|
|
817
872
|
if (!raw.trim()) return 0;
|
|
818
873
|
|
|
819
874
|
try {
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// Contract (Claude Code and Codex): JSON on stdin; exit 0 = allow, exit 2 = block, and
|
|
7
7
|
// stderr is shown to the agent as the reason.
|
|
8
8
|
// Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
|
|
9
|
-
// codex.test.ts › "$guard blocks with a neutral,
|
|
10
|
-
//
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
9
|
+
// codex.test.ts (absent in a generated rig) › "$guard blocks with a neutral,
|
|
10
|
+
// actionable size-limit refusal" — a downstream edit requires a local replacement test.
|
|
12
11
|
import { editFragments } from './lib/edit-input.mjs';
|
|
12
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
13
13
|
|
|
14
14
|
/** The only non-relative import the core may use: its schema/validation library. */
|
|
15
15
|
const ALLOWED_PACKAGES = ['zod'];
|
|
@@ -29,12 +29,8 @@ const BANNED = [
|
|
|
29
29
|
];
|
|
30
30
|
|
|
31
31
|
function main() {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
35
|
-
} catch {
|
|
36
|
-
return 0; // unparseable payload: not ours to judge
|
|
37
|
-
}
|
|
32
|
+
const input = readHookInput();
|
|
33
|
+
if (input === null) return 0; // unparseable payload: not ours to judge
|
|
38
34
|
const fragments = editFragments(input);
|
|
39
35
|
const blocked = fragments.find(
|
|
40
36
|
({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
//
|
|
5
5
|
// The rulebook is the set of files that decide what this session may do — hooks
|
|
6
6
|
// and settings wiring, the queue config and selector, all scripts, rules, skills
|
|
7
|
-
// and agents, the `.codex/` configuration, the integrity manifest,
|
|
8
|
-
// and `CLAUDE.md`. Every one of
|
|
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
|
|
9
10
|
// Write to `.claude/hooks/dod-checks.json` with `[]` switched the stop gate
|
|
10
11
|
// off, and nothing refused it until the merge sweep, long after.
|
|
11
12
|
//
|
|
@@ -60,10 +61,11 @@
|
|
|
60
61
|
// adversary.
|
|
61
62
|
//
|
|
62
63
|
// The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
|
|
63
|
-
import {
|
|
64
|
+
import { realpathSync } from 'node:fs';
|
|
64
65
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
65
66
|
import { editFragments } from './lib/edit-input.mjs';
|
|
66
67
|
import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
|
|
68
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
67
69
|
|
|
68
70
|
export { RULEBOOK_PREFIXES, isRulebookPath };
|
|
69
71
|
|
|
@@ -71,9 +73,20 @@ const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply
|
|
|
71
73
|
|
|
72
74
|
const toPosix = (value) => String(value ?? '').replaceAll('\\', '/');
|
|
73
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".
|
|
74
87
|
const canonicalRoot = (root) => {
|
|
75
88
|
try {
|
|
76
|
-
return realpathSync(root);
|
|
89
|
+
return realpathSync.native(root);
|
|
77
90
|
} catch {
|
|
78
91
|
return root;
|
|
79
92
|
}
|
|
@@ -85,7 +98,7 @@ const canonicalPath = (filePath) => {
|
|
|
85
98
|
const tail = [];
|
|
86
99
|
for (;;) {
|
|
87
100
|
try {
|
|
88
|
-
return join(realpathSync(cursor), ...tail);
|
|
101
|
+
return join(realpathSync.native(cursor), ...tail);
|
|
89
102
|
} catch {
|
|
90
103
|
const parent = dirname(cursor);
|
|
91
104
|
if (parent === cursor) return filePath;
|
|
@@ -112,12 +125,8 @@ const protectedRelative = (roots, filePath) =>
|
|
|
112
125
|
.find(isRulebookPath);
|
|
113
126
|
|
|
114
127
|
function main() {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
118
|
-
} catch {
|
|
119
|
-
return 0; // unparseable payload: not ours to judge
|
|
120
|
-
}
|
|
128
|
+
const input = readHookInput();
|
|
129
|
+
if (input === null) return 0; // unparseable payload: not ours to judge
|
|
121
130
|
if (!EDIT_TOOLS.has(input?.tool_name)) return 0;
|
|
122
131
|
|
|
123
132
|
const selectedRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
@@ -82,10 +82,10 @@
|
|
|
82
82
|
// by an explicit per-line candidate cap rather than running to exhaustion. Any
|
|
83
83
|
// unbounded work in a fail-open guard is a total bypass of every rule at once,
|
|
84
84
|
// not just of this one.
|
|
85
|
-
import { readFileSync } from 'node:fs';
|
|
86
85
|
|
|
87
86
|
import { findSecretValues, isCredentialPath } from '../scripts/lib/secrets.mjs';
|
|
88
87
|
import { editFragments } from './lib/edit-input.mjs';
|
|
88
|
+
import { readHookInput } from './lib/hook-input.mjs';
|
|
89
89
|
|
|
90
90
|
/** Where a refusal points the agent, so the block is actionable rather than a wall. */
|
|
91
91
|
const WHERE_CREDENTIALS_BELONG =
|
|
@@ -93,12 +93,8 @@ const WHERE_CREDENTIALS_BELONG =
|
|
|
93
93
|
'and reach the process through the environment — see .claude/rules/autonomy.md, "Never".';
|
|
94
94
|
|
|
95
95
|
function main() {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
99
|
-
} catch {
|
|
100
|
-
return 0; // unparseable payload: not ours to judge
|
|
101
|
-
}
|
|
96
|
+
const input = readHookInput();
|
|
97
|
+
if (input === null) return 0; // unparseable payload: not ours to judge
|
|
102
98
|
|
|
103
99
|
const editTools = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
|
|
104
100
|
if (!editTools.has(input?.tool_name)) return 0;
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// Contract (Claude Code and Codex): JSON on stdin; exit 0 = allow, exit 2 = block, and
|
|
7
7
|
// stderr is shown to the agent as the reason.
|
|
8
8
|
// Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
|
|
9
|
-
// codex.test.ts › "$guard blocks with a neutral,
|
|
10
|
-
//
|
|
11
|
-
import { readFileSync } from 'node:fs';
|
|
9
|
+
// codex.test.ts (absent in a generated rig) › "$guard blocks with a neutral,
|
|
10
|
+
// actionable size-limit refusal" — a downstream edit requires a local replacement test.
|
|
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;
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ── The shape of a shell command, for the Never-tier SHELL guards (RP-80) ───
|
|
59
|
+
*
|
|
60
|
+
* One place, for the two guards that read `tool_input.command` on a shell tool —
|
|
61
|
+
* `guard-bash` and `block-no-verify`. It is deliberately NOT a repository-wide
|
|
62
|
+
* ruling on the word `command`: `guard-secret-file` reads an `apply_patch`
|
|
63
|
+
* `command` that is a **list of strings**, which is exactly the shape this
|
|
64
|
+
* module classifies as unreadable, and it is right to — the reasoning is in
|
|
65
|
+
* `docs/decisions/codex-adapter.md`. Routing a third guard through here without
|
|
66
|
+
* checking which shape its tool actually sends would start refusing input
|
|
67
|
+
* another guard exists to read.
|
|
68
|
+
*
|
|
69
|
+
* `.claude/rules/invariants.md` ("Refusing to inspect is a third outcome, not a
|
|
70
|
+
* match and not an error") draws the line these guards need:
|
|
71
|
+
*
|
|
72
|
+
* - **absent** — nothing to judge, so the guard fails OPEN. A `Write` with no
|
|
73
|
+
* content and a shell tool with no `command` are the same case.
|
|
74
|
+
* - **`string`** — the guard inspects it as it always did.
|
|
75
|
+
* - **present and unreadable** — the guard was handed something and can tell
|
|
76
|
+
* that it cannot read it. That is a REFUSAL: block, name the shape expected,
|
|
77
|
+
* and say to resend in that shape.
|
|
78
|
+
*
|
|
79
|
+
* 🔴 Getting that last line backwards costs the whole rule set. Measured on
|
|
80
|
+
* `master` at `254b25c8`: a payload whose `command` was `["gh","pr","merge","1"]`
|
|
81
|
+
* exited 0 from `guard-bash` **with the kill switch armed** — the brake was
|
|
82
|
+
* never consulted, because the guard had already excused itself. `block-no-verify`
|
|
83
|
+
* had the same hole by another road: `String(argv)` comma-joins, and its
|
|
84
|
+
* tokeniser never splits on a comma, so `--no-verify` became invisible rather
|
|
85
|
+
* than unreadable.
|
|
86
|
+
*
|
|
87
|
+
* 🔴 The remedy is carried as a FIELD beside the reason, not inferred from the
|
|
88
|
+
* reason's wording by whoever prints it — `invariants.md` again. A remedy
|
|
89
|
+
* chosen by pattern-matching a sentence is wrong the day somebody rewords the
|
|
90
|
+
* sentence, in every copy at once. And it is deliberately NOT "split the change
|
|
91
|
+
* and retry": that advice belongs to a crossed BOUND, where a smaller input
|
|
92
|
+
* really does fit. Nothing about splitting changes a container's shape, so
|
|
93
|
+
* offering it here would turn a refusal into a loop.
|
|
94
|
+
*
|
|
95
|
+
* Bounded work, because a fail-open guard is a total bypass if any input can
|
|
96
|
+
* make it spin or throw: a fixed number of `typeof` tests and one lookup. The
|
|
97
|
+
* offending value is NEVER serialised into the message — only its shape word —
|
|
98
|
+
* so an enormous or cyclic `command` costs nothing and leaks nothing.
|
|
99
|
+
*
|
|
100
|
+
* Limits, stated rather than implied:
|
|
101
|
+
* - It reads `tool_input.command`. A surface that names its command field
|
|
102
|
+
* differently is not seen at all, and that is the same blind spot the whole
|
|
103
|
+
* list in `.claude/scripts/lib/shell-tools.mjs` has: harness → guard is
|
|
104
|
+
* guarded by nobody.
|
|
105
|
+
* - `absent` covers `null` as well as `undefined`, on both `tool_input` and
|
|
106
|
+
* `command`. A key explicitly set to null carries no command to read, and
|
|
107
|
+
* refusing it would fire on payloads that mean "no command".
|
|
108
|
+
* - An empty or whitespace-only string is READABLE and allowed. It is a
|
|
109
|
+
* command that does nothing, not a shape the guard failed to parse.
|
|
110
|
+
* Pinned in hook-command-shape.test.ts (absent in a generated rig) ›
|
|
111
|
+
* "refuses an unreadable command through %s while the kill switch is armed"
|
|
112
|
+
* and › "allows an ABSENT command through %s".
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/** The shape word for a value, bounded: never the value itself. */
|
|
116
|
+
const shapeOf = (value) => {
|
|
117
|
+
if (Array.isArray(value)) return 'an array';
|
|
118
|
+
const type = typeof value;
|
|
119
|
+
return type === 'object' ? 'an object' : `a ${type}`;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a shell guard was handed, as one of three outcomes.
|
|
124
|
+
*
|
|
125
|
+
* @returns {{kind:'absent'}
|
|
126
|
+
* |{kind:'string', command:string}
|
|
127
|
+
* |{kind:'unreadable', reason:string, remedy:string}}
|
|
128
|
+
*/
|
|
129
|
+
export const shellCommandOf = (input) => {
|
|
130
|
+
const toolInput = input?.tool_input;
|
|
131
|
+
if (toolInput === undefined || toolInput === null) return { kind: 'absent' };
|
|
132
|
+
if (typeof toolInput !== 'object' || Array.isArray(toolInput)) {
|
|
133
|
+
return unreadable('tool_input', shapeOf(toolInput), 'an object');
|
|
134
|
+
}
|
|
135
|
+
const command = toolInput.command;
|
|
136
|
+
if (command === undefined || command === null) return { kind: 'absent' };
|
|
137
|
+
if (typeof command !== 'string') {
|
|
138
|
+
return unreadable('tool_input.command', shapeOf(command), 'a string');
|
|
139
|
+
}
|
|
140
|
+
return { kind: 'string', command };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const unreadable = (field, got, expected) => ({
|
|
144
|
+
kind: 'unreadable',
|
|
145
|
+
reason:
|
|
146
|
+
`BLOCKED — ${field} is present as ${got}, and this guard reads ${expected}. ` +
|
|
147
|
+
'An input it cannot read is refused, never allowed: the Never-tier rules and ' +
|
|
148
|
+
'the kill switch are decided by reading the command, so allowing what was ' +
|
|
149
|
+
'not read would report a check that did not happen ' +
|
|
150
|
+
'(.claude/rules/invariants.md, "Refusing to inspect is a third outcome").',
|
|
151
|
+
remedy: `Resend the call with ${field} as ${expected}.`,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One refusal, one wording — so two guards cannot drift apart in what they say.
|
|
156
|
+
*
|
|
157
|
+
* It answers for the `unreadable` member only. Handed anything else it returns
|
|
158
|
+
* the empty string rather than `"undefined undefined"`: a guard that printed
|
|
159
|
+
* that would be reporting a refusal it cannot explain, and the caller could not
|
|
160
|
+
* act on it. The call sites below never do this today; the guard is here so a
|
|
161
|
+
* later one cannot introduce it silently.
|
|
162
|
+
*/
|
|
163
|
+
export const refusalText = (refusal) =>
|
|
164
|
+
refusal?.kind === 'unreadable' ? `${refusal.reason} ${refusal.remedy}` : '';
|
|
@@ -223,6 +223,25 @@ an artifact may cite the generator's upstream tests, which are absent locally,
|
|
|
223
223
|
only when the pointer says they are absent and `.claude/.rig-manifest.json`
|
|
224
224
|
proves the current artifact's hash matches the installed manifest.
|
|
225
225
|
|
|
226
|
+
**In the generator, part of that is mechanical.** A citation there is a
|
|
227
|
+
test-file name followed by `›` and a quoted test name, and the generator's
|
|
228
|
+
`test/template/evidence-pointers.test.ts` (absent in a generated rig) resolves
|
|
229
|
+
the citations it reads: › "names a test file this repository still has" and ›
|
|
230
|
+
"quotes a test name that file still declares" go red when a target is renamed
|
|
231
|
+
or a test retitled, and › "says so when the test it names is one a generated
|
|
232
|
+
project never receives" goes red when a pointer into the generator's suite
|
|
233
|
+
carries no word that the reader does not have it. The wording that satisfies
|
|
234
|
+
the last one is `(absent in a generated rig)` beside the pointer, or the
|
|
235
|
+
one-line disclosure nine scripts here open with — a phrase naming the generator
|
|
236
|
+
is not enough, because it tells the reader where the test lives and nothing
|
|
237
|
+
about whether they have it.
|
|
238
|
+
|
|
239
|
+
⚠ **"the citations it reads" is not all of them**, and the test's own header
|
|
240
|
+
states where the edges are: it looks only at the tree that ships into a rig,
|
|
241
|
+
and it reads a citation's names from the line the file is named on and the two
|
|
242
|
+
after it. A green run means no citation it read has gone dead — not that every
|
|
243
|
+
pointer in the generator was verified.
|
|
244
|
+
|
|
226
245
|
A manifest-backed upgrade remains an inherited, generator-owned artifact even
|
|
227
246
|
though the upgrade diff changes its bytes. The exception applies **only while the
|
|
228
247
|
manifest hash matches**. A hash mismatch, missing manifest, or no evidence ends
|