nearly-cli 0.1.12 → 0.1.17
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/README.md +64 -6
- package/bin/nearly.mjs +12 -2
- package/package.json +2 -2
- package/scripts/agents.mjs +10 -6
- package/scripts/attach.mjs +141 -35
- package/scripts/build-recap.mjs +50 -11
- package/scripts/detect.mjs +2 -1
- package/scripts/doctor.mjs +38 -17
- package/scripts/hook.mjs +12 -5
- package/scripts/install-push-hook.mjs +30 -12
- package/scripts/post-recap.mjs +1 -1
- package/scripts/push-record.mjs +7 -5
- package/scripts/runtime.mjs +84 -0
- package/scripts/update-check.mjs +58 -5
- package/server/adapters.mjs +21 -5
- package/server/index.mjs +14 -4
- package/server/policy.mjs +821 -17
- package/server/reclaim.mjs +22 -2
- package/ui/recap.template.html +1 -1
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
|
|
7
|
-
//
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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}` };
|