pi-claude-supervisor 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { lstatSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
2
3
  import { basename, dirname, isAbsolute, join, posix, relative, resolve } from "node:path";
3
4
 
4
5
  export type PolicyDecision = "allow" | "review" | "deny";
@@ -151,7 +152,614 @@ export function evaluatePermission(toolName: string, input: unknown, cwd = proce
151
152
  if (!command) return { decision: "deny", reason: "Bash request has no recognizable command" };
152
153
  // Lex the command itself: wrapping it as a literal `bash -lc` argument would
153
154
  // hide its structure (heredoc bodies, dynamic words) from the token checks.
154
- return evaluateCommand(command, [], options.remote);
155
+ const result = evaluateCommand(command, [], options.remote);
156
+ if (result.decision === "deny") return result;
157
+ const floor = deleteFloorViolation(command, cwd, { writeRoots: options.writeRoots ?? [] });
158
+ return floor ? { decision: "deny", reason: floor } : result;
159
+ }
160
+
161
+ export interface DeleteFloorOptions {
162
+ /** Extra directories the task may write (and so delete) in. */
163
+ writeRoots?: readonly string[];
164
+ /** Shared scratch roots; defaults to the OS temp directory and /tmp. */
165
+ tempRoots?: readonly string[];
166
+ }
167
+
168
+ /**
169
+ * The delete floor: the one line that holds whatever else is relaxed, so no
170
+ * authority mode (policy, hybrid, Decision Worker) can let a Bash command
171
+ * delete or move something outside the task. It is read fail-closed: every
172
+ * word of a statement that names a delete or move (`rm`, `rmdir`, `unlink`,
173
+ * `shred`, `mv`, `rimraf`), and `find -delete`/`-exec rm`, `git clean`,
174
+ * `rsync --delete`, counts wherever it stands, so any wrapper (`sudo`,
175
+ * `busybox`, `env -i`, a function or `case` body) is seen through without a
176
+ * table of its options. Only text commands (`echo`, `grep`, …), interpreters
177
+ * and tools whose own `rm` subcommand is not a file delete (`git rm`, `npm rm`,
178
+ * `docker rm`) end the scan. Quoted scripts that mention a delete (`sh -lc
179
+ * '…'`, `watch '…'`, `trap '…'`), text piped into a shell, and the bodies of
180
+ * `$(…)`, backticks and `<(…)` are judged as scripts of their own; past the
181
+ * nesting limit a delete is refused outright. A target is refused when it
182
+ * - is not literal (a variable, a command substitution), follows a `cd` that
183
+ * cannot be resolved, or comes from input (`xargs`, `parallel`);
184
+ * - resolves outside the task directory, its extra write roots and the temp
185
+ * directory, or is one of those roots itself (or a glob across its top);
186
+ * - is the task directory itself, or any directory that contains it;
187
+ * - is a glob over hidden entries (`.*` would take `.git`), a glob followed by
188
+ * `..`, or a `find` over the whole task tree whose filter could reach `.git`;
189
+ * - is Git's own store (`.git` or anything in it, bar a stale `*.lock`).
190
+ * Brace alternatives are expanded and judged one by one; literal globs, names
191
+ * bound to literal text or to `$(mktemp …)`, and `$HOME`/`$TMPDIR`/`$PWD` are
192
+ * judged like the paths they spell. `git gc --prune`, `git prune` and `git
193
+ * reflog expire/delete` are refused too: with no remote authority the local
194
+ * commits are the only copy. Returns the reason, or undefined. Interpreters
195
+ * (`python -c "shutil.rmtree(...)"`, a script file) and overwrites (`cp`,
196
+ * `ln -sf`, `>`) remain outside what this floor reads.
197
+ */
198
+ export function deleteFloorViolation(command: string, cwd: string, options: DeleteFloorOptions = {}): string | undefined {
199
+ const roots = deleteFloorRoots(cwd, options.writeRoots ?? [], options.tempRoots ?? [tmpdir(), "/tmp"]);
200
+ return floorScript(command, cwd, roots, new Map(), 0);
201
+ }
202
+
203
+ interface FloorRoots { cwd: string; others: string[] }
204
+
205
+ const FLOOR_MAX_DEPTH = 6;
206
+ const DELETE_COMMANDS = new Set(["rm", "rmdir", "unlink", "shred", "mv", "rimraf"]);
207
+ const DELETE_WORD = /(?:^|[^A-Za-z0-9_.-])(?:rm|rmdir|unlink|shred|mv|rimraf)(?:[^A-Za-z0-9_.-]|$)|-delete\b|--delete\b/u;
208
+ /** Commands whose words are text, code in another language, or their own `rm` subcommand: the scan stops at them. */
209
+ const FLOOR_STOP_COMMANDS = new Set([
210
+ // DATA_SINK_COMMANDS is declared further down, so its words are repeated here.
211
+ "cat", "tee", "head", "tail", "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "diff", "less", "more", "base64", "md5sum",
212
+ "sha1sum", "sha256sum", "jq", "column", "fold", "paste", "comm", "cmp", "od", "hexdump", "xxd", "nl", "tac", "rev", "echo", "printf",
213
+ "egrep", "fgrep", "ag", "ack", "man", "which", "type", "whatis", "apropos", "info", "help", "tldr", "alias", "logger", "yq",
214
+ "python", "python2", "python3", "node", "deno", "perl", "ruby", "php", "lua", "awk", "gawk", "mawk", "sed", "Rscript",
215
+ "npm", "pnpm", "yarn", "bun", "docker", "podman", "nerdctl", "kubectl", "helm", "conda", "mamba", "micromamba", "brew",
216
+ "pip", "pip3", "uv", "poetry", "cargo", "go", "gh", "hg", "svn", "jj", "dvc", "aws", "gsutil", "gcloud", "az", "mc",
217
+ "rclone", "s3cmd", "snap", "flatpak", "apt", "apt-get", "dnf", "yum", "zypper", "pacman", "systemctl", "ssh", "scp",
218
+ ]);
219
+ /** Stop-set tools that run an arbitrary command after one of RUNNER_SUBCOMMANDS. */
220
+ const PACKAGE_RUNNERS = new Set(["npm", "pnpm", "yarn", "bun", "uv", "poetry", "conda", "mamba", "micromamba", "cargo", "go"]);
221
+ const RUNNER_SUBCOMMANDS = new Set(["exec", "run", "dlx", "x"]);
222
+ /** Runners that also run a package bin named directly (`yarn rimraf …`). */
223
+ const BIN_RUNNERS = new Set(["yarn", "pnpm", "bun"]);
224
+ /** Git options whose value is message or search text, not shell. */
225
+ const GIT_MESSAGE_OPTIONS = new Set(["-m", "--message", "-F", "--file", "--grep", "-S", "-G"]);
226
+ const REDIRECT_OPERATORS = new Set([">", ">>", "<", "<<", ">|", "&>", ">&"]);
227
+ /** Entries under `.git` a find filter must not be able to match. */
228
+ const GIT_STORE_PROBES = [".git", ".git/HEAD", ".git/index", ".git/config", ".git/packed-refs", ".git/objects", ".git/objects/ab/cdef0123",
229
+ ".git/objects/pack/pack-1.pack", ".git/refs/heads/main", ".git/hooks/pre-commit", ".git/logs/HEAD"];
230
+
231
+ function floorScript(command: string, start: string | undefined, roots: FloorRoots, inherited: ReadonlyMap<string, string>, depth: number): string | undefined {
232
+ if (depth >= FLOOR_MAX_DEPTH) {
233
+ return DELETE_WORD.test(command) ? "Worker cannot delete through commands nested this deep; flatten the command" : undefined;
234
+ }
235
+ const lexical = lexShell(command.trim());
236
+ // At the top an unparsable command is already refused by evaluateCommand;
237
+ // nested text that does not parse cannot be read, so a delete in it is refused.
238
+ if (lexical.error) return depth > 0 && DELETE_WORD.test(command) ? "Worker cannot delete through a script the policy cannot parse" : undefined;
239
+ const bindings = floorBindings(command, lexical.tokens, inherited);
240
+ const nested = (body: string, directory: string | undefined): string | undefined => floorScript(body, directory, roots, bindings, depth + 1);
241
+ for (const body of substitutionBodies(withoutQuotedHeredocs(command))) {
242
+ const violation = nested(body, start);
243
+ if (violation) return violation;
244
+ }
245
+ const { tokens: dataResolved, embedded } = resolveDataTokens(lexical.tokens);
246
+ for (const body of embedded) {
247
+ const violation = nested(body, start);
248
+ if (violation) return violation;
249
+ }
250
+ const tokens = resolveLiteralBindings(applyFloorBindings(dataResolved, bindings));
251
+ const segments = floorSegments(tokens);
252
+ // The directory each statement runs in, as far as a static reading can
253
+ // tell; undefined once a `cd` it cannot resolve has run.
254
+ let directory = start;
255
+ const stack: (string | undefined)[] = [];
256
+ for (let index = 0; index < segments.length; index += 1) {
257
+ const segment = segments[index]!;
258
+ const words = segmentWords(segment.tokens);
259
+ let first = 0;
260
+ while (first < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[first]!.value) || FLOOR_KEYWORDS.has(words[first]!.value))) first += 1;
261
+ if (first >= words.length) continue;
262
+ const head = floorCommandName(words[first]!);
263
+ // Text piped into a shell is a script.
264
+ const intoShell = segment.joiner === "|" && SHELL_NAMES.has(floorCommandName(segmentWords(segments[index + 1]?.tokens ?? [])[0]) ?? "");
265
+ if (intoShell) {
266
+ const violation = nested(words.slice(first + 1).map((word) => word.value).join(" "), directory);
267
+ if (violation) return violation;
268
+ }
269
+ // A quoted argument that mentions a delete is a script for whatever runs it
270
+ // (`sh -lc`, `watch`, `trap`, `su -c`), unless the command only reads text.
271
+ // Package runners run whatever follows `exec`/`run`/`dlx`/`x`; git runs
272
+ // shell text from options (`-c core.pager=…`, `difftool -x`) but not from a message.
273
+ const runs = head !== undefined && PACKAGE_RUNNERS.has(head) && words.slice(first + 1).some((word) => RUNNER_SUBCOMMANDS.has(word.value)
274
+ || (BIN_RUNNERS.has(head) && DELETE_COMMANDS.has(floorCommandName(word) ?? "")));
275
+ const stops = head !== undefined && FLOOR_STOP_COMMANDS.has(head) && !runs;
276
+ if (!stops) {
277
+ for (let cursor = first + 1; cursor < words.length; cursor += 1) {
278
+ const word = words[cursor]!;
279
+ if (head === "git" && (GIT_MESSAGE_OPTIONS.has(words[cursor - 1]!.value) || /^--(?:message|grep|file)=/u.test(word.value))) continue;
280
+ if (/\s/u.test(word.value) && DELETE_WORD.test(word.value)) {
281
+ const violation = nested(word.value.replace(/^[A-Za-z0-9_.-]+=/u, ""), directory);
282
+ if (violation) return violation;
283
+ }
284
+ }
285
+ }
286
+ let local = directory;
287
+ for (let cursor = first; cursor < words.length; cursor += 1) {
288
+ const word = words[cursor]!;
289
+ const name = floorCommandName(word);
290
+ if (name === undefined) continue;
291
+ if (name === "cd" || name === "pushd") {
292
+ if (name === "pushd") stack.push(directory);
293
+ directory = resolveCd(words.slice(cursor + 1), directory);
294
+ break;
295
+ }
296
+ if (name === "popd") { directory = stack.length > 0 ? stack.pop() : undefined; break; }
297
+ // Only the statement's own command can end the scan: `sudo -u git rm …` still deletes.
298
+ if (cursor === first && stops) break;
299
+ // `env -C DIR` and `sudo -D DIR` run the command elsewhere.
300
+ if (name === "env" || name === "sudo") {
301
+ const next = words[cursor + 1]?.value;
302
+ const flags = name === "env" ? ["-C", "--chdir"] : ["-D", "--chdir"];
303
+ if (next !== undefined && flags.includes(next)) local = resolveCd(words.slice(cursor + 2, cursor + 3), local);
304
+ else if (next?.startsWith("--chdir=")) local = resolveCd([{ ...words[cursor + 1]!, value: next.slice("--chdir=".length) }], local);
305
+ continue;
306
+ }
307
+ if (name === "xargs" || name === "parallel") {
308
+ const later = words.slice(cursor + 1);
309
+ const shell = later.some((word) => SHELL_NAMES.has(floorCommandName(word) ?? "") || floorCommandName(word) === "eval");
310
+ const deletingOption = (word: ShellToken): boolean => /^(?:-delete|--delete\S*|--remove-source-files)$/u.test(word.value)
311
+ || (word.value === "clean" && later.some((other) => floorCommandName(other) === "git"));
312
+ const inner = later.find((word) => DELETE_COMMANDS.has(floorCommandName(word) ?? "") || deletingOption(word) || (shell && DELETE_WORD.test(word.value)));
313
+ if (inner) return `Worker cannot delete targets taken from input (${name} ${inner.value}); name the paths, or use find -delete inside the task directory`;
314
+ break;
315
+ }
316
+ const args = words.slice(cursor + 1).map((arg) => (/^(?:\$\(|<\(|>\(|\(|`)/u.test(word.value) ? { ...arg, value: arg.value.replace(/[)`]+$/u, "") } : arg));
317
+ const found = deleteTargets(name, args);
318
+ if (found) {
319
+ if (found.refuse) return found.refuse;
320
+ for (const target of found.targets) {
321
+ const violation = judgeDeleteTarget(target, local, roots, name, found.contentsOnly);
322
+ if (violation) return violation;
323
+ }
324
+ for (const target of found.plain ?? []) {
325
+ const violation = judgeDeleteTarget(target, local, roots, name);
326
+ if (violation) return violation;
327
+ }
328
+ break;
329
+ }
330
+ // A recognised command that deletes nothing ends the scan only as the
331
+ // statement's own command; as a wrapper's option value it does not.
332
+ if (cursor === first && (name === "find" || name === "git" || name === "rsync")) break;
333
+ }
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ const FLOOR_KEYWORDS = new Set(["if", "then", "elif", "else", "while", "until", "do", "!", "(", "{", "}", "done", "fi", "esac"]);
339
+
340
+ function deleteFloorRoots(cwd: string, writeRoots: readonly string[], tempRoots: readonly string[]): FloorRoots {
341
+ const canonical = (path: string): string => {
342
+ try { return realpathSync.native(path); } catch { return resolve(path); }
343
+ };
344
+ const others = [...writeRoots, ...tempRoots].filter((root) => isAbsolute(root)).map(canonical);
345
+ return { cwd: canonical(cwd), others: [...new Set(others)] };
346
+ }
347
+
348
+ /** Statements split at `|`, `&&`, `||`, `;` and `&`, each with the operator that follows it. */
349
+ function floorSegments(tokens: readonly ShellToken[]): { tokens: ShellToken[]; joiner?: string }[] {
350
+ const segments: { tokens: ShellToken[]; joiner?: string }[] = [{ tokens: [] }];
351
+ for (const token of tokens) {
352
+ if (token.operator && SEGMENT_SPLIT_OPERATORS.has(token.value)) {
353
+ segments.at(-1)!.joiner = token.value;
354
+ segments.push({ tokens: [] });
355
+ continue;
356
+ }
357
+ segments.at(-1)!.tokens.push(token);
358
+ }
359
+ return segments;
360
+ }
361
+
362
+ /** A statement's words: no operators, quoted heredoc data or redirection targets (a process substitution and a here-string stay). */
363
+ function segmentWords(segment: readonly ShellToken[]): ShellToken[] {
364
+ const words: ShellToken[] = [];
365
+ let skipNext = false;
366
+ for (const token of segment) {
367
+ if (token.operator) { skipNext = REDIRECT_OPERATORS.has(token.value); continue; }
368
+ const skip = skipNext && !token.value.startsWith("(");
369
+ skipNext = false;
370
+ if (skip || token.data) continue;
371
+ words.push(token);
372
+ }
373
+ return words;
374
+ }
375
+
376
+ /** The command a word would run: the basename, with a leading `(`, `$(`, `<(`, backtick or `{` and trailing `)`/`;` stripped. */
377
+ function floorCommandName(token: ShellToken | undefined): string | undefined {
378
+ if (!token || token.operator) return undefined;
379
+ const bare = token.value.replace(/^(?:\$\(|<\(|>\(|[({`])+/u, "").replace(/[)`;]+$/u, "");
380
+ const name = bare.split(/[\\/]/u).at(-1);
381
+ return name ? name.toLowerCase() : undefined;
382
+ }
383
+
384
+ /** The command with quoted heredoc bodies removed: nothing in them expands, so a backtick there is text. */
385
+ function withoutQuotedHeredocs(command: string): string {
386
+ return command.replace(/<<-?[ \t]*(['"])([^'"\s]+)\1([^\n]*)\n[\s\S]*?(?:\n[ \t]*\2[ \t]*(?=\n|\)|$)|$)/gu, "<<$1$2$1$3");
387
+ }
388
+
389
+ /** The bodies of `$(…)`, `<(…)`, `>(…)` and backticks outside single quotes. */
390
+ function substitutionBodies(command: string): string[] {
391
+ const bodies: string[] = [];
392
+ let single = false;
393
+ for (let index = 0; index < command.length; index += 1) {
394
+ const character = command[index]!;
395
+ if (character === "\\" && !single) { index += 1; continue; }
396
+ if (character === "'") { single = !single; continue; }
397
+ if (single) continue;
398
+ if (character === "`") {
399
+ const close = command.indexOf("`", index + 1);
400
+ if (close < 0) break;
401
+ bodies.push(command.slice(index + 1, close));
402
+ index = close;
403
+ continue;
404
+ }
405
+ if (character === "(" && index > 0 && "$<>".includes(command[index - 1]!)) {
406
+ let level = 1;
407
+ let cursor = index + 1;
408
+ for (; cursor < command.length && level > 0; cursor += 1) {
409
+ if (command[cursor] === "(") level += 1;
410
+ else if (command[cursor] === ")") level -= 1;
411
+ }
412
+ bodies.push(command.slice(index + 1, level === 0 ? cursor - 1 : cursor));
413
+ index = cursor - 1;
414
+ }
415
+ }
416
+ return bodies;
417
+ }
418
+
419
+ /**
420
+ * Names whose value the floor can still tell: `NAME=$(mktemp …)` is a fresh
421
+ * entry under the temp directory, `for NAME in *.tmp` is that glob, and
422
+ * `$HOME`/`$TMPDIR` are the process's own — each only while the command
423
+ * does not assign the name again.
424
+ */
425
+ function floorBindings(command: string, tokens: readonly ShellToken[], inherited: ReadonlyMap<string, string>): Map<string, string> {
426
+ // A name is trusted only while its one definition is its only bare mention:
427
+ // `d+=…`, `printf -v d`, `read d`, `mapfile d` or a second `d=` drop it.
428
+ const bareMentions = (name: string): number => (command.match(new RegExp(`(?<![$\\w{-])${name}(?![\\w])`, "gu")) ?? []).length;
429
+ const bindings = new Map([...inherited].filter(([name]) => bareMentions(name) === 0));
430
+ for (const [name, value] of [["HOME", homedir()], ["TMPDIR", tmpdir()]] as const) {
431
+ if (!bindings.has(name) && bareMentions(name) === 0) bindings.set(name, value);
432
+ }
433
+ const assignedOnce = (name: string): boolean => bareMentions(name) === 1;
434
+ for (const match of command.matchAll(/(?:^|[\s;&|(])([A-Za-z_][A-Za-z0-9_]*)=(["']?)\$\(\s*mktemp\b([^)]*)\)\2(?=[\s;&|)]|$)/gu)) {
435
+ const name = match[1]!;
436
+ if (!assignedOnce(name)) { bindings.delete(name); continue; }
437
+ const words = match[3]!.trim().split(/\s+/u).filter(Boolean);
438
+ let directory: string | undefined = tmpdir();
439
+ for (let index = 0; index < words.length; index += 1) {
440
+ const word = words[index]!;
441
+ if (word === "-p" || word === "--tmpdir") directory = words[index + 1];
442
+ else if (word.startsWith("--tmpdir=")) directory = word.slice("--tmpdir=".length);
443
+ else if (!word.startsWith("-") && word.includes("/")) directory = dirname(word);
444
+ if (word === "-p") index += 1;
445
+ }
446
+ if (directory !== undefined && /^\$\{?TMPDIR\}?(?=\/|$)/u.test(directory)) directory = tmpdir() + directory.replace(/^\$\{?TMPDIR\}?/u, "");
447
+ if (directory === undefined || !isAbsolute(directory) || /[$`*?[{~]/u.test(directory)) bindings.delete(name);
448
+ else bindings.set(name, join(directory, `mktemp-${name}`));
449
+ }
450
+ for (let index = 0; index + 2 < tokens.length; index += 1) {
451
+ if (tokens[index]!.value !== "for" || tokens[index + 2]!.value !== "in") continue;
452
+ const name = tokens[index + 1]!.value;
453
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) continue;
454
+ const items: string[] = [];
455
+ let literal = true;
456
+ for (let cursor = index + 3; cursor < tokens.length; cursor += 1) {
457
+ const item = tokens[cursor]!;
458
+ if (item.operator || item.value === "do") break;
459
+ if (item.data || /[$`]/u.test(item.value)) literal = false;
460
+ items.push(item.value);
461
+ }
462
+ if (literal && items.some((item) => /[*?[]/u.test(item)) && assignedOnce(name)) bindings.set(name, items.join(" "));
463
+ }
464
+ return bindings;
465
+ }
466
+
467
+ function applyFloorBindings(tokens: readonly ShellToken[], bindings: ReadonlyMap<string, string>): ShellToken[] {
468
+ if (bindings.size === 0) return [...tokens];
469
+ return tokens.map((token) => {
470
+ if (token.operator || token.data || !token.dynamic) return token;
471
+ // `${f%.bak}` keeps the directory part, so it reads as the bound value; a
472
+ // `#` prefix strip, or a `%` pattern with `/` or a wildcard (which matches
473
+ // `/` in parameter expansion, as can one arriving through `$p`), can drop
474
+ // directories and stays unknown.
475
+ const value = token.value.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)(?::?[-?=+][^}]*|%{1,2}[^}/*?[\]\\$`]*)?\}|([A-Za-z_][A-Za-z0-9_]*))/gu,
476
+ (match, braced: string | undefined, bare: string | undefined) => bindings.get(braced ?? bare ?? "") ?? match);
477
+ if (value === token.value) return token;
478
+ return { ...token, value, dynamic: /[$`*?[\]{}~]/u.test(value) };
479
+ });
480
+ }
481
+
482
+ /** The directory a `cd`/`pushd` leads to, or undefined when a static reading cannot tell. */
483
+ function resolveCd(args: readonly ShellToken[], directory: string | undefined): string | undefined {
484
+ const target = args.find((token) => !token.value.startsWith("-") || token.value === "-");
485
+ if (!target) return homedir();
486
+ if (target.value === "-") return undefined;
487
+ // `cd "$(git rev-parse --show-toplevel)"`: the work tree that holds the current directory.
488
+ if (/^\$\(\s*git\s+rev-parse\s+--show-toplevel\s*\)$/u.test(target.value)) return directory === undefined ? undefined : gitTopLevel(directory);
489
+ const value = target.value.replace(/[)`;]+$/u, "");
490
+ let literal = value;
491
+ if (target.dynamic) {
492
+ if (literal === "~" || literal.startsWith("~/")) literal = homedir() + literal.slice(1);
493
+ if (/[$`*?[{~]/u.test(literal)) return undefined;
494
+ }
495
+ if (isAbsolute(literal)) return resolve(literal);
496
+ return directory === undefined ? undefined : resolve(directory, literal);
497
+ }
498
+
499
+ function gitTopLevel(directory: string): string | undefined {
500
+ for (let current = resolve(directory); ; current = dirname(current)) {
501
+ if (existsSync(join(current, ".git"))) return current;
502
+ if (dirname(current) === current) return undefined;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * What a command would delete or move away. `contentsOnly` marks a target
508
+ * whose own directory survives (a filtered find, `git clean -C`), so a start at
509
+ * the task directory itself is fine.
510
+ */
511
+ function deleteTargets(name: string, args: readonly ShellToken[]): { targets: ShellToken[]; contentsOnly?: boolean; plain?: ShellToken[]; refuse?: string } | undefined {
512
+ if (DELETE_COMMANDS.has(name)) {
513
+ const targets: ShellToken[] = [];
514
+ let options = true;
515
+ for (let index = 0; index < args.length; index += 1) {
516
+ const token = args[index]!;
517
+ if (options && token.value === "--") { options = false; continue; }
518
+ if (options && token.value.startsWith("-") && token.value !== "-") {
519
+ // `mv -t DIR` / `--target-directory=DIR` names the destination.
520
+ if (name === "mv" && (token.value === "-t" || token.value === "--target-directory")) {
521
+ const next = args[index + 1];
522
+ if (next) targets.push(next);
523
+ index += 1;
524
+ } else if (name === "mv" && token.value.startsWith("--target-directory=")) {
525
+ targets.push({ ...token, value: token.value.slice("--target-directory=".length) });
526
+ } else if (name === "shred" && ["-n", "-s", "--iterations", "--size"].includes(token.value)) {
527
+ index += 1;
528
+ }
529
+ continue;
530
+ }
531
+ targets.push(token);
532
+ }
533
+ return { targets };
534
+ }
535
+ if (name === "find") {
536
+ const expression = args.findIndex((token) => /^[-(!]/u.test(token.value));
537
+ const starts = expression < 0 ? [...args] : args.slice(0, expression);
538
+ const rest = expression < 0 ? [] : args.slice(expression);
539
+ const execEnd = (index: number): number => {
540
+ const end = rest.findIndex((later, cursor) => cursor > index + 1 && (later.value === ";" || later.value === "+"));
541
+ return end < 0 ? rest.length : end;
542
+ };
543
+ // The command an `-exec` runs, read through any wrapper (`sudo`, `env`).
544
+ const execDelete = (index: number): number => rest.slice(index + 1, execEnd(index)).findIndex((word) => DELETE_COMMANDS.has(floorCommandName(word) ?? ""));
545
+ const deletes = rest.some((token, index) => token.value === "-delete" || (FIND_EXEC_ACTIONS.has(token.value) && execDelete(index) >= 0));
546
+ if (!deletes) return undefined;
547
+ const startValues = starts.length > 0 ? starts.map((token) => token.value) : ["."];
548
+ // A filter keeps the start directory only when every `-o` branch carries a
549
+ // positive name/path filter and none of those can match Git's own store; a
550
+ // negated filter (`-not -path './node_modules/*'`) only narrows an AND.
551
+ const branches: ShellToken[][] = [[]];
552
+ for (const token of rest) {
553
+ if (token.value === "-o" || token.value === "-or" || token.value === ",") branches.push([]);
554
+ else branches.at(-1)!.push(token);
555
+ }
556
+ const positive = (branch: readonly ShellToken[]) => branch.flatMap((token, index) => (FIND_NAME_FILTERS.has(token.value) && !["!", "-not"].includes(branch[index - 1]?.value ?? "")
557
+ ? [{ kind: token.value, pattern: branch[index + 1]?.value }] : []));
558
+ const contentsOnly = branches.every((branch) => {
559
+ const filters = positive(branch);
560
+ return filters.length > 0 && filters.every(({ kind, pattern }) => pattern !== undefined && !findFilterReachesGit(kind, pattern, startValues));
561
+ });
562
+ // The literal words of an `-exec rm/mv …` are targets of their own.
563
+ const plain: ShellToken[] = [];
564
+ let refuse: string | undefined;
565
+ rest.forEach((token, index) => {
566
+ if (!FIND_EXEC_ACTIONS.has(token.value)) return;
567
+ const offset = execDelete(index);
568
+ if (offset < 0) return;
569
+ const execName = floorCommandName(rest[index + 1 + offset]) ?? "";
570
+ const argv = rest.slice(index + 2 + offset, execEnd(index));
571
+ if (argv.some((word) => word.value.includes("{}") && word.value !== "{}")) refuse ??= `Worker cannot ${execName} a path built around find's {} (${argv.find((word) => word.value.includes("{}") && word.value !== "{}")!.value}); pass {} alone`;
572
+ const words = argv.filter((word) => word.value !== "{}");
573
+ const extra = deleteTargets(execName, words)?.targets ?? [];
574
+ if (token.value.endsWith("dir") && extra.some((word) => !isAbsolute(word.value))) refuse ??= `Worker cannot ${execName} a relative path from find ${token.value}; it runs in every matched directory`;
575
+ plain.push(...extra);
576
+ });
577
+ return { targets: starts.length > 0 ? starts : [{ value: ".", operator: false, dynamic: false }], contentsOnly, plain, ...(refuse ? { refuse } : {}) };
578
+ }
579
+ if (name === "rsync") {
580
+ const words = args.map((token) => token.value);
581
+ const removesSources = words.includes("--remove-source-files");
582
+ if (!removesSources && !words.some((word) => word.startsWith("--delete"))) return undefined;
583
+ // A `host:path` operand is remote and outside this floor.
584
+ const operands = args.filter((token) => !token.value.startsWith("-") && !/^[^/]*:/u.test(token.value));
585
+ const destination = args.filter((token) => !token.value.startsWith("-")).at(-1);
586
+ const targets = removesSources ? operands : operands.filter((token) => token === destination);
587
+ return { targets };
588
+ }
589
+ if (name === "git") {
590
+ const words = args.map((token) => token.value.toLowerCase());
591
+ const subcommand = gitSubcommandIndexOf(words);
592
+ const verb = words[subcommand];
593
+ if (verb === "prune" || (verb === "gc" && words.slice(subcommand + 1).some((word) => word.startsWith("--prune")))
594
+ || (verb === "reflog" && ["expire", "delete"].includes(words[subcommand + 1] ?? ""))) {
595
+ return { targets: [{ value: ".git", operator: false, dynamic: false }] };
596
+ }
597
+ // `git clean` deletes untracked files of the work tree it is pointed at.
598
+ if (verb === "clean") {
599
+ const targets: ShellToken[] = [];
600
+ for (let index = 0; index < subcommand; index += 1) {
601
+ const word = args[index]!;
602
+ if (["-C", "--work-tree"].includes(word.value) && args[index + 1]) targets.push(args[index + 1]!);
603
+ else if (word.value.startsWith("--work-tree=")) targets.push({ ...word, value: word.value.slice("--work-tree=".length) });
604
+ }
605
+ return targets.length > 0 ? { targets, contentsOnly: true } : undefined;
606
+ }
607
+ // A forced worktree removal throws away its uncommitted files.
608
+ if (verb === "worktree" && words[subcommand + 1] === "remove" && words.some((word) => word === "-f" || word === "--force")) {
609
+ const path = args.slice(subcommand + 2).find((token) => !token.value.startsWith("-"));
610
+ return path ? { targets: [path] } : undefined;
611
+ }
612
+ }
613
+ return undefined;
614
+ }
615
+
616
+ /** True when a find name/path/regex filter could select `.git` or a file inside it. */
617
+ function findFilterReachesGit(kind: string, pattern: string, starts: readonly string[]): boolean {
618
+ if (kind === "-name" || kind === "-iname") return GIT_STORE_PROBES.some((probe) => globMatches(pattern, probe.split("/").at(-1)!));
619
+ const candidates = GIT_STORE_PROBES.flatMap((probe) => ["./", ...starts.map((start) => `${start.replace(/\/+$/u, "")}/`)].map((prefix) => `${prefix}${probe}`));
620
+ let matcher: RegExp;
621
+ try {
622
+ if (kind === "-regex" || kind === "-iregex") matcher = new RegExp(`^(?:${pattern})$`, kind === "-iregex" ? "iu" : "u");
623
+ else {
624
+ // find's -path wildcards also match `/`.
625
+ const source = pattern.replace(/[.+^${}()|\\]/gu, "\\$&").replace(/\*/gu, ".*").replace(/\?/gu, ".");
626
+ matcher = new RegExp(`^${source}$`, kind === "-ipath" || kind === "-iwholename" ? "iu" : "u");
627
+ }
628
+ } catch { return true; }
629
+ return candidates.some((candidate) => matcher.test(candidate));
630
+ }
631
+
632
+ /** Index of git's subcommand in its argv, after global options such as `-C dir` and `-c k=v`. */
633
+ function gitSubcommandIndexOf(words: readonly string[]): number {
634
+ let index = 0;
635
+ while (index < words.length && words[index]!.startsWith("-")) {
636
+ index += ["-c", "-C", "--git-dir", "--work-tree", "--namespace"].includes(words[index]!) ? 2 : 1;
637
+ }
638
+ return index;
639
+ }
640
+
641
+ /** Brace expansion is textual: every alternative a word can spell, or undefined past a sane count. `{a..z}` ranges stand for a wildcard. */
642
+ function expandBraces(word: string): string[] | undefined {
643
+ const results: string[] = [];
644
+ const pending = [word];
645
+ while (pending.length > 0) {
646
+ const current = pending.pop()!;
647
+ const group = firstBraceGroup(current);
648
+ if (!group) { results.push(current); continue; }
649
+ for (const part of group.parts) pending.push(current.slice(0, group.start) + part + current.slice(group.end + 1));
650
+ if (pending.length + results.length > 256) return undefined;
651
+ }
652
+ return results;
653
+ }
654
+
655
+ function firstBraceGroup(word: string): { start: number; end: number; parts: string[] } | undefined {
656
+ for (let start = word.indexOf("{"); start >= 0; start = word.indexOf("{", start + 1)) {
657
+ let level = 0;
658
+ const commas: number[] = [];
659
+ let end = -1;
660
+ for (let cursor = start; cursor < word.length; cursor += 1) {
661
+ const character = word[cursor];
662
+ if (character === "{") level += 1;
663
+ else if (character === "}") { level -= 1; if (level === 0) { end = cursor; break; } }
664
+ else if (character === "," && level === 1) commas.push(cursor);
665
+ }
666
+ if (end < 0) return undefined;
667
+ const inner = word.slice(start + 1, end);
668
+ if (commas.length > 0) {
669
+ const bounds = [start, ...commas, end];
670
+ return { start, end, parts: bounds.slice(0, -1).map((bound, index) => word.slice(bound + 1, bounds[index + 1])) };
671
+ }
672
+ if (/^[^{}]+\.\.[^{}]+$/u.test(inner)) return { start, end, parts: ["*"] };
673
+ }
674
+ return undefined;
675
+ }
676
+
677
+ function judgeDeleteTarget(target: ShellToken, directory: string | undefined, roots: FloorRoots, name: string, contentsOnly = false): string | undefined {
678
+ const verb = name === "mv" ? "move" : name === "git" ? "prune" : "delete";
679
+ let value = target.value;
680
+ let dynamic = target.dynamic;
681
+ if (directory !== undefined && /\$(?:\{PWD\}|PWD(?![A-Za-z0-9_]))/u.test(value)) {
682
+ value = value.replace(/\$(?:\{PWD\}|PWD(?![A-Za-z0-9_]))/gu, directory);
683
+ dynamic = /[$`*?[\]{}~]/u.test(value);
684
+ }
685
+ const alternatives = dynamic && value.includes("{") ? expandBraces(value) : [value];
686
+ if (!alternatives) return `Worker cannot ${verb} a brace expansion this large (${target.value}); name the paths`;
687
+ for (const alternative of alternatives) {
688
+ // A value bound from a loop or a split word holds several paths.
689
+ for (const part of alternative.split(/\s+/u).filter(Boolean)) {
690
+ const violation = judgeDeletePath(part, dynamic, target.value, directory, roots, name, verb, contentsOnly);
691
+ if (violation) return violation;
692
+ }
693
+ }
694
+ return undefined;
695
+ }
696
+
697
+ function judgeDeletePath(path: string, dynamic: boolean, shown: string, directory: string | undefined, roots: FloorRoots, name: string, verb: string, contentsOnly: boolean): string | undefined {
698
+ let literal = path;
699
+ if (dynamic) {
700
+ if (literal === "~" || literal.startsWith("~/")) literal = homedir() + literal.slice(1);
701
+ if (/[$`]/u.test(literal) || literal.startsWith("~")) return `Worker cannot ${verb} a path that is only known at run time (${shown}); name it literally`;
702
+ }
703
+ if (!isAbsolute(literal) && directory === undefined) return `Worker cannot ${verb} a relative path after a cd the policy cannot follow (${shown})`;
704
+ // A glob is judged by the literal directory before it; what follows must not
705
+ // climb out (`*/../..`), reach hidden entries (`.*` would take `.git`) or name `.git`.
706
+ const segments = literal.split("/");
707
+ const globAt = dynamic ? segments.findIndex((segment) => /[*?[]/u.test(segment)) : -1;
708
+ if (globAt >= 0) {
709
+ for (const segment of segments.slice(globAt)) {
710
+ if (segment === "..") return `Worker cannot ${verb} through a glob followed by .. (${shown})`;
711
+ if (segment === ".git") return `Worker cannot ${verb} Git's own store (${shown}); with no remote authority the local commits are the only copy`;
712
+ if (/[*?[]/u.test(segment) && (segment.startsWith(".") || /^\[[^\]]*\./u.test(segment))) return `Worker cannot ${verb} hidden entries with a glob (${shown})`;
713
+ }
714
+ }
715
+ const base = globAt < 0 ? literal : (segments.slice(0, globAt).join("/") || (literal.startsWith("/") ? "/" : "."));
716
+ const resolved = resolveForDelete(resolve(directory ?? "/", base), globAt < 0 && !literal.endsWith("/"));
717
+ // Checked first: a task under a shared root (/tmp) must still not take its own parent.
718
+ if (roots.cwd.startsWith(resolved === "/" ? "/" : `${resolved}/`)) return `Worker cannot ${verb} a directory that contains the task (${shown})`;
719
+ const within = (root: string): boolean => resolved === root || resolved.startsWith(root === "/" ? "/" : `${root}/`);
720
+ if (resolved.split("/").includes(".git")) {
721
+ // A lock left by an interrupted git command is routine to clear.
722
+ const staleLock = globAt < 0 && (name === "rm" || name === "unlink") && within(roots.cwd) && resolved.endsWith(".lock");
723
+ if (!staleLock) return `Worker cannot ${verb} Git's own store (${shown}); with no remote authority the local commits are the only copy`;
724
+ }
725
+ if (within(roots.cwd)) {
726
+ if (resolved === roots.cwd && globAt < 0 && !contentsOnly) {
727
+ return name === "find"
728
+ ? `Worker cannot delete the whole task tree with find (${shown}); it would take .git too — filter with -name/-path`
729
+ : `Worker cannot ${verb} the task directory itself (${shown})`;
730
+ }
731
+ return undefined;
732
+ }
733
+ const other = roots.others.find(within);
734
+ if (other !== undefined) {
735
+ if (resolved === other) return `Worker cannot ${verb} ${globAt < 0 ? "a whole" : "everything at the top of a"} shared directory (${shown})`;
736
+ return undefined;
737
+ }
738
+ return `Worker cannot ${verb} outside the task directory (${shown})`;
739
+ }
740
+
741
+ /**
742
+ * The real location a delete acts on: the parent resolved through symlinks
743
+ * with the last name kept as written (`rm link` removes the link, not its
744
+ * target), or the whole path when it is a directory to be followed.
745
+ */
746
+ function resolveForDelete(absolute: string, keepLast: boolean): string {
747
+ const normalized = resolve(absolute);
748
+ const canonical = (path: string): string => {
749
+ let current = path;
750
+ const missing: string[] = [];
751
+ for (;;) {
752
+ try { return join(realpathSync.native(current), ...[...missing].reverse()); }
753
+ catch {
754
+ const parent = dirname(current);
755
+ if (parent === current) return path;
756
+ missing.push(basename(current));
757
+ current = parent;
758
+ }
759
+ }
760
+ };
761
+ if (!keepLast || normalized === "/") return canonical(normalized);
762
+ return join(canonical(dirname(normalized)), basename(normalized));
155
763
  }
156
764
 
157
765
  function fileToolPaths(input: unknown): string[] {
@@ -437,6 +1045,7 @@ const DYNAMIC_SENSITIVE_COMMANDS = new Set([
437
1045
  ]);
438
1046
  /** `find` runs its `-exec`/`-ok` argv and so joins the sensitive set when one is present. */
439
1047
  const FIND_EXEC_ACTIONS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
1048
+ const FIND_NAME_FILTERS = new Set(["-name", "-iname", "-path", "-ipath", "-wholename", "-iwholename", "-regex", "-iregex"]);
440
1049
  /** Commands that only store or display their input; a quoted heredoc fed to one never executes. */
441
1050
  const DATA_SINK_COMMANDS = new Set([
442
1051
  "cat", "tee", "head", "tail", "grep", "rg", "wc", "sort", "uniq", "cut", "tr", "diff", "less", "more", "base64",