create-agent-rig 0.4.0 → 0.6.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +316 -11
  2. package/README.md +58 -19
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +85 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +81 -19
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +31 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/scripts/prepare.mjs +1 -1
  14. package/templates/agent-os/init/AGENTS.md +199 -0
  15. package/templates/agent-os/init/CLAUDE.md +69 -9
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
  17. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  18. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
  19. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  20. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
  21. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  22. package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
  23. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
  24. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  26. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  27. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  28. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
  29. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  30. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
  31. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
  32. package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
  33. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
  34. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  35. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  36. package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
  37. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  38. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  39. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  40. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
  41. package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
  42. package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
  43. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  44. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
  45. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  46. package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
  47. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  48. package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
  49. package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
  50. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  51. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
  52. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
  53. package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
  54. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
  55. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
  56. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  57. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
  58. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
  59. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
  60. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
  61. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  62. package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
  63. package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
  64. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  65. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
  66. package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
  67. package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
  68. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
  69. package/templates/agent-os/universal/.claude/settings.json +11 -2
  70. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
  71. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
  72. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
  73. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  74. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  75. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  76. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  77. package/templates/agent-os/universal/.codex/hooks.json +70 -0
  78. package/templates/agent-os/universal/AGENTS.md +166 -0
  79. package/templates/agent-os/universal/CLAUDE.md +49 -14
  80. package/templates/agent-os/universal/PLAN.md +7 -40
  81. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  82. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  83. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  84. package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
  85. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  86. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  87. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  88. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  89. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
  90. package/templates/agent-os/universal/journal/README.md +101 -0
  91. package/templates/agent-os/universal/layers.json +45 -2
  92. package/templates/hash-history.json +310 -49
  93. package/templates/release-ledger.json +9 -0
  94. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  95. package/templates/skeleton/aws-serverless/README.md +91 -9
  96. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  97. package/templates/skeleton/aws-serverless/gitignore +37 -0
  98. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  99. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  100. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  101. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  102. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  103. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  104. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  105. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  106. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  107. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  108. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  109. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  110. package/templates/skeleton/node-service/README.md +11 -1
  111. package/templates/skeleton/node-service/gitignore +34 -0
  112. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  113. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  114. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  115. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  116. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  117. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  118. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -135,6 +135,43 @@ const WRAPPER_VALUE_FLAGS = {
135
135
  const KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'fi', 'done', 'in', '!', '{', '}']);
136
136
  /** Shells whose `-c` argument is itself a command line, so it must be parsed too. */
137
137
  const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
138
+ /** Shell options that consume the following token before the `-c` script. */
139
+ const SHELL_VALUE_FLAGS = new Set(['-o', '-O', '+o', '+O', '--init-file', '--rcfile']);
140
+ const compactShellValueCount = (value) =>
141
+ /^[+-][^+-]+$/.test(value)
142
+ ? [...value].filter((flag) => flag === 'o' || flag === 'O').length
143
+ : 0;
144
+
145
+ const shellScript = (args) => {
146
+ let commandFlag = -1;
147
+ for (let index = 0; index < args.length; index += 1) {
148
+ const value = args[index].value;
149
+ if (value === '--' || value === '-' || value === '+' || !/^[+-]/.test(value)) break;
150
+ if (value === '-c' || (/^-[^-]+$/.test(value) && value.includes('c'))) {
151
+ commandFlag = index;
152
+ break;
153
+ }
154
+ const valueCount = compactShellValueCount(value);
155
+ if (valueCount > 0 || SHELL_VALUE_FLAGS.has(value)) index += Math.max(1, valueCount);
156
+ }
157
+ if (commandFlag === -1) return '';
158
+
159
+ const compactFlag = args[commandFlag].value;
160
+ const compactValues = compactShellValueCount(compactFlag);
161
+ const firstCandidate = commandFlag + 1 + compactValues;
162
+ for (let index = firstCandidate; index < args.length; index += 1) {
163
+ const value = args[index].value;
164
+ if (value === '--') return args[index + 1]?.value ?? '';
165
+ const valueCount = compactShellValueCount(value);
166
+ if (valueCount > 0 || SHELL_VALUE_FLAGS.has(value)) {
167
+ index += Math.max(1, valueCount);
168
+ continue;
169
+ }
170
+ if ((value.startsWith('-') || value.startsWith('+')) && value !== '-' && value !== '+') continue;
171
+ return value;
172
+ }
173
+ return '';
174
+ };
138
175
  /**
139
176
  * Flags whose VALUE is prose or a path, never a ref. Skipping them is what keeps
140
177
  * a commit message from being read as a live argument.
@@ -735,11 +772,10 @@ export const inspect = (raw, brake, depth = 0) => {
735
772
  // `bash -c "<command line>"` / `eval "<command line>"` — the payload is a
736
773
  // command line of its own. Taken whether or not it is quote-delimited: a
737
774
  // backslash-joined payload is still a payload.
738
- const flagIndex = command.args.findIndex(({ value }) => value === '-c');
739
775
  const script =
740
- flagIndex >= 0
741
- ? command.args[flagIndex + 1]?.value
742
- : command.args.map(({ value }) => value).join(' ');
776
+ command.name === 'eval'
777
+ ? command.args.map(({ value }) => value).join(' ')
778
+ : shellScript(command.args);
743
779
  if (script) {
744
780
  const reason = inspect(script, brake, depth + 1);
745
781
  if (reason) return reason;
@@ -774,7 +810,9 @@ function main() {
774
810
  return 0;
775
811
  }
776
812
  if (input.tool_name !== 'Bash') return 0;
777
- const raw = String(input.tool_input?.command ?? '');
813
+ const commandValue = input.tool_input?.command;
814
+ if (typeof commandValue !== 'string') return 0;
815
+ const raw = commandValue;
778
816
  if (!raw.trim()) return 0;
779
817
 
780
818
  try {
@@ -3,9 +3,13 @@
3
3
  // an agent (or a human using the agent) cannot write an impure line into
4
4
  // packages/core/src/ even if it wants to.
5
5
  //
6
- // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
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
+ // Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
9
+ // codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
10
+ // projects do not carry that suite, and a downstream edit requires a local replacement test.
8
11
  import { readFileSync } from 'node:fs';
12
+ import { editFragments } from './lib/edit-input.mjs';
9
13
 
10
14
  /** The only non-relative import the core may use: its schema/validation library. */
11
15
  const ALLOWED_PACKAGES = ['zod'];
@@ -31,17 +35,27 @@ function main() {
31
35
  } catch {
32
36
  return 0; // unparseable payload: not ours to judge
33
37
  }
34
- const toolName = input.tool_name;
35
- const toolInput = input.tool_input ?? {};
36
- if (toolName !== 'Write' && toolName !== 'Edit') return 0;
37
-
38
- const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
39
- if (!CORE_PATH.test(filePath) || !CODE_FILE.test(filePath)) return 0;
40
-
41
- const fragment = String(
42
- (toolName === 'Write' ? toolInput.content : toolInput.new_string) ?? '',
38
+ const fragments = editFragments(input);
39
+ const blocked = fragments.find(
40
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
43
41
  );
44
- const violations = findViolations(fragment);
42
+ const globalRefusal = blocked?.inspectionRefusal;
43
+ if (globalRefusal) {
44
+ process.stderr.write(
45
+ `BLOCKED — cannot safely inspect this edit: ${globalRefusal}\n` +
46
+ // The remedy has to match the refusal: splitting cannot change a
47
+ // container shape, and a fixed line sent the agent into a retry loop
48
+ // on the one path it could not retry out of.
49
+ `${blocked.remedy ?? 'Split it into a smaller patch and retry.'}\n`,
50
+ );
51
+ return 2;
52
+ }
53
+
54
+ const violations = fragments.flatMap(({ filePath, fragment, inspectionRefusal }) => {
55
+ if (!CORE_PATH.test(filePath) || !CODE_FILE.test(filePath)) return [];
56
+ if (inspectionRefusal) return [`cannot safely inspect this move — ${inspectionRefusal}`];
57
+ return findViolations(fragment);
58
+ });
45
59
  if (violations.length === 0) return 0;
46
60
 
47
61
  process.stderr.write(
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook: in an UNATTENDED run, the rulebook is never edited outside
3
+ // the current item's allow-list (AR-51).
4
+ //
5
+ // The rulebook is the set of files that decide what this session may do — 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.
10
+ //
11
+ // How it decides — and it is deliberately dumb:
12
+ // 1. No unattended flag on disk (`.claude/scripts/unattended-flag.mjs`) → an
13
+ // attended session → exit 0 for everything. A human at the keyboard edits
14
+ // the rulebook on purpose.
15
+ // 2. Flag present and readable → every edit fragment whose repo-relative path
16
+ // 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
+ // 3. Flag present and UNREADABLE → a rulebook edit is refused and the reason
20
+ // names the flag; an edit outside the rulebook still passes. Refusing to
21
+ // inspect is not allowing (`.claude/rules/invariants.md`).
22
+ //
23
+ // Limits — each stated here and each measured in the generator's
24
+ // `test/template/guard-rulebook.test.ts` (absent in a generated rig), by the
25
+ // test named beside it:
26
+ // - it sees one edit at a time, as text, before it lands — a rulebook file
27
+ // rewritten through a Bash redirect (`echo … > .claude/settings.json`), a
28
+ // generated file, or `git checkout` of another branch is not an edit tool
29
+ // call and never reaches it, and `guard-bash` does not cover that either —
30
+ // › "a Bash redirect into the rulebook is not an edit tool call and passes
31
+ // — guard-bash does not cover it either";
32
+ // - the flag in either home arms it (the env-derived one and the password
33
+ // database one, like the kill switch), and ONLY a flag arms it: an
34
+ // exported variable changes nothing, and an attended session that never
35
+ // set a flag is exactly as free as before — › "only a flag arms it — an
36
+ // exported RIG_UNATTENDED=1 with no flag changes nothing";
37
+ // - it judges paths, not content: a README that merely mentions
38
+ // `.claude/hooks/guard-bash.mjs` is not a rulebook edit — › "guards the
39
+ // 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)";
49
+ // - an `allow` prefix is a string prefix of the repo-relative path and may
50
+ // not widen the rulebook — an entry that is itself a prefix of a rulebook
51
+ // prefix (`.`, `.claude/`, `.claude/scripts/`) makes the flag unreadable
52
+ // and the guard refuses — › "a flag whose allow-list widens the rulebook is
53
+ // unreadable, so `--allow .` cannot disarm it";
54
+ // - fail-open on its own errors and on a payload it cannot parse — › "allows
55
+ // an empty payload object" and › "allows non-JSON stdin" — and fail-closed
56
+ // on a flag it cannot read — › "blocks a rulebook edit when the flag exists
57
+ // but cannot be read, and names the file": the guard targets drift, not an
58
+ // adversary.
59
+ //
60
+ // The rule it enforces is stated in `.claude/rules/autonomy.md`, "Never".
61
+ import { readFileSync } from 'node:fs';
62
+ import { editFragments } from './lib/edit-input.mjs';
63
+ import { RULEBOOK_PREFIXES, isRulebookPath, readUnattended } from '../scripts/unattended-flag.mjs';
64
+
65
+ export { RULEBOOK_PREFIXES, isRulebookPath };
66
+
67
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'apply_patch']);
68
+
69
+ const toPosix = (value) => String(value ?? '').replaceAll('\\', '/');
70
+
71
+ /** The repo-relative tail of an absolute path, or the path itself when it is not under the root. */
72
+ export const relativeTo = (root, filePath) => {
73
+ const dir = toPosix(root).replace(/\/+$/, '');
74
+ const file = toPosix(filePath);
75
+ if (dir !== '' && file.startsWith(`${dir}/`)) return file.slice(dir.length + 1);
76
+ return file.replace(/^\.\//, '');
77
+ };
78
+
79
+ export const isAllowed = (rel, allow) =>
80
+ (Array.isArray(allow) ? allow : []).some((prefix) => prefix !== '' && (rel === prefix || rel.startsWith(prefix)));
81
+
82
+ 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
+ }
89
+ if (!EDIT_TOOLS.has(input?.tool_name)) return 0;
90
+
91
+ const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
92
+ const paths = [];
93
+ for (const { filePath } of editFragments(input)) {
94
+ 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;
98
+ }
99
+ if (paths.length === 0) return 0; // nothing under the rulebook: never judged
100
+
101
+ const mode = readUnattended();
102
+ if (!mode.on) return 0; // attended session
103
+
104
+ if (mode.unreadable) {
105
+ process.stderr.write(
106
+ `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',
108
+ );
109
+ return 2;
110
+ }
111
+
112
+ const refused = paths.filter((rel) => !isAllowed(rel, mode.allow));
113
+ if (refused.length === 0) return 0;
114
+ process.stderr.write(
115
+ `BLOCKED — "${refused[0]}" is part of the rulebook, and an unattended run never edits the rulebook outside its item's allow-list ` +
116
+ `(item ${mode.item ?? '(none)'}; allowed prefixes: ${mode.allow.length === 0 ? 'none' : mode.allow.join(', ')}). ` +
117
+ 'If the item really needs this path, it belongs in the allow-list the loop wrote at claim time — a decision, not a default. ' +
118
+ 'See .claude/rules/autonomy.md, "Never".\n',
119
+ );
120
+ return 2;
121
+ }
122
+
123
+ try {
124
+ process.exit(main());
125
+ } catch {
126
+ process.exit(0); // a crashed guard must not block the session
127
+ }
@@ -0,0 +1,180 @@
1
+ // PreToolUse hook: a credential never enters the repository through an edit.
2
+ //
3
+ // Two arms, because a credential arrives two ways and only one of them has a
4
+ // telltale name:
5
+ //
6
+ // - the PATH is a credential file (`jira.env`, `id_rsa`, `secrets/prod.txt`)
7
+ // - the CONTENT carries a credential VALUE, in a file named nothing special
8
+ //
9
+ // The second is the likelier path and the one nothing in this rulebook refused
10
+ // before: an ignore rule can only ever answer the first.
11
+ //
12
+ // The vocabulary and both matchers come from `../scripts/lib/secrets.mjs`. This
13
+ // file holds no list of its own on purpose — `.claude/rules/invariants.md`, "one
14
+ // mechanism, one implementation": the same invariant written in two places will
15
+ // disagree, and the copy nobody is looking at is the one that is wrong.
16
+ //
17
+ // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
18
+ // stderr is shown to the agent as the reason.
19
+ //
20
+ // 🔴 LIMITS, stated because a guard's own claim about its reach is the first
21
+ // thing to go stale. Each names the test that pins it where one exists, and says
22
+ // so plainly where none does — a limits comment nothing checks drifts into
23
+ // overstatement, which is the direction that gets a reader hurt. ⚠ Those tests live in the GENERATOR this rig came from, not here;
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.
26
+ //
27
+ // There are FOUR:
28
+ //
29
+ // - 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
31
+ // see a credential split across two edits, because it is shown one fragment
32
+ // at a time". This is the same limit every guard in this directory has,
33
+ // stated in full in `.claude/rules/invariants.md`, "What the enforcement
34
+ // actually is — stated exactly".
35
+ // - 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
37
+ // tool returns before inspection. A human editing the file, or a
38
+ // `git commit` of something already on disk, never reaches a PreToolUse hook
39
+ // at all, and no test here can show that: it is a property of the harness,
40
+ // not of this file.
41
+ //
42
+ // 🔴 WHETHER ANYTHING ELSE CATCHES THAT IS NOT A QUESTION THIS FILE CAN
43
+ // ANSWER, and it is the one worth asking before relying on the rule. The
44
+ // layer that covers a human's edit is a COMMIT-TIME check — a git hook, a CI
45
+ // sweep — and this hook cannot see whether one is installed. Look at
46
+ // `.husky/` and the CI workflow rather than assuming: the generator this
47
+ // rulebook came from has both, running the same vocabulary; a freshly
48
+ // generated rig ships neither, and adding one is a decision for the project.
49
+ //
50
+ // - It reads at most the first 2 MB of the text being written, the cap
51
+ // `findSecretValues` applies by default so a fail-open guard cannot be made
52
+ // to hang. A credential past that point is not seen. ⚠ No test here pins
53
+ // 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
55
+ // cap; this hook cannot, and that asymmetry is the point.
56
+ // - It FAILS OPEN on what it cannot understand — see guard-secret-file.test.ts
57
+ // › "allows a payload that is not JSON at all" and its neighbours. An
58
+ // unparseable payload, a missing field, or an internal throw all allow the
59
+ // edit; a crashed guard that blocks everything gets deleted within the hour.
60
+ //
61
+ // ⚠ **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 ›
63
+ // "refuses, rather than failing open, when apply_patch command is supplied
64
+ // as %s". The line between them is whether the guard can tell: an absent
65
+ // field is a payload it does not understand, a container it detects and
66
+ // cannot read is a decision it can report. That reversed a contract this
67
+ // file previously pinned the other way, so it is stated rather than
68
+ // assumed. What catches the rest is whatever this project has put
69
+ // behind it: review always, a commit-time check once one exists.
70
+ //
71
+ // Failing open is also why every line here does provably bounded work: the scan
72
+ // is capped inside `findSecretValues`, there is no recursion, and the one arm
73
+ // that revisits offsets — the one that judges a candidate's value — is bounded
74
+ // by an explicit per-line candidate cap rather than running to exhaustion. Any
75
+ // unbounded work in a fail-open guard is a total bypass of every rule at once,
76
+ // not just of this one.
77
+ import { readFileSync } from 'node:fs';
78
+
79
+ import { findSecretValues, isCredentialPath } from '../scripts/lib/secrets.mjs';
80
+ import { editFragments } from './lib/edit-input.mjs';
81
+
82
+ /** Where a refusal points the agent, so the block is actionable rather than a wall. */
83
+ const WHERE_CREDENTIALS_BELONG =
84
+ 'Credentials live outside the repo (an env file in your home config, a secret manager) ' +
85
+ 'and reach the process through the environment — see .claude/rules/autonomy.md, "Never".';
86
+
87
+ 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
+ }
94
+
95
+ const toolName = input?.tool_name;
96
+ if (toolName !== 'Write' && toolName !== 'Edit' && toolName !== 'apply_patch') return 0;
97
+
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;
124
+ }
125
+
126
+ const toolInput = input?.tool_input ?? {};
127
+ const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
128
+ if (filePath === '') return 0; // nothing to judge; fail open
129
+
130
+ // The tool sends an absolute path. Judge the repo-relative tail so a checkout
131
+ // living under a directory literally called `secrets` does not make every edit
132
+ // in the project a credential.
133
+ // Trailing slashes stripped: with `CLAUDE_PROJECT_DIR=/repo/` the prefix test
134
+ // below never matches, every path stays absolute, and a checkout that happens
135
+ // to live under a directory called `secrets` has EVERY edit refused. That is
136
+ // 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".
139
+ const projectDir = String(process.env.CLAUDE_PROJECT_DIR ?? '')
140
+ .replaceAll('\\', '/')
141
+ .replace(/\/+$/, '');
142
+ const relativePath =
143
+ projectDir !== '' && filePath.startsWith(`${projectDir}/`)
144
+ ? filePath.slice(projectDir.length + 1)
145
+ : filePath;
146
+
147
+ if (isCredentialPath(relativePath)) {
148
+ 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`,
152
+ );
153
+ return 2;
154
+ }
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;
172
+ }
173
+
174
+ let status;
175
+ try {
176
+ status = main();
177
+ } catch {
178
+ status = 0; // fail open — see the LIMITS block above
179
+ }
180
+ process.exit(status);
@@ -3,9 +3,13 @@
3
3
  // layer (`…/db`) and never the services. Enforced at the tool layer, same as
4
4
  // core purity: best-effort text scan, failing safe toward a false block.
5
5
  //
6
- // Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
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
+ // Generator-owned coverage for the neutral bounded-inspection refusal lives upstream in
9
+ // codex.test.ts › "$guard blocks with a neutral, actionable size-limit refusal"; generated
10
+ // projects do not carry that suite, and a downstream edit requires a local replacement test.
8
11
  import { readFileSync } from 'node:fs';
12
+ import { editFragments } from './lib/edit-input.mjs';
9
13
 
10
14
  const WEB_PATH = /(^|\/)apps\/web\//;
11
15
  const CODE_FILE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
@@ -22,21 +26,36 @@ function main() {
22
26
  } catch {
23
27
  return 0; // unparseable payload: not ours to judge
24
28
  }
25
- const toolName = input.tool_name;
26
- const toolInput = input.tool_input ?? {};
27
- if (toolName !== 'Write' && toolName !== 'Edit') return 0;
28
-
29
- const filePath = String(toolInput.file_path ?? '').replaceAll('\\', '/');
30
- if (!WEB_PATH.test(filePath) || !CODE_FILE.test(filePath)) return 0;
31
-
32
- const fragment = String((toolName === 'Write' ? toolInput.content : toolInput.new_string) ?? '');
33
29
  const violations = [];
34
30
  const importRe =
35
31
  /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"]([^'"]+)['"]/gm;
36
- for (const match of fragment.matchAll(importRe)) {
37
- const spec = match[1];
38
- if (FORBIDDEN_WORKSPACE.test(spec) || FORBIDDEN_RELATIVE.test(spec)) {
39
- violations.push(spec);
32
+ const fragments = editFragments(input);
33
+ const blocked = fragments.find(
34
+ ({ inspectionRefusal, appliesToAll }) => appliesToAll && inspectionRefusal,
35
+ );
36
+ const globalRefusal = blocked?.inspectionRefusal;
37
+ if (globalRefusal) {
38
+ process.stderr.write(
39
+ `BLOCKED — cannot safely inspect this edit: ${globalRefusal}\n` +
40
+ // The remedy has to match the refusal: splitting cannot change a
41
+ // container shape, and a fixed line sent the agent into a retry loop
42
+ // on the one path it could not retry out of.
43
+ `${blocked.remedy ?? 'Split it into a smaller patch and retry.'}\n`,
44
+ );
45
+ return 2;
46
+ }
47
+
48
+ for (const { filePath, fragment, inspectionRefusal } of fragments) {
49
+ if (!WEB_PATH.test(filePath) || !CODE_FILE.test(filePath)) continue;
50
+ if (inspectionRefusal) {
51
+ violations.push(`cannot safely inspect this move — ${inspectionRefusal}`);
52
+ continue;
53
+ }
54
+ for (const match of fragment.matchAll(importRe)) {
55
+ const spec = match[1];
56
+ if (FORBIDDEN_WORKSPACE.test(spec) || FORBIDDEN_RELATIVE.test(spec)) {
57
+ violations.push(spec);
58
+ }
40
59
  }
41
60
  }
42
61
  if (violations.length === 0) return 0;