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.
- package/CHANGELOG.md +316 -11
- package/README.md +58 -19
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +85 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +81 -19
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +31 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/scripts/prepare.mjs +1 -1
- package/templates/agent-os/init/AGENTS.md +199 -0
- package/templates/agent-os/init/CLAUDE.md +69 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +105 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +41 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +41 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/stack/node-ts/.claude/rules/node-ts.md +29 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +236 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +958 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +396 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +40 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +71 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +40 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +301 -26
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-rulebook.mjs +127 -0
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +503 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +69 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +104 -19
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1260 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/doctor.mjs +351 -0
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/gate-coverage.mjs +306 -0
- package/templates/agent-os/universal/.claude/scripts/lib/revalidation-points.mjs +28 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +462 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +33 -17
- package/templates/agent-os/universal/.claude/scripts/queue/as-of.mjs +51 -0
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +149 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +809 -28
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +95 -16
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +543 -15
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +457 -47
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +289 -25
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/revalidate.mjs +316 -0
- package/templates/agent-os/universal/.claude/scripts/revalidation-report.mjs +180 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +539 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +15 -8
- package/templates/agent-os/universal/.claude/scripts/unattended-flag.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +198 -0
- package/templates/agent-os/universal/.claude/settings.json +11 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +118 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +667 -38
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +359 -26
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +70 -0
- package/templates/agent-os/universal/AGENTS.md +166 -0
- package/templates/agent-os/universal/CLAUDE.md +49 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/gate-coverage.md +83 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +74 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +45 -2
- package/templates/hash-history.json +310 -49
- package/templates/release-ledger.json +9 -0
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- 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
|
-
|
|
741
|
-
? command.args
|
|
742
|
-
: command.args
|
|
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
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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;
|