create-agent-rig 0.3.2 → 0.5.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 +294 -10
- package/README.md +84 -13
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +41 -8
- package/packages/cli/dist/commands/init.js +120 -34
- package/packages/cli/dist/commands/upgrade.js +382 -0
- package/packages/cli/dist/index.js +132 -21
- package/packages/cli/dist/lib/copy-tree.js +44 -7
- package/packages/cli/dist/lib/history.js +49 -0
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +44 -0
- package/packages/cli/dist/lib/manifest.js +111 -0
- package/packages/cli/dist/lib/prompts.js +20 -0
- package/packages/cli/dist/lib/safe-path.js +71 -0
- package/packages/cli/dist/lib/substitute.js +32 -0
- package/packages/cli/dist/lib/version.js +15 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -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 +34 -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 +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -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 +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- 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-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 +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +395 -33
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- 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 +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -29
- 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/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 +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +264 -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
|
@@ -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,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;
|
|
@@ -10,8 +10,144 @@
|
|
|
10
10
|
// The injected content is deliberately STATELESS — rules, never facts about
|
|
11
11
|
// the moment (mid-session injections are replayed on resume, so timestamps
|
|
12
12
|
// or SHAs here would lie). And it is only the load-bearing part, not the
|
|
13
|
-
// whole rulebook
|
|
14
|
-
|
|
13
|
+
// whole rulebook.
|
|
14
|
+
//
|
|
15
|
+
// Which part is load-bearing is not this hook's judgment to make: the rule
|
|
16
|
+
// file marks what it does not need injected, and everything else goes. That
|
|
17
|
+
// division of labour is the whole design, and it was arrived at the expensive
|
|
18
|
+
// way. An earlier version of this file selected `## ` sections from a kept
|
|
19
|
+
// list — and four review rounds each found a different way for that selection
|
|
20
|
+
// to return a silently truncated excerpt: a heading inside a code fence, a
|
|
21
|
+
// heading inside a skipped region, a section whose heading and body fell on
|
|
22
|
+
// opposite sides of a marker, a heading inside an HTML block. Each fix closed
|
|
23
|
+
// one spelling and the next round found another, because a parser that infers
|
|
24
|
+
// structure has no bottom. This one does not parse structure at all.
|
|
25
|
+
//
|
|
26
|
+
// ⚠ The saving rests on an assumption this repository cannot enforce: that the
|
|
27
|
+
// tool already loads `.claude/rules/*.md` as project instructions, so injecting
|
|
28
|
+
// the whole file pays for it twice. That is harness behaviour, observable but
|
|
29
|
+
// not pinned here. Where it does not hold, this is a plain subtraction — which
|
|
30
|
+
// is why every ambiguity resolves toward injecting more.
|
|
31
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
// Regions the rule file marks as not worth injecting. The marker is explicit
|
|
35
|
+
// and lives in the rule file itself, where the person editing it can see it —
|
|
36
|
+
// an earlier version keyed on heading level instead, which made "what every
|
|
37
|
+
// session is governed by" a silent consequence of a formatting choice.
|
|
38
|
+
const SKIP_OPEN = '<!-- inject:skip -->';
|
|
39
|
+
const SKIP_CLOSE = '<!-- /inject:skip -->';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A fence opener, as CommonMark defines one: three or more backticks or
|
|
43
|
+
* tildes. The closer has to be the same character and no shorter, which is why
|
|
44
|
+
* this returns the run rather than a boolean — a three-backtick line inside a
|
|
45
|
+
* four-backtick block is content, not the end of the block.
|
|
46
|
+
*/
|
|
47
|
+
function fenceRun(line) {
|
|
48
|
+
const trimmed = line.trimStart();
|
|
49
|
+
const char = trimmed[0];
|
|
50
|
+
if (char !== '`' && char !== '~') return null;
|
|
51
|
+
let length = 0;
|
|
52
|
+
while (trimmed[length] === char) length += 1;
|
|
53
|
+
if (length < 3) return null;
|
|
54
|
+
// `bare` is what separates a closer from an opener: an opener may carry an
|
|
55
|
+
// info string (```sh), a closer may not.
|
|
56
|
+
return { char, length, bare: trimmed.slice(length).trim() === '' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The whole file, minus every region the file itself marks with
|
|
61
|
+
* `<!-- inject:skip -->` … `<!-- /inject:skip -->`. That is the entire
|
|
62
|
+
* operation: nothing here reads a heading, so no arrangement of headings —
|
|
63
|
+
* indented, inside an HTML block, split across a marker — can change what
|
|
64
|
+
* survives. What is omitted is a decision made in the rule file, by whoever
|
|
65
|
+
* writes the rule, and visible on the line above it.
|
|
66
|
+
*
|
|
67
|
+
* Fenced code is data, not structure, so a marker inside a fence is content.
|
|
68
|
+
* Both fence characters, with the CommonMark closing rule (same character, at
|
|
69
|
+
* least as long, nothing but whitespace after it) — a rules file quotes shell
|
|
70
|
+
* and markdown at each other, and an opener may carry an info string where a
|
|
71
|
+
* closer may not.
|
|
72
|
+
*
|
|
73
|
+
* It returns the input UNCHANGED whenever the markup is malformed: a skip
|
|
74
|
+
* region left open, one closed without being opened, one nested inside
|
|
75
|
+
* another, or a fence left open. Partial output is the dangerous answer,
|
|
76
|
+
* because a governance section can go missing with nothing to notice it; a run
|
|
77
|
+
* that gets the whole file has only paid twice.
|
|
78
|
+
*
|
|
79
|
+
* The limits, so nobody relies on cover that is not here. A marker is
|
|
80
|
+
* recognised only as the first non-whitespace text on its own line — so one
|
|
81
|
+
* inside a blockquote is not a marker, and its text reaches the context. And
|
|
82
|
+
* fence-awareness is the only structure it knows: a BALANCED marker pair
|
|
83
|
+
* written inside an indented code block or an HTML comment is obeyed, so a
|
|
84
|
+
* document that demonstrates the markers loses the lines between them. Show
|
|
85
|
+
* them inside a fence.
|
|
86
|
+
*/
|
|
87
|
+
export function excerptAutonomy(markdown) {
|
|
88
|
+
const lines = markdown.split('\n');
|
|
89
|
+
const kept = [];
|
|
90
|
+
let skipping = false;
|
|
91
|
+
let fence = null;
|
|
92
|
+
let malformed = false;
|
|
93
|
+
|
|
94
|
+
for (const line of lines) {
|
|
95
|
+
const run = fenceRun(line);
|
|
96
|
+
if (fence) {
|
|
97
|
+
if (run && run.char === fence.char && run.length >= fence.length && run.bare) fence = null;
|
|
98
|
+
} else if (run) {
|
|
99
|
+
fence = run;
|
|
100
|
+
} else {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
// Matched by prefix: a marker with something after it is still a marker,
|
|
103
|
+
// and the whole line goes. Requiring the line to be exactly the marker
|
|
104
|
+
// would let `<!-- inject:skip --> note` open nothing and then print
|
|
105
|
+
// itself into the context.
|
|
106
|
+
if (trimmed.startsWith(SKIP_OPEN)) {
|
|
107
|
+
// A second open inside a region is as much a mistake as a stray close,
|
|
108
|
+
// and the two are the same signal: the markers do not pair up.
|
|
109
|
+
malformed ||= skipping;
|
|
110
|
+
skipping = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (trimmed.startsWith(SKIP_CLOSE)) {
|
|
114
|
+
malformed ||= !skipping;
|
|
115
|
+
skipping = false;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!skipping) kept.push(line);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (fence || skipping || malformed) return markdown;
|
|
123
|
+
const excerpt = kept.join('\n').trim();
|
|
124
|
+
// Nothing left is the largest possible version of "partial output", and the
|
|
125
|
+
// marker pairing cannot see it: a balanced pair around the whole file is
|
|
126
|
+
// well-formed. A zero, not a threshold — this is deliberately not the byte
|
|
127
|
+
// ratio an earlier round used and deleted.
|
|
128
|
+
return excerpt === '' ? markdown : excerpt;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Whether this file is being run as a script rather than imported.
|
|
133
|
+
*
|
|
134
|
+
* The realpath on both sides is the point: ESM resolves `import.meta.url`
|
|
135
|
+
* through symlinks while `process.argv[1]` keeps the path as typed, so a
|
|
136
|
+
* project under a symlinked directory — a macOS temp dir, a symlinked home, a
|
|
137
|
+
* checkout behind a link — fails a naive equality check. The hook would then
|
|
138
|
+
* print nothing and exit 0, which reads exactly like a healthy session.
|
|
139
|
+
*/
|
|
140
|
+
function invokedDirectly() {
|
|
141
|
+
if (!process.argv[1]) return false;
|
|
142
|
+
const real = (p) => {
|
|
143
|
+
try {
|
|
144
|
+
return realpathSync(p);
|
|
145
|
+
} catch {
|
|
146
|
+
return p;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
150
|
+
}
|
|
15
151
|
|
|
16
152
|
function main() {
|
|
17
153
|
let input;
|
|
@@ -22,15 +158,47 @@ function main() {
|
|
|
22
158
|
}
|
|
23
159
|
if (input.hook_event_name !== 'SessionStart') return 0;
|
|
24
160
|
|
|
161
|
+
let rules;
|
|
25
162
|
try {
|
|
26
|
-
|
|
27
|
-
process.stdout.write(
|
|
28
|
-
`[agent-os] Autonomy rules refresh — in force regardless of compaction:\n\n${rules}\n`,
|
|
29
|
-
);
|
|
163
|
+
rules = readFileSync(new URL('../rules/autonomy.md', import.meta.url), 'utf8');
|
|
30
164
|
} catch {
|
|
31
165
|
// no rules file — nothing to inject, never an error
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The cut is the only thing allowed to fail here, and its failure resolves
|
|
170
|
+
// the way every other ambiguity in this file does: inject more. Leaving it
|
|
171
|
+
// inside the read's catch meant a throw in the excerpter printed NOTHING and
|
|
172
|
+
// exited 0 — the rules gone, and the session looking healthy.
|
|
173
|
+
let body;
|
|
174
|
+
try {
|
|
175
|
+
body = excerptAutonomy(rules);
|
|
176
|
+
} catch {
|
|
177
|
+
body = rules;
|
|
32
178
|
}
|
|
179
|
+
|
|
180
|
+
// The banner reports what happened, so it cannot be written once and assumed:
|
|
181
|
+
// on the fallback path nothing was removed, and telling a session to go read
|
|
182
|
+
// four sections it is already holding is the same kind of false report the
|
|
183
|
+
// cut itself is built to avoid.
|
|
184
|
+
// Compared trimmed, because the two paths differ by a trailing newline that
|
|
185
|
+
// is not a removal: the excerpt is trimmed and the fallback is not. A rules
|
|
186
|
+
// file that marks nothing would otherwise be announced as an excerpt — which
|
|
187
|
+
// is the case a project re-scoping its own rulebook lands in, every session.
|
|
188
|
+
const notice =
|
|
189
|
+
body.trim() === rules.trim()
|
|
190
|
+
? 'This is `.claude/rules/autonomy.md` in full.\n\n'
|
|
191
|
+
: 'This is `.claude/rules/autonomy.md` with the sections it marks as ' +
|
|
192
|
+
'reference removed — read the file itself for those: how the Tier-2 ' +
|
|
193
|
+
'gate is swept from outside, how external work is reconciled, ' +
|
|
194
|
+
'post-deploy verification, and the escalation format.\n\n';
|
|
195
|
+
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
`[agent-os] Autonomy rules refresh — in force regardless of compaction.\n${notice}${body}\n`,
|
|
198
|
+
);
|
|
33
199
|
return 0;
|
|
34
200
|
}
|
|
35
201
|
|
|
36
|
-
|
|
202
|
+
if (invokedDirectly()) {
|
|
203
|
+
process.exit(main());
|
|
204
|
+
}
|