create-agent-rig 0.2.0 → 0.3.1
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 +170 -0
- package/README.md +66 -10
- package/package.json +9 -2
- package/packages/cli/dist/commands/init.js +73 -18
- package/packages/cli/dist/index.js +11 -1
- package/packages/cli/dist/lib/init-settings.js +52 -0
- package/packages/cli/dist/lib/summary.js +19 -5
- package/packages/cli/dist/templates.js +8 -0
- package/templates/agent-os/init/CLAUDE.md +133 -0
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +46 -0
- package/templates/agent-os/stack/aws-cdk/.claude/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +1 -1
- package/templates/agent-os/universal/.claude/hooks/block-no-verify.mjs +12 -2
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +808 -0
- package/templates/agent-os/universal/.claude/queue.json +3 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +43 -0
- package/templates/agent-os/universal/.claude/rules/invariants.md +170 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +489 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +161 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +305 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +231 -0
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +175 -0
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +345 -0
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +239 -0
- package/templates/agent-os/universal/.claude/scripts/reconcile-external-prs.mjs +280 -0
- package/templates/agent-os/universal/.claude/scripts/stop-flag.mjs +62 -0
- package/templates/agent-os/universal/.claude/settings.json +4 -0
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +297 -40
- package/templates/agent-os/universal/.claude/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.claude/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.claude/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/CLAUDE.md +57 -7
- package/templates/agent-os/universal/PLAN.md +28 -2
- package/templates/agent-os/universal/layers.json +20 -1
- package/templates/skeleton/aws-serverless/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/aws-serverless/gitignore +8 -0
- package/templates/skeleton/node-service/.github/workflows/ci.yml +6 -1
- package/templates/skeleton/node-service/gitignore +8 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
// PreToolUse hook: the part of the "Never" tier of .claude/rules/autonomy.md
|
|
2
|
+
// that a text scan can decide, made mechanical. A prompt-level rule is followed most of the time; a hook is
|
|
3
|
+
// followed every time, and these are the actions where "most of the time" is not
|
|
4
|
+
// good enough because they are not reversible.
|
|
5
|
+
//
|
|
6
|
+
// It is a SECOND Bash guard on purpose. `block-no-verify` owns exactly one
|
|
7
|
+
// invariant (the pre-commit gate may not be bypassed) and stays readable because
|
|
8
|
+
// of it; this one owns the irreversible actions and the kill switch.
|
|
9
|
+
//
|
|
10
|
+
// ── Why this parses instead of pattern-matching ──────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// The first version ran regexes over the command string after splitting it on
|
|
13
|
+
// `|&;`. An adversarial pass found 26 false negatives and 6 false positives, and
|
|
14
|
+
// nearly all of them had one cause: **it matched before it understood quoting.**
|
|
15
|
+
// - `git commit -m "cleanup; rm -rf / was possible"` → the `;` inside the
|
|
16
|
+
// message manufactured a segment whose first word was `rm`, and the guard
|
|
17
|
+
// blocked a commit. A guard that fires on prose is a guard people disable.
|
|
18
|
+
// - `git push --force origin "main"` → quoted text was blanked before the
|
|
19
|
+
// branch was read, so the branch vanished and the force-push was allowed.
|
|
20
|
+
//
|
|
21
|
+
// So it now TOKENISES first: quotes are honoured, separators inside quotes are
|
|
22
|
+
// just characters, and every rule reads structured arguments. That single change
|
|
23
|
+
// closed both directions at once.
|
|
24
|
+
//
|
|
25
|
+
// ── The limits, stated exactly — and TESTED ──────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// This block is a credibility claim, so `test/template/guard-hardening.test.ts`
|
|
28
|
+
// asserts each line twice: that the limit is documented here, and that the
|
|
29
|
+
// command really does pass. A limits comment nothing checks drifts into fiction,
|
|
30
|
+
// which is what happened the first time — an earlier version of this list was
|
|
31
|
+
// understated in six ways.
|
|
32
|
+
//
|
|
33
|
+
// Not caught:
|
|
34
|
+
// - a value that only exists at runtime: `git push --force origin $BRANCH`;
|
|
35
|
+
// - a user-defined alias, or a wrapper script that shells out:
|
|
36
|
+
// `./scripts/deploy-prod.sh`;
|
|
37
|
+
// - a command assembled at runtime: `eval "$(printf ...)"`;
|
|
38
|
+
// - brace expansion: `git push --force origin mai{n..n}` really does push to
|
|
39
|
+
// `main`, and the guard does not expand it;
|
|
40
|
+
// - more than 32 heredocs in one command: past that budget the bodies are read
|
|
41
|
+
// as commands, so a script with 33+ heredocs can be falsely BLOCKED on its
|
|
42
|
+
// own data. The budget exists because each lookahead scans forward, and an
|
|
43
|
+
// unbounded number of them is the quadratic hazard that once killed the hook.
|
|
44
|
+
// Erring toward a false block past the budget is the safe direction — but it
|
|
45
|
+
// is a limit, so it is written here rather than discovered.
|
|
46
|
+
//
|
|
47
|
+
// That last one is here BY CHOICE, and the choice is the point. Expanding braces
|
|
48
|
+
// needs a cross-product, and a bound per group is not a bound on the result: the
|
|
49
|
+
// implementation that did it could be made to overflow the stack, which the
|
|
50
|
+
// fail-open catch below turned into "allow" for every rule at once. A guard that
|
|
51
|
+
// can be disarmed by ten characters is worse than one with a documented gap.
|
|
52
|
+
// See .claude/rules/invariants.md, "A guard that fails open must do provably
|
|
53
|
+
// bounded work".
|
|
54
|
+
//
|
|
55
|
+
// And the SCOPE of each rule, because "refuses the Never tier" reads wider than
|
|
56
|
+
// what is actually inspected:
|
|
57
|
+
// - deletes: only `rm` is examined. `find -delete`, `dd`, `shred`, `truncate`,
|
|
58
|
+
// `mv`, `rsync --delete` and `chmod -R 000` are not;
|
|
59
|
+
// - production deploys: only a workflow dispatch (`gh workflow run`, `gh api
|
|
60
|
+
// …/dispatches`). A deploy driven straight from an infrastructure CLI, or a
|
|
61
|
+
// registry publish, is not caught — and on a target whose own deploy command
|
|
62
|
+
// IS such a CLI, that is the ordinary spelling, not an exotic one;
|
|
63
|
+
// - direct pushes: only when the command NAMES the branch. Bare `git push` and
|
|
64
|
+
// `git push origin HEAD` depend on the checked-out branch, which this guard
|
|
65
|
+
// cannot know without running git. While the kill switch is on they are
|
|
66
|
+
// refused for that reason; the rest of the time they are not;
|
|
67
|
+
// - branch deletion: `git push --delete` is caught; `git branch -D main`,
|
|
68
|
+
// `git update-ref -d` and `gh api -X DELETE …/refs/heads/main` are not;
|
|
69
|
+
// - a command carried as a flag value (`find … -exec`, `env -S`) is not
|
|
70
|
+
// followed.
|
|
71
|
+
//
|
|
72
|
+
// The list is **not exhaustive**. The guard targets DRIFT — the ordinary spelling
|
|
73
|
+
// written without thinking — not an adversary, and circumventing it is itself a
|
|
74
|
+
// Never-tier violation. The layers behind it are review and CI.
|
|
75
|
+
//
|
|
76
|
+
// Contract (Claude Code): JSON on stdin; exit 0 = allow, exit 2 = block, and
|
|
77
|
+
// stderr is shown to the agent as the reason. Fails open on anything it cannot
|
|
78
|
+
// parse — a crashed guard must never make the session unusable.
|
|
79
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
80
|
+
import { fileURLToPath } from 'node:url';
|
|
81
|
+
import { brakeIsOn } from '../scripts/stop-flag.mjs';
|
|
82
|
+
|
|
83
|
+
/** Branches that are shared by definition. */
|
|
84
|
+
const PROTECTED_BRANCH = /^(main|master|develop|development|trunk)$/;
|
|
85
|
+
/** Command wrappers that stand between the shell and the real command. */
|
|
86
|
+
const WRAPPERS = new Set([
|
|
87
|
+
'sudo',
|
|
88
|
+
'doas',
|
|
89
|
+
'env',
|
|
90
|
+
'command',
|
|
91
|
+
'nohup',
|
|
92
|
+
'time',
|
|
93
|
+
'timeout',
|
|
94
|
+
'nice',
|
|
95
|
+
'ionice',
|
|
96
|
+
'stdbuf',
|
|
97
|
+
'setsid',
|
|
98
|
+
'xargs',
|
|
99
|
+
'exec',
|
|
100
|
+
'npx',
|
|
101
|
+
'bunx',
|
|
102
|
+
]);
|
|
103
|
+
/**
|
|
104
|
+
* Shell keywords that can begin a segment. Without these the word `do` or `then`
|
|
105
|
+
* becomes the "command name" and the segment is never inspected — so
|
|
106
|
+
* `for b in a; do git push --force origin main; done` was invisible.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* Wrapper options that consume the next argument — keyed BY WRAPPER, because the
|
|
110
|
+
* same letter differs between them: `-n` takes a value for `xargs` and `nice`,
|
|
111
|
+
* but is `--non-interactive` for `sudo`. One flat set ate the real command after
|
|
112
|
+
* `sudo -n`, so the force-push behind it was never inspected.
|
|
113
|
+
*/
|
|
114
|
+
const WRAPPER_VALUE_FLAGS = {
|
|
115
|
+
sudo: new Set([
|
|
116
|
+
'-u', '-g', '-U', '-C', '-r', '-t', '-p', '-D', '-R',
|
|
117
|
+
'--user', '--group', '--other-user', '--close-from', '--role', '--type',
|
|
118
|
+
'--prompt', '--host', '--chdir', '--chroot',
|
|
119
|
+
]),
|
|
120
|
+
doas: new Set(['-u', '-C']),
|
|
121
|
+
// `-S` deliberately absent: its value is a whole command line, so skipping it
|
|
122
|
+
// would hide the command. Left visible, it becomes an unrecognised command name
|
|
123
|
+
// — a miss, but a miss that inspects rather than one that hides.
|
|
124
|
+
env: new Set(['-u', '-C', '-P', '--unset', '--chdir']),
|
|
125
|
+
xargs: new Set([
|
|
126
|
+
'-n', '-I', '-L', '-P', '-s', '-d', '-a', '-E', '-e',
|
|
127
|
+
'--max-args', '--replace', '--max-lines', '--max-procs', '--max-chars',
|
|
128
|
+
'--delimiter', '--arg-file', '--eof-str',
|
|
129
|
+
]),
|
|
130
|
+
nice: new Set(['-n', '--adjustment']),
|
|
131
|
+
ionice: new Set(['-c', '-n']),
|
|
132
|
+
timeout: new Set(['-s', '-k', '--signal', '--kill-after']),
|
|
133
|
+
stdbuf: new Set(['-i', '-o', '-e']),
|
|
134
|
+
};
|
|
135
|
+
const KEYWORDS = new Set(['do', 'then', 'else', 'elif', 'fi', 'done', 'in', '!', '{', '}']);
|
|
136
|
+
/** Shells whose `-c` argument is itself a command line, so it must be parsed too. */
|
|
137
|
+
const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh']);
|
|
138
|
+
/**
|
|
139
|
+
* Flags whose VALUE is prose or a path, never a ref. Skipping them is what keeps
|
|
140
|
+
* a commit message from being read as a live argument.
|
|
141
|
+
*/
|
|
142
|
+
const VALUE_FLAGS = new Set([
|
|
143
|
+
'-m',
|
|
144
|
+
'--message',
|
|
145
|
+
'-F',
|
|
146
|
+
'--file',
|
|
147
|
+
'-C',
|
|
148
|
+
'--grep',
|
|
149
|
+
'--author',
|
|
150
|
+
'--date',
|
|
151
|
+
'--reuse-message',
|
|
152
|
+
'--title',
|
|
153
|
+
'--body',
|
|
154
|
+
'-t',
|
|
155
|
+
'-b',
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Targets that make a delete unrecoverable wherever you run it. Compared after
|
|
160
|
+
* normalisation, so `//`, `/.`, `${HOME}` and a trailing slash all collapse onto
|
|
161
|
+
* these — the list stays literal and readable while the variants close.
|
|
162
|
+
*/
|
|
163
|
+
const CATASTROPHIC = new Set([
|
|
164
|
+
'/',
|
|
165
|
+
'/*',
|
|
166
|
+
'~',
|
|
167
|
+
'~/*',
|
|
168
|
+
'$HOME',
|
|
169
|
+
'$HOME/*',
|
|
170
|
+
'/usr',
|
|
171
|
+
'/etc',
|
|
172
|
+
'/var',
|
|
173
|
+
'/bin',
|
|
174
|
+
'/lib',
|
|
175
|
+
'/opt',
|
|
176
|
+
'/home',
|
|
177
|
+
'/Users',
|
|
178
|
+
'~/.ssh',
|
|
179
|
+
'$HOME/.ssh',
|
|
180
|
+
'~/.ssh/*',
|
|
181
|
+
'$HOME/.ssh/*',
|
|
182
|
+
'/usr/*',
|
|
183
|
+
'/etc/*',
|
|
184
|
+
'/var/*',
|
|
185
|
+
'/bin/*',
|
|
186
|
+
'/lib/*',
|
|
187
|
+
'/opt/*',
|
|
188
|
+
'/home/*',
|
|
189
|
+
'/Users/*',
|
|
190
|
+
'/System',
|
|
191
|
+
'/Library',
|
|
192
|
+
'/Applications',
|
|
193
|
+
'/private',
|
|
194
|
+
'/Volumes',
|
|
195
|
+
'/System/*',
|
|
196
|
+
'/Library/*',
|
|
197
|
+
'/Applications/*',
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The only directories whose CHILDREN are also catastrophic.
|
|
202
|
+
*
|
|
203
|
+
* Deliberately two entries, checked by a prefix test — bounded, no recursion. The
|
|
204
|
+
* general prefix rule that once lived here blocked `/private/tmp`, `$TMPDIR`,
|
|
205
|
+
* Homebrew and `/Volumes` (routine cleanup) and had to go; but `~/.ssh` was the
|
|
206
|
+
* case that motivated it, and nothing in a project routinely deletes a file under
|
|
207
|
+
* there. So it returns scoped to exactly that.
|
|
208
|
+
*/
|
|
209
|
+
const CATASTROPHIC_SUBTREES = ['~/.ssh', '$HOME/.ssh'];
|
|
210
|
+
|
|
211
|
+
const isCatastrophic = (target) =>
|
|
212
|
+
CATASTROPHIC.has(target) ||
|
|
213
|
+
CATASTROPHIC_SUBTREES.some((root) => target.startsWith(`${root}/`));
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* While the brake is on, the network clients are refused.
|
|
217
|
+
*
|
|
218
|
+
* Pushing to a protected branch is refused with or without the brake, so the only
|
|
219
|
+
* thing the brake has to add is the routes that land a PR — and every one of them
|
|
220
|
+
* goes through a network client (`gh`, `curl`, `wget`). Denying the clients, with
|
|
221
|
+
* a short allowlist of read-only and PR-opening subcommands, covers `gh pr merge`,
|
|
222
|
+
* `gh api …/merge`, the GraphQL mutation and a raw `curl` in one rule, without
|
|
223
|
+
* matching text at all.
|
|
224
|
+
*
|
|
225
|
+
* The previous attempt matched the substring `merge` across every token. It denied
|
|
226
|
+
* 19 ordinary commands — including `git log --no-merges` and pushing a branch named
|
|
227
|
+
* `fix/merge-conflict-handling`, which are literally what the brake's own message
|
|
228
|
+
* tells the agent to do while stopping. A rule that forbids the wind-down it
|
|
229
|
+
* prescribes is not coarse, it is wrong.
|
|
230
|
+
*
|
|
231
|
+
* ⚠ This is NOT a complete list of ways to reach the API, and cannot be:
|
|
232
|
+
* `python3 -c "urllib…"` and `node -e "fetch(…)"` reach the same endpoint and are
|
|
233
|
+
* the "assembled at runtime" limit stated at the top of this file. The brake
|
|
234
|
+
* covers the clients an agent reaches for by habit, which is the drift it exists
|
|
235
|
+
* to stop — not an adversary who has already decided to route around it.
|
|
236
|
+
*/
|
|
237
|
+
const NETWORK_CLIENTS = new Set([
|
|
238
|
+
'gh',
|
|
239
|
+
'hub',
|
|
240
|
+
'curl',
|
|
241
|
+
'curlie',
|
|
242
|
+
'wget',
|
|
243
|
+
'xh',
|
|
244
|
+
'http',
|
|
245
|
+
'https',
|
|
246
|
+
'httpie',
|
|
247
|
+
]);
|
|
248
|
+
/** `gh` subcommands that read, or open a PR — the wind-down the brake asks for. */
|
|
249
|
+
const BRAKE_SAFE_GH = new Set(['view', 'list', 'status', 'diff', 'checks', 'create', 'help']);
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Under the brake a `git push` must name an explicit branch.
|
|
253
|
+
*
|
|
254
|
+
* The brake's premise is that pushing to a protected branch is refused anyway —
|
|
255
|
+
* but that is only true when the command NAMES the branch. Bare `git push`,
|
|
256
|
+
* `git push origin HEAD` and `git push --all` all land on the default branch when
|
|
257
|
+
* you are on it, and the guard cannot know which branch you are on without
|
|
258
|
+
* running git (it is deliberately pure). So while stopped, a push has to say
|
|
259
|
+
* where it is going. `git push origin feat/x` — the wind-down the brake asks for —
|
|
260
|
+
* is unaffected.
|
|
261
|
+
*/
|
|
262
|
+
const pushWithoutExplicitRef = ({ name, args }) => {
|
|
263
|
+
if (name !== 'git') return false;
|
|
264
|
+
const operands = operandsOf(args);
|
|
265
|
+
if (!operands.some(({ value }) => value === 'push')) return false;
|
|
266
|
+
if (hasFlag(args, '--all', '--mirror')) return true;
|
|
267
|
+
const refs = operands.filter(({ value }) => value !== 'push').slice(1);
|
|
268
|
+
return refs.length === 0 || refs.some(({ value }) => /^HEAD(:|$)/.test(value));
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const deniedByBrake = (name, args) => {
|
|
272
|
+
if (pushWithoutExplicitRef({ name, args })) return true;
|
|
273
|
+
if (!NETWORK_CLIENTS.has(name)) return false;
|
|
274
|
+
if (name !== 'gh' && name !== 'hub') return true;
|
|
275
|
+
const operands = operandsOf(args, GH_FLAGS).map(({ value }) => value);
|
|
276
|
+
// Nothing to do is not dangerous: `gh --version`, `gh help`.
|
|
277
|
+
if (operands.length === 0) return false;
|
|
278
|
+
// Read by POSITION. `some()` let any operand anywhere satisfy the allowlist,
|
|
279
|
+
// so `gh pr merge 12 --subject create` passed on the word `create`.
|
|
280
|
+
const [group, verb] = operands;
|
|
281
|
+
// `create` is only safe for a PR — `gh release create --target main` is not the
|
|
282
|
+
// wind-down the brake permits.
|
|
283
|
+
if (verb === 'create') return group !== 'pr';
|
|
284
|
+
return !(BRAKE_SAFE_GH.has(verb) || BRAKE_SAFE_GH.has(group));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// ── Tokenising ───────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Split a command line into segments of arguments, honouring quotes.
|
|
291
|
+
*
|
|
292
|
+
* Each token records whether it was quoted, because the two facts matter
|
|
293
|
+
* separately: a quoted argument is still an argument (so `"main"` is the branch),
|
|
294
|
+
* but a separator inside quotes is text (so a commit message is not a command).
|
|
295
|
+
*
|
|
296
|
+
* A subshell, a pipeline, `&&`, a command substitution and a newline all end the
|
|
297
|
+
* current segment — every one of them introduces a new command whose first word
|
|
298
|
+
* must be examined on its own.
|
|
299
|
+
*/
|
|
300
|
+
export const tokenize = (raw) => {
|
|
301
|
+
const segments = [];
|
|
302
|
+
let args = [];
|
|
303
|
+
let value = '';
|
|
304
|
+
let quoted = false;
|
|
305
|
+
let started = false;
|
|
306
|
+
let heredocBudget = 32;
|
|
307
|
+
let pendingHeredoc = null;
|
|
308
|
+
|
|
309
|
+
const endArg = () => {
|
|
310
|
+
if (started) {
|
|
311
|
+
args.push({ value, quoted });
|
|
312
|
+
value = '';
|
|
313
|
+
quoted = false;
|
|
314
|
+
started = false;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
const endSegment = () => {
|
|
318
|
+
endArg();
|
|
319
|
+
if (args.length > 0) segments.push(args);
|
|
320
|
+
args = [];
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
let i = 0;
|
|
324
|
+
while (i < raw.length) {
|
|
325
|
+
const ch = raw[i];
|
|
326
|
+
|
|
327
|
+
if (ch === '\\' && raw[i + 1] === '\n') {
|
|
328
|
+
i += 2; // a line continuation is whitespace, not a segment boundary
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (ch === '\\' && i + 1 < raw.length) {
|
|
332
|
+
value += raw[i + 1];
|
|
333
|
+
started = true;
|
|
334
|
+
i += 2;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (ch === '$' && raw[i + 1] === "'") {
|
|
338
|
+
// ANSI-C quoting. `$'main'` is just `main` to the shell; leaving the `$`
|
|
339
|
+
// glued on hid the branch name, and the escaped `'` inside desynchronised
|
|
340
|
+
// the plain single-quote scanner for the rest of the line.
|
|
341
|
+
let j = i + 2;
|
|
342
|
+
while (j < raw.length && raw[j] !== "'") j += raw[j] === '\\' ? 2 : 1;
|
|
343
|
+
value += raw
|
|
344
|
+
.slice(i + 2, Math.min(j, raw.length))
|
|
345
|
+
.replace(/\\(.)/g, '$1');
|
|
346
|
+
started = true;
|
|
347
|
+
i = j < raw.length ? j + 1 : raw.length;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (ch === "'") {
|
|
351
|
+
const end = raw.indexOf("'", i + 1);
|
|
352
|
+
value += end === -1 ? raw.slice(i + 1) : raw.slice(i + 1, end);
|
|
353
|
+
quoted = true;
|
|
354
|
+
started = true;
|
|
355
|
+
i = end === -1 ? raw.length : end + 1;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (ch === '"') {
|
|
359
|
+
let j = i + 1;
|
|
360
|
+
while (j < raw.length && raw[j] !== '"') {
|
|
361
|
+
if (raw[j] === '\\' && j + 1 < raw.length) {
|
|
362
|
+
value += raw[j + 1];
|
|
363
|
+
j += 2;
|
|
364
|
+
} else {
|
|
365
|
+
value += raw[j];
|
|
366
|
+
j += 1;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
quoted = true;
|
|
370
|
+
started = true;
|
|
371
|
+
i = j < raw.length ? j + 1 : raw.length;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (ch === '#' && !started) {
|
|
375
|
+
const end = raw.indexOf('\n', i);
|
|
376
|
+
i = end === -1 ? raw.length : end; // an unquoted comment is not arguments
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (ch === '<' && raw[i + 1] === '<' && raw[i + 2] === '<') {
|
|
380
|
+
// A here-string, not a heredoc: the word after it is DATA on stdin, and no
|
|
381
|
+
// terminator line follows. Consumed whole — testing `raw[i+2] !== '<'` only
|
|
382
|
+
// skipped the FIRST `<`, so the scanner advanced one and matched `<<WORD`
|
|
383
|
+
// on the second, turning the here-string's word into a terminator and
|
|
384
|
+
// swallowing everything up to the next line equal to it. Three characters
|
|
385
|
+
// disarmed every rule.
|
|
386
|
+
i += 3;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (ch === '<' && raw[i + 1] === '<' && heredocBudget > 0) {
|
|
390
|
+
// A heredoc body is data, not commands. Recognised HERE, inside the
|
|
391
|
+
// scanner, because only here is it known that the `<<` is unquoted.
|
|
392
|
+
//
|
|
393
|
+
// The marker is only NOTED — the rest of the marker line keeps tokenising,
|
|
394
|
+
// and the body is skipped when its newline arrives. Jumping straight past
|
|
395
|
+
// the terminator swallowed `cat <<EOF; rm -rf /` and merged the command
|
|
396
|
+
// after the terminator into this segment. Both were hide-anything shapes,
|
|
397
|
+
// which is precisely what moving this inside the tokenizer was meant to end.
|
|
398
|
+
// The marker must be CLEANLY delimited. `<<EOF"X"` concatenates to `EOFX`
|
|
399
|
+
// for the shell, and a guard that stops at `EOF` swallows further than the
|
|
400
|
+
// shell does — the hide primitive again. When the models cannot be made to
|
|
401
|
+
// agree, the marker is left inert and the body gets inspected.
|
|
402
|
+
const marker = /^<<(-?)[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2(?=[\s;|&<>()`]|$)/.exec(
|
|
403
|
+
raw.slice(i, i + 64),
|
|
404
|
+
);
|
|
405
|
+
if (marker) {
|
|
406
|
+
// A TOTAL budget, not a per-step one: each lookahead scans forward, so an
|
|
407
|
+
// input full of markers would be quadratic. Past the budget `<<` is two
|
|
408
|
+
// characters again, which keeps the body visible — erring toward
|
|
409
|
+
// inspecting more, never less.
|
|
410
|
+
heredocBudget -= 1;
|
|
411
|
+
// `<<-` strips leading TABS from the terminator line, so the terminator
|
|
412
|
+
// the shell accepts is not the one a plain `\nEOF\n` search finds.
|
|
413
|
+
pendingHeredoc = { word: marker[3], tabs: marker[1] === '-' };
|
|
414
|
+
i += marker[0].length;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
i += 2;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (ch === '$' && raw[i + 1] === '{') {
|
|
421
|
+
// A parameter expansion is part of the token, not a brace group — `${HOME}`
|
|
422
|
+
// must survive tokenising to be normalised into `$HOME` later.
|
|
423
|
+
const end = raw.indexOf('}', i + 2);
|
|
424
|
+
value += end === -1 ? raw.slice(i) : raw.slice(i, end + 1);
|
|
425
|
+
started = true;
|
|
426
|
+
i = end === -1 ? raw.length : end + 1;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (ch === '$' && raw[i + 1] === '(' && raw[i + 2] === '(') {
|
|
430
|
+
// Arithmetic. `$((1<<n))` contains a LEFT SHIFT, not a heredoc — splitting
|
|
431
|
+
// it as a subshell left `1<<n))` to be scanned as ordinary text, where the
|
|
432
|
+
// marker regex matched `<<n` and swallowed the rest of the input.
|
|
433
|
+
const end = raw.indexOf('))', i + 3);
|
|
434
|
+
value += end === -1 ? raw.slice(i) : raw.slice(i, end + 2);
|
|
435
|
+
started = true;
|
|
436
|
+
i = end === -1 ? raw.length : end + 2;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (ch === '$' && raw[i + 1] === '(') {
|
|
440
|
+
endSegment();
|
|
441
|
+
i += 2;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
// `{`/`}` are NOT boundaries: a brace group is handled by the keyword skip in
|
|
445
|
+
// `commandOf`, and braces inside a token belong to the token.
|
|
446
|
+
if ('|;&\n()`'.includes(ch)) {
|
|
447
|
+
endSegment();
|
|
448
|
+
i += 1;
|
|
449
|
+
if (ch === '\n' && pendingHeredoc) {
|
|
450
|
+
// Skip the body and its terminator LINE, leaving the newline that ends
|
|
451
|
+
// that line to act as the next boundary. If the terminator never appears
|
|
452
|
+
// the body is kept and inspected rather than dropped — losing lines is
|
|
453
|
+
// how the pre-pass hid commands.
|
|
454
|
+
const { word, tabs } = pendingHeredoc;
|
|
455
|
+
const terminator = new RegExp(`\n${tabs ? '\t*' : ''}${word}(?=\n|$)`);
|
|
456
|
+
const found = terminator.exec(raw.slice(i - 1));
|
|
457
|
+
if (found) i = i - 1 + found.index + found[0].length;
|
|
458
|
+
pendingHeredoc = null;
|
|
459
|
+
}
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (/\s/.test(ch)) {
|
|
463
|
+
endArg();
|
|
464
|
+
i += 1;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
value += ch;
|
|
468
|
+
started = true;
|
|
469
|
+
i += 1;
|
|
470
|
+
}
|
|
471
|
+
endSegment();
|
|
472
|
+
return segments;
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The real command in a segment: leading `VAR=value` assignments and wrappers
|
|
477
|
+
* (`sudo`, `env`, …) are stepped over, and a path is reduced to its basename, so
|
|
478
|
+
* `FOO=1 sudo /usr/bin/git push` is recognised as `git push`.
|
|
479
|
+
*/
|
|
480
|
+
export const commandOf = (args) => {
|
|
481
|
+
let i = 0;
|
|
482
|
+
let sawWrapper = false;
|
|
483
|
+
let lastWrapper = null;
|
|
484
|
+
while (i < args.length) {
|
|
485
|
+
const { value } = args[i];
|
|
486
|
+
// An assignment prefix, whether or not it was quoted. Only the UNQUOTED form
|
|
487
|
+
// used to be stepped over, so `GIT_SSH_COMMAND="ssh -i k" git push …` — the
|
|
488
|
+
// ordinary spelling for any value with a space — defeated every rule.
|
|
489
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(value)) {
|
|
490
|
+
i += 1;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (KEYWORDS.has(value)) {
|
|
494
|
+
i += 1;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (WRAPPERS.has(value.split('/').pop())) {
|
|
498
|
+
lastWrapper = value.split('/').pop();
|
|
499
|
+
i += 1;
|
|
500
|
+
sawWrapper = true;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
// A wrapper's own options (`sudo -u root`, `env -i`, `xargs -n1`, and the
|
|
504
|
+
// bare duration in `timeout 60`) — step over them rather than treating `-u`
|
|
505
|
+
// as the command name and giving up.
|
|
506
|
+
if (sawWrapper && value.startsWith('-')) {
|
|
507
|
+
const takesValue = WRAPPER_VALUE_FLAGS[lastWrapper]?.has(value) ?? false;
|
|
508
|
+
i += value !== '--' && takesValue && args[i + 1] ? 2 : 1;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (sawWrapper && /^\d+(\.\d+)?[smhd]?$/.test(value)) {
|
|
512
|
+
i += 1; // `timeout 60 …`, `nice 10 …`
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
const name = (args[i]?.value ?? '').split('/').pop();
|
|
518
|
+
return { name, args: args.slice(i + 1) };
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* `gh` flags that take a value. Without these the value stayed in the operand
|
|
523
|
+
* list, which shifted every positional: `gh --repo o/r pr merge` no longer looked
|
|
524
|
+
* like a merge, and `gh workflow run --repo org/prod-release-api ci.yml` looked
|
|
525
|
+
* like a production deploy. One defect, both directions.
|
|
526
|
+
*/
|
|
527
|
+
const GH_VALUE_FLAGS = new Set([
|
|
528
|
+
'--repo',
|
|
529
|
+
'-R',
|
|
530
|
+
'--ref',
|
|
531
|
+
'--method',
|
|
532
|
+
'-X',
|
|
533
|
+
'-f',
|
|
534
|
+
'-F',
|
|
535
|
+
'--field',
|
|
536
|
+
'--raw-field',
|
|
537
|
+
'--body-file',
|
|
538
|
+
'--label',
|
|
539
|
+
'-l',
|
|
540
|
+
'-H',
|
|
541
|
+
'--jq',
|
|
542
|
+
'-q',
|
|
543
|
+
'--template',
|
|
544
|
+
]);
|
|
545
|
+
|
|
546
|
+
/** Arguments that are neither a flag nor the value of a prose/path/config flag. */
|
|
547
|
+
const operandsOf = (args, valueFlags = VALUE_FLAGS) => {
|
|
548
|
+
const operands = [];
|
|
549
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
550
|
+
const { value } = args[i];
|
|
551
|
+
if (valueFlags.has(value)) {
|
|
552
|
+
i += 1; // skip the value: it is a message, a path or a config, never a ref
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (value.startsWith('-')) continue;
|
|
556
|
+
operands.push(args[i]);
|
|
557
|
+
}
|
|
558
|
+
return operands;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const GH_FLAGS = new Set([...VALUE_FLAGS, ...GH_VALUE_FLAGS]);
|
|
562
|
+
|
|
563
|
+
const hasFlag = (args, ...names) =>
|
|
564
|
+
args.some(({ value }) => names.some((name) => value === name || value.startsWith(`${name}=`)));
|
|
565
|
+
|
|
566
|
+
// ── Rules ────────────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
/** Every branch name a refspec token designates (`+`, `src:dst`, `refs/heads/`). */
|
|
569
|
+
export const refNames = (token) =>
|
|
570
|
+
token
|
|
571
|
+
.replace(/^\+/, '')
|
|
572
|
+
.split(':')
|
|
573
|
+
.map((part) => part.replace(/^refs\/heads\//, ''));
|
|
574
|
+
|
|
575
|
+
const namesProtected = (token) => refNames(token).some((name) => PROTECTED_BRANCH.test(name));
|
|
576
|
+
|
|
577
|
+
function checkGit({ args }) {
|
|
578
|
+
const operands = operandsOf(args);
|
|
579
|
+
if (!operands.some(({ value }) => value === 'push')) return null;
|
|
580
|
+
|
|
581
|
+
const forced =
|
|
582
|
+
hasFlag(args, '-f', '--force', '--force-with-lease') ||
|
|
583
|
+
operands.some(({ value }) => value.startsWith('+'));
|
|
584
|
+
const protectedRef = operands.some(({ value }) => value !== 'push' && namesProtected(value));
|
|
585
|
+
|
|
586
|
+
if (forced && protectedRef) {
|
|
587
|
+
return (
|
|
588
|
+
'BLOCKED — force-pushing a shared branch is a Never-tier action ' +
|
|
589
|
+
'(.claude/rules/autonomy.md). It rewrites history other people and other ' +
|
|
590
|
+
'sessions have already built on. Push a branch and open a PR instead.'
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
if (hasFlag(args, '--mirror') || (forced && hasFlag(args, '--all'))) {
|
|
594
|
+
return (
|
|
595
|
+
'BLOCKED — a --mirror/--all force-push carries every ref, including the ' +
|
|
596
|
+
'shared branches, whether or not you named them (.claude/rules/autonomy.md). ' +
|
|
597
|
+
'Push the one branch you mean, by name.'
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
if (protectedRef) {
|
|
601
|
+
return (
|
|
602
|
+
'BLOCKED — the default branch is never written to directly, and never ' +
|
|
603
|
+
'deleted: it stays releasable at all times (.claude/rules/workflow.md, ' +
|
|
604
|
+
'"Branches and commits"). Work reaches it through a PR.'
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** `-f key=value` / `--field key=value` pairs, which is where a stage actually lives. */
|
|
611
|
+
const fieldValues = (args) =>
|
|
612
|
+
args.flatMap(({ value }, index) =>
|
|
613
|
+
value === '-f' || value === '-F' || value === '--field' || value === '--raw-field'
|
|
614
|
+
? [args[index + 1]?.value ?? '']
|
|
615
|
+
: value.startsWith('-f=') || value.startsWith('--field=')
|
|
616
|
+
? [value.split('=').slice(1).join('=')]
|
|
617
|
+
: [],
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
const PROD_FIELD = /(^|\[)(stage|environment|env|target)\]?=(prod|production)$/i;
|
|
621
|
+
const DEPLOY_TARGET = /deploy|release|publish|ship|(^|[^a-z])cd([^a-z]|$)|(^|[^a-z])prod/i;
|
|
622
|
+
|
|
623
|
+
function checkGh({ args }) {
|
|
624
|
+
const operands = operandsOf(args, GH_FLAGS);
|
|
625
|
+
const isWorkflowRun = operands[0]?.value === 'workflow' && operands[1]?.value === 'run';
|
|
626
|
+
const fields = fieldValues(args);
|
|
627
|
+
|
|
628
|
+
if (isWorkflowRun) {
|
|
629
|
+
// The workflow being run is the operand after `run` — NOT a repo name and not
|
|
630
|
+
// a --ref value. `prod` in `org/prod-api` or `release/prod-hotfix` is not a
|
|
631
|
+
// production deploy, and blocking those is how the rule gets switched off.
|
|
632
|
+
const workflow = operands[2]?.value ?? '';
|
|
633
|
+
const prodish = /prod/i.test(workflow) || fields.some((field) => PROD_FIELD.test(field));
|
|
634
|
+
if (DEPLOY_TARGET.test(workflow) && prodish) {
|
|
635
|
+
return (
|
|
636
|
+
'BLOCKED — triggering a production deploy from an agent session is a hard ' +
|
|
637
|
+
'stop (.claude/rules/autonomy.md, "Never"). Escalate to the human who owns ' +
|
|
638
|
+
'that release.'
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const route = operands.map(({ value }) => value).join(' ');
|
|
644
|
+
if (/\/dispatches\b/.test(route)) {
|
|
645
|
+
// Only the WORKFLOW segment counts, never the owner or repo name: `prod` in
|
|
646
|
+
// `repos/o/prod-api/…` is a repository, not a production deploy.
|
|
647
|
+
const workflowSegment = /workflows\/([^/\s]+)\/dispatches/.exec(route)?.[1] ?? '';
|
|
648
|
+
const prodish =
|
|
649
|
+
/prod|production/i.test(workflowSegment) || fields.some((f) => PROD_FIELD.test(f));
|
|
650
|
+
if (prodish) {
|
|
651
|
+
return (
|
|
652
|
+
'BLOCKED — dispatching a production workflow through the API is the same ' +
|
|
653
|
+
'hard stop as running it (.claude/rules/autonomy.md, "Never"). Escalate.'
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* `//`, `/.`, `${HOME}` and a trailing slash all collapse onto the literal list.
|
|
663
|
+
*
|
|
664
|
+
* Single-pass on purpose. The previous version looped a NON-global `replace`, so
|
|
665
|
+
* it copied the whole string once per `/.` — quadratic. At 1.4MB the hook was
|
|
666
|
+
* killed by its own timeout, and a killed hook does not block, so every rule
|
|
667
|
+
* silently switched off for that command. That made the guard, for that input,
|
|
668
|
+
* worse than no guard at all.
|
|
669
|
+
*/
|
|
670
|
+
export const normalizeTarget = (token) => {
|
|
671
|
+
const path = token.replace(/\$\{HOME\}/g, '$HOME');
|
|
672
|
+
const leading = path.startsWith('/') ? '/' : '';
|
|
673
|
+
const parts = path.split('/').filter((part) => part !== '' && part !== '.');
|
|
674
|
+
const joined = leading + parts.join('/');
|
|
675
|
+
return joined === '' ? (leading || path) : joined;
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
function checkRm({ args }, atCatastrophicCwd) {
|
|
679
|
+
for (const { value } of operandsOf(args)) {
|
|
680
|
+
const target = normalizeTarget(value);
|
|
681
|
+
if (isCatastrophic(target)) {
|
|
682
|
+
return (
|
|
683
|
+
'BLOCKED — this deletes the filesystem root or the whole home directory, ' +
|
|
684
|
+
'which no task in this project requires. If a path really needs removing, ' +
|
|
685
|
+
'name it relative to the project.'
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
// An upward escape from root or home reaches the same place by another name.
|
|
689
|
+
if (/^(\/|~|\$HOME)/.test(target) && target.split('/').includes('..')) {
|
|
690
|
+
return (
|
|
691
|
+
'BLOCKED — this path escapes upward out of the home directory or the ' +
|
|
692
|
+
'filesystem root, which reaches the same place as deleting it outright.'
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
// `cd / && rm -rf *` is `rm -rf /*` with the target hidden in a prior segment.
|
|
696
|
+
if (atCatastrophicCwd && (target === '*' || target === '.' || target === './*')) {
|
|
697
|
+
return (
|
|
698
|
+
'BLOCKED — an earlier segment changed directory to the filesystem root or ' +
|
|
699
|
+
'the home directory, so this wildcard delete is a root delete.'
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── Entry ────────────────────────────────────────────────────────────────────
|
|
707
|
+
|
|
708
|
+
/** Walk every segment of a command line, following shells and subshells inward. */
|
|
709
|
+
export const inspect = (raw, brake, depth = 0) => {
|
|
710
|
+
// Nested `eval`/`bash -c` beyond this is not drift, and following it forever is
|
|
711
|
+
// unbounded work. The depth is a stated limit, not an accident.
|
|
712
|
+
if (depth > 16) return null;
|
|
713
|
+
let atCatastrophicCwd = false;
|
|
714
|
+
|
|
715
|
+
for (const segment of tokenize(raw)) {
|
|
716
|
+
// The brake, before any per-command rule: while the flag is on, the
|
|
717
|
+
// network clients are refused whatever they are being asked to do.
|
|
718
|
+
const braked = brake && (() => {
|
|
719
|
+
const { name, args } = commandOf(segment);
|
|
720
|
+
return deniedByBrake(name, args);
|
|
721
|
+
})();
|
|
722
|
+
if (braked) {
|
|
723
|
+
return (
|
|
724
|
+
`BLOCKED — the kill switch is set (${brake}), so nothing may land on the ` +
|
|
725
|
+
'default branch. Everything else stays allowed on purpose: finish the ' +
|
|
726
|
+
'current task, push the branch, open the PR, write the journal entry, and ' +
|
|
727
|
+
`stop. "Stop cleanly" never means "lose the work". Clear it with: rm ${brake}`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const command = commandOf(segment);
|
|
732
|
+
if (!command.name) continue;
|
|
733
|
+
|
|
734
|
+
if (SHELLS.has(command.name) || command.name === 'eval') {
|
|
735
|
+
// `bash -c "<command line>"` / `eval "<command line>"` — the payload is a
|
|
736
|
+
// command line of its own. Taken whether or not it is quote-delimited: a
|
|
737
|
+
// backslash-joined payload is still a payload.
|
|
738
|
+
const flagIndex = command.args.findIndex(({ value }) => value === '-c');
|
|
739
|
+
const script =
|
|
740
|
+
flagIndex >= 0
|
|
741
|
+
? command.args[flagIndex + 1]?.value
|
|
742
|
+
: command.args.map(({ value }) => value).join(' ');
|
|
743
|
+
if (script) {
|
|
744
|
+
const reason = inspect(script, brake, depth + 1);
|
|
745
|
+
if (reason) return reason;
|
|
746
|
+
}
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (command.name === 'cd') {
|
|
751
|
+
const target = normalizeTarget(operandsOf(command.args)[0]?.value ?? '');
|
|
752
|
+
atCatastrophicCwd = CATASTROPHIC.has(target);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const reason =
|
|
757
|
+
command.name === 'git'
|
|
758
|
+
? checkGit(command)
|
|
759
|
+
: command.name === 'gh'
|
|
760
|
+
? checkGh(command, brake)
|
|
761
|
+
: command.name === 'rm'
|
|
762
|
+
? checkRm(command, atCatastrophicCwd)
|
|
763
|
+
: null;
|
|
764
|
+
if (reason) return reason;
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
function main() {
|
|
770
|
+
let input;
|
|
771
|
+
try {
|
|
772
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
773
|
+
} catch {
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
if (input.tool_name !== 'Bash') return 0;
|
|
777
|
+
const raw = String(input.tool_input?.command ?? '');
|
|
778
|
+
if (!raw.trim()) return 0;
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
const reason = inspect(raw, brakeIsOn());
|
|
782
|
+
if (reason) {
|
|
783
|
+
process.stderr.write(`${reason}\n`);
|
|
784
|
+
return 2;
|
|
785
|
+
}
|
|
786
|
+
} catch {
|
|
787
|
+
return 0; // a guard that crashes must not block the work
|
|
788
|
+
}
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Only act when invoked as the hook. Importing this module (a test reading
|
|
794
|
+
* `normalizeTarget`, say) must not run the guard and exit the process.
|
|
795
|
+
*/
|
|
796
|
+
const invokedDirectly = () => {
|
|
797
|
+
if (!process.argv[1]) return false;
|
|
798
|
+
const real = (p) => {
|
|
799
|
+
try {
|
|
800
|
+
return realpathSync(p);
|
|
801
|
+
} catch {
|
|
802
|
+
return p;
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
return real(fileURLToPath(import.meta.url)) === real(process.argv[1]);
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
if (invokedDirectly()) process.exit(main());
|