nearly-cli 0.1.12 → 0.1.16

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/server/policy.mjs CHANGED
@@ -3,23 +3,820 @@
3
3
  //
4
4
  // Kept apart from the server because this is the one piece where a mistake is
5
5
  // silent and expensive. A rule key that is too broad turns one "always" into
6
- // blanket permission for a whole class of commands; a never pattern that does
7
- // not match means a destructive command reaches a human who approves it out of
8
- // habit. Both are testable, so they are tested.
6
+ // blanket permission for a whole class of commands; a never rule that does not
7
+ // match means a destructive command runs. Both are testable, so they are tested.
9
8
 
10
9
  import path from 'node:path';
10
+ import os from 'node:os';
11
+ import { execFileSync } from 'node:child_process';
12
+ import { existsSync, lstatSync, realpathSync } from 'node:fs';
11
13
 
12
- // Denied outright. These never reach a person, because a prompt is a chance to
13
- // say yes and there is no version of these worth saying yes to in an agent's
14
- // worktree.
15
- export const NEVER_PATTERNS = [
16
- /\brm\s+-rf?\b/,
17
- /\bgit\s+push\b/,
18
- /\bsudo\b/,
19
- /\.env\b/,
20
- /curl[^|]*\|\s*(ba)?sh/,
21
- /\bchmod\s+777\b/,
22
- ];
14
+ // ===========================================================================
15
+ // Never: refused outright, with nobody asked.
16
+ // ===========================================================================
17
+ //
18
+ // One principle: refuse what cannot be undone. A file deleted inside the repo
19
+ // comes back from git; a home directory does not. A feature branch pushed is how
20
+ // work reaches review; a force-push rewrites what other people already have.
21
+ // A secret read into an agent's context cannot be un-read.
22
+ //
23
+ // The first version matched keywords, which refused `rm -rf node_modules` and
24
+ // every push. The second parsed commands naively, and two independent test runs
25
+ // walked straight past it — `cd ~ && rm -rf Documents`, `/bin/rm -rf ~`,
26
+ // `bash -c "rm -rf ~"`, `git -c x=y push --force`, a symlink out of the repo —
27
+ // and in a live session three folders were actually deleted. Under unattended
28
+ // mode anything these rules miss simply runs.
29
+ //
30
+ // So this reads a command the way a shell runs it: a `cd` moves where later
31
+ // paths land, `bash -c`, `eval`, `$(…)` and `xargs` carry commands inside other
32
+ // commands, a symlink points somewhere else, `git -c` pushes `push` further
33
+ // along, and `git checkout main &&` changes which branch a later push leaves.
34
+ //
35
+ // It is still not a sandbox. It catches what a well-meaning agent actually
36
+ // types, and is honest about that in the README. Code handed to an interpreter
37
+ // is only inspected for the obvious; real isolation needs a container.
38
+
39
+ const TEMPLATE_SUFFIX = new Set(['example', 'sample', 'template', 'dist', 'defaults', 'schema', 'vault']);
40
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'ash', 'busybox', 'powershell', 'pwsh', 'cmd']);
41
+ const INTERPRETERS = new Set(['python', 'python2', 'python3', 'node', 'nodejs', 'deno', 'bun', 'perl', 'ruby', 'php', 'osascript']);
42
+ const RUNNERS = new Set([...SHELLS, ...INTERPRETERS, 'iex', 'invoke-expression', 'source', '.']);
43
+ const DOWNLOADERS = new Set(['curl', 'wget', 'fetch', 'http', 'https', 'invoke-webrequest', 'iwr', 'invoke-restmethod', 'irm', 'aria2c']);
44
+ const WRAPPERS = new Set(['env', 'command', 'builtin', 'exec', 'nohup', 'time', 'nice', 'ionice', 'stdbuf', 'caffeinate', 'chronic', 'unbuffer', 'noglob', 'watch']);
45
+ const ESCALATE = new Set(['sudo', 'doas', 'pkexec', 'su', 'run0', 'runas', 'gsudo']);
46
+ const READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'batcat', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'awk', 'gawk', 'sed',
47
+ 'strings', 'xxd', 'od', 'hexdump', 'base64', 'nl', 'tac', 'jq', 'yq', 'sort', 'uniq', 'cut', 'diff', 'cmp', 'openssl', 'gpg',
48
+ 'type', 'get-content', 'gc', 'select-string', 'sls', 'pbcopy', 'xclip', 'wl-copy', 'clip']);
49
+ const SENDERS = new Set(['curl', 'wget', 'scp', 'sftp', 'rsync', 'nc', 'ncat', 'netcat', 'ssh', 'ftp', 'http', 'https', 'mail', 'sendmail', 'mutt',
50
+ 'invoke-webrequest', 'iwr', 'invoke-restmethod', 'irm']);
51
+ const COPIERS = new Set(['cp', 'mv', 'install', 'ln', 'tar', 'zip', '7z', 'gzip', 'bzip2', 'xz', 'copy-item', 'move-item', 'copy', 'move', 'xcopy', 'robocopy']);
52
+ const DISK = new Set(['mkfs', 'mke2fs', 'newfs', 'wipefs', 'fdisk', 'sfdisk', 'gdisk', 'parted', 'diskpart', 'format-volume', 'clear-disk', 'initialize-disk']);
53
+ const DELETERS = ['rm', 'unlink', 'shred', 'rimraf', 'del-cli', 'del', 'erase', 'remove-item', 'ri', 'rd', 'rmdir', 'truncate'];
54
+ const PROTECTED = ['main', 'master'];
55
+ const GIT_BUILTINS = new Set(['add', 'am', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle', 'cat-file', 'check-ignore', 'checkout',
56
+ 'cherry-pick', 'clean', 'clone', 'commit', 'config', 'describe', 'diff', 'fetch', 'for-each-ref', 'format-patch', 'fsck', 'gc', 'grep',
57
+ 'hash-object', 'help', 'init', 'log', 'ls-files', 'ls-remote', 'maintenance', 'merge', 'mv', 'notes', 'pull', 'push', 'range-diff',
58
+ 'rebase', 'reflog', 'remote', 'reset', 'restore', 'rev-list', 'rev-parse', 'revert', 'rm', 'shortlog', 'show', 'show-ref', 'sparse-checkout',
59
+ 'stash', 'status', 'submodule', 'switch', 'symbolic-ref', 'tag', 'update-ref', 'version', 'worktree', 'lfs', 'credential', 'var']);
60
+
61
+ const base = (s) => String(s ?? '').replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat|ps1)$/i, '').toLowerCase();
62
+ const isWinAbs = (s) => /^[A-Za-z]:[\\/]?/.test(s) || /^\\\\/.test(s);
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Git, quietly and briefly. Only asked when a command makes the answer matter.
66
+ // ---------------------------------------------------------------------------
67
+ function git(dir, args, gitDir) {
68
+ if (!dir && !gitDir) return null;
69
+ try {
70
+ const pre = gitDir ? ['--git-dir', gitDir] : [];
71
+ return execFileSync('git', [...pre, ...args], {
72
+ cwd: dir || undefined, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000,
73
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
74
+ }).trim();
75
+ } catch { return null; }
76
+ }
77
+
78
+ // The native realpath: on Windows it expands 8.3 short names (RUNNER~1) to the
79
+ // long ones git reports. Without it the same folder had two spellings, nothing
80
+ // was ever "inside the repo" there, and ordinary deletes were refused while
81
+ // `rm -rf .git` was let through.
82
+ const real = (p) => {
83
+ try { return realpathSync.native(p); } catch { try { return realpathSync(p); } catch { return null; } }
84
+ };
85
+ const lstatSafe = (p) => { try { return lstatSync(p); } catch { return null; } };
86
+
87
+ // The deepest part of a path that exists, resolved through symlinks, with the
88
+ // rest appended. `link/data` where link points outside the repo resolves outside.
89
+ function realish(abs) {
90
+ let head = abs;
91
+ const tail = [];
92
+ while (head && !existsSync(head)) {
93
+ const parent = path.dirname(head);
94
+ if (parent === head) break;
95
+ tail.unshift(path.basename(head));
96
+ head = parent;
97
+ }
98
+ const r = real(head) || head;
99
+ return tail.length ? path.join(r, ...tail) : r;
100
+ }
101
+
102
+ const insideDir = (dir, target) => {
103
+ if (!dir || !target) return false;
104
+ const rel = path.relative(dir, target);
105
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
106
+ };
107
+
108
+ const TEMP_ROOTS = [...new Set([os.tmpdir(), '/tmp', '/private/tmp', '/var/tmp', process.env.TMPDIR]
109
+ .filter(Boolean).map((d) => real(d) || d))];
110
+ // Somewhere meant to be thrown away — but not the whole of it, not a folder that
111
+ // holds this repo, and not another git repository that happens to live there.
112
+ // Without those, a repo checked out under /tmp could delete its own parent.
113
+ const inTemp = (p, st) => TEMP_ROOTS.some((t) => insideDir(t, p) && path.relative(t, p) !== '')
114
+ && !(st && st.root && insideDir(p, st.root))
115
+ && !existsSync(path.join(p, '.git'));
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Reading a command the way a shell does
119
+ // ---------------------------------------------------------------------------
120
+
121
+ // Find the closing paren of a `$(` or `<(` whose `(` is at `open`.
122
+ function balanced(src, open) {
123
+ let depth = 0, q = null;
124
+ for (let i = open; i < src.length; i++) {
125
+ const c = src[i];
126
+ if (q) { if (c === '\\' && q === '"') { i++; continue; } if (c === q) q = null; continue; }
127
+ if (c === '"' || c === "'") { q = c; continue; }
128
+ if (c === '(') depth++;
129
+ else if (c === ')') { depth--; if (depth === 0) return [src.slice(open + 1, i), i + 1]; }
130
+ }
131
+ return [src.slice(open + 1), src.length];
132
+ }
133
+
134
+ // Words and operators, with quotes removed, backslash escapes applied, and every
135
+ // command substitution noted so it can be checked in its own right.
136
+ function lex(src) {
137
+ const out = [];
138
+ let w = null, i = 0;
139
+ const flush = () => { if (w) { out.push(w); w = null; } };
140
+ const word = () => (w ||= { t: 'word', v: '', subs: [] });
141
+ while (i < src.length) {
142
+ const c = src[i], n = src[i + 1];
143
+ if (c === ' ' || c === '\t' || c === '\r') { flush(); i++; continue; }
144
+ if (c === '\n') { flush(); out.push({ t: 'op', v: ';' }); i++; continue; }
145
+ if (c === '#' && !w) { while (i < src.length && src[i] !== '\n') i++; continue; }
146
+ if (c === '&' && n === '&') { flush(); out.push({ t: 'op', v: '&&' }); i += 2; continue; }
147
+ if (c === '|' && n === '|') { flush(); out.push({ t: 'op', v: '||' }); i += 2; continue; }
148
+ if (c === '|') { flush(); out.push({ t: 'op', v: '|' }); i += n === '&' ? 2 : 1; continue; }
149
+ if (c === ';') { flush(); out.push({ t: 'op', v: ';' }); i++; continue; }
150
+ if ((c === '<' || c === '>') && n === '(') {
151
+ const [body, end] = balanced(src, i + 1); word().subs.push(body); w.v += '<(…)'; i = end; continue;
152
+ }
153
+ if (c === '&' && n === '>') { flush(); out.push({ t: 'op', v: '>' }); i += src[i + 2] === '>' ? 3 : 2; continue; }
154
+ if (c === '&') { flush(); out.push({ t: 'op', v: ';' }); i++; continue; }
155
+ if (c === '>' || c === '<') {
156
+ if (w && /^\d+$/.test(w.v) && !w.subs.length) w = null; // `2>` names a descriptor, not a word
157
+ flush();
158
+ let op = c; i++;
159
+ if (src[i] === '>' || (c === '<' && src[i] === '<')) { op += src[i]; i++; }
160
+ if (src[i] === '&') { op += '&'; i++; }
161
+ out.push({ t: 'op', v: op });
162
+ continue;
163
+ }
164
+ if (c === "'") {
165
+ const j = src.indexOf("'", i + 1), end = j === -1 ? src.length : j;
166
+ word().v += src.slice(i + 1, end); i = end + 1; continue;
167
+ }
168
+ if (c === '"') {
169
+ word(); i++;
170
+ while (i < src.length && src[i] !== '"') {
171
+ // Inside double quotes a backslash escapes only $ ` " \\ and newline.
172
+ if (src[i] === '\\' && i + 1 < src.length && /[$`"\\\n]/.test(src[i + 1])) { w.v += src[i + 1]; i += 2; continue; }
173
+ if (src[i] === '$' && src[i + 1] === '(') { const [body, end] = balanced(src, i + 1); w.subs.push(body); w.v += `$(${body})`; i = end; continue; }
174
+ if (src[i] === '`') { const j = src.indexOf('`', i + 1), end = j === -1 ? src.length : j; w.subs.push(src.slice(i + 1, end)); w.v += `\`${src.slice(i + 1, end)}\``; i = end + 1; continue; }
175
+ w.v += src[i]; i++;
176
+ }
177
+ i++; continue;
178
+ }
179
+ if (c === '\\' && i + 1 < src.length) {
180
+ // A backslash either escapes, as in `\\rm` or `my\\ folder`, or is a
181
+ // Windows path separator, as in `C:\\Users`. Treating every one as an
182
+ // escape turned `C:\\Users` into `C:Users`, and let `rmdir /s C:\\Users`
183
+ // through on the one platform where it runs.
184
+ const mid = !!(w && w.v.length);
185
+ if (mid && !/[\s;&|<>()$`"'\\]/.test(n)) { word().v += c; i++; continue; }
186
+ if (!mid && n === '\\' && /[A-Za-z0-9._-]/.test(src[i + 2] || '')) { word().v += '\\\\'; i += 2; continue; }
187
+ word().v += n; i += 2; continue;
188
+ }
189
+ if (c === '$' && n === '(') { const [body, end] = balanced(src, i + 1); word().subs.push(body); w.v += `$(${body})`; i = end; continue; }
190
+ if (c === '`') { const j = src.indexOf('`', i + 1), end = j === -1 ? src.length : j; word().subs.push(src.slice(i + 1, end)); w.v += `\`${src.slice(i + 1, end)}\``; i = end + 1; continue; }
191
+ word().v += c; i++;
192
+ }
193
+ flush();
194
+ return out;
195
+ }
196
+
197
+ // Pipelines, in the order they run, each a list of stages.
198
+ function pipelines(src) {
199
+ const toks = lex(src);
200
+ const all = [];
201
+ let pipe = [[]];
202
+ for (const tk of toks) {
203
+ if (tk.t === 'op' && (tk.v === ';' || tk.v === '&&' || tk.v === '||')) { all.push(pipe); pipe = [[]]; continue; }
204
+ if (tk.t === 'op' && tk.v === '|') { pipe.push([]); continue; }
205
+ pipe[pipe.length - 1].push(tk);
206
+ }
207
+ all.push(pipe);
208
+ return all
209
+ .map((p) => p.map((stageToks) => {
210
+ const words = [], redirects = [], subs = [];
211
+ for (let k = 0; k < stageToks.length; k++) {
212
+ const tk = stageToks[k];
213
+ if (tk.t === 'op') {
214
+ const target = stageToks[k + 1];
215
+ if (target && target.t === 'word') {
216
+ if (!tk.v.includes('&')) redirects.push({ op: tk.v, target: target.v });
217
+ subs.push(...target.subs);
218
+ k++;
219
+ }
220
+ continue;
221
+ }
222
+ words.push(tk.v);
223
+ subs.push(...tk.subs);
224
+ }
225
+ return { words, redirects, subs };
226
+ }).filter((s) => s.words.length || s.redirects.length || s.subs.length))
227
+ .filter((p) => p.length);
228
+ }
229
+
230
+ // Peel off wrappers and `VAR=value` so the rule sees what actually runs.
231
+ function unwrap(words) {
232
+ const argv = [...words];
233
+ const assigns = {};
234
+ for (let guard = 0; guard < 20; guard++) {
235
+ while (argv.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(argv[0])) {
236
+ const [k, ...v] = argv.shift().split('=');
237
+ assigns[k] = v.join('=');
238
+ }
239
+ if (!argv.length) break;
240
+ const p = base(argv[0]);
241
+ if (WRAPPERS.has(p)) {
242
+ argv.shift();
243
+ while (argv.length && argv[0].startsWith('-')) {
244
+ const f = argv.shift();
245
+ if ((p === 'nice' && f === '-n') || (p === 'env' && (f === '-u' || f === '-C')) || (p === 'watch' && (f === '-n' || f === '-d'))) argv.shift();
246
+ }
247
+ continue;
248
+ }
249
+ if (p === 'timeout' || p === 'gtimeout') {
250
+ argv.shift();
251
+ while (argv.length && argv[0].startsWith('-')) { const f = argv.shift(); if (['-s', '-k', '--signal', '--kill-after'].includes(f)) argv.shift(); }
252
+ if (argv.length) argv.shift();
253
+ continue;
254
+ }
255
+ break;
256
+ }
257
+ return { argv, assigns, prog: argv.length ? base(argv[0]) : '' };
258
+ }
259
+
260
+ const quote = (s) => (/^[A-Za-z0-9_./:=@%+,{}~-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
261
+ const fork = (st) => ({ ...st, aliases: { ...st.aliases } });
262
+
263
+ // ---------------------------------------------------------------------------
264
+ // Paths
265
+ // ---------------------------------------------------------------------------
266
+
267
+ // Expand what can be known now. Anything else is decided at run time, and a
268
+ // path decided at run time might be `dist` or might be `/`.
269
+ function expand(raw, st) {
270
+ let s = String(raw);
271
+ if (s === '~' || s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(1));
272
+ else if (/^~[^/\\]/.test(s)) return { unknown: `another user's home directory (${raw})` };
273
+ const vars = { HOME: os.homedir(), USERPROFILE: os.homedir(), TMPDIR: process.env.TMPDIR || os.tmpdir(), TMP: os.tmpdir(), TEMP: os.tmpdir(), PWD: st.cwd };
274
+ s = s.replace(/\$\(\s*pwd\s*\)/g, () => st.cwd ?? '\0');
275
+ s = s.replace(/\$\{?env:([A-Za-z_]+)\}?/gi, (_, k) => vars[k.toUpperCase()] ?? '\0');
276
+ s = s.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, k) => vars[k] ?? '\0');
277
+ if (s.includes('\0') || /`|\$\(/.test(s)) return { unknown: `a path decided at run time (${raw})` };
278
+ return { path: s };
279
+ }
280
+
281
+ // One level of `{a,b}`, which is all anyone types into a delete.
282
+ function braces(s) {
283
+ const m = s.match(/^(.*?)\{([^{}]*,[^{}]*)\}(.*)$/);
284
+ return m ? m[2].split(',').flatMap((part) => braces(m[1] + part + m[3])) : [s];
285
+ }
286
+
287
+ // A glob as a regular expression, built piece by piece. No placeholder
288
+ // characters: an earlier version used one and wrote a NUL byte into this file,
289
+ // which made every tool that reads source treat it as binary.
290
+ function globRe(g) {
291
+ let re = '^';
292
+ for (let i = 0; i < g.length; i++) {
293
+ const c = g[i];
294
+ if (c === '*' && g[i + 1] === '*') { re += '.*'; i++; continue; }
295
+ if (c === '*') { re += '[^/]*'; continue; }
296
+ if (c === '?') { re += '.'; continue; }
297
+ if (c === '[') {
298
+ const j = g.indexOf(']', i + 1);
299
+ if (j === -1) { re += '\\['; continue; }
300
+ let body = g.slice(i + 1, j);
301
+ if (body[0] === '!') body = '^' + body.slice(1);
302
+ re += '[' + body.replace(/\\/g, '\\\\') + ']';
303
+ i = j;
304
+ continue;
305
+ }
306
+ re += c.replace(/[.+^$()|{}\\]/g, '\\$&');
307
+ }
308
+ return new RegExp(re + '$');
309
+ }
310
+
311
+ function deleteReason(raw, st, recursive) {
312
+ for (const one of braces(String(raw))) {
313
+ const why = deleteOne(one, st, recursive);
314
+ if (why) return why;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ function deleteOne(raw, st, recursive) {
320
+ const x = expand(raw, st);
321
+ if (x.unknown) return `deletes ${x.unknown}`;
322
+ const p = x.path;
323
+ if (/^\/dev\/(null|stdout|stderr|tty)$/.test(p)) return null;
324
+ const root = st.root;
325
+ const outside = `deletes ${raw}, outside this repo, where git cannot give it back`;
326
+ if (isWinAbs(p) && process.platform !== 'win32') return outside;
327
+ if (!st.cwd && !path.isAbsolute(p)) return `deletes ${raw} from a directory that could not be identified`;
328
+
329
+ const abs = path.resolve(st.cwd || '/', p);
330
+
331
+ if (/[*?[]/.test(p)) {
332
+ // The fixed part says where it lands; the pattern says how much it takes.
333
+ const parts = abs.split(path.sep);
334
+ const firstGlob = parts.findIndex((part) => /[*?[]/.test(part));
335
+ const dir = parts.slice(0, firstGlob).join(path.sep) || path.sep;
336
+ const first = parts[firstGlob];
337
+ const dirReal = realish(dir);
338
+ if (!insideDir(root, dirReal) && !inTemp(dirReal, st)) return outside;
339
+ const re = globRe(first);
340
+ // A shell only lets a pattern match a dot-name when the pattern itself
341
+ // starts with a dot, so `dist/*` never reaches `..` and `.?*` does.
342
+ const dotted = first.startsWith('.');
343
+ if (dotted && re.test('..')) return `deletes ${raw}, which can match the parent directory`;
344
+ if (root && path.relative(root, dirReal) === '') {
345
+ if (dotted && re.test('.git')) return `deletes ${raw}, which matches .git — the history nothing can restore`;
346
+ if (/^\*+$/.test(first) && recursive) return `deletes everything in the repo (${raw})`;
347
+ }
348
+ return null;
349
+ }
350
+
351
+ // A trailing slash follows a symlink; without one, rm removes the link itself.
352
+ const target = !/[\\/]$/.test(p) && lstatSafe(abs)?.isSymbolicLink()
353
+ ? path.join(realish(path.dirname(abs)), path.basename(abs))
354
+ : realish(abs);
355
+
356
+ if (root && insideDir(root, target)) {
357
+ const rel = path.relative(root, target);
358
+ if (rel === '') return 'deletes the repo itself';
359
+ const segs = rel.split(path.sep);
360
+ if (segs[0] === '.git' && (segs.length === 1 || ['objects', 'refs', 'logs', 'packed-refs', 'HEAD'].includes(segs[1]))) {
361
+ return 'deletes git history, which nothing can restore';
362
+ }
363
+ return null;
364
+ }
365
+ if (inTemp(target, st)) return null;
366
+ return outside;
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // Secrets
371
+ // ---------------------------------------------------------------------------
372
+ const HOME_SECRETS = ['.ssh', '.aws/credentials', '.aws/config', '.netrc', '.git-credentials', '.docker/config.json', '.kube/config',
373
+ '.npmrc', '.pypirc', '.gnupg', '.config/gh/hosts.yml', '.azure', '.config/gcloud'];
374
+ const SECRET_NAME = /^(\.env(\..+)?|\.envrc|\.netrc|\.git-credentials|\.npmrc|\.pypirc|id_(rsa|dsa|ecdsa|ed25519)(_sk)?)$/i;
375
+
376
+ // `.env.example` and `.env.local.example` are templates; `.env.example.real`
377
+ // and `.env.dist.local` are not — the last part is what the file is.
378
+ function envTemplate(name) {
379
+ const m = name.match(/^\.env\.(.+)$/i);
380
+ return !!m && TEMPLATE_SUFFIX.has(m[1].split('.').pop().toLowerCase());
381
+ }
382
+
383
+ // Is this a place secrets live? Returns the name to show, or null. A file git
384
+ // already tracks is in the repo for anyone to read, so reading it exposes
385
+ // nothing new; an untracked one is exactly where secrets are kept.
386
+ function secretPath(raw, st) {
387
+ let s = String(raw).replace(/^[@<>]+/, '').replace(/^[A-Za-z0-9_-]+=@?/, '');
388
+ const x = expand(s, st);
389
+ if (x.unknown) return null;
390
+ s = x.path;
391
+ if (!s) return null;
392
+ const name = path.basename(s);
393
+ const abs = path.resolve(st.cwd || '/', s);
394
+ const home = os.homedir();
395
+ const underHomeSecret = HOME_SECRETS.some((h) => insideDir(path.join(home, h), abs));
396
+ if (/[*?[]/.test(name)) {
397
+ return ['.env', '.env.local', '.env.production'].some((c) => globRe(name).test(c)) ? s : null;
398
+ }
399
+ if (!underHomeSecret && !(SECRET_NAME.test(name) && !envTemplate(name))) return null;
400
+ if (st.root && insideDir(st.root, abs) && existsSync(abs)) {
401
+ if (git(st.root, ['ls-files', '--error-unmatch', path.relative(st.root, abs)]) !== null) return null;
402
+ }
403
+ return s;
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Git
408
+ // ---------------------------------------------------------------------------
409
+ function gitCtx(argv, assigns, st) {
410
+ let i = 1, dir = st.cwd, gitDir = (assigns && assigns.GIT_DIR) || st.gitDir || null;
411
+ while (i < argv.length && argv[i].startsWith('-')) {
412
+ const a = argv[i];
413
+ if (a === '-C') { dir = dir ? path.resolve(dir, argv[i + 1] ?? '') : argv[i + 1]; i += 2; continue; }
414
+ if (['-c', '--config-env', '--git-dir', '--work-tree', '--namespace', '--super-prefix'].includes(a)) {
415
+ if (a === '--git-dir') gitDir = argv[i + 1];
416
+ i += 2; continue;
417
+ }
418
+ if (a.startsWith('--git-dir=')) gitDir = a.slice('--git-dir='.length);
419
+ i++;
420
+ }
421
+ if (gitDir && dir && !path.isAbsolute(gitDir)) gitDir = path.resolve(dir, gitDir);
422
+ return { i, dir, gitDir };
423
+ }
424
+
425
+ function protectedBranch(name, ctx) {
426
+ const n = String(name || '').replace(/^refs\/heads\//, '').toLowerCase();
427
+ if (PROTECTED.includes(n)) return n;
428
+ const def = git(ctx.dir, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], ctx.gitDir);
429
+ return def && def.replace(/^[^/]+\//, '').toLowerCase() === n ? n : null;
430
+ }
431
+
432
+ function pushReason(args, ctx, st) {
433
+ const valued = new Set(['--repo', '--receive-pack', '--exec', '-o', '--push-option']);
434
+ const flags = [], positional = [];
435
+ for (let k = 0; k < args.length; k++) {
436
+ const a = args[k];
437
+ if (a.startsWith('-')) { flags.push(a); if (valued.has(a)) k++; continue; }
438
+ positional.push(a);
439
+ }
440
+ if (flags.some((f) => f === '--dry-run' || f === '-n')) return null;
441
+ if (flags.includes('--mirror')) return 'mirrors the repo to the remote, overwriting and deleting its branches';
442
+ const force = flags.some((f) => /^--force/.test(f) || (/^-[a-zA-Z]+$/.test(f) && f.includes('f')));
443
+ if (force || positional.some((r) => r.startsWith('+'))) return 'force-pushes, rewriting history other people already have';
444
+ if (flags.includes('--delete') || flags.includes('-d') || positional.slice(1).some((r) => r.startsWith(':'))) return 'deletes a branch on the remote';
445
+ if (flags.includes('--prune')) return 'deletes remote branches that do not exist locally';
446
+
447
+ if (flags.includes('--all') || flags.includes('--branches')) {
448
+ const locals = git(ctx.dir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], ctx.gitDir) || '';
449
+ const hit = locals.split('\n').filter(Boolean).find((b) => protectedBranch(b, ctx));
450
+ return hit ? `pushes every branch, including ${hit}, skipping review` : null;
451
+ }
452
+
453
+ const refspecs = positional.slice(1);
454
+ if (!refspecs.length && flags.includes('--tags')) return null; // tags only; no branch moves
455
+ const dests = refspecs.length ? refspecs.map((r) => (r.includes(':') ? r.split(':').pop() : r)) : ['HEAD'];
456
+ for (const d of dests) {
457
+ const name = d === 'HEAD' || d === '@' ? (st.branch || git(ctx.dir, ['rev-parse', '--abbrev-ref', 'HEAD'], ctx.gitDir)) : d;
458
+ if (!name || name === 'HEAD') return 'pushes a branch that could not be identified';
459
+ const hit = protectedBranch(name, ctx);
460
+ if (hit) return `pushes straight to ${hit}, skipping review`;
461
+ }
462
+ return null;
463
+ }
464
+
465
+ function dirty(ctx) {
466
+ const out = git(ctx.dir, ['status', '--porcelain', '--untracked-files=no'], ctx.gitDir);
467
+ return out === null ? true : out !== '';
468
+ }
469
+
470
+ function gitReason(argv, assigns, st, depth) {
471
+ const ctx = gitCtx(argv, assigns, st);
472
+ const sub = argv[ctx.i];
473
+ const rest = argv.slice(ctx.i + 1);
474
+ if (!sub) return null;
475
+
476
+ if (!GIT_BUILTINS.has(sub)) {
477
+ const alias = st.aliases[sub] ?? git(ctx.dir, ['config', '--get', `alias.${sub}`], ctx.gitDir);
478
+ if (alias && depth < 6) {
479
+ if (alias.startsWith('!')) return analyze(`${alias.slice(1)} ${rest.map(quote).join(' ')}`, fork(st), depth + 1);
480
+ const words = pipelines(alias)[0]?.[0]?.words ?? [];
481
+ return gitReason(['git', ...words, ...rest], assigns, st, depth + 1);
482
+ }
483
+ return null;
484
+ }
485
+
486
+ if (sub === 'push') return pushReason(rest, ctx, st);
487
+ if (sub === 'clean') {
488
+ const short = rest.filter((a) => /^-[a-zA-Z]+$/.test(a)).join('');
489
+ const forced = /f/.test(short) || rest.includes('--force');
490
+ const dry = /n/.test(short) || rest.includes('--dry-run');
491
+ // -X removes only ignored files: build output, by definition regenerable.
492
+ if (forced && !dry && !/X/.test(short)) return 'deletes untracked files, which git never had a copy of';
493
+ return null;
494
+ }
495
+ if (sub === 'reset' && rest.includes('--hard') && dirty(ctx)) return 'throws away uncommitted changes, which git never had a copy of';
496
+ const discards = (sub === 'checkout' && (rest.includes('--') || rest.includes('.') || rest.includes('-f') || rest.includes('--force')))
497
+ || (sub === 'restore' && !rest.includes('--staged') && !rest.includes('-S') && rest.some((a) => !a.startsWith('-')));
498
+ if (discards && dirty(ctx)) return 'throws away uncommitted changes, which git never had a copy of';
499
+ if (sub === 'stash' && rest[0] === 'clear') return 'deletes every stash, which cannot be recovered';
500
+ if (sub === 'reflog' && rest[0] === 'expire') return 'destroys the reflog, the record that lets lost commits be recovered';
501
+ if (sub === 'gc' && rest.some((a) => /^--prune=(now|all)$/.test(a))) return 'permanently deletes unreachable commits';
502
+ return null;
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Interpreters: inspected for the obvious, and no more
507
+ // ---------------------------------------------------------------------------
508
+ function interpreterCode(prog, argv) {
509
+ const flags = { python: ['-c'], python2: ['-c'], python3: ['-c'], node: ['-e', '--eval', '-p', '--print'], nodejs: ['-e', '--eval', '-p'],
510
+ deno: ['eval'], bun: ['-e', '--eval'], perl: ['-e', '-E'], ruby: ['-e'], php: ['-r'], osascript: ['-e'] }[prog] || [];
511
+ for (let k = 1; k < argv.length; k++) {
512
+ if (flags.includes(argv[k])) return argv[k + 1] ?? '';
513
+ const glued = flags.find((f) => /^-.$/.test(f) && argv[k].startsWith(f) && argv[k].length > 2);
514
+ if (glued) return argv[k].slice(2);
515
+ }
516
+ return null;
517
+ }
518
+
519
+ function interpreterReason(code, st, depth) {
520
+ const literals = [...code.matchAll(/(['"`])((?:\\.|(?!\1).)*)\1/g)].map((m) => m[2]).concat(
521
+ [...code.matchAll(/\bq[qwx]?\s*[({[<|/](.*?)[)}\]>|/]/g)].map((m) => m[1]));
522
+ if (/\b(system|exec|execSync|execFileSync|spawn|spawnSync|popen|subprocess|shell_exec|passthru|Popen|check_output|check_call|os\.system)\b/.test(code)
523
+ || /`[^`]+`/.test(code) || /\bqx\s*[({[<|/]/.test(code)) {
524
+ for (const lit of literals) { const why = analyze(lit, fork(st), depth + 1); if (why) return why; }
525
+ }
526
+ if (/\b(rmtree|remove|unlink|unlinkSync|rmSync|rmdirSync|rmdir|rimraf|rm_rf|rm_r|delete)\b|\bfs\.(promises\.)?rm\b|Remove-Item/.test(code)) {
527
+ const paths = literals.filter((l) => l && !/\s/.test(l) && l.length < 400 && /[/~.$]|^[\w-]+$/.test(l));
528
+ if (!paths.length) return 'deletes a path computed inside the code, which cannot be checked';
529
+ for (const lit of paths) { const why = deleteReason(lit, st, true); if (why) return why; }
530
+ }
531
+ if (/\b(open|readFile|readFileSync|read_text|read_bytes|read|file_get_contents|readlines|fopen|slurp)\b|Get-Content/.test(code)) {
532
+ for (const lit of literals) { const s = secretPath(lit, st); if (s) return `reads secrets from ${s}`; }
533
+ }
534
+ return null;
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // One stage of a pipeline
539
+ // ---------------------------------------------------------------------------
540
+ function findRoots(argv) {
541
+ const roots = [];
542
+ for (let k = 1; k < argv.length && !/^[-(!]/.test(argv[k]); k++) roots.push(argv[k]);
543
+ return roots.length ? roots : ['.'];
544
+ }
545
+
546
+ // `find` deletes what it matches under a folder, not the folder. So a start
547
+ // inside the repo is fine when something narrows the match, and only
548
+ // "everything" when nothing does.
549
+ const FIND_FILTERS = /^-(i?name|i?path|i?regex|type|size|mtime|mmin|atime|amin|ctime|cmin|newer\w*|perm|user|group|empty|links|inum|samefile|wholename|iwholename|lname|ilname)$/;
550
+ function findRootReason(root, expr, st) {
551
+ const x = expand(root, st);
552
+ if (x.unknown) return `deletes under ${x.unknown}`;
553
+ const abs = realish(path.resolve(st.cwd || '/', x.path));
554
+ const filtered = expr.some((a) => FIND_FILTERS.test(a));
555
+ if (st.root && insideDir(st.root, abs)) {
556
+ if (!filtered && path.relative(st.root, abs) === '') return 'deletes everything in the repo';
557
+ return null;
558
+ }
559
+ if (inTemp(abs, st)) return null;
560
+ return `deletes under ${root}, outside this repo, where git cannot give it back`;
561
+ }
562
+
563
+ function stageReason(stage, pipe, idx, st, depth) {
564
+ for (const s of stage.subs) { const why = analyze(s, fork(st), depth + 1); if (why) return why; }
565
+
566
+ const { argv, assigns, prog } = unwrap(stage.words);
567
+
568
+ for (const r of stage.redirects) {
569
+ // `>` destroys what was there; `>>` only adds to it.
570
+ if (r.op === '>' || r.op === '>|') {
571
+ const why = deleteReason(r.target, st, false);
572
+ if (why) return why.replace(/^deletes/, 'overwrites');
573
+ }
574
+ if ((r.op === '<' || r.op === '<<') && (READERS.has(prog) || RUNNERS.has(prog) || SENDERS.has(prog))) {
575
+ const s = secretPath(r.target, st);
576
+ if (s) return `reads secrets from ${s}`;
577
+ }
578
+ }
579
+ if (!prog) return null;
580
+
581
+ if (ESCALATE.has(prog)) return 'runs as root';
582
+
583
+ // Something that runs code, fed by something that fetched it.
584
+ const fedDownload = pipe.slice(0, idx).some((s) => DOWNLOADERS.has(unwrap(s.words).prog))
585
+ || stage.subs.some((body) => pipelines(body).some((p) => p.some((q) => DOWNLOADERS.has(unwrap(q.words).prog))));
586
+ if (RUNNERS.has(prog) && fedDownload) return 'runs a download without anyone reading it first';
587
+
588
+ if (SHELLS.has(prog)) {
589
+ const k = argv.findIndex((a, n) => n > 0 && (/^-[a-zA-Z]*c[a-zA-Z]*$/.test(a) || /^\/[ck]$/i.test(a) || /^-command$/i.test(a)));
590
+ return k !== -1 && argv[k + 1] !== undefined ? analyze(argv.slice(k + 1).join(' '), fork(st), depth + 1) : null;
591
+ }
592
+ if (prog === 'eval' || prog === 'invoke-expression' || prog === 'iex') {
593
+ return argv.length > 1 ? analyze(argv.slice(1).join(' '), fork(st), depth + 1) : null;
594
+ }
595
+ if (INTERPRETERS.has(prog)) {
596
+ const code = interpreterCode(prog, argv);
597
+ return code === null ? null : interpreterReason(code, st, depth);
598
+ }
599
+ if (prog === 'git') return gitReason(argv, assigns, st, depth);
600
+ if (prog === 'gh' && argv[1] === 'repo' && argv[2] === 'delete') return 'deletes a GitHub repository';
601
+
602
+ // rm, and everything that behaves like it
603
+ const npxLike = ['npx', 'bunx', 'pnpx'].includes(prog) || (prog === 'pnpm' && argv[1] === 'dlx');
604
+ const toolAt = npxLike ? argv.findIndex((a, n) => n > (prog === 'pnpm' ? 1 : 0) && !a.startsWith('-')) : 0;
605
+ const tool = npxLike ? base(argv[toolAt] || '') : prog;
606
+ if (DELETERS.includes(tool)) {
607
+ let recursive = tool === 'rimraf' || tool === 'del-cli', end = false;
608
+ const targets = [];
609
+ for (let k = toolAt + 1; k < argv.length; k++) {
610
+ const a = argv[k];
611
+ if (!end && a === '--') { end = true; continue; }
612
+ if (!end && /^\/[a-zA-Z]$/.test(a) && ['del', 'erase', 'rd', 'rmdir'].includes(tool)) { if (/s/i.test(a)) recursive = true; continue; }
613
+ if (!end && a.startsWith('-')) {
614
+ const low = a.toLowerCase();
615
+ if (low === '--recursive' || low === '-recurse' || (/^-[a-z]+$/i.test(a) && /r/i.test(a) && !['-force'].includes(low))) recursive = true;
616
+ if (tool === 'truncate' && (a === '-s' || a === '--size')) k++;
617
+ if ((low === '-path' || low === '-literalpath') && argv[k + 1]) { targets.push(argv[k + 1]); k++; }
618
+ continue;
619
+ }
620
+ targets.push(a);
621
+ }
622
+ // Unix rmdir only ever removes empty directories.
623
+ if (tool === 'rmdir' && !recursive && process.platform !== 'win32' && !targets.some(isWinAbs)) return null;
624
+ if (tool === 'truncate' || tool === 'shred') recursive = false;
625
+ for (const t of targets) { const why = deleteReason(t, st, recursive); if (why) return why; }
626
+ return null;
627
+ }
628
+
629
+ if (prog === 'find') {
630
+ const roots = findRoots(argv);
631
+ const expr = argv.slice(1 + (argv.length > 1 && !/^[-(!]/.test(argv[1]) ? roots.length : 0));
632
+ const execAt = expr.findIndex((a) => ['-exec', '-execdir', '-ok', '-okdir'].includes(a));
633
+ if (expr.includes('-delete') || execAt !== -1) {
634
+ for (const r of roots) { const why = findRootReason(r, expr, st); if (why && expr.includes('-delete')) return why; }
635
+ }
636
+ if (execAt !== -1) {
637
+ const stop = expr.findIndex((a, n) => n > execAt && (a === ';' || a === '+' || a === '\\;'));
638
+ const cmd = expr.slice(execAt + 1, stop === -1 ? undefined : stop);
639
+ const filtered = expr.slice(0, execAt).some((a) => FIND_FILTERS.test(a));
640
+ for (const r of roots) {
641
+ // What {} stands for: a match somewhere under the start, or the start
642
+ // itself when nothing narrows it.
643
+ const stand = filtered ? path.join(r, '__match__') : r;
644
+ const why = analyze(cmd.map((c) => (c === '{}' ? stand : c)).map(quote).join(' '), fork(st), depth + 1);
645
+ if (why) return why;
646
+ }
647
+ }
648
+ return null;
649
+ }
650
+
651
+ if (prog === 'xargs') {
652
+ const valued = new Set(['-I', '-i', '-n', '-P', '-L', '-l', '-s', '-d', '-E', '-e', '-a']);
653
+ let k = 1;
654
+ for (; k < argv.length && argv[k].startsWith('-'); k++) if (valued.has(argv[k])) k++;
655
+ const inner = argv.slice(k);
656
+ if (!inner.length) return null;
657
+ const why = analyze(inner.map(quote).join(' '), fork(st), depth + 1);
658
+ if (why) return why;
659
+ if (['rm', 'unlink', 'shred', 'rimraf', 'truncate'].includes(unwrap(inner).prog)) {
660
+ // Its paths arrive on stdin. Trust them only when they come from something
661
+ // that can only name paths inside the repo.
662
+ const up = idx > 0 ? unwrap(pipe[idx - 1].words) : null;
663
+ const fromRepo = up && ((up.prog === 'find' && findRoots(up.argv).every((r) => !findRootReason(r, ['-name'], st)))
664
+ || (up.prog === 'git' && ['ls-files', 'diff'].includes(up.argv[gitCtx(up.argv, up.assigns, st).i])));
665
+ if (!fromRepo) return 'deletes paths it reads from its input, which cannot be checked';
666
+ }
667
+ return null;
668
+ }
669
+
670
+ if (prog === 'dd') {
671
+ const of = argv.find((a) => a.startsWith('of='));
672
+ if (!of) return null;
673
+ const t = of.slice(3);
674
+ if (/^\/dev\//.test(t) && !/^\/dev\/(null|stdout|stderr)$/.test(t)) return `overwrites the device ${t}`;
675
+ const why = deleteReason(t, st, false);
676
+ return why ? why.replace(/^deletes/, 'overwrites') : null;
677
+ }
678
+ if (DISK.has(prog) || /^mkfs(\.|$)/.test(prog) || /^newfs/.test(prog)) return 'erases a disk or filesystem';
679
+ if (prog === 'diskutil' && /erase|partition|zero|random|reformat|secure/i.test(argv.slice(1).join(' '))) return 'erases a disk or filesystem';
680
+ if (prog === 'format' && argv.slice(1).some((a) => /^[A-Za-z]:$/.test(a))) return 'erases a disk or filesystem';
681
+
682
+ if (prog === 'chmod') {
683
+ const mode = argv.slice(1).find((a) => !a.startsWith('-')) || '';
684
+ const worldWritable = (/^[0-7]{3,4}$/.test(mode) && ['2', '3', '6', '7'].includes(mode.slice(-1)))
685
+ || mode.split(',').some((clause) => { const m = clause.match(/^([ugoa]*)[+=]([rwxXst]*)$/); return !!m && /[ao]/.test(m[1]) && m[2].includes('w'); });
686
+ return worldWritable ? 'makes files writable by everyone' : null;
687
+ }
688
+
689
+ if (prog === 'rsync' && argv.some((a) => /^--delete/.test(a))) {
690
+ const dest = argv.slice(1).filter((a) => !a.startsWith('-')).pop();
691
+ if (dest && !/^[^/]*:/.test(dest)) { const why = deleteReason(dest.replace(/\/+$/, '') || '/', st, true); if (why) return why; }
692
+ return null;
693
+ }
694
+
695
+ if (prog === 'mv') {
696
+ const paths = argv.slice(1).filter((a) => !a.startsWith('-'));
697
+ for (const src of paths.slice(0, -1)) {
698
+ const x = expand(src, st);
699
+ const abs = x.path ? path.resolve(st.cwd || '/', x.path) : null;
700
+ if (abs && (abs === os.homedir() || abs === path.parse(abs).root)) return `moves ${src} away`;
701
+ }
702
+ const dest = paths[paths.length - 1];
703
+ const x = dest ? expand(dest, st) : {};
704
+ if (x.path && paths.length > 1) {
705
+ const abs = realish(path.resolve(st.cwd || '/', x.path));
706
+ if (existsSync(abs) && !lstatSafe(abs)?.isDirectory() && !(st.root && insideDir(st.root, abs)) && !inTemp(abs, st)) {
707
+ return `overwrites ${dest}, outside this repo`;
708
+ }
709
+ }
710
+ }
711
+
712
+ // Secrets: shown to the agent, sent somewhere, or carried out of the repo.
713
+ if (READERS.has(prog) || SENDERS.has(prog)) {
714
+ let files = argv.slice(1);
715
+ if (['grep', 'egrep', 'fgrep', 'rg', 'ag', 'select-string', 'sls'].includes(prog)) {
716
+ const explicit = files.some((a) => a === '-e' || a === '-f' || a.startsWith('--regexp') || /^-pattern$/i.test(a));
717
+ const nonFlag = files.filter((a) => !a.startsWith('-'));
718
+ files = explicit ? nonFlag : nonFlag.slice(1);
719
+ }
720
+ if (prog === 'sed' && argv.some((a) => /^-i/.test(a) || a === '--in-place')) files = [];
721
+ for (const f of files) { const s = secretPath(f, st); if (s) return SENDERS.has(prog) ? `sends secrets from ${s}` : `reads secrets from ${s}`; }
722
+ }
723
+ if (['tar', 'zip', '7z', 'gzip', 'bzip2', 'xz', 'base64', 'gpg', 'openssl'].includes(prog)) {
724
+ const leak = argv.slice(1).map((a) => secretPath(a, st)).find(Boolean);
725
+ if (leak) return `packs secrets from ${leak}`;
726
+ }
727
+ if (COPIERS.has(prog)) {
728
+ const args = argv.slice(1);
729
+ const tIdx = args.findIndex((a) => a === '-t' || a === '--target-directory');
730
+ const positional = args.filter((a, n) => !a.startsWith('-') && !(tIdx !== -1 && n === tIdx + 1));
731
+ const dest = tIdx !== -1 ? args[tIdx + 1] : positional[positional.length - 1];
732
+ const sources = tIdx !== -1 ? positional : positional.slice(0, -1);
733
+ const leak = sources.map((s) => secretPath(s, st)).find(Boolean);
734
+ if (leak) {
735
+ const x = dest ? expand(dest, st) : {};
736
+ const destAbs = x.path ? realish(path.resolve(st.cwd || '/', x.path)) : null;
737
+ if (!destAbs || !(st.root && insideDir(st.root, destAbs))) return `copies secrets out of ${leak}`;
738
+ }
739
+ }
740
+ return null;
741
+ }
742
+
743
+ // What a command changes for the commands after it on the same line.
744
+ function applyState(stage, st) {
745
+ const { argv, prog } = unwrap(stage.words);
746
+ if (['cd', 'pushd', 'set-location', 'sl', 'chdir'].includes(prog)) {
747
+ const target = argv.slice(1).find((a) => !a.startsWith('-'));
748
+ if (!target) { st.cwd = os.homedir(); return; }
749
+ if (target === '-') { st.cwd = null; return; }
750
+ const x = expand(target, st);
751
+ st.cwd = x.path ? path.resolve(st.cwd || '/', x.path) : null;
752
+ return;
753
+ }
754
+ if (prog === 'popd') { st.cwd = null; return; }
755
+ if (prog === 'export') {
756
+ for (const a of argv.slice(1)) { const m = a.match(/^GIT_DIR=(.*)$/); if (m) st.gitDir = m[1]; }
757
+ return;
758
+ }
759
+ if (prog !== 'git') return;
760
+ const ctx = gitCtx(argv, {}, st);
761
+ const sub = argv[ctx.i], rest = argv.slice(ctx.i + 1);
762
+ if (sub === 'checkout' || sub === 'switch') {
763
+ const b = rest.findIndex((a) => ['-b', '-B', '-c', '-C', '--create', '--force-create'].includes(a));
764
+ if (b !== -1 && rest[b + 1]) { st.branch = rest[b + 1]; return; }
765
+ if (rest.includes('--')) return;
766
+ const name = rest.find((a) => !a.startsWith('-'));
767
+ if (!name) return;
768
+ const known = git(ctx.dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], ctx.gitDir) !== null
769
+ || git(ctx.dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${name}`], ctx.gitDir) !== null;
770
+ if (known) st.branch = name;
771
+ return;
772
+ }
773
+ if (sub === 'config') {
774
+ const args = rest.filter((a) => !a.startsWith('--'));
775
+ const m = args[0] && args[0].match(/^alias\.(.+)$/);
776
+ if (m && args[1] !== undefined) st.aliases[m[1]] = args.slice(1).join(' ');
777
+ }
778
+ }
779
+
780
+ function analyze(src, st, depth = 0) {
781
+ if (depth > 8) return 'nests commands too deeply to check';
782
+ for (const pipe of pipelines(String(src ?? ''))) {
783
+ for (let idx = 0; idx < pipe.length; idx++) {
784
+ const why = stageReason(pipe[idx], pipe, idx, st, depth);
785
+ if (why) return why;
786
+ }
787
+ if (pipe.length === 1) applyState(pipe[0], st);
788
+ }
789
+ return null;
790
+ }
791
+
792
+ // The repo is the boundary, and it is the git root — not wherever the command
793
+ // happens to run. Treating the working directory as the repo refused
794
+ // `rm -rf ../dist` from a subfolder and allowed `rm -rf Library` from $HOME.
795
+ function stateFor(cwd) {
796
+ const dir = cwd ? (real(cwd) || cwd) : null;
797
+ const top = dir ? git(dir, ['rev-parse', '--show-toplevel']) : null;
798
+ return { cwd: dir, root: top ? (real(top) || top) : null, branch: null, gitDir: null, aliases: {} };
799
+ }
800
+
801
+ // The reason a command must never run, or null. Exported for the tests, which
802
+ // hold both halves of the promise: the destructive refused, the ordinary not.
803
+ export function neverReason(command, cwd) {
804
+ return analyze(command, stateFor(cwd));
805
+ }
806
+
807
+ // A file tool pointed at a secret. Refusing `cat .env` while the Read tool
808
+ // handed back the same file protected nothing — a live session did exactly that.
809
+ export function fileReason(tool, input, cwd) {
810
+ const fp = input?.file_path ?? input?.path ?? input?.notebook_path;
811
+ if (typeof fp !== 'string' || !fp) return null;
812
+ const st = stateFor(cwd);
813
+ const s = secretPath(fp, st);
814
+ if (!s) return null;
815
+ const x = expand(fp, st);
816
+ const abs = path.resolve(st.cwd || '/', x.path || fp);
817
+ if (tool === 'Write' && !existsSync(abs)) return null; // creating one is setup
818
+ return tool === 'Read' || tool === 'Grep' ? `reads secrets from ${s}` : `overwrites secrets in ${s}`;
819
+ }
23
820
 
24
821
  // Everything not listed falls through to "ask", so a tool nobody has thought
25
822
  // about yet is held rather than allowed.
@@ -51,9 +848,16 @@ export function ruleKey(hook) {
51
848
  // never > learned rule > default for the tool > ask.
52
849
  export function classify(hook, rules = new Map()) {
53
850
  const t = hook.tool_name;
54
- const cmd = String(hook.tool_input?.command || '');
55
- if (t === 'Bash' && NEVER_PATTERNS.some((r) => r.test(cmd))) {
56
- return { tier: 'never', reason: 'matches a never rule' };
851
+ const input = hook.tool_input;
852
+ // Any tool carrying a command runs it, whatever it is called. Only checking
853
+ // `Bash` let a shell-running MCP tool skip every rule here.
854
+ if (input && typeof input.command === 'string') {
855
+ const why = neverReason(input.command, hook.cwd);
856
+ if (why) return { tier: 'never', reason: why };
857
+ }
858
+ if (['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Grep'].includes(t)) {
859
+ const why = fileReason(t, input, hook.cwd);
860
+ if (why) return { tier: 'never', reason: why };
57
861
  }
58
862
  const key = ruleKey(hook);
59
863
  if (rules.has(key)) return { tier: rules.get(key), reason: `rule ${key}` };