@andares/pi-toolkits 0.8.0 → 0.9.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.
@@ -0,0 +1,995 @@
1
+ /**
2
+ * permission feature — bash command decomposition.
3
+ *
4
+ * One `bash` tool call becomes an ordered list of {@link CommandUnit}s, each
5
+ * carrying the command it runs, whether that command mutates, the working
6
+ * directory in force at that point, and the paths it touches. The policy layer
7
+ * answers per unit and the strictest unit wins.
8
+ *
9
+ * Precision is deliberately bounded (this is a boundary guard, not a sandbox):
10
+ * - `shell-quote` tokenizes with real quoting rules; operators split the
11
+ * stream into simple commands.
12
+ * - Command substitution, backticks, `bash -c`/`eval` payloads, and the common
13
+ * indirection wrappers (`sudo`, `env`, `xargs`, `find -exec`, …) are peeled
14
+ * or recursed into.
15
+ * - Heredoc bodies are stripped before parsing.
16
+ * - Anything the parser cannot account for is marked `opaque`; policy answers
17
+ * `ask` for an opaque unit regardless of mode (fail closed).
18
+ */
19
+ import { homedir } from "node:os";
20
+ import { dirname, resolve } from "node:path";
21
+ import {
22
+ type ControlOperator,
23
+ type GlobPattern,
24
+ type ParseEntry,
25
+ parse as parseShell,
26
+ quote as quoteWords,
27
+ } from "shell-quote";
28
+ import type { Effect } from "./constants.js";
29
+ import { expandHome } from "./paths.js";
30
+
31
+ /** One filesystem path a unit touches. */
32
+ export interface UnitTarget {
33
+ /** Absolute lexical path (policy canonicalizes it). */
34
+ readonly path: string;
35
+ readonly effect: Effect;
36
+ readonly source: "redirect" | "operand";
37
+ }
38
+
39
+ /** One simple command of a bash program. */
40
+ export interface CommandUnit {
41
+ /** Reconstructed text, for prompts and denial reasons. */
42
+ readonly raw: string;
43
+ /** First word before wrapper peeling (`sudo` stays visible here). */
44
+ readonly headWord: string;
45
+ /** Command after wrapper peeling. */
46
+ readonly command: string;
47
+ /** Arguments after wrapper peeling, without redirects. */
48
+ readonly args: readonly string[];
49
+ readonly effect: Effect;
50
+ readonly opaque: boolean;
51
+ /** Working directory in force, or `null` when it cannot be resolved. */
52
+ readonly base: string | null;
53
+ readonly targets: readonly UnitTarget[];
54
+ /** Human-facing reason when {@link opaque} is set. */
55
+ readonly note?: string;
56
+ }
57
+
58
+ const SENTINEL = "\u0000";
59
+
60
+ /* -------------------------------------------------------------------------- */
61
+ /* Read/write classification */
62
+ /* -------------------------------------------------------------------------- */
63
+
64
+ /** Commands that only read, unless an option guard below withdraws the claim. */
65
+ const READ_COMMANDS = new Set([
66
+ "ls", "cat", "tac", "head", "tail", "less", "more", "bat", "grep", "egrep",
67
+ "fgrep", "rg", "ag", "wc", "uniq", "cut", "tr", "column", "file", "stat",
68
+ "du", "df", "tree", "pwd", "echo", "printf", "date", "whoami", "id",
69
+ "uname", "which", "type", "ps", "history", "diff", "cmp", "md5sum",
70
+ "sha1sum", "sha256sum", "base64", "realpath", "dirname", "basename",
71
+ "readlink", "test", "[", "true", "false", "sleep", "seq", "bc", "expr",
72
+ "tput", "jq", "yq", "sort", "sed", "awk", "find", "fd", "nl", "rev",
73
+ "comm", "paste", "join", "split", "fold", "fmt", "pr", "od", "xxd",
74
+ ]);
75
+
76
+ /** Commands that mutate by name. */
77
+ const WRITE_COMMANDS = new Set([
78
+ "rm", "rmdir", "mv", "cp", "mkdir", "touch", "chmod", "chown", "chgrp",
79
+ "ln", "truncate", "shred", "dd", "tee", "install", "rsync", "scp", "sftp",
80
+ "unlink", "mkfifo", "mknod", "setfacl", "chattr", "patch", "docker",
81
+ "podman", "systemctl", "service", "kill", "killall", "pkill", "crontab",
82
+ "useradd", "userdel", "groupadd", "passwd", "mount", "umount", "swapon",
83
+ "swapoff", "iptables", "ufw", "git", "npm", "pnpm", "yarn", "bun",
84
+ "pip", "pip3", "uv", "cargo", "go", "make", "cmake", "python", "python3",
85
+ "node", "ruby", "perl", "php", "java", "tsx", "deno",
86
+ ]);
87
+
88
+ /** Commands whose operands are always paths, even when they are bare names. */
89
+ const WRITE_PATH_COMMANDS = new Set([
90
+ "rm", "rmdir", "mv", "cp", "mkdir", "touch", "chmod", "chown", "chgrp",
91
+ "ln", "truncate", "shred", "unlink", "tee", "install", "rsync", "scp",
92
+ "sftp", "mkfifo", "mknod", "setfacl", "chattr", "patch",
93
+ ]);
94
+
95
+ /** Git verbs that only read. Anything else is treated as mutating. */
96
+ const GIT_READ_VERBS = new Set([
97
+ "log", "diff", "show", "status", "blame", "grep", "rev-parse", "rev-list",
98
+ "describe", "shortlog", "cat-file", "show-ref", "for-each-ref", "ls-files",
99
+ "ls-tree", "merge-base", "cherry", "check-ignore", "check-attr",
100
+ "count-objects", "whatchanged", "diff-tree", "name-rev", "stash",
101
+ "reflog", "worktree", "tag", "config", "remote", "branch", "help",
102
+ ]);
103
+
104
+ /** Package-manager verbs that only read. */
105
+ const PKG_READ_VERBS = new Set([
106
+ "view", "info", "search", "list", "ls", "why", "outdated", "config",
107
+ "licenses", "tree",
108
+ ]);
109
+
110
+ /** Git options that take a value; the verb follows them. */
111
+ const GIT_VALUE_OPTS = new Set([
112
+ "-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path",
113
+ ]);
114
+
115
+ /** Index of the first non-option word, skipping values of `valueOpts`. */
116
+ function firstNonOption(
117
+ args: readonly string[],
118
+ valueOpts: ReadonlySet<string>,
119
+ ): number {
120
+ for (let i = 0; i < args.length; i++) {
121
+ const arg = args[i];
122
+ if (arg === "--") return i + 1;
123
+ if (arg.startsWith("-") && arg !== "-") {
124
+ if (valueOpts.has(arg)) i++;
125
+ continue;
126
+ }
127
+ return i;
128
+ }
129
+ return -1;
130
+ }
131
+
132
+ function hasFlag(args: readonly string[], ...flags: string[]): boolean {
133
+ return args.some((arg) => flags.includes(arg));
134
+ }
135
+
136
+ /**
137
+ * Effective read/write classification for one peeled command.
138
+ * Unknown commands are writes: mutation is the fail-closed assumption.
139
+ */
140
+ export function commandEffect(command: string, args: readonly string[]): Effect {
141
+ if (command === "git") {
142
+ const verbIndex = firstNonOption(args, GIT_VALUE_OPTS);
143
+ const verb = verbIndex >= 0 ? args[verbIndex] : "";
144
+ const rest = verbIndex >= 0 ? args.slice(verbIndex + 1) : [];
145
+ if (!GIT_READ_VERBS.has(verb)) return "write";
146
+ if (verb === "stash") {
147
+ return rest.length === 0 || rest[0] === "list" || rest[0] === "show"
148
+ ? "read"
149
+ : "write";
150
+ }
151
+ if (verb === "tag") return hasFlag(rest, "-l", "--list") ? "read" : "write";
152
+ if (verb === "branch") {
153
+ const positional = rest.filter((a) => !a.startsWith("-"));
154
+ return positional.length === 0 ? "read" : "write";
155
+ }
156
+ if (verb === "config") {
157
+ return hasFlag(rest, "--get", "--get-all", "--get-regexp", "-l", "--list")
158
+ ? "read"
159
+ : "write";
160
+ }
161
+ if (verb === "remote") {
162
+ return hasFlag(rest, "-v", "--verbose", "show", "get-url") ? "read" : "write";
163
+ }
164
+ if (verb === "reflog") {
165
+ return rest.length === 0 || rest[0] === "show" ? "read" : "write";
166
+ }
167
+ return "read";
168
+ }
169
+ if (["npm", "pnpm", "yarn", "bun"].includes(command)) {
170
+ const verbIndex = firstNonOption(args, new Set(["--prefix", "-C", "--dir"]));
171
+ const verb = verbIndex >= 0 ? args[verbIndex] : "";
172
+ return PKG_READ_VERBS.has(verb) ? "read" : "write";
173
+ }
174
+ if (command === "sed") {
175
+ const inPlace = args.some(
176
+ (arg) => arg === "--in-place" || /^-[a-zA-Z]*i/.test(arg),
177
+ );
178
+ return inPlace ? "write" : "read";
179
+ }
180
+ if (command === "sort") {
181
+ return hasFlag(args, "-o", "--output") ? "write" : "read";
182
+ }
183
+ if (command === "find") {
184
+ return args.some(
185
+ (arg) =>
186
+ arg === "-delete" || arg === "-exec" || arg === "-execdir" ||
187
+ arg === "-ok" || arg === "-okdir",
188
+ )
189
+ ? "write"
190
+ : "read";
191
+ }
192
+ if (command === "fd") {
193
+ return args.some(
194
+ (arg) => arg === "-x" || arg === "-X" || arg.startsWith("--exec"),
195
+ )
196
+ ? "write"
197
+ : "read";
198
+ }
199
+ if (command === "curl") {
200
+ const writes = args.some(
201
+ (arg) =>
202
+ arg === "-O" || arg === "--remote-name" || arg === "-T" ||
203
+ arg === "--upload-file" || arg === "--output" || arg === "-o" ||
204
+ /^-[a-zA-Z]*[oO]$/.test(arg),
205
+ );
206
+ return writes ? "write" : "read";
207
+ }
208
+ if (command === "wget") {
209
+ const toStdout = args.some(
210
+ (arg) => arg === "-O-" || arg === "-qO-" || arg === "--output-document=-",
211
+ );
212
+ return toStdout ? "read" : "write";
213
+ }
214
+ if (command === "tar") {
215
+ const verb = args.find((arg) => /^[a-zA-Z]+$/.test(arg)) ?? "";
216
+ return /[xc]/.test(verb) ? "write" : "read";
217
+ }
218
+ if (READ_COMMANDS.has(command)) return "read";
219
+ if (WRITE_COMMANDS.has(command)) return "write";
220
+ return "write";
221
+ }
222
+
223
+ /* -------------------------------------------------------------------------- */
224
+ /* Token helpers */
225
+ /* -------------------------------------------------------------------------- */
226
+
227
+ const SEPARATORS = new Set([
228
+ "&&", "||", ";", "&", "|", "|&", ";;", ";;&", ";&",
229
+ ]);
230
+ const REDIRECTS = new Set([
231
+ ">", ">>", ">|", "&>", "&>>", "<", "<<<", ">&", "<&", "<>",
232
+ ]);
233
+ const WRITE_REDIRECTS = new Set([">", ">>", ">|", "&>", "&>>", ">&", "<>"]);
234
+ const PROCESS_SUBSTITUTION = new Set(["<(", ">("]);
235
+
236
+ function opOf(entry: ParseEntry): string | undefined {
237
+ if (typeof entry !== "object" || entry === null) return undefined;
238
+ if (!("op" in entry)) return undefined;
239
+ return (entry as { op: string }).op;
240
+ }
241
+
242
+ function isGlob(entry: ParseEntry): entry is GlobPattern {
243
+ return opOf(entry) === "glob";
244
+ }
245
+
246
+ function isControl(entry: ParseEntry): entry is ControlOperator {
247
+ const op = opOf(entry);
248
+ return op !== undefined && op !== "glob";
249
+ }
250
+
251
+ function wordOf(entry: ParseEntry): string | undefined {
252
+ if (typeof entry === "string") return entry;
253
+ if (isGlob(entry)) return entry.pattern;
254
+ return undefined;
255
+ }
256
+
257
+ function hasSentinel(value: string): boolean {
258
+ return value.includes(SENTINEL);
259
+ }
260
+
261
+ /* -------------------------------------------------------------------------- */
262
+ /* Pre-passes */
263
+ /* -------------------------------------------------------------------------- */
264
+
265
+ /**
266
+ * Remove heredoc bodies so their text is never parsed as commands.
267
+ * `undefined` means an unterminated heredoc: the caller marks the whole
268
+ * command opaque.
269
+ */
270
+ export function stripHeredocs(input: string): string | undefined {
271
+ const lines = input.split("\n");
272
+ const kept: string[] = [];
273
+ let index = 0;
274
+ while (index < lines.length) {
275
+ const line = lines[index];
276
+ const match = /<<-?\s*(['"]?)([A-Za-z0-9_]+)\1/.exec(line);
277
+ if (!match) {
278
+ kept.push(line);
279
+ index++;
280
+ continue;
281
+ }
282
+ const delimiter = match[2];
283
+ kept.push(
284
+ line.slice(0, match.index) + line.slice(match.index + match[0].length),
285
+ );
286
+ index++;
287
+ let closed = false;
288
+ while (index < lines.length) {
289
+ const body = lines[index];
290
+ index++;
291
+ if (body.trim() === delimiter) {
292
+ closed = true;
293
+ break;
294
+ }
295
+ }
296
+ if (!closed) return undefined;
297
+ }
298
+ return kept.join("\n");
299
+ }
300
+
301
+ /**
302
+ * Replace `$(…)` and backtick substitutions with placeholder words and return
303
+ * their payloads, so the outer command parses cleanly and each payload can be
304
+ * analysed as a command of its own.
305
+ */
306
+ export function extractSubstitutions(input: string): {
307
+ text: string;
308
+ payloads: string[];
309
+ } | undefined {
310
+ const payloads: string[] = [];
311
+ let out = "";
312
+ let index = 0;
313
+ while (index < input.length) {
314
+ const char = input[index];
315
+ if (char === "\\") {
316
+ out += input.slice(index, index + 2);
317
+ index += 2;
318
+ continue;
319
+ }
320
+ if (char === "`") {
321
+ const end = input.indexOf("`", index + 1);
322
+ if (end < 0) return undefined;
323
+ payloads.push(input.slice(index + 1, end));
324
+ out += `${SENTINEL}SUBST${payloads.length - 1}${SENTINEL}`;
325
+ index = end + 1;
326
+ continue;
327
+ }
328
+ if (char === "$" && input[index + 1] === "(") {
329
+ const end = findClosingParen(input, index + 2);
330
+ if (end < 0) return undefined;
331
+ payloads.push(input.slice(index + 2, end));
332
+ out += `${SENTINEL}SUBST${payloads.length - 1}${SENTINEL}`;
333
+ index = end + 1;
334
+ continue;
335
+ }
336
+ out += char;
337
+ index++;
338
+ }
339
+ return { text: out, payloads };
340
+ }
341
+
342
+ /**
343
+ * Remove shell comments and verify quote balance in one pass.
344
+ *
345
+ * `shell-quote` silently accepts an unterminated quote, so the balance check
346
+ * is what keeps malformed input from being parsed as a command we then judge.
347
+ * Comments are dropped first because an apostrophe in `# it's fine` would
348
+ * otherwise look like an opening quote. `undefined` means unbalanced.
349
+ */
350
+ function stripComments(input: string): string | undefined {
351
+ let out = "";
352
+ let quote: "'" | '"' | undefined;
353
+ for (let i = 0; i < input.length; i++) {
354
+ const char = input[i];
355
+ if (quote !== "'") {
356
+ if (char === "\\") {
357
+ out += input.slice(i, i + 2);
358
+ i++;
359
+ continue;
360
+ }
361
+ }
362
+ if (quote) {
363
+ out += char;
364
+ if (char === quote) quote = undefined;
365
+ continue;
366
+ }
367
+ if (char === "'" || char === '"') {
368
+ quote = char;
369
+ out += char;
370
+ continue;
371
+ }
372
+ if (char === "#" && (i === 0 || /\s/.test(input[i - 1]))) {
373
+ while (i < input.length && input[i] !== "\n") i++;
374
+ out += "\n";
375
+ continue;
376
+ }
377
+ out += char;
378
+ }
379
+ return quote === undefined ? out : undefined;
380
+ }
381
+
382
+ /** Index of the `)` matching an already-consumed `(`, quote-aware. */
383
+ function findClosingParen(input: string, start: number): number {
384
+ let depth = 1;
385
+ let quote: "'" | '"' | undefined;
386
+ for (let i = start; i < input.length; i++) {
387
+ const char = input[i];
388
+ if (char === "\\") {
389
+ i++;
390
+ continue;
391
+ }
392
+ if (quote) {
393
+ if (char === quote) quote = undefined;
394
+ continue;
395
+ }
396
+ if (char === "'" || char === '"') {
397
+ quote = char;
398
+ continue;
399
+ }
400
+ if (char === "(") depth++;
401
+ if (char === ")") {
402
+ depth--;
403
+ if (depth === 0) return i;
404
+ }
405
+ }
406
+ return -1;
407
+ }
408
+
409
+ /**
410
+ * Environment proxy: unknown variables expand to a detectable sentinel
411
+ * instead of the empty string, so `$DIR/secret` cannot silently become
412
+ * `/secret` and slip past the boundary check.
413
+ */
414
+ function envFor(base: string | null): Record<string, string> {
415
+ const known: Record<string, string> = { HOME: homedir() };
416
+ if (base) known.PWD = base;
417
+ return new Proxy(known, {
418
+ get(target, key) {
419
+ if (typeof key !== "string") return undefined;
420
+ if (Object.hasOwn(target, key)) return target[key];
421
+ return `${SENTINEL}VAR${key}${SENTINEL}`;
422
+ },
423
+ });
424
+ }
425
+
426
+ /* -------------------------------------------------------------------------- */
427
+ /* Wrapper peeling */
428
+ /* -------------------------------------------------------------------------- */
429
+
430
+ const WRAPPER_VALUE_OPTS: Record<string, ReadonlySet<string>> = {
431
+ sudo: new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-T", "-r", "-R", "-D", "-t"]),
432
+ doas: new Set(["-u", "-C"]),
433
+ env: new Set(["-u", "-S", "-C", "--unset", "--split-string", "--chdir"]),
434
+ xargs: new Set([
435
+ "-I", "-n", "-P", "-L", "-s", "-d", "-E", "-a", "--replace", "--max-args",
436
+ "--max-procs", "--max-lines", "--max-chars", "--delimiter", "--eof", "--arg-file",
437
+ ]),
438
+ timeout: new Set(["-s", "-k", "--signal", "--kill-after"]),
439
+ stdbuf: new Set(["-i", "-o", "-e"]),
440
+ watch: new Set(["-n", "--interval"]),
441
+ flock: new Set(["-w", "-E", "--timeout", "--conflict-exit-code"]),
442
+ nice: new Set(["-n", "--adjustment"]),
443
+ time: new Set(["-f", "--format", "-o", "--output"]),
444
+ exec: new Set(["-a"]),
445
+ parallel: new Set(["-j", "--jobs", "-n", "--max-args", "-I", "--replace"]),
446
+ command: new Set(),
447
+ builtin: new Set(),
448
+ nohup: new Set(),
449
+ setsid: new Set(),
450
+ };
451
+
452
+ const SHELLS = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
453
+
454
+ /** Index of the first word after the wrapper's own options. */
455
+ function afterOptions(
456
+ words: string[],
457
+ start: number,
458
+ values: ReadonlySet<string>,
459
+ ): number {
460
+ let index = start;
461
+ while (index < words.length) {
462
+ const word = words[index];
463
+ if (word === "--") return index + 1;
464
+ if (word.startsWith("-") && word !== "-") {
465
+ index += values.has(word) ? 2 : 1;
466
+ continue;
467
+ }
468
+ return index;
469
+ }
470
+ return index;
471
+ }
472
+
473
+ /** Peel one wrapper, returning the index of the inner command. */
474
+ function peelOne(words: string[]): { wrapper: string; inner: number } | undefined {
475
+ const head = words[0];
476
+ if (!head || !Object.hasOwn(WRAPPER_VALUE_OPTS, head)) return undefined;
477
+ const values = WRAPPER_VALUE_OPTS[head];
478
+ let index = afterOptions(words, 1, values);
479
+ if (head === "timeout" || head === "flock") {
480
+ // First non-option word: the duration (timeout) or the lock file (flock).
481
+ if (index < words.length) index++;
482
+ } else if (head === "env") {
483
+ while (index < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[index])) {
484
+ index++;
485
+ }
486
+ }
487
+ return { wrapper: head, inner: index };
488
+ }
489
+
490
+ /* -------------------------------------------------------------------------- */
491
+ /* Segment analysis */
492
+ /* -------------------------------------------------------------------------- */
493
+
494
+ function opaqueUnit(
495
+ raw: string,
496
+ headWord: string,
497
+ command: string,
498
+ base: string | null,
499
+ note: string,
500
+ ): CommandUnit {
501
+ return {
502
+ raw,
503
+ headWord,
504
+ command,
505
+ args: [],
506
+ effect: "write",
507
+ opaque: true,
508
+ base,
509
+ targets: [],
510
+ note,
511
+ };
512
+ }
513
+
514
+ /** First word that is not a leading `VAR=value` assignment. */
515
+ function firstCommandWord(words: readonly string[]): string {
516
+ for (const word of words) {
517
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) return word;
518
+ }
519
+ return "";
520
+ }
521
+
522
+ /** Whether a word looks like a filesystem path. */
523
+ export function looksLikePath(word: string): boolean {
524
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(word)) return false; // URL
525
+ return (
526
+ word === "." ||
527
+ word === ".." ||
528
+ word.startsWith("/") ||
529
+ word.startsWith("~") ||
530
+ word.includes("/") ||
531
+ word.includes("\\") ||
532
+ word.includes("*") ||
533
+ word.includes("?") ||
534
+ word.includes("[")
535
+ );
536
+ }
537
+
538
+ /** Resolve one path word against a base; `undefined` when not resolvable. */
539
+ function resolveTargetPath(
540
+ base: string | null,
541
+ word: string,
542
+ ): string | undefined {
543
+ if (hasSentinel(word)) return undefined;
544
+ const expanded = expandHome(word);
545
+ if (/[*?[\]]/.test(expanded)) {
546
+ // A glob targets the directory it expands in — the directory is what a
547
+ // boundary decision can actually judge.
548
+ const prefix = expanded.slice(0, expanded.search(/[*?[\]]/));
549
+ const directory = prefix.includes("/") ? dirname(prefix || ".") : ".";
550
+ if (!directory.startsWith("/") && !directory.startsWith("~") && base === null) {
551
+ return undefined;
552
+ }
553
+ return resolve(base ?? "/", expandHome(directory));
554
+ }
555
+ if (expanded.startsWith("/")) return resolve(expanded);
556
+ if (base === null) return undefined;
557
+ return resolve(base, expanded);
558
+ }
559
+
560
+ /** Positional words, skipping options and the values of known value options. */
561
+ export function operands(
562
+ args: readonly string[],
563
+ valueOpts: ReadonlySet<string>,
564
+ ): string[] {
565
+ const out: string[] = [];
566
+ let afterDoubleDash = false;
567
+ for (let i = 0; i < args.length; i++) {
568
+ const word = args[i];
569
+ if (afterDoubleDash) {
570
+ out.push(word);
571
+ continue;
572
+ }
573
+ if (word === "--") {
574
+ afterDoubleDash = true;
575
+ continue;
576
+ }
577
+ if (word.startsWith("-") && word !== "-") {
578
+ if (valueOpts.has(word)) i++;
579
+ continue;
580
+ }
581
+ out.push(word);
582
+ }
583
+ return out;
584
+ }
585
+
586
+ const WRITE_OPERAND_VALUE_OPTS = new Set([
587
+ "-t", "-S", "-e", "-C", "-o", "--output", "--target-directory", "--suffix",
588
+ "--reference", "--mode",
589
+ ]);
590
+
591
+ function dedupeTargets(targets: UnitTarget[]): UnitTarget[] {
592
+ const seen = new Set<string>();
593
+ const out: UnitTarget[] = [];
594
+ for (const target of targets) {
595
+ const key = `${target.effect}:${target.path}`;
596
+ if (seen.has(key)) continue;
597
+ seen.add(key);
598
+ out.push(target);
599
+ }
600
+ return out;
601
+ }
602
+
603
+ /** Separate redirects from the command words, keeping redirect targets typed. */
604
+ function splitRedirects(tokens: string[]): {
605
+ words: string[];
606
+ redirects: Array<{ op: string; target?: string }>;
607
+ processSubstitution: boolean;
608
+ } {
609
+ const words: string[] = [];
610
+ const redirects: Array<{ op: string; target?: string }> = [];
611
+ let processSubstitution = false;
612
+ for (let i = 0; i < tokens.length; i++) {
613
+ const token = tokens[i];
614
+ if (PROCESS_SUBSTITUTION.has(token)) {
615
+ processSubstitution = true;
616
+ continue;
617
+ }
618
+ if (!REDIRECTS.has(token)) {
619
+ words.push(token);
620
+ continue;
621
+ }
622
+ // Keep a file descriptor (`2>`) out of the argument list.
623
+ if (words.length > 0 && /^\d+$/.test(words[words.length - 1])) {
624
+ words.pop();
625
+ }
626
+ const target = tokens[i + 1];
627
+ if (target !== undefined && !REDIRECTS.has(target)) i++;
628
+ redirects.push({ op: token, target });
629
+ }
630
+ return { words, redirects, processSubstitution };
631
+ }
632
+
633
+ /**
634
+ * Turn one simple command (already split on separators) into units.
635
+ * Recursion depth is bounded so a pathological substitution cannot loop.
636
+ */
637
+ function analyzeSegment(
638
+ tokens: string[],
639
+ base: string | null,
640
+ depth: number,
641
+ ): { units: CommandUnit[]; nextBase: string | null } {
642
+ const raw = tokens.join(" ");
643
+ const { words, redirects, processSubstitution } = splitRedirects(tokens);
644
+ const headWord = firstCommandWord(words);
645
+
646
+ if (processSubstitution) {
647
+ return {
648
+ units: [opaqueUnit(raw, headWord, "", base, "process substitution")],
649
+ nextBase: base,
650
+ };
651
+ }
652
+ if (words.length === 0) return { units: [], nextBase: base };
653
+
654
+ // Leading `VAR=value` assignments belong to the segment's environment, not
655
+ // to the command.
656
+ let peeled = words.filter(() => true);
657
+ while (peeled.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(peeled[0])) {
658
+ peeled = peeled.slice(1);
659
+ }
660
+
661
+ // Wrapper peeling: `sudo rm` is decided by `rm`, while `headWord` keeps the
662
+ // wrapper visible for the danger table.
663
+ for (let round = 0; round < 4; round++) {
664
+ const step = peelOne(peeled);
665
+ if (!step) break;
666
+ if (step.inner >= peeled.length) {
667
+ return {
668
+ units: [opaqueUnit(raw, headWord, step.wrapper, base, "wrapper runs no visible command")],
669
+ nextBase: base,
670
+ };
671
+ }
672
+ peeled = peeled.slice(step.inner);
673
+ }
674
+
675
+ const command = peeled[0] ?? "";
676
+ const args = peeled.slice(1);
677
+
678
+ // Shell payloads and `eval`: recurse into the payload text.
679
+ if (SHELLS.has(command) || command === "eval") {
680
+ let payload: string | undefined;
681
+ if (command === "eval") {
682
+ payload = args.join(" ");
683
+ } else {
684
+ const flag = args.indexOf("-c");
685
+ if (flag >= 0) payload = args[flag + 1];
686
+ }
687
+ if (payload !== undefined) {
688
+ if (payload === "" || hasSentinel(payload) || depth >= 4) {
689
+ return {
690
+ units: [opaqueUnit(raw, headWord, command, base, "shell payload is not a literal")],
691
+ nextBase: base,
692
+ };
693
+ }
694
+ return { units: parseSubText(payload, base, depth + 1), nextBase: base };
695
+ }
696
+ }
697
+
698
+ // `cd` changes the base for the commands that follow it.
699
+ if (command === "cd") {
700
+ const target = operands(args, new Set())[0];
701
+ if (target === undefined) {
702
+ const home = homedir();
703
+ return {
704
+ units: [{
705
+ raw,
706
+ headWord,
707
+ command,
708
+ args,
709
+ effect: "read",
710
+ opaque: false,
711
+ base,
712
+ targets: [{ path: home, effect: "read", source: "operand" }],
713
+ }],
714
+ nextBase: home,
715
+ };
716
+ }
717
+ if (target === "-" || hasSentinel(target) || /^~[^/]/.test(target)) {
718
+ return {
719
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved cd target")],
720
+ nextBase: null,
721
+ };
722
+ }
723
+ const destination = resolveTargetPath(base, target);
724
+ if (destination === undefined) {
725
+ return {
726
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved cd target")],
727
+ nextBase: null,
728
+ };
729
+ }
730
+ return {
731
+ units: [{
732
+ raw,
733
+ headWord,
734
+ command,
735
+ args,
736
+ effect: "read",
737
+ opaque: false,
738
+ base,
739
+ targets: [{ path: destination, effect: "read", source: "operand" }],
740
+ }],
741
+ nextBase: destination,
742
+ };
743
+ }
744
+
745
+ const effect = commandEffect(command, args);
746
+ const targets: UnitTarget[] = [];
747
+ const units: CommandUnit[] = [];
748
+
749
+ // `find`/`fd` exec flags run a nested command.
750
+ if (command === "find" || command === "fd") {
751
+ const flagIndex = args.findIndex(
752
+ (arg) =>
753
+ arg === "-exec" || arg === "-execdir" || arg === "-ok" || arg === "-okdir" ||
754
+ arg === "-x" || arg === "-X" || arg === "--exec" || arg === "--exec-batch",
755
+ );
756
+ if (flagIndex >= 0 && depth < 4) {
757
+ const end = args.indexOf(";", flagIndex);
758
+ const inner = args.slice(flagIndex + 1, end < 0 ? undefined : end);
759
+ if (inner.length > 0) {
760
+ units.push(...parseSubText(quoteWords(inner), base, depth + 1));
761
+ }
762
+ }
763
+ }
764
+
765
+ // Redirect targets are proof, independent of the command. `/dev/null` (and
766
+ // the standard streams) are not files a boundary decision can meaningfully
767
+ // guard; `cmd 2>/dev/null` is noise, not a write.
768
+ for (const redirect of redirects) {
769
+ if (redirect.target === undefined) continue;
770
+ if (redirect.op === "<<<") continue; // herestring: data, not a path
771
+ if (redirect.target === "/dev/null" || redirect.target === "/dev/stdout" || redirect.target === "/dev/stderr") {
772
+ continue;
773
+ }
774
+ const descriptor =
775
+ (redirect.op === ">&" || redirect.op === "<&" || redirect.op === "&>") &&
776
+ /^\d+$/.test(redirect.target);
777
+ if (descriptor) continue;
778
+ const pathValue = resolveTargetPath(base, redirect.target);
779
+ if (pathValue === undefined) {
780
+ return {
781
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved redirect target")],
782
+ nextBase: base,
783
+ };
784
+ }
785
+ targets.push({
786
+ path: pathValue,
787
+ effect: WRITE_REDIRECTS.has(redirect.op) ? "write" : "read",
788
+ source: "redirect",
789
+ });
790
+ }
791
+
792
+ // `git -C <dir>` makes every relative path argument (and the repo itself)
793
+ // resolve against <dir> for this invocation; treat it like a local cd.
794
+ let commandBase = base;
795
+ if (command === "git") {
796
+ for (let i = 0; i < args.length; i++) {
797
+ const value = args[i] === "-C" ? args[i + 1] : undefined;
798
+ if (value === undefined) continue;
799
+ const destination = resolveTargetPath(commandBase, value);
800
+ if (destination === undefined) {
801
+ return {
802
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved git -C target")],
803
+ nextBase: base,
804
+ };
805
+ }
806
+ commandBase = destination;
807
+ }
808
+ }
809
+
810
+ // Command-specific target extraction.
811
+ if (WRITE_PATH_COMMANDS.has(command)) {
812
+ for (const operand of operands(args, WRITE_OPERAND_VALUE_OPTS)) {
813
+ const pathValue = resolveTargetPath(commandBase, operand);
814
+ if (pathValue === undefined) {
815
+ return {
816
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved path operand")],
817
+ nextBase: base,
818
+ };
819
+ }
820
+ targets.push({ path: pathValue, effect: "write", source: "operand" });
821
+ }
822
+ } else if (
823
+ command === "sed" &&
824
+ effect === "write"
825
+ ) {
826
+ // `sed -i` writes its file operands; the first non-option operand is the
827
+ // script unless -e/-f already named one.
828
+ const ops = operands(args, new Set(["-e", "--expression", "-f", "--file"]));
829
+ const files = hasFlag(args, "-e", "--expression", "-f", "--file")
830
+ ? ops
831
+ : ops.slice(1);
832
+ for (const file of files) {
833
+ const pathValue = resolveTargetPath(base, file);
834
+ if (pathValue === undefined) {
835
+ return {
836
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved sed target")],
837
+ nextBase: base,
838
+ };
839
+ }
840
+ targets.push({ path: pathValue, effect: "write", source: "operand" });
841
+ }
842
+ } else if (command === "dd") { for (const arg of args) {
843
+ if (arg.startsWith("of=")) {
844
+ const pathValue = resolveTargetPath(base, arg.slice(3));
845
+ if (pathValue === undefined) {
846
+ return {
847
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved dd target")],
848
+ nextBase: base,
849
+ };
850
+ }
851
+ targets.push({ path: pathValue, effect: "write", source: "operand" });
852
+ } else if (arg.startsWith("if=")) {
853
+ const pathValue = resolveTargetPath(base, arg.slice(3));
854
+ if (pathValue) {
855
+ targets.push({ path: pathValue, effect: "read", source: "operand" });
856
+ }
857
+ }
858
+ }
859
+ }
860
+
861
+ // Path-like arguments are targets for every command.
862
+ for (const arg of args) {
863
+ if (!looksLikePath(arg)) continue;
864
+ // `sed -i` operands were already classified above; its script is not a path.
865
+ if (command === "sed" && effect === "write") continue;
866
+ const pathValue = resolveTargetPath(commandBase, arg);
867
+ if (pathValue === undefined) {
868
+ return {
869
+ units: [opaqueUnit(raw, headWord, command, base, "unresolved path argument")],
870
+ nextBase: base,
871
+ };
872
+ }
873
+ targets.push({ path: pathValue, effect, source: "operand" });
874
+ }
875
+
876
+ // `-o FILE` / `--output FILE` writes whatever the command is.
877
+ for (let i = 0; i < args.length; i++) {
878
+ if (args[i] !== "-o" && args[i] !== "--output") continue;
879
+ const value = args[i + 1];
880
+ if (value === undefined) continue;
881
+ const pathValue = resolveTargetPath(commandBase, value);
882
+ if (pathValue) targets.push({ path: pathValue, effect: "write", source: "operand" });
883
+ }
884
+
885
+ units.push({
886
+ raw,
887
+ headWord,
888
+ command,
889
+ args,
890
+ effect: targets.some((t) => t.effect === "write") ? "write" : effect,
891
+ opaque: false,
892
+ base: commandBase ?? base,
893
+ targets: dedupeTargets(targets),
894
+ });
895
+ return { units, nextBase: base };
896
+ }
897
+
898
+ /* -------------------------------------------------------------------------- */
899
+ /* Program parsing */
900
+ /* -------------------------------------------------------------------------- */
901
+
902
+ function segmentTokens(entries: ParseEntry[]): string[][] {
903
+ const segments: string[][] = [];
904
+ let current: string[] = [];
905
+ for (const entry of entries) {
906
+ if (isControl(entry)) {
907
+ if (SEPARATORS.has(entry.op)) {
908
+ if (current.length > 0) segments.push(current);
909
+ current = [];
910
+ continue;
911
+ }
912
+ if (entry.op === "(" || entry.op === ")") continue;
913
+ current.push(entry.op);
914
+ continue;
915
+ }
916
+ const word = wordOf(entry);
917
+ if (word !== undefined) current.push(word);
918
+ }
919
+ if (current.length > 0) segments.push(current);
920
+ return segments;
921
+ }
922
+
923
+ const PLACEHOLDER = /\u0000SUBST(\d+)\u0000/g;
924
+
925
+ /**
926
+ * Token-level walker: substitutions are analysed where their placeholder
927
+ * appears, so a payload after `cd /tmp` is judged against `/tmp`.
928
+ */
929
+ function parseText(
930
+ text: string,
931
+ base: string | null,
932
+ depth: number,
933
+ payloads: readonly string[],
934
+ ): CommandUnit[] {
935
+ let entries: ParseEntry[];
936
+ try {
937
+ entries = parseShell(text, envFor(base));
938
+ } catch {
939
+ return [opaqueUnit(text, "", "", base, "the parser could not tokenize this command")];
940
+ }
941
+ const units: CommandUnit[] = [];
942
+ let currentBase = base;
943
+ for (const segment of segmentTokens(entries)) {
944
+ const cleaned: string[] = [];
945
+ for (const word of segment) {
946
+ let rest = word;
947
+ for (const match of word.matchAll(PLACEHOLDER)) {
948
+ const payload = payloads[Number(match[1])];
949
+ if (payload !== undefined && depth < 4) {
950
+ units.push(...parseSubText(payload, currentBase, depth + 1));
951
+ }
952
+ rest = rest.replace(match[0], "");
953
+ }
954
+ if (rest !== "") cleaned.push(rest);
955
+ }
956
+ if (cleaned.length === 0) continue;
957
+ const analyzed = analyzeSegment(cleaned, currentBase, depth);
958
+ units.push(...analyzed.units);
959
+ currentBase = analyzed.nextBase;
960
+ }
961
+ return units;
962
+ }
963
+
964
+ /** Extract substitutions from one command string, then walk it. */
965
+ function parseSubText(
966
+ text: string,
967
+ base: string | null,
968
+ depth: number,
969
+ ): CommandUnit[] {
970
+ const withoutComments = stripComments(text);
971
+ if (withoutComments === undefined) {
972
+ return [opaqueUnit(text, "", "", base, "unbalanced quoting")];
973
+ }
974
+ const extracted = extractSubstitutions(withoutComments);
975
+ if (extracted === undefined) {
976
+ return [opaqueUnit(text, "", "", base, "unterminated command substitution")];
977
+ }
978
+ return parseText(extracted.text, base, depth, extracted.payloads);
979
+ }
980
+
981
+ /**
982
+ * Parse one bash tool call into ordered command units.
983
+ *
984
+ * The initial base is the session working directory; `cd` folds forward
985
+ * between segments. An empty command yields no units (nothing to gate); a
986
+ * pre-pass failure yields one opaque unit.
987
+ */
988
+ export function parseCommandUnits(command: string, cwd: string): CommandUnit[] {
989
+ const stripped = stripHeredocs(command);
990
+ if (stripped === undefined) {
991
+ return [opaqueUnit(command, "", "", cwd, "unterminated heredoc")];
992
+ }
993
+ if (stripped.trim() === "") return [];
994
+ return parseSubText(stripped, cwd, 0);
995
+ }