@mortz.prk/opencode-auto-mode 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CONTRIBUTING.md +234 -0
  2. package/README.llm.md +314 -0
  3. package/README.md +175 -166
  4. package/dist/gui-experimental.cjs +1 -0
  5. package/package.json +27 -16
  6. package/src/audit.ts +136 -38
  7. package/src/classifier.ts +45 -37
  8. package/src/code-mode.ts +55 -0
  9. package/src/config.ts +104 -98
  10. package/src/debug-events.ts +223 -0
  11. package/src/decision.ts +72 -0
  12. package/src/errors.ts +39 -0
  13. package/src/gates.ts +16 -0
  14. package/src/global-options.ts +85 -0
  15. package/src/gui-state.ts +269 -0
  16. package/src/gui.tsx +78 -0
  17. package/src/index.ts +510 -131
  18. package/src/mode.ts +237 -0
  19. package/src/reviewer.ts +71 -85
  20. package/src/rpc.ts +127 -0
  21. package/src/score/files.ts +49 -0
  22. package/src/score/history.ts +141 -0
  23. package/src/score/index.ts +37 -0
  24. package/src/score/limits.ts +24 -0
  25. package/src/score/shell-argv.ts +74 -0
  26. package/src/score/shell-baseline.ts +113 -0
  27. package/src/score/shell-safety.ts +54 -0
  28. package/src/score/shell.ts +30 -0
  29. package/src/score/types.ts +14 -0
  30. package/src/score.ts +9 -0
  31. package/src/shell-policy.ts +1979 -0
  32. package/src/shell.ts +508 -225
  33. package/src/status.ts +85 -0
  34. package/src/tiers.ts +286 -40
  35. package/src/timeout.ts +30 -0
  36. package/src/tui.tsx +44 -34
  37. package/src/user-files.ts +75 -0
  38. package/src/worktree.ts +30 -0
  39. package/src/decisions.ts +0 -213
  40. package/src/logger.ts +0 -26
  41. package/src/otlp.ts +0 -74
  42. package/src/policy.ts +0 -36
  43. package/src/state.ts +0 -135
  44. package/src/storage.ts +0 -26
  45. package/src/traces.ts +0 -84
  46. package/src/transcript.ts +0 -208
  47. package/src/tui-state.ts +0 -7
@@ -0,0 +1,1979 @@
1
+ import { basename, posix } from "node:path";
2
+ import { parse, type ParseEntry } from "shell-quote";
3
+
4
+ /**
5
+ * Command vocabulary and per-command safety policy for shell approval.
6
+ *
7
+ * Everything here works on parsed words rather than raw command text, so text that
8
+ * merely mentions a dangerous word (a commit message, a search pattern, a heredoc
9
+ * body) is never treated as a dangerous operation.
10
+ */
11
+
12
+ export const SHELLS = new Set(["sh", "bash", "dash", "zsh", "ksh", "fish", "pwsh", "powershell", "cmd"]);
13
+ export const SCRIPT_HOSTS = new Set(["python", "python3", "node", "perl", "ruby", "php", "awk", "gawk", "lua"]);
14
+ export const STATEFUL = new Set(["alias", "unalias", "export", "unset", "set", "shopt", "trap", "umask",
15
+ "readonly", "declare", "typeset", "local", "function"]);
16
+ export const DYNAMIC = new Set(["eval", "source", ".", "exec", "xargs"]);
17
+ export const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
18
+ export const CONTROL_OPERATORS = new Set([";", "&&", "||", "|", "|&", "&"]);
19
+ export const REDIRECT_OPERATORS = new Set([">", ">>", ">&", "<&", "<", "<<", "<<-", "<<<", "<>", ">|", "&>", "&>>"]);
20
+ /**
21
+ * Characters a shell treats as word separators.
22
+ *
23
+ * JS `\s` is wider — it also matches CR, FF, VT, NBSP, U+2028/U+2029, and other
24
+ * Unicode spaces — so every boundary scan here spells the shell set out, and
25
+ * `hasNonShellWhitespace` rejects anything wider before parsing.
26
+ */
27
+ const WORD_BREAK = /[ \t\n;|&()<>]/;
28
+
29
+ /** True when the text contains whitespace a shell does not treat as a separator. */
30
+ export function hasNonShellWhitespace(text: string): boolean {
31
+ return /[^\S \t\n]/.test(text);
32
+ }
33
+
34
+ /**
35
+ * Syntax this policy does not decode: whitespace a shell does not separate words
36
+ * on, legacy `$[...]` arithmetic, and ANSI-C / locale quoting.
37
+ *
38
+ * Callers check both the raw text and the text after continuation removal:
39
+ * normalization deletes `\`-newlines, which can assemble one of these forms from
40
+ * characters that were separate in the raw source (`$`+`\`-newline+`'...'` is
41
+ * ANSI-C quoting to a shell).
42
+ */
43
+ export function hasUndecodedSyntax(text: string): boolean {
44
+ return hasNonShellWhitespace(text) || text.includes("$[") ||
45
+ text.includes("$'") || text.includes('$"');
46
+ }
47
+
48
+ /**
49
+ * Matches text whose quotes are balanced and whose backslashes escape a character
50
+ * on the same line.
51
+ *
52
+ * A prefix cut before an undecoded opener is only a complete command when this
53
+ * matches; otherwise an open quote could turn quoted data into commands.
54
+ */
55
+ export const BALANCED_QUOTES = /^(?:[^'"\\]|\\[^\r\n]|'[^']*'|"(?:[^"\\]|\\[^\r\n])*")*$/;
56
+
57
+ /**
58
+ * True when removing `\`-newlines would assemble syntax this policy does not
59
+ * decode. Parsing such a line can place a quote or word boundary differently
60
+ * from the shell, so callers must not take a deny from it.
61
+ */
62
+ export function hasAssembledUndecodedSyntax(text: string): boolean {
63
+ return !hasUndecodedSyntax(text) && hasUndecodedSyntax(normalizeContinuations(text));
64
+ }
65
+
66
+ type OptionArity = "flag" | "value";
67
+
68
+ interface WrapperGrammar {
69
+ short: Readonly<Record<string, OptionArity>>;
70
+ long: Readonly<Record<string, OptionArity>>;
71
+ /** A bare `-N` adjustment (`nice -5 cmd`) is an option rather than a command word. */
72
+ numeric?: boolean;
73
+ }
74
+
75
+ /** Parses a short-option spec; a letter followed by `:` takes a value. */
76
+ function shortOptions(spec: string): Record<string, OptionArity> {
77
+ const options: Record<string, OptionArity> = {};
78
+ for (let index = 0; index < spec.length; index++) {
79
+ if (spec[index] === ":") continue;
80
+ options[spec[index]] = spec[index + 1] === ":" ? "value" : "flag";
81
+ }
82
+ return options;
83
+ }
84
+
85
+ /** Parses a whitespace-separated long-option spec; a trailing `:` takes a value. */
86
+ function longOptions(spec: string): Record<string, OptionArity> {
87
+ const options: Record<string, OptionArity> = {};
88
+ for (const option of spec.split(/\s+/).filter(Boolean)) {
89
+ const takesValue = option.endsWith(":");
90
+ options[takesValue ? option.slice(0, -1) : option] = takesValue ? "value" : "flag";
91
+ }
92
+ return options;
93
+ }
94
+
95
+ /**
96
+ * Option grammar for every command wrapper. Options that take a value consume
97
+ * the following word, so `stdbuf -o L sudo id` and `ionice -c 3 sudo id` resolve
98
+ * to `sudo` rather than to the option value.
99
+ */
100
+ const WRAPPER_GRAMMARS: Record<string, WrapperGrammar> = {
101
+ // `env -S` / `--split-string` reparses its value into command words, so the
102
+ // effective executable is not the next word. Leaving the option unknown makes
103
+ // the invocation fail closed to manual approval instead of resolving past it.
104
+ env: { short: shortOptions("u:C:i0v"), long: longOptions(
105
+ "--unset: --chdir: --ignore-environment --null --debug --block-signal --default-signal --ignore-signal") },
106
+ command: { short: shortOptions("pvV"), long: longOptions("") },
107
+ builtin: { short: shortOptions(""), long: longOptions("") },
108
+ exec: { short: shortOptions("a:cl"), long: longOptions("") },
109
+ time: { short: shortOptions("f:o:pavql"), long: longOptions("--format: --output: --verbose --quiet --portability --append") },
110
+ "!": { short: shortOptions(""), long: longOptions("") },
111
+ nohup: { short: shortOptions(""), long: longOptions("--help --version") },
112
+ setsid: { short: shortOptions("fwc"), long: longOptions("--fork --wait --ctty --help --version") },
113
+ nice: { short: shortOptions("n:"), long: longOptions("--adjustment: --help --version"), numeric: true },
114
+ ionice: { short: shortOptions("c:n:p:P:u:t"), long: longOptions(
115
+ "--class: --classdata: --pid: --pgid: --uid: --ignore --help --version") },
116
+ stdbuf: { short: shortOptions("i:o:e:"), long: longOptions("--input: --output: --error: --help --version") },
117
+ timeout: { short: shortOptions("s:k:"), long: longOptions(
118
+ "--signal: --kill-after: --foreground --preserve-status --verbose --help --version") },
119
+ };
120
+
121
+ export const WRAPPERS = new Set(Object.keys(WRAPPER_GRAMMARS));
122
+
123
+ const DANGEROUS = "dangerous shell operation";
124
+ const FORK_BOMB = /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/;
125
+ const MAX_NESTING = 2;
126
+
127
+ const DENY_EXECUTABLES = new Set(["sudo", "doas", "su", "shutdown", "reboot", "halt", "poweroff"]);
128
+
129
+ /**
130
+ * The arity of a long option, accepting the unambiguous abbreviations GNU
131
+ * option parsing allows (`stdbuf --out=L` means `--output`). An abbreviation
132
+ * that could name options of different arity is unknowable.
133
+ */
134
+ function longOptionArity(grammar: WrapperGrammar, option: string): OptionArity | undefined {
135
+ if (Object.hasOwn(grammar.long, option)) return grammar.long[option];
136
+ const arities = new Set(Object.keys(grammar.long)
137
+ .filter((name) => name.startsWith(option))
138
+ .map((name) => grammar.long[name]));
139
+ return arities.size === 1 ? [...arities][0] : undefined;
140
+ }
141
+
142
+ /**
143
+ * Index of the first word after a wrapper's options, or undefined when an
144
+ * unknown option makes its arity — and therefore the effective executable —
145
+ * unknowable. Unknown options fail closed to manual approval.
146
+ */
147
+ function wrapperCommand(grammar: WrapperGrammar, words: readonly string[], start: number): number | undefined {
148
+ let index = start;
149
+ while (index < words.length) {
150
+ const word = words[index];
151
+ if (word === "--") return index + 1;
152
+ if (word.startsWith("--")) {
153
+ const separator = word.indexOf("=");
154
+ const arity = longOptionArity(grammar, separator === -1 ? word : word.slice(0, separator));
155
+ if (arity === undefined) return undefined;
156
+ index += arity === "value" && separator === -1 ? 2 : 1;
157
+ continue;
158
+ }
159
+ if (word.startsWith("-") && word.length > 1) {
160
+ const letters = word.slice(1);
161
+ if (grammar.numeric && /^\d+$/.test(letters)) { index++; continue; }
162
+ let separateValue = false;
163
+ for (let position = 0; position < letters.length; position++) {
164
+ const arity = grammar.short[letters[position]];
165
+ if (arity === undefined) return undefined;
166
+ if (arity === "flag") continue;
167
+ // Any remaining letters are the attached value (`-n5`).
168
+ separateValue = position === letters.length - 1;
169
+ break;
170
+ }
171
+ index += separateValue ? 2 : 1;
172
+ continue;
173
+ }
174
+ return index;
175
+ }
176
+ return index;
177
+ }
178
+
179
+ /** Resolves leading assignments and wrapper options to the effective executable. */
180
+ export function resolveExecutable(words: readonly string[]): { name: string; index: number } | undefined {
181
+ let index = 0;
182
+ while (index < words.length && ASSIGNMENT.test(words[index])) index++;
183
+ while (index < words.length) {
184
+ const name = basename(words[index]);
185
+ const grammar = WRAPPERS.has(name) ? WRAPPER_GRAMMARS[name] : undefined;
186
+ if (grammar === undefined) break;
187
+ const command = wrapperCommand(grammar, words, index + 1);
188
+ if (command === undefined) return undefined;
189
+ index = command;
190
+ // `timeout DURATION COMMAND` consumes the duration before the command.
191
+ if (name === "timeout") {
192
+ if (index >= words.length) return undefined;
193
+ index++;
194
+ }
195
+ while (index < words.length && ASSIGNMENT.test(words[index])) index++;
196
+ }
197
+ return index < words.length ? { name: basename(words[index]), index } : undefined;
198
+ }
199
+
200
+ /** Commands whose non-option operands are files they read or modify. */
201
+ const PATH_OPERAND_COMMANDS = new Set(["cat", "less", "more", "head", "tail", "strings", "xxd", "od",
202
+ "hexdump", "file", "stat", "wc", "md5sum", "diff", "sort", "uniq", "cut", "bat", "cp", "mv", "rm",
203
+ "chmod", "chown", "truncate", "install", "open", "ls", "du"]);
204
+ /** Commands whose first non-option operand is a pattern and whose rest are paths. */
205
+ const PATTERN_FIRST_COMMANDS = new Set(["grep", "rg"]);
206
+ /** `shasum`, `sha1sum`, `sha256sum`, `sha384sum`, `sha512sum`. */
207
+ const CHECKSUM_COMMAND = /^(?:shasum|sha(?:1|224|256|384|512)sum)$/;
208
+
209
+ /** How a value-taking option consumes its value: pattern data, or a file path. */
210
+ type ValueRole = "data" | "path";
211
+
212
+ /**
213
+ * Known long options and the role of their value, used for exact matches and
214
+ * unambiguous abbreviations: `--reg=.` is `--regexp=`, `--fil=` is `--file=`.
215
+ * `data` values are patterns, types, encodings, labels, formats or numbers and
216
+ * must never be read as paths; `path` values name files.
217
+ */
218
+ const LONG_OPTION_VALUES = new Map<string, ValueRole>([
219
+ // grep
220
+ ["--regexp", "data"], ["--file", "path"], ["--exclude", "data"], ["--include", "data"],
221
+ ["--exclude-dir", "data"], ["--include-dir", "data"], ["--exclude-from", "path"],
222
+ ["--ignore-file", "path"],
223
+ ["--label", "data"], ["--directories", "data"], ["--devices", "data"], ["--binary-files", "data"],
224
+ ["--after-context", "data"], ["--before-context", "data"], ["--context", "data"], ["--max-count", "data"],
225
+ // rg
226
+ ["--type", "data"], ["--type-not", "data"], ["--type-add", "data"], ["--type-clear", "data"],
227
+ ["--encoding", "data"], ["--replace", "data"], ["--glob", "data"], ["--iglob", "data"],
228
+ ["--max-columns", "data"], ["--threads", "data"], ["--max-depth", "data"],
229
+ ["--sort", "data"], ["--sortr", "data"], ["--field-match-separator", "data"],
230
+ ["--field-context-separator", "data"], ["--context-separator", "data"],
231
+ // sed
232
+ ["--expression", "data"], ["--line-length", "data"],
233
+ // jq
234
+ ["--from-file", "path"],
235
+ // git
236
+ ["--grep", "data"], ["--format", "data"],
237
+ // sort: a file of random bytes; value options that must not shift a path.
238
+ ["--random-source", "path"], ["--key", "data"], ["--field-separator", "data"],
239
+ ["--batch-size", "data"], ["--buffer-size", "data"], ["--parallel", "data"],
240
+ // diff: a file compared from/to; formats and labels are data.
241
+ ["--to-file", "path"], ["--line-format", "data"], ["--old-line-format", "data"],
242
+ ["--new-line-format", "data"], ["--unchanged-line-format", "data"], ["--width", "data"],
243
+ ["--horizon-lines", "data"], ["--palette", "data"], ["--ignore-matching-lines", "data"],
244
+ // file: an alternate magic file.
245
+ ["--magic-file", "path"],
246
+ // tree: an input file of paths.
247
+ ["--gitfile", "path"],
248
+ // Writing, temporary, program and library paths; attached values are still
249
+ // file operands for the scope and symlink checks. jq's --arg/--argjson are
250
+ // data names, kept exact so `--arg` does not abbreviate to `--argfile`.
251
+ ["--output", "path"], ["--temporary-directory", "path"], ["--compress-program", "path"],
252
+ ["--library-path", "path"], ["--slurpfile", "path"], ["--rawfile", "path"], ["--argfile", "path"],
253
+ ["--arg", "data"], ["--argjson", "data"],
254
+ ]);
255
+
256
+ /** Resolve a long option or its unambiguous abbreviation, when it is known. */
257
+ export function resolveLongOption(option: string): string | undefined {
258
+ if (LONG_OPTION_VALUES.has(option)) return option;
259
+ if (!option.startsWith("--") || option.length <= 2) return undefined;
260
+ const matches = [...LONG_OPTION_VALUES.keys()].filter((name) => name.startsWith(option));
261
+ return matches.length === 1 ? matches[0] : undefined;
262
+ }
263
+
264
+ /** True when a long option's value is data this policy must not treat as a path. */
265
+ export function isDataOption(option: string): boolean {
266
+ const resolved = resolveLongOption(option);
267
+ return resolved !== undefined && LONG_OPTION_VALUES.get(resolved) === "data";
268
+ }
269
+
270
+ /** Value-taking letters for `grep -A 2`, `-m 1`, `-d skip`, `-e PATTERN`, `-f FILE`. */
271
+ const GREP_SHORT_VALUES: Record<string, ValueRole> = {
272
+ e: "data", f: "path", A: "data", B: "data", C: "data", m: "data", d: "data", D: "data",
273
+ };
274
+ /** Value-taking letters for `rg -t ts`, `-g GLOB`, `-e PATTERN`, `-f FILE`, ... */
275
+ const RG_SHORT_VALUES: Record<string, ValueRole> = {
276
+ e: "data", f: "path", g: "data", t: "data", T: "data", E: "data", j: "data", M: "data",
277
+ r: "data", A: "data", B: "data", C: "data", m: "data",
278
+ };
279
+ /** sed's expression/script files and the numeric line length. */
280
+ const SED_SHORT_VALUES: Record<string, ValueRole> = { e: "data", f: "path", l: "data" };
281
+ /** jq's filter and library files. */
282
+ const JQ_SHORT_VALUES: Record<string, ValueRole> = { f: "path", L: "path" };
283
+ /** Numeric/format/size values of the plain readers and file tools. */
284
+ const PLAIN_SHORT_VALUES: Record<string, Record<string, ValueRole>> = {
285
+ head: { n: "data", c: "data" },
286
+ tail: { n: "data", c: "data", s: "data" },
287
+ sort: { k: "data", t: "data", S: "data", o: "path", T: "path" },
288
+ uniq: { f: "data", s: "data", w: "data" },
289
+ diff: { U: "data", C: "data", I: "data", x: "data", X: "path", L: "data" },
290
+ cut: { d: "data", f: "data", b: "data", c: "data" },
291
+ xxd: { l: "data", s: "data", c: "data", g: "data" },
292
+ od: { N: "data", j: "data", t: "data" },
293
+ hexdump: { n: "data", s: "data", e: "data", f: "path" },
294
+ strings: { n: "data", t: "data", e: "data" },
295
+ stat: { c: "data" },
296
+ less: { p: "data", o: "path", b: "data", h: "data", j: "data", x: "data", y: "data", z: "data" },
297
+ more: { n: "data" },
298
+ open: { a: "data", b: "data" },
299
+ file: { f: "path", m: "path", e: "data", F: "data" },
300
+ // `ls -I PATTERN` is an ignore glob; `-w`/`-T` take column/tab-size numbers.
301
+ ls: { I: "data", w: "data", T: "data" },
302
+ // `du -d N`/`--max-depth` is a number; `-X FILE` is the exclude-from file.
303
+ du: { d: "data", X: "path" },
304
+ };
305
+ /** Git subcommands whose operands this policy can place. */
306
+ const GIT_HANDLED = new Set(["commit", "tag", "branch", "checkout", "switch", "push", "fetch"]);
307
+ const GIT_LONG_VALUES: Record<string, Record<string, ValueRole>> = {
308
+ commit: { "--message": "data", "--file": "path", "--author": "data", "--date": "data",
309
+ "--reuse-message": "data", "--reedit-message": "data", "--fixup": "data", "--squash": "data" },
310
+ tag: { "--message": "data", "--file": "path" },
311
+ branch: { "--contains": "data", "--points-at": "data", "--merged": "data", "--no-merged": "data" },
312
+ checkout: { "--orphan": "data", "--conflict": "data" },
313
+ switch: { "--orphan": "data", "--conflict": "data" },
314
+ push: { "--push-option": "data", "--repo": "path" },
315
+ fetch: { "--depth": "data", "--deepen": "data", "--upload-pack": "path" },
316
+ };
317
+ const GIT_SHORT_VALUES: Record<string, Record<string, ValueRole>> = {
318
+ commit: { m: "data", F: "path", C: "data", c: "data" },
319
+ tag: { m: "data", F: "path" },
320
+ branch: { m: "data", M: "data", c: "data", C: "data" },
321
+ checkout: { b: "data", B: "data" },
322
+ switch: { c: "data", C: "data" },
323
+ };
324
+
325
+ /**
326
+ * Which words of a simple command are files and which are data.
327
+ *
328
+ * A shell word is data or a path depending on the position a command gives it:
329
+ * `cat auth` reads a file named `auth`, `grep auth file` searches for `auth`,
330
+ * `sed s/.env/.x/p file` runs a script, and `jq .name file` evaluates a filter.
331
+ * `paths` are indices of standalone file operands, `data` indices of pattern,
332
+ * script, filter or option-value words, and `pathValues` are file values
333
+ * attached to an option (`-fFILE`, `--file=FILE`). Every other word (the
334
+ * executable, ordinary options) is neither; callers keep their own check for
335
+ * it. Commands outside the grammar below report no roles at all.
336
+ */
337
+ export interface OperandRoles {
338
+ /** Indices of words the command consumes as file paths. */
339
+ paths: number[];
340
+ /** Indices of words the command consumes as data. */
341
+ data: number[];
342
+ /** File values attached to an option (`-fFILE`, `--file=FILE`). */
343
+ pathValues: string[];
344
+ }
345
+
346
+ interface RoleSets { paths: Set<number>; data: Set<number>; pathValues: string[] }
347
+
348
+ interface ShortValue { letter: string; role: ValueRole; attached?: string }
349
+
350
+ /** Splits `--name=value` into its option name and attached value. */
351
+ function longOptionPart(word: string): { option: string; attached?: string } | undefined {
352
+ if (!word.startsWith("--") || word.length <= 2) return undefined;
353
+ const separator = word.indexOf("=");
354
+ return separator === -1 ? { option: word } : { option: word.slice(0, separator), attached: word.slice(separator + 1) };
355
+ }
356
+
357
+ /**
358
+ * Reads a short-option bundle letter by letter. The first letter that takes a
359
+ * value wins: the rest of the word is its attached value (`-ifpats` is `-i -f
360
+ * pats`), or the next word when the bundle ends at that letter (`-if pats`).
361
+ * Unknown letters are ordinary flags.
362
+ */
363
+ function shortOption(word: string, table: Record<string, ValueRole>): ShortValue | undefined {
364
+ if (!word.startsWith("-") || word.startsWith("--") || word.length < 2) return undefined;
365
+ const letters = word.slice(1);
366
+ for (let index = 0; index < letters.length; index++) {
367
+ const role = table[letters[index]];
368
+ if (role === undefined) continue;
369
+ const attached = letters.slice(index + 1);
370
+ return { letter: letters[index], role, attached: attached.length ? attached : undefined };
371
+ }
372
+ return undefined;
373
+ }
374
+
375
+ /**
376
+ * The flag letters of one short bundle, stopping at the first letter that takes
377
+ * a value. An attached value (`-fpatterns`, `-Xcredentials`) is data or a path,
378
+ * never a flag, so recursion checks cannot be spoofed by a value's spelling.
379
+ */
380
+ function shortFlags(word: string, table: Record<string, ValueRole>): string {
381
+ if (!word.startsWith("-") || word.startsWith("--") || word.length < 2) return "";
382
+ const letters = word.slice(1);
383
+ let position = 0;
384
+ while (position < letters.length && table[letters[position]] === undefined) position++;
385
+ return letters.slice(0, position);
386
+ }
387
+
388
+ /** Marks the value a short option consumes and returns the new word index. */
389
+ function takeShortValue(parsed: ShortValue, index: number, base: number,
390
+ args: readonly string[], roles: RoleSets): number {
391
+ if (parsed.role === "path") {
392
+ if (parsed.attached !== undefined) roles.pathValues.push(parsed.attached);
393
+ else if (index + 1 < args.length) {
394
+ roles.paths.add(base + index + 1);
395
+ return index + 1;
396
+ }
397
+ } else if (parsed.attached === undefined && index + 1 < args.length) {
398
+ roles.data.add(base + index + 1);
399
+ return index + 1;
400
+ }
401
+ return index;
402
+ }
403
+
404
+ /** Marks attached (`--grep=X`) and separate (`--grep X`) data option values. */
405
+ function dataOptionRoles(args: readonly string[], base: number): RoleSets {
406
+ const roles: RoleSets = { paths: new Set(), data: new Set(), pathValues: [] };
407
+ let endOfOptions = false;
408
+ for (let index = 0; index < args.length; index++) {
409
+ const arg = args[index];
410
+ if (endOfOptions) continue;
411
+ if (arg === "--") { endOfOptions = true; continue; }
412
+ const part = longOptionPart(arg);
413
+ if (part === undefined) continue;
414
+ const resolved = resolveLongOption(part.option);
415
+ if (resolved === undefined || LONG_OPTION_VALUES.get(resolved) !== "data") continue;
416
+ if (part.attached !== undefined) { roles.data.add(base + index); continue; }
417
+ if (index + 1 < args.length) { roles.data.add(base + index + 1); index++; }
418
+ }
419
+ return roles;
420
+ }
421
+
422
+ /** Marks attached (`--file=X`) path option values as file operands. */
423
+ function pathOptionRoles(args: readonly string[], roles: RoleSets): void {
424
+ let endOfOptions = false;
425
+ for (const arg of args) {
426
+ if (endOfOptions) continue;
427
+ if (arg === "--") { endOfOptions = true; continue; }
428
+ const part = longOptionPart(arg);
429
+ if (part === undefined || part.attached === undefined) continue;
430
+ const resolved = resolveLongOption(part.option);
431
+ if (resolved === undefined || LONG_OPTION_VALUES.get(resolved) !== "path") continue;
432
+ roles.pathValues.push(part.attached);
433
+ }
434
+ }
435
+
436
+ /** Adds every non-option positional as a path (`cat`, `sort`, checksums, ...). */
437
+ function plainRoles(name: string, args: readonly string[], base: number, roles: RoleSets): void {
438
+ const table = PLAIN_SHORT_VALUES[name] ?? {};
439
+ let endOfOptions = false;
440
+ for (let index = 0; index < args.length; index++) {
441
+ const word = args[index];
442
+ if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
443
+ if (!endOfOptions && word.startsWith("-") && word.length > 1) {
444
+ if (word.startsWith("--")) continue;
445
+ const parsed = shortOption(word, table);
446
+ if (parsed !== undefined) index = takeShortValue(parsed, index, base, args, roles);
447
+ continue;
448
+ }
449
+ if (roles.data.has(base + index)) continue;
450
+ roles.paths.add(base + index);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * `grep`/`rg`: the first positional is a pattern unless `-e`/`--regexp` already
456
+ * supplied one, and `-f`/`--file` names a pattern file. Every positional after
457
+ * a pattern is a path.
458
+ */
459
+ function grepRoles(name: string, args: readonly string[], base: number, roles: RoleSets): void {
460
+ const table = name === "rg" ? RG_SHORT_VALUES : GREP_SHORT_VALUES;
461
+ let seenPattern = false;
462
+ let endOfOptions = false;
463
+ for (let index = 0; index < args.length; index++) {
464
+ const word = args[index];
465
+ if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
466
+ if (!endOfOptions && word.startsWith("-") && word.length > 1) {
467
+ if (word.startsWith("--")) {
468
+ const part = longOptionPart(word);
469
+ const resolved = part === undefined ? undefined : resolveLongOption(part.option);
470
+ if (resolved === "--regexp") { seenPattern = true; continue; }
471
+ // Any path-valued long option names a file: `--file` (pattern file),
472
+ // `--exclude-from`/`--ignore-file` (files of patterns). Only `--file`
473
+ // supplies the pattern; the others leave the next positional a pattern.
474
+ if (resolved !== undefined && LONG_OPTION_VALUES.get(resolved) === "path") {
475
+ if (part?.attached !== undefined) roles.pathValues.push(part.attached);
476
+ else if (index + 1 < args.length) roles.paths.add(base + ++index);
477
+ if (resolved === "--file") seenPattern = true;
478
+ continue;
479
+ }
480
+ continue;
481
+ }
482
+ const parsed = shortOption(word, table);
483
+ if (parsed === undefined) continue;
484
+ index = takeShortValue(parsed, index, base, args, roles);
485
+ if (parsed.letter === "e" || parsed.letter === "f") seenPattern = true;
486
+ continue;
487
+ }
488
+ if (roles.data.has(base + index)) continue;
489
+ if (!seenPattern) { roles.data.add(base + index); seenPattern = true; continue; }
490
+ roles.paths.add(base + index);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * `sed`: the first positional is the script unless `-e`/`--expression` supplied
496
+ * one. `-f`/`--file` names a script file and also means every positional is a
497
+ * file; `-l`/`--line-length` takes a number that is not an operand.
498
+ */
499
+ function sedRoles(args: readonly string[], base: number, roles: RoleSets): void {
500
+ let scripted = false;
501
+ let endOfOptions = false;
502
+ for (let index = 0; index < args.length; index++) {
503
+ const word = args[index];
504
+ if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
505
+ if (!endOfOptions && word.startsWith("-") && word.length > 1) {
506
+ if (word.startsWith("--")) {
507
+ const part = longOptionPart(word);
508
+ const resolved = part === undefined ? undefined : resolveLongOption(part.option);
509
+ if (resolved === "--expression") { scripted = true; continue; }
510
+ if (resolved === "--file") {
511
+ if (part?.attached !== undefined) roles.pathValues.push(part.attached);
512
+ else if (index + 1 < args.length) roles.paths.add(base + ++index);
513
+ scripted = true;
514
+ continue;
515
+ }
516
+ continue;
517
+ }
518
+ const parsed = shortOption(word, SED_SHORT_VALUES);
519
+ if (parsed === undefined) continue;
520
+ index = takeShortValue(parsed, index, base, args, roles);
521
+ if (parsed.letter === "e" || parsed.letter === "f") scripted = true;
522
+ continue;
523
+ }
524
+ if (roles.data.has(base + index)) continue;
525
+ if (!scripted) { roles.data.add(base + index); scripted = true; continue; }
526
+ roles.paths.add(base + index);
527
+ }
528
+ }
529
+
530
+ /**
531
+ * `jq`: the first positional is the filter unless `-f`/`--from-file` supplies
532
+ * one. All value-taking options are stated explicitly, so a bare `1` after a
533
+ * flag (`-e`, `-r`, `--tab`) is the filter. `--arg`/`--argjson` take a name and
534
+ * a value as data, `--slurpfile`/`--rawfile`/`--argfile` take a name (data) and
535
+ * a file (path), and `--args`/`--jsonargs` make positionals string data.
536
+ */
537
+ function jqRoles(args: readonly string[], base: number, roles: RoleSets): void {
538
+ let filterSupplied = false;
539
+ let positionalData = false;
540
+ let endOfOptions = false;
541
+ const takeValues = (index: number, count: number, role: ValueRole): number => {
542
+ let taken = 0;
543
+ while (taken < count && index + 1 < args.length) {
544
+ const next = base + ++index;
545
+ (role === "data" ? roles.data : roles.paths).add(next);
546
+ taken++;
547
+ }
548
+ return index;
549
+ };
550
+ for (let index = 0; index < args.length; index++) {
551
+ const word = args[index];
552
+ if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
553
+ if (!endOfOptions && word.startsWith("-") && word.length > 1) {
554
+ if (word.startsWith("--")) {
555
+ const part = longOptionPart(word);
556
+ const option = part?.option;
557
+ const resolved = part === undefined ? undefined : resolveLongOption(part.option);
558
+ if (resolved === "--from-file") {
559
+ if (part?.attached !== undefined) roles.pathValues.push(part.attached);
560
+ else if (index + 1 < args.length) roles.paths.add(base + ++index);
561
+ filterSupplied = true;
562
+ continue;
563
+ }
564
+ if (option === "--arg" || option === "--argjson") { index = takeValues(index, 2, "data"); continue; }
565
+ if (option === "--slurpfile" || option === "--rawfile" || option === "--argfile") {
566
+ index = takeValues(index, 1, "data");
567
+ index = takeValues(index, 1, "path");
568
+ continue;
569
+ }
570
+ if (option === "--args" || option === "--jsonargs") { positionalData = true; continue; }
571
+ if (option === "--library-path") { index = takeValues(index, 1, "path"); continue; }
572
+ if (option === "--indent") { index = takeValues(index, 1, "data"); continue; }
573
+ continue;
574
+ }
575
+ const parsed = shortOption(word, JQ_SHORT_VALUES);
576
+ if (parsed !== undefined) {
577
+ index = takeShortValue(parsed, index, base, args, roles);
578
+ if (parsed.letter === "f") filterSupplied = true;
579
+ }
580
+ continue;
581
+ }
582
+ if (roles.data.has(base + index)) continue;
583
+ if (positionalData) { roles.data.add(base + index); continue; }
584
+ if (!filterSupplied) { roles.data.add(base + index); filterSupplied = true; continue; }
585
+ roles.paths.add(base + index);
586
+ }
587
+ }
588
+
589
+ /** `find` tests whose value is a path read for comparison, not a pattern. */
590
+ const FIND_PATH_VALUE_TESTS = new Set(["-newer", "-anewer", "-cnewer", "-samefile",
591
+ "-fprint", "-fprint0", "-fprintf", "-fls"]);
592
+ /** `find` tests whose value is data: patterns, types, sizes, times, ids. */
593
+ const FIND_DATA_VALUE_TESTS = new Set(["-name", "-iname", "-path", "-ipath", "-regex", "-iregex",
594
+ "-wholename", "-iwholename", "-type", "-mtime", "-atime", "-ctime", "-mmin", "-amin", "-cmin",
595
+ "-size", "-perm", "-user", "-group", "-uid", "-gid", "-links", "-inum", "-maxdepth", "-mindepth",
596
+ "-printf", "-regextype", "-used", "-context"]);
597
+ /** `-exec`/`-execdir`/`-ok`/`-okdir` consume an inline command, not a path test. */
598
+ const FIND_EXEC_TESTS = new Set(["-exec", "-execdir", "-ok", "-okdir"]);
599
+
600
+ /** True for the tokens (an option, `(`, `)`, `!`) that start `find`'s expression. */
601
+ function startsFindExpression(word: string): boolean {
602
+ return word === "(" || word === ")" || word === "!" || word.startsWith("-");
603
+ }
604
+
605
+ /**
606
+ * Options `find` accepts before its starting paths: the symlink modes
607
+ * `-H`/`-L`/`-P`, the optimisation level `-O[level]` and `-D debugopts`.
608
+ */
609
+ function findLeadingOption(word: string): "flag" | "value" | undefined {
610
+ if (word === "-H" || word === "-L" || word === "-P") return "flag";
611
+ if (word === "-D") return "value";
612
+ return /^-O\d*$/.test(word) ? "flag" : undefined;
613
+ }
614
+
615
+ /** Marks an inline `-exec ... ;`/`-exec ... +` command as data and returns its end index. */
616
+ function consumeFindExecArgs(args: readonly string[], start: number, base: number, roles: RoleSets): number {
617
+ let index = start + 1;
618
+ while (index < args.length && args[index] !== ";" && args[index] !== "+") { roles.data.add(base + index); index++; }
619
+ return index;
620
+ }
621
+
622
+ /** Marks `find`'s leading options as data and returns the index its starting paths begin at. */
623
+ function consumeFindLeadingOptions(args: readonly string[], base: number, roles: RoleSets): number {
624
+ let index = 0;
625
+ while (index < args.length) {
626
+ const option = findLeadingOption(args[index]);
627
+ if (option === undefined) break;
628
+ roles.data.add(base + index);
629
+ if (option === "value" && index + 1 < args.length) roles.data.add(base + ++index);
630
+ index++;
631
+ }
632
+ return index;
633
+ }
634
+
635
+ /**
636
+ * `find [-H|-L|-P|-O[level]|-D debugopts] [path...] [expression]`: positionals
637
+ * before the first expression token (an option, `(`, `)` or `!`) are starting
638
+ * paths; `-exec`/`-execdir`/`-ok`/`-okdir` consume a command up to `;`/`+` as
639
+ * data, and other tests consume their value as a path or data per the tables
640
+ * above.
641
+ */
642
+ function findRoles(args: readonly string[], base: number, roles: RoleSets): void {
643
+ let sawExpression = false;
644
+ for (let index = consumeFindLeadingOptions(args, base, roles); index < args.length; index++) {
645
+ const word = args[index];
646
+ if (!sawExpression) {
647
+ if (!startsFindExpression(word)) { roles.paths.add(base + index); continue; }
648
+ sawExpression = true;
649
+ }
650
+ if (FIND_EXEC_TESTS.has(word)) { index = consumeFindExecArgs(args, index, base, roles); continue; }
651
+ if (FIND_PATH_VALUE_TESTS.has(word) && index + 1 < args.length) { roles.paths.add(base + ++index); continue; }
652
+ if (FIND_DATA_VALUE_TESTS.has(word) && index + 1 < args.length) { roles.data.add(base + ++index); continue; }
653
+ }
654
+ }
655
+
656
+ /**
657
+ * Git subcommands whose operands are refs, messages or other data rather than
658
+ * files: `commit -m` messages, `branch <name>`, `checkout`/`switch` branches,
659
+ * `push`/`fetch` refspecs and `tag` names. `git add .env` stays an ordinary
660
+ * word, so a file operand is still protected once `--` ends ref parsing.
661
+ */
662
+ function gitRoles(args: readonly string[], base: number, roles: RoleSets): void {
663
+ const subcommand = args[0];
664
+ if (subcommand === undefined || !GIT_HANDLED.has(subcommand)) return;
665
+ const rest = args.slice(1);
666
+ const long = GIT_LONG_VALUES[subcommand] ?? {};
667
+ const short = GIT_SHORT_VALUES[subcommand] ?? {};
668
+ const refs = subcommand === "tag" || subcommand === "branch" || subcommand === "push" || subcommand === "fetch";
669
+ let endOfOptions = false;
670
+ for (let index = 0; index < rest.length; index++) {
671
+ const word = rest[index];
672
+ const position = base + 1 + index;
673
+ if (!endOfOptions && word === "--") { endOfOptions = true; continue; }
674
+ if (!endOfOptions && word.startsWith("--") && word.length > 2) {
675
+ const part = longOptionPart(word);
676
+ const role = part === undefined ? undefined : long[part.option];
677
+ if (part === undefined || role === undefined) continue;
678
+ if (part.attached !== undefined) {
679
+ if (role === "path") roles.pathValues.push(part.attached);
680
+ else roles.data.add(position);
681
+ continue;
682
+ }
683
+ if (index + 1 < rest.length) {
684
+ const next = base + 1 + ++index;
685
+ (role === "data" ? roles.data : roles.paths).add(next);
686
+ }
687
+ continue;
688
+ }
689
+ if (!endOfOptions && word.startsWith("-") && word.length > 1) {
690
+ const parsed = shortOption(word, short);
691
+ if (parsed !== undefined) index = takeShortValue(parsed, index, base + 1, rest, roles);
692
+ continue;
693
+ }
694
+ if (roles.data.has(position)) continue;
695
+ // `checkout`/`switch` treat positionals before `--` as refs and after it as
696
+ // paths; `commit` positionals are always paths.
697
+ if (refs || ((subcommand === "checkout" || subcommand === "switch") && !endOfOptions))
698
+ roles.data.add(position);
699
+ else roles.paths.add(position);
700
+ }
701
+ }
702
+
703
+ /** The path and data roles of one simple command's argv. */
704
+ export function operandRoles(words: readonly string[]): OperandRoles {
705
+ const executable = resolveExecutable(words);
706
+ if (executable === undefined) return { paths: [], data: [], pathValues: [] };
707
+ const args = words.slice(executable.index + 1);
708
+ const roles = dataOptionRoles(args, executable.index + 1);
709
+ pathOptionRoles(args, roles);
710
+ const name = executable.name;
711
+ if (PATTERN_FIRST_COMMANDS.has(name)) grepRoles(name, args, executable.index + 1, roles);
712
+ else if (name === "sed") sedRoles(args, executable.index + 1, roles);
713
+ else if (name === "jq") jqRoles(args, executable.index + 1, roles);
714
+ else if (name === "git") gitRoles(args, executable.index + 1, roles);
715
+ else if (name === "find") findRoles(args, executable.index + 1, roles);
716
+ else if (PATH_OPERAND_COMMANDS.has(name) || CHECKSUM_COMMAND.test(name))
717
+ plainRoles(name, args, executable.index + 1, roles);
718
+ const ascending = (left: number, right: number) => left - right;
719
+ return {
720
+ paths: [...roles.paths].sort(ascending),
721
+ data: [...roles.data].sort(ascending),
722
+ pathValues: [...new Set(roles.pathValues)],
723
+ };
724
+ }
725
+
726
+ /** The standalone words one simple command consumes as files. */
727
+ export function pathOperands(words: readonly string[]): string[] {
728
+ return operandRoles(words).paths.map((index) => words[index]);
729
+ }
730
+
731
+ /**
732
+ * True when an argument names one of the given long options, including the
733
+ * abbreviations GNU option parsing accepts (`sort --out=/tmp/out` means
734
+ * `--output`). Abbreviations fail closed: any prefix that could name a dangerous
735
+ * option requires review rather than approval.
736
+ */
737
+ function hasLongOption(args: readonly string[], names: readonly string[]): boolean {
738
+ return args.some((arg) => {
739
+ const option = arg.split("=", 1)[0];
740
+ if (!option.startsWith("--") || option.length === 2) return false;
741
+ return names.some((name) => name.startsWith(option));
742
+ });
743
+ }
744
+
745
+ /**
746
+ * True when an argument is a long option or an unambiguous GNU-style abbreviation
747
+ * of it (`--forc` means `--force`). Abbreviations fail closed: any prefix of a
748
+ * dangerous option is treated as that option.
749
+ */
750
+ export function isLongOption(argument: string, name: string): boolean {
751
+ const option = argument.split("=", 1)[0];
752
+ return option === name || (option.length > 2 && option.startsWith("--") && name.startsWith(option));
753
+ }
754
+
755
+ function hasShortOption(args: readonly string[], letters: string): boolean {
756
+ return args.some((arg) => arg.startsWith("-") && !arg.startsWith("--") &&
757
+ [...arg.slice(1)].some((character) => letters.includes(character)));
758
+ }
759
+
760
+ function isWorldWritableMode(arg: string): boolean {
761
+ // `chmod +w` and `chmod +rwx` omit the `who` part, which means "all users".
762
+ // Only a leading `-` is a removal; a leading `+` has to be read as `a+`.
763
+ const mode = arg.replace(/^-+/, "").replace(/^\+/, "a+");
764
+ return /^[0-7]{3,4}$/.test(mode) ? (Number.parseInt(mode, 8) & 0o002) !== 0 :
765
+ /(?:^|,)(?:[ug]*o[ugoa]*|a|)[+=][rwxXst]*w/.test(mode);
766
+ }
767
+
768
+ /** chmod options, including the BSD flags, that never consume the mode operand. */
769
+ const CHMOD_FLAGS = /^-[RcfvHhLP]+$/;
770
+
771
+ /** The mode operand chmod applies, ignoring options and earlier operands. */
772
+ function chmodMode(args: readonly string[]): string | undefined {
773
+ let end = false;
774
+ for (const arg of args) {
775
+ if (end) return arg;
776
+ if (arg === "--") { end = true; continue; }
777
+ // `--reference` copies a mode from another file, so there is no mode operand.
778
+ if (arg === "--reference" || arg.startsWith("--reference=")) return undefined;
779
+ if (arg.startsWith("--") || CHMOD_FLAGS.test(arg)) continue;
780
+ return arg;
781
+ }
782
+ return undefined;
783
+ }
784
+
785
+ /** True when a path names the filesystem root rather than a directory below it. */
786
+ function isFilesystemRoot(target: string): boolean {
787
+ if (!target.startsWith("/")) return false;
788
+ const trimmed = target.replace(/\/+$/, "").replace(/\/\*+$/, "");
789
+ return trimmed === "" || posix.normalize(trimmed) === "/";
790
+ }
791
+
792
+ /**
793
+ * True when `rm` removes the filesystem root recursively. Force is irrelevant:
794
+ * a writable root is already destroyed by `rm -r / --no-preserve-root`.
795
+ *
796
+ * `--` ends option parsing, so every later argument is a target: `rm -- -r /`
797
+ * asks `rm` to remove the files `-r` and `/` without recursion, which fails on
798
+ * the root instead of deleting it, while `rm -r -- /` still denies.
799
+ */
800
+ function isRecursiveRootRemoval(args: readonly string[]): boolean {
801
+ let recursive = false;
802
+ let endOfOptions = false;
803
+ const targets: string[] = [];
804
+ for (const arg of args) {
805
+ if (endOfOptions) { targets.push(arg); continue; }
806
+ if (arg === "--") { endOfOptions = true; continue; }
807
+ if (arg.startsWith("--")) {
808
+ // GNU option parsing accepts `--recursi` for `--recursive`.
809
+ if (isLongOption(arg, "--recursive")) recursive = true;
810
+ continue;
811
+ }
812
+ if (arg.startsWith("-") && arg.length > 1) {
813
+ const letters = arg.slice(1);
814
+ if (letters.includes("r") || letters.includes("R")) recursive = true;
815
+ continue;
816
+ }
817
+ targets.push(arg);
818
+ }
819
+ if (!recursive || targets.length === 0) return false;
820
+ return targets.some(isFilesystemRoot);
821
+ }
822
+
823
+ /** Shell options that take no value and may follow `-c` before its script. */
824
+ const SHELL_FLAGS = new Set(["-a", "-b", "-C", "-e", "-f", "-h", "-i", "-k", "-l", "-m", "-n", "-p", "-r", "-s",
825
+ "-t", "-u", "-v", "-x", "-B", "-E", "-H", "-P", "-T", "--login", "--noprofile", "--norc", "--posix",
826
+ "--restricted", "--verbose", "--noediting"]);
827
+ /** Shell options that consume the next word as their value. */
828
+ const SHELL_VALUE_OPTIONS = new Set(["-o", "-O", "--rcfile", "--init-file"]);
829
+ const SHELL_FLAG_LETTERS = "abcCefhiklmnprstuvxBEHPT";
830
+
831
+ /**
832
+ * The script operand that follows the `-c` option at `start`.
833
+ *
834
+ * A shell keeps parsing options after `-c`, so `sh -c -e 'pwd`+newline+`sudo id'`
835
+ * runs the quoted string, not `-e`, and `sh -c -- script` runs `script`. Known
836
+ * flags and their values are skipped; an unknown option could consume the script
837
+ * itself, so its arity is unknowable and the caller must fail closed.
838
+ */
839
+ function shellScript(args: readonly string[], start: number): string | undefined {
840
+ let index = start;
841
+ while (index < args.length) {
842
+ const arg = args[index];
843
+ if (arg === "--") return args[index + 1];
844
+ if (SHELL_VALUE_OPTIONS.has(arg)) { index += 2; continue; }
845
+ if (SHELL_FLAGS.has(arg)) { index++; continue; }
846
+ if (/^-[^-]+$/.test(arg) && [...arg.slice(1)].every((letter) => SHELL_FLAG_LETTERS.includes(letter))) {
847
+ index++;
848
+ continue;
849
+ }
850
+ if (arg.startsWith("-") && arg.length > 1) return undefined;
851
+ return arg;
852
+ }
853
+ return undefined;
854
+ }
855
+
856
+ /** The script a shell or `eval` would run, when invoked non-interactively. */
857
+ function scriptArgument(name: string, args: readonly string[]): string | undefined {
858
+ if (name === "eval") return args.length ? args.join(" ") : undefined;
859
+ for (let index = 0; index < args.length; index++) {
860
+ const arg = args[index];
861
+ if (arg === "--") return undefined;
862
+ if (!arg.startsWith("-") || arg.startsWith("--")) continue;
863
+ if (arg.slice(1).includes("c")) return shellScript(args, index + 1);
864
+ }
865
+ return undefined;
866
+ }
867
+
868
+ /**
869
+ * Removes file-descriptor prefixes (`2` in `2>/dev/null`) that are adjacent to
870
+ * the redirection operator in the source.
871
+ *
872
+ * Only an unquoted, unescaped digit run that starts a word is a descriptor. A
873
+ * numeric operand separated by whitespace (`chmod 777 > /dev/null secret`) is
874
+ * preserved, as is a quoted (`"2">file`) or escaped (`\2>file`) digit argument.
875
+ */
876
+ export function stripDescriptorPrefixes(command: string): string {
877
+ let result = "";
878
+ let boundary = true;
879
+ let index = 0;
880
+ while (index < command.length) {
881
+ const character = command[index];
882
+ if (character === "\\") {
883
+ result += command.slice(index, index + 2);
884
+ boundary = false;
885
+ index += 2;
886
+ continue;
887
+ }
888
+ if (character === "'") {
889
+ const end = command.indexOf("'", index + 1);
890
+ const stop = end === -1 ? command.length : end + 1;
891
+ result += command.slice(index, stop);
892
+ boundary = false;
893
+ index = stop;
894
+ continue;
895
+ }
896
+ // ANSI-C quoting (`$'...'`) is the one quoted form where a backslash escapes
897
+ // the closing quote, so its body needs the same escape handling as `"..."`.
898
+ if (character === "$" && command[index + 1] === "'") {
899
+ let cursor = index + 2;
900
+ while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
901
+ const stop = Math.min(cursor + 1, command.length);
902
+ result += command.slice(index, stop);
903
+ boundary = false;
904
+ index = stop;
905
+ continue;
906
+ }
907
+ if (character === '"') {
908
+ let cursor = index + 1;
909
+ while (cursor < command.length && command[cursor] !== '"') cursor += command[cursor] === "\\" ? 2 : 1;
910
+ const stop = Math.min(cursor + 1, command.length);
911
+ result += command.slice(index, stop);
912
+ boundary = false;
913
+ index = stop;
914
+ continue;
915
+ }
916
+ if (boundary && /\d/.test(character)) {
917
+ let cursor = index;
918
+ while (cursor < command.length && /\d/.test(command[cursor])) cursor++;
919
+ if (command[cursor] === ">" || command[cursor] === "<") {
920
+ // Replace the run with a space instead of deleting it: `&2>` is `&`
921
+ // followed by a redirect, and deleting the `2` would fuse `&` and `>`
922
+ // into the different `&>` operator.
923
+ result += " ";
924
+ index = cursor;
925
+ continue;
926
+ }
927
+ result += command.slice(index, cursor);
928
+ boundary = false;
929
+ index = cursor;
930
+ continue;
931
+ }
932
+ result += character;
933
+ boundary = /[ \t\n;|&()<>]/.test(character);
934
+ index++;
935
+ }
936
+ return result;
937
+ }
938
+
939
+ /**
940
+ * Escapes a `#` that is part of a word so `shell-quote` does not mistake it for
941
+ * the start of a comment.
942
+ *
943
+ * Shell comments begin only at the start of a word. `shell-quote` instead ends
944
+ * the command at any unquoted `#`, which can hide a later command
945
+ * (`echo a#b ; sudo id`). Escaping that literal hash preserves the word text and
946
+ * lets the deny pass see the rest of the line. A real word-start comment is left
947
+ * untouched, and quoted or escaped hashes are already literal.
948
+ */
949
+ export function escapeLiteralHashes(command: string): string {
950
+ let result = "";
951
+ let wordStart = true;
952
+ let quote: "'" | '"' | "$'" | undefined;
953
+ for (let index = 0; index < command.length; index++) {
954
+ const character = command[index];
955
+ if (quote === "'") {
956
+ result += character;
957
+ if (character === "'") quote = undefined;
958
+ continue;
959
+ }
960
+ if (quote === "$'") {
961
+ result += character;
962
+ if (character === "\\") {
963
+ if (index + 1 < command.length) result += command[++index];
964
+ } else if (character === "'") quote = undefined;
965
+ continue;
966
+ }
967
+ if (quote === '"') {
968
+ result += character;
969
+ if (character === "\\") {
970
+ if (index + 1 < command.length) result += command[++index];
971
+ } else if (character === '"') quote = undefined;
972
+ continue;
973
+ }
974
+ if (character === "\\") {
975
+ result += character;
976
+ if (index + 1 < command.length) {
977
+ const escaped = command[++index];
978
+ result += escaped;
979
+ if (escaped !== "\n") wordStart = false;
980
+ } else wordStart = false;
981
+ continue;
982
+ }
983
+ if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
984
+ result += character;
985
+ wordStart = false;
986
+ if (character === "'" || character === '"') quote = character;
987
+ else {
988
+ quote = "$'";
989
+ result += command[++index];
990
+ }
991
+ continue;
992
+ }
993
+ if (character === "#") {
994
+ if (wordStart) {
995
+ result += command.slice(index);
996
+ break;
997
+ }
998
+ result += "\\#";
999
+ continue;
1000
+ }
1001
+ if (WORD_BREAK.test(character)) {
1002
+ result += character;
1003
+ wordStart = true;
1004
+ continue;
1005
+ }
1006
+ result += character;
1007
+ wordStart = false;
1008
+ }
1009
+ return result;
1010
+ }
1011
+
1012
+ export interface HeredocMarker { delimiter: string; quoted: boolean; stripTabs: boolean }
1013
+
1014
+ /**
1015
+ * Reads the here-document marker that starts at `<<`, returning the marker and
1016
+ * the offset just past its delimiter.
1017
+ *
1018
+ * Only delimiters this parser decodes exactly are accepted: a bare word of
1019
+ * `[A-Za-z0-9_]`, or one fully quoted word. Single quotes keep a backslash
1020
+ * literal, so `'E\OF'` decodes to `E\OF`; a double-quoted word decodes `\\`,
1021
+ * `\$`, `` \` ``, and `\"`, so the raw text is not the delimiter and any
1022
+ * backslash there is unsupported. Escapes, expansion, concatenated words, and
1023
+ * an unterminated quote all return undefined, and callers fail closed.
1024
+ */
1025
+ function heredocMarkerAt(line: string, index: number): { marker: HeredocMarker; end: number } | undefined {
1026
+ if (line[index] !== "<" || line[index + 1] !== "<" || line[index + 2] === "<" || line[index - 1] === "<")
1027
+ return undefined;
1028
+ let cursor = index + 2;
1029
+ const stripTabs = line[cursor] === "-";
1030
+ if (stripTabs) cursor++;
1031
+ while (line[cursor] === " " || line[cursor] === "\t") cursor++;
1032
+ let delimiter = "";
1033
+ let quoted = false;
1034
+ if (line[cursor] === "'" || line[cursor] === '"') {
1035
+ const closing = line[cursor];
1036
+ quoted = true;
1037
+ cursor++;
1038
+ let closed = false;
1039
+ while (cursor < line.length) {
1040
+ const character = line[cursor];
1041
+ if (character === closing) { closed = true; cursor++; break; }
1042
+ if (closing === '"' && character === "\\") return undefined;
1043
+ delimiter += character;
1044
+ cursor++;
1045
+ }
1046
+ if (!closed) return undefined;
1047
+ } else {
1048
+ while (cursor < line.length && /[A-Za-z0-9_]/.test(line[cursor])) delimiter += line[cursor++];
1049
+ }
1050
+ if (!delimiter) return undefined;
1051
+ // The delimiter word must end here; a quoted run glued to more word text is a
1052
+ // concatenation this parser does not decode.
1053
+ if (cursor < line.length && !WORD_BREAK.test(line[cursor])) return undefined;
1054
+ return { marker: { delimiter, quoted, stripTabs }, end: cursor };
1055
+ }
1056
+
1057
+ interface HeredocScan { markers: HeredocMarker[]; unsupported: boolean }
1058
+
1059
+ /**
1060
+ * Scans one command line for here-document markers, ignoring markers inside
1061
+ * quoted strings and comments.
1062
+ *
1063
+ * `unsupported` reports a marker this parser cannot decode exactly: an escaped
1064
+ * or expanded delimiter (`<<\EOF`, `<<$name`), a delimiter word assembled from
1065
+ * quoted and unquoted parts (`<<E'OF'`, `<<'EOF'x`), a backslash inside a
1066
+ * double-quoted delimiter, or an unterminated quote. A wrong delimiter would
1067
+ * move the body boundary, so callers fail closed instead.
1068
+ */
1069
+ function scanHeredoc(line: string): HeredocScan {
1070
+ const markers: HeredocMarker[] = [];
1071
+ let unsupported = false;
1072
+ let wordStart = true;
1073
+ let quote: "'" | '"' | undefined;
1074
+ for (let index = 0; index < line.length; index++) {
1075
+ const character = line[index];
1076
+ if (quote === "'") { if (character === "'") quote = undefined; continue; }
1077
+ if (quote === '"') {
1078
+ if (character === "\\") { index++; continue; }
1079
+ if (character === '"') quote = undefined;
1080
+ continue;
1081
+ }
1082
+ if (character === "'" || character === '"') { quote = character; wordStart = false; continue; }
1083
+ if (character === "\\") { index++; wordStart = false; continue; }
1084
+ if (character === "#" && wordStart) break;
1085
+ if (character === "<") {
1086
+ const parsed = heredocMarkerAt(line, index);
1087
+ if (parsed === undefined) {
1088
+ if (line[index + 1] === "<" && line[index + 2] !== "<" && line[index - 1] !== "<") unsupported = true;
1089
+ } else {
1090
+ markers.push(parsed.marker);
1091
+ index = parsed.end - 1;
1092
+ wordStart = false;
1093
+ continue;
1094
+ }
1095
+ }
1096
+ wordStart = WORD_BREAK.test(character);
1097
+ }
1098
+ return { markers, unsupported };
1099
+ }
1100
+
1101
+ /** Reads heredoc markers on one line, ignoring markers inside quoted strings and comments. */
1102
+ export function heredocMarkers(line: string): HeredocMarker[] {
1103
+ return scanHeredoc(line).markers;
1104
+ }
1105
+
1106
+ /**
1107
+ * True when the text contains a here-document operator outside quotes.
1108
+ * Heredoc bodies are data, so callers that already located a body scan only
1109
+ * the command line.
1110
+ */
1111
+ export function hasHeredoc(text: string): boolean {
1112
+ let quote: "'" | '"' | undefined;
1113
+ for (let index = 0; index < text.length; index++) {
1114
+ const character = text[index];
1115
+ if (quote === "'") { if (character === "'") quote = undefined; continue; }
1116
+ if (quote === '"') {
1117
+ if (character === "\\") { index++; continue; }
1118
+ if (character === '"') quote = undefined;
1119
+ continue;
1120
+ }
1121
+ if (character === "'" || character === '"') { quote = character; continue; }
1122
+ if (character === "\\") { index++; continue; }
1123
+ if (character === "<" && text[index + 1] === "<" && text[index + 2] !== "<" &&
1124
+ text[index - 1] !== "<") return true;
1125
+ }
1126
+ return false;
1127
+ }
1128
+
1129
+ /**
1130
+ * The raw command line that starts at `start`, and the offset of the newline
1131
+ * that ends it. Quoted newlines and `\`-newlines stay inside the line; a
1132
+ * comment's newline ends it.
1133
+ */
1134
+ function commandLineAt(command: string, start: number): { line: string; end: number } {
1135
+ let quote: "'" | '"' | "$'" | undefined;
1136
+ let wordStart = true;
1137
+ for (let index = start; index < command.length; index++) {
1138
+ const character = command[index];
1139
+ if (quote === "'") { if (character === "'") quote = undefined; continue; }
1140
+ if (quote === "$'") {
1141
+ if (character === "\\") index++;
1142
+ else if (character === "'") quote = undefined;
1143
+ continue;
1144
+ }
1145
+ if (quote === '"') {
1146
+ if (character === "\\") index++;
1147
+ else if (character === '"') quote = undefined;
1148
+ continue;
1149
+ }
1150
+ if (character === "\\") { index++; continue; }
1151
+ if (character === "'" || character === '"') { quote = character; wordStart = false; continue; }
1152
+ if (character === "$" && command[index + 1] === "'") { quote = "$'"; index++; wordStart = false; continue; }
1153
+ if (character === "#" && wordStart) {
1154
+ const end = command.indexOf("\n", index);
1155
+ return { line: command.slice(start, end === -1 ? command.length : end),
1156
+ end: end === -1 ? command.length : end };
1157
+ }
1158
+ if (character === "\n") return { line: command.slice(start, index), end: index };
1159
+ wordStart = WORD_BREAK.test(character);
1160
+ }
1161
+ return { line: command.slice(start), end: command.length };
1162
+ }
1163
+
1164
+ export interface RawHeredocScript {
1165
+ /**
1166
+ * Raw logical command lines that are certainly script, through the first header
1167
+ * whose boundary cannot be read. Here-document bodies are omitted.
1168
+ */
1169
+ lines: string[];
1170
+ /** True when a raw header's marker or delimiter cannot be placed exactly. */
1171
+ ambiguous: boolean;
1172
+ }
1173
+
1174
+ /**
1175
+ * Projects the raw source onto its command lines up to the first here-document
1176
+ * header this reader cannot decode, skipping the bodies of headers it can.
1177
+ *
1178
+ * Every header is validated here, not only the first line: a continuation in a
1179
+ * later header (`cat <<'A'`+newline+`a`+newline+`A`+newline+`cat <<\`+newline+
1180
+ * `'EOF'`) is invisible to a first-line check, and normalization would then join
1181
+ * the marker and treat a following `\`-newline body line as script, hiding a
1182
+ * command Bash runs. Bodies are copied with the same raw comparison as
1183
+ * `logicalLines`, so data is never returned as a script line; an unquoted body
1184
+ * whose continuation can move its own terminator stops the scan because the
1185
+ * existing projection already asks for it.
1186
+ *
1187
+ * A `\`-newline in the header, an undecodable marker, or a marker that only
1188
+ * appears after normalization ends the certain prefix: the header line itself
1189
+ * still runs, but nothing after it can be trusted.
1190
+ */
1191
+ export function rawHeredocScript(command: string): RawHeredocScript {
1192
+ const lines: string[] = [];
1193
+ let start = 0;
1194
+ while (start < command.length) {
1195
+ const { line, end } = commandLineAt(command, start);
1196
+ lines.push(line);
1197
+ const scan = scanHeredoc(line);
1198
+ const rawMarker = hasHeredoc(line);
1199
+ const normalizedMarker = hasHeredoc(normalizeContinuations(line));
1200
+ if (rawMarker || normalizedMarker) {
1201
+ if (line.includes("\\\n") || scan.unsupported) return { lines, ambiguous: true };
1202
+ // A `<<` the reader cannot place without a continuation is just as unknown.
1203
+ if (!scan.markers.length) return { lines, ambiguous: true };
1204
+ const bodies = heredocBodies(command, end + 1, scan.markers);
1205
+ if (bodies.unstable) return { lines, ambiguous: false };
1206
+ start = bodies.end;
1207
+ continue;
1208
+ }
1209
+ start = end + 1;
1210
+ }
1211
+ return { lines, ambiguous: false };
1212
+ }
1213
+
1214
+ /** The copied bodies and the offset where the command script resumes. */
1215
+ interface HeredocCopy {
1216
+ text: string;
1217
+ end: number;
1218
+ /**
1219
+ * True when an unquoted body line ends with a backslash. A shell removes that
1220
+ * `\`-newline before matching the terminator, so the body can run past the line
1221
+ * the raw text compares and the terminator may match somewhere else entirely —
1222
+ * including on a line the collector treats as script.
1223
+ */
1224
+ unstable: boolean;
1225
+ }
1226
+
1227
+ /**
1228
+ * Copies here-document bodies verbatim, returning the copied text and the
1229
+ * offset where the command script resumes.
1230
+ */
1231
+ function heredocBodies(command: string, start: number, markers: readonly HeredocMarker[]): HeredocCopy {
1232
+ let index = start;
1233
+ let text = "";
1234
+ let unstable = false;
1235
+ for (const marker of markers) {
1236
+ let closed = false;
1237
+ while (index < command.length) {
1238
+ const newline = command.indexOf("\n", index);
1239
+ const stop = newline === -1 ? command.length : newline + 1;
1240
+ const line = command.slice(index, newline === -1 ? command.length : newline);
1241
+ text += command.slice(index, stop);
1242
+ index = stop;
1243
+ if ((marker.stripTabs ? line.replace(/^\t+/, "") : line) === marker.delimiter) { closed = true; break; }
1244
+ // A quoted body keeps its backslashes, so only an unquoted one can move.
1245
+ if (!marker.quoted && line.endsWith("\\")) unstable = true;
1246
+ }
1247
+ if (!closed) break;
1248
+ }
1249
+ return { text, end: index, unstable };
1250
+ }
1251
+
1252
+ /**
1253
+ * Removes `\`-newline continuations so the command is validated and parsed as
1254
+ * the shell would run it.
1255
+ *
1256
+ * A shell deletes a backslash-newline pair while reading a command line, so
1257
+ * `sudo echo first \`+newline+`second` is one `sudo` invocation, and a continued
1258
+ * flag such as `rm -r`+newline+`f` is the dangerous `-rf`. Removing the pair
1259
+ * before quote validation and before the source scans keeps every later check
1260
+ * on the same text the shell sees.
1261
+ *
1262
+ * Continuations are kept wherever the shell keeps them:
1263
+ * - single quotes and `$'...'` hold the backslash and the newline as data,
1264
+ * - an escaped backslash (`\\`+newline) leaves a real newline that ends the line,
1265
+ * - a comment ends at its newline, so a trailing backslash does not continue it,
1266
+ * - here-document bodies are data and are copied unchanged.
1267
+ */
1268
+ export function normalizeContinuations(command: string): string {
1269
+ if (!command.includes("\n")) return command;
1270
+ let result = "";
1271
+ let index = 0;
1272
+ let wordStart = true;
1273
+ let pending: HeredocMarker[] = [];
1274
+ while (index < command.length) {
1275
+ const character = command[index];
1276
+ if (character === "\\") {
1277
+ const next = command[index + 1];
1278
+ if (next === "\n") { index += 2; continue; }
1279
+ if (next === undefined) { result += character; break; }
1280
+ result += character + next;
1281
+ wordStart = false;
1282
+ index += 2;
1283
+ continue;
1284
+ }
1285
+ if (character === "'") {
1286
+ const end = command.indexOf("'", index + 1);
1287
+ const stop = end === -1 ? command.length : end + 1;
1288
+ result += command.slice(index, stop);
1289
+ wordStart = false;
1290
+ index = stop;
1291
+ continue;
1292
+ }
1293
+ if (character === "$" && command[index + 1] === "'") {
1294
+ let cursor = index + 2;
1295
+ while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
1296
+ const stop = Math.min(cursor + 1, command.length);
1297
+ result += command.slice(index, stop);
1298
+ wordStart = false;
1299
+ index = stop;
1300
+ continue;
1301
+ }
1302
+ if (character === '"') {
1303
+ let cursor = index + 1;
1304
+ let quoted = "\"";
1305
+ while (cursor < command.length) {
1306
+ const inner = command[cursor];
1307
+ if (inner === "\\") {
1308
+ const next = command[cursor + 1];
1309
+ if (next === "\n") { cursor += 2; continue; }
1310
+ quoted += next === undefined ? inner : inner + next;
1311
+ cursor += next === undefined ? 1 : 2;
1312
+ continue;
1313
+ }
1314
+ quoted += inner;
1315
+ cursor++;
1316
+ if (inner === '"') break;
1317
+ }
1318
+ result += quoted;
1319
+ wordStart = false;
1320
+ index = cursor;
1321
+ continue;
1322
+ }
1323
+ if (character === "#" && wordStart) {
1324
+ const end = command.indexOf("\n", index);
1325
+ const stop = end === -1 ? command.length : end;
1326
+ result += command.slice(index, stop);
1327
+ index = stop;
1328
+ continue;
1329
+ }
1330
+ if (character === "<") {
1331
+ const parsed = heredocMarkerAt(command, index);
1332
+ if (parsed !== undefined) {
1333
+ result += command.slice(index, parsed.end);
1334
+ pending.push(parsed.marker);
1335
+ wordStart = false;
1336
+ index = parsed.end;
1337
+ continue;
1338
+ }
1339
+ }
1340
+ if (character === "\n") {
1341
+ result += character;
1342
+ index++;
1343
+ wordStart = true;
1344
+ if (pending.length) {
1345
+ const bodies = heredocBodies(command, index, pending);
1346
+ result += bodies.text;
1347
+ index = bodies.end;
1348
+ pending = [];
1349
+ }
1350
+ continue;
1351
+ }
1352
+ result += character;
1353
+ wordStart = WORD_BREAK.test(character);
1354
+ index++;
1355
+ }
1356
+ return result;
1357
+ }
1358
+
1359
+ export interface LogicalScript {
1360
+ /** One entry per logical command line, in source order; heredoc bodies are omitted. */
1361
+ lines: string[];
1362
+ /**
1363
+ * How many leading entries in `lines` are certainly script. A here-document
1364
+ * header this scanner cannot decode, or an unquoted body whose continuation can
1365
+ * move its own terminator, ends that prefix: the header line itself still runs,
1366
+ * but later entries may be body data.
1367
+ */
1368
+ certain: number;
1369
+ }
1370
+
1371
+ /**
1372
+ * Splits a script into the logical command lines a shell would run.
1373
+ *
1374
+ * The scan is quote-aware: a newline inside `'...'`, `"..."`, or `$'...'` is part
1375
+ * of the word being built, so `sudo echo 'first`+newline+`second'` stays one
1376
+ * invocation and a blocked command is visible as a whole instead of becoming two
1377
+ * harmless-looking fragments. A `\`-newline keeps an escaped character attached
1378
+ * to its word (callers normalize continuations first, so only the escaped form
1379
+ * remains here), and a comment ends its own physical line without hiding the next.
1380
+ *
1381
+ * Here-document bodies are data, not commands: they are skipped, so the `sudo` in
1382
+ * `cat <<'EOF'`+newline+`sudo id`+newline+`EOF` is plain text, while a command
1383
+ * after the terminator is still analyzed. `certain` reports the prefix of lines
1384
+ * whose script/data split is known; callers must not auto-approve past it.
1385
+ */
1386
+ export function logicalLines(command: string): LogicalScript {
1387
+ const lines: string[] = [];
1388
+ let certain: number | undefined;
1389
+ let start = 0;
1390
+ let wordStart = true;
1391
+ let quote: "'" | '"' | "$'" | undefined;
1392
+ let pending: HeredocMarker[] = [];
1393
+ /** Records that everything from the line at `line` onwards may be data. */
1394
+ const markUncertain = (line: number) => {
1395
+ if (certain === undefined) certain = line + 1;
1396
+ };
1397
+ /** Ends the current line at `end` and jumps past the bodies of its markers. */
1398
+ const endLine = (end: number) => {
1399
+ lines.push(command.slice(start, end));
1400
+ start = end + 1;
1401
+ if (!pending.length) return;
1402
+ const bodies = heredocBodies(command, start, pending);
1403
+ if (bodies.unstable) markUncertain(lines.length - 1);
1404
+ start = bodies.end;
1405
+ pending = [];
1406
+ };
1407
+ for (let index = 0; index < command.length; index++) {
1408
+ const character = command[index];
1409
+ if (quote === "'") {
1410
+ if (character === "'") quote = undefined;
1411
+ continue;
1412
+ }
1413
+ if (quote === "$'") {
1414
+ if (character === "\\") index++;
1415
+ else if (character === "'") quote = undefined;
1416
+ continue;
1417
+ }
1418
+ if (quote === '"') {
1419
+ if (character === "\\") index++;
1420
+ else if (character === '"') quote = undefined;
1421
+ continue;
1422
+ }
1423
+ if (character === "\\") {
1424
+ if (index + 1 < command.length) {
1425
+ if (command[++index] !== "\n") wordStart = false;
1426
+ } else wordStart = false;
1427
+ continue;
1428
+ }
1429
+ if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
1430
+ wordStart = false;
1431
+ if (character === "'" || character === '"') quote = character;
1432
+ else { quote = "$'"; index++; }
1433
+ continue;
1434
+ }
1435
+ if (character === "#" && wordStart) {
1436
+ // A comment runs to the end of the physical line; quotes inside it are text.
1437
+ const end = command.indexOf("\n", index);
1438
+ if (end === -1) break;
1439
+ endLine(end);
1440
+ index = start - 1;
1441
+ wordStart = true;
1442
+ continue;
1443
+ }
1444
+ if (character === "<") {
1445
+ const parsed = heredocMarkerAt(command, index);
1446
+ if (parsed !== undefined) {
1447
+ pending.push(parsed.marker);
1448
+ wordStart = false;
1449
+ index = parsed.end - 1;
1450
+ continue;
1451
+ }
1452
+ if (command[index + 1] === "<" && command[index + 2] !== "<" && command[index - 1] !== "<")
1453
+ markUncertain(lines.length);
1454
+ }
1455
+ if (character === "\n") {
1456
+ endLine(index);
1457
+ // Resume after the bodies, if this line opened any.
1458
+ index = start - 1;
1459
+ wordStart = true;
1460
+ continue;
1461
+ }
1462
+ wordStart = WORD_BREAK.test(character);
1463
+ }
1464
+ lines.push(command.slice(start));
1465
+ return { lines, certain: certain ?? lines.length };
1466
+ }
1467
+
1468
+ /**
1469
+ * Parses the command a shell would run: continuations are removed, descriptor
1470
+ * prefixes and midword comment markers are stripped, and the remainder is handed
1471
+ * to `shell-quote`. Nested scripts (`sh -c`, `eval`) reach this same parser, so
1472
+ * they get the same normalization as a top-level command line.
1473
+ *
1474
+ * Text with whitespace a shell does not treat as a separator is refused instead
1475
+ * of parsed: `shell-quote` splits on JS `\s`, which would move word and heredoc
1476
+ * boundaries. Callers treat the throw as unparseable and ask.
1477
+ */
1478
+ export function parseShell(command: string): ParseEntry[] {
1479
+ if (hasNonShellWhitespace(command)) throw new Error("shell whitespace is not supported");
1480
+ return parse(escapeLiteralHashes(stripDescriptorPrefixes(normalizeContinuations(command))));
1481
+ }
1482
+
1483
+ /**
1484
+ * True when a descriptor close (`>&-` or `<&-`) is immediately followed by more
1485
+ * word characters.
1486
+ *
1487
+ * Bash reads `2>&-sudo id` as closing file descriptor 2 and then running
1488
+ * `sudo id`, while shell-quote and other shells read `-sudo` as the redirection
1489
+ * word and drop the command. The two readings disagree about which command
1490
+ * runs, so ambiguous invocations must not be approved automatically.
1491
+ */
1492
+ export function hasAmbiguousDescriptorClose(command: string): boolean {
1493
+ const delimiter = /[ \t\n;|&()<>]/;
1494
+ let boundary = true;
1495
+ let index = 0;
1496
+ while (index < command.length) {
1497
+ const character = command[index];
1498
+ if (character === "\\") { index += 2; boundary = false; continue; }
1499
+ if (character === "'") {
1500
+ const end = command.indexOf("'", index + 1);
1501
+ index = end === -1 ? command.length : end + 1;
1502
+ boundary = false;
1503
+ continue;
1504
+ }
1505
+ if (character === "$" && command[index + 1] === "'") {
1506
+ let cursor = index + 2;
1507
+ while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
1508
+ index = Math.min(cursor + 1, command.length);
1509
+ boundary = false;
1510
+ continue;
1511
+ }
1512
+ if (character === '"') {
1513
+ let cursor = index + 1;
1514
+ while (cursor < command.length && command[cursor] !== '"') cursor += command[cursor] === "\\" ? 2 : 1;
1515
+ index = Math.min(cursor + 1, command.length);
1516
+ boundary = false;
1517
+ continue;
1518
+ }
1519
+ if (boundary && character === "#") {
1520
+ const end = command.indexOf("\n", index);
1521
+ index = end === -1 ? command.length : end;
1522
+ continue;
1523
+ }
1524
+ if ((character === ">" || character === "<") && command[index + 1] === "&" && command[index + 2] === "-") {
1525
+ const next = command[index + 3];
1526
+ if (next !== undefined && !delimiter.test(next)) return true;
1527
+ }
1528
+ boundary = delimiter.test(character);
1529
+ index++;
1530
+ }
1531
+ return false;
1532
+ }
1533
+
1534
+ /** Splits a script into simple commands, dropping redirection targets. */
1535
+ function invocations(command: string): string[][] {
1536
+ let tokens;
1537
+ try { tokens = parseShell(command); } catch { return []; }
1538
+ const result: string[][] = [];
1539
+ let words: string[] = [];
1540
+ let skipNext = false;
1541
+ for (const token of tokens) {
1542
+ if (typeof token === "string") {
1543
+ if (!skipNext) words.push(token);
1544
+ skipNext = false;
1545
+ continue;
1546
+ }
1547
+ if ("comment" in token) continue;
1548
+ if ("op" in token) {
1549
+ if (token.op === "glob") {
1550
+ if (!skipNext) words.push(token.pattern);
1551
+ skipNext = false;
1552
+ continue;
1553
+ }
1554
+ if (CONTROL_OPERATORS.has(token.op)) {
1555
+ if (words.length) result.push(words);
1556
+ words = [];
1557
+ skipNext = false;
1558
+ continue;
1559
+ }
1560
+ // A redirection does not end a simple command; only its target is dropped.
1561
+ // Descriptor prefixes were already removed from the source before parsing.
1562
+ if (REDIRECT_OPERATORS.has(token.op)) skipNext = true;
1563
+ }
1564
+ }
1565
+ if (words.length) result.push(words);
1566
+ return result;
1567
+ }
1568
+
1569
+ /** Offset of the first `$'`, `$"`, or `$[` in the normalized text. */
1570
+ function undecodedSyntaxIndex(text: string): number | undefined {
1571
+ let earliest: number | undefined;
1572
+ for (const opener of ["$'", '$"', "$["]) {
1573
+ const index = text.indexOf(opener);
1574
+ if (index !== -1 && (earliest === undefined || index < earliest)) earliest = index;
1575
+ }
1576
+ return earliest;
1577
+ }
1578
+
1579
+ /**
1580
+ * Code whose contents this prefix parse does not project. A line containing one of
1581
+ * these forms is skipped rather than guessed at, because the words inside it are
1582
+ * commands or data that this scan cannot place.
1583
+ */
1584
+ const NESTED_DYNAMIC_SYNTAX = /\$\(|`|[<>]\(/;
1585
+
1586
+ /**
1587
+ * Deny reason visible in the clean prefix before the first syntax this policy
1588
+ * refuses to decode, or undefined when that prefix has no mandatory deny.
1589
+ *
1590
+ * The marker and everything after it are never parsed: an undecoded quote can
1591
+ * move word and line boundaries, and normalization may have assembled it from a
1592
+ * `\`-newline. The prefix is projected with the shared `logicalLines` scan, so
1593
+ * heredoc bodies stay data. A line whose quotes are not balanced at the cut, or
1594
+ * that still contains nested dynamic syntax, is skipped instead of guessed at.
1595
+ * Callers use this before falling back to ask.
1596
+ */
1597
+ export function denyCleanPrefix(normalized: string, depth = 0): string | undefined {
1598
+ const cut = undecodedSyntaxIndex(normalized);
1599
+ if (cut === undefined) return undefined;
1600
+ const { lines, certain } = logicalLines(normalized.slice(0, cut));
1601
+ for (const line of lines.slice(0, certain)) {
1602
+ if (NESTED_DYNAMIC_SYNTAX.test(line) || !BALANCED_QUOTES.test(line)) continue;
1603
+ for (const words of invocations(line)) {
1604
+ const reason = denyInvocation(words, depth);
1605
+ if (reason !== undefined) return reason;
1606
+ }
1607
+ }
1608
+ return undefined;
1609
+ }
1610
+
1611
+ /**
1612
+ * Deny reason for a nested script (`sh -c`, `eval`), or undefined when nothing in
1613
+ * it is a dangerous operation.
1614
+ *
1615
+ * The script is projected onto the commands a shell would actually run, using the
1616
+ * same quote-aware `logicalLines` split as a top-level script: newlines separate
1617
+ * commands only outside quotes, so `sh -c 'pwd`+newline+`sudo id'` still shows the
1618
+ * mandatory deny, while `sh -c 'echo "first`+newline+`second"'` stays one word.
1619
+ * Here-document bodies are data and are never scanned as commands.
1620
+ *
1621
+ * When the split between script and data is unknown — an undecodable header, a
1622
+ * continuation in a raw header, or an unquoted body that can move its own
1623
+ * terminator — only the raw command lines that are certainly script are checked.
1624
+ * The header line always runs: `cat <<\EOF; sudo id` still runs `sudo id`, and a
1625
+ * marker's delimiter is only a redirection target. Every header is validated, not
1626
+ * just the first, because a later continued header has the same boundary problem.
1627
+ * Guessing past an undecodable header could deny quoted here-document data, so it
1628
+ * is left to manual approval.
1629
+ */
1630
+ function denyScript(command: string, depth: number): string | undefined {
1631
+ if (FORK_BOMB.test(command)) return DANGEROUS;
1632
+ const normalized = normalizeContinuations(command);
1633
+ const raw = rawHeredocScript(command);
1634
+ if (raw.ambiguous) {
1635
+ for (const line of raw.lines) {
1636
+ // A line whose own normalization assembles unsupported quoting cannot be
1637
+ // parsed safely; its deny is left to the caller's manual approval.
1638
+ if (hasAssembledUndecodedSyntax(line)) continue;
1639
+ for (const words of invocations(line)) {
1640
+ const reason = denyInvocation(words, depth);
1641
+ if (reason !== undefined) return reason;
1642
+ }
1643
+ }
1644
+ return undefined;
1645
+ }
1646
+ // Normalization can assemble quoting this reader does not decode. The inner
1647
+ // shell could then run a command the projection does not place, so the rest of
1648
+ // the projection stands down; a deny already visible in the clean prefix still
1649
+ // keeps its precedence, and the caller asks when there is none.
1650
+ if (!hasUndecodedSyntax(command) && hasUndecodedSyntax(normalized))
1651
+ return denyCleanPrefix(normalized, depth);
1652
+ const { lines, certain } = logicalLines(normalized);
1653
+ for (const line of lines.slice(0, certain)) {
1654
+ for (const words of invocations(line)) {
1655
+ const reason = denyInvocation(words, depth);
1656
+ if (reason !== undefined) return reason;
1657
+ }
1658
+ }
1659
+ return undefined;
1660
+ }
1661
+
1662
+ /**
1663
+ * Deny reason for one parsed simple command, or undefined when it is not a
1664
+ * dangerous operation. Nested shell scripts are checked recursively.
1665
+ */
1666
+ export function denyInvocation(words: readonly string[], depth = 0): string | undefined {
1667
+ if (depth > MAX_NESTING) return undefined;
1668
+ const executable = resolveExecutable(words);
1669
+ if (executable === undefined) return undefined;
1670
+ const args = words.slice(executable.index + 1);
1671
+ if (executable.name === "sshpass" || executable.name === "printenv" ||
1672
+ (executable.name === "env" && args.length === 0)) return DANGEROUS;
1673
+ if (["terraform", "tofu", "pulumi"].includes(executable.name) && args.includes("destroy")) return DANGEROUS;
1674
+ if (executable.name === "printf") {
1675
+ // `printf -vPATH x` sets a variable exactly like `printf -v PATH x`; `--` ends
1676
+ // option parsing, so `printf -- -v` prints the literal text.
1677
+ const flag = args[0] ?? "";
1678
+ const variable = flag === "-v" ? args[1] : flag.startsWith("-v") && flag.length > 2 ? flag.slice(2) : undefined;
1679
+ if (variable !== undefined && /^(?:PATH|CDPATH|ENV|BASH_ENV|SHELLOPTS|LD_.*|DYLD_.*)$/.test(variable)) return DANGEROUS;
1680
+ }
1681
+ if (executable.name === "jq" && args.some((arg) => /(?:^|[^a-zA-Z_])(?:env|\$ENV)(?:$|[^a-zA-Z_])/.test(arg))) return DANGEROUS;
1682
+ if (executable.name === "gh" && ((args[0] === "auth" && args[1] === "token") ||
1683
+ (args[0] === "api" && args.some((arg) => /^(?:-[fF]|--(?:raw-)?field(?:=|$)|--input(?:=|$))/.test(arg))))) return DANGEROUS;
1684
+ if (["npm", "pnpm", "yarn", "bun", "node"].includes(executable.name) &&
1685
+ args.some((arg) => /^(?:--(?:preload|require|import)(?:=|$)|-r)/.test(arg))) return DANGEROUS;
1686
+ if (executable.name.startsWith("mkfs")) return DANGEROUS;
1687
+ if (DENY_EXECUTABLES.has(executable.name)) return DANGEROUS;
1688
+ if (executable.name === "chmod" && isWorldWritableMode(chmodMode(args) ?? "")) return DANGEROUS;
1689
+ if (executable.name === "rm" && isRecursiveRootRemoval(args)) return DANGEROUS;
1690
+ if (executable.name === "dd" && args.some((arg) => /^of=\/(?:dev|sd|nvme|hd)/.test(arg))) return DANGEROUS;
1691
+ if (SHELLS.has(executable.name) || executable.name === "eval") {
1692
+ const script = scriptArgument(executable.name, args);
1693
+ if (script !== undefined) return denyScript(script, depth + 1);
1694
+ }
1695
+ return undefined;
1696
+ }
1697
+
1698
+ const UNIQ_FLAGS = new Set(["c", "d", "D", "i", "u"]);
1699
+ const UNIQ_VALUE_FLAGS = new Set(["f", "s", "w"]);
1700
+ const UNIQ_LONG_FLAGS = new Set(["--count", "--repeated", "--ignore-case", "--unique", "--zero-terminated",
1701
+ "--help", "--version"]);
1702
+ const UNIQ_LONG_OPTIONAL_FLAGS = new Set(["--all-repeated", "--group"]);
1703
+ const UNIQ_LONG_VALUE_FLAGS = new Set(["--skip-fields", "--skip-chars", "--check-chars"]);
1704
+
1705
+ /**
1706
+ * `uniq INPUT OUTPUT` writes OUTPUT. macOS stops option parsing at the first
1707
+ * operand, so `uniq in.txt -out.txt` writes a file named `-out.txt`; every
1708
+ * argument after an operand is therefore counted as an operand. Unknown leading
1709
+ * flags fail closed, while known flags keep `uniq -f 2 file` read-only.
1710
+ *
1711
+ * An unquoted expansion can turn one operand into several (`uniq *.txt` becomes
1712
+ * `uniq a.txt b.txt` and overwrites the last match), which
1713
+ * `isReadOnlyInvocation` already refuses before any policy runs.
1714
+ */
1715
+ function isReadOnlyUniq(args: readonly string[]): boolean {
1716
+ let operands = 0;
1717
+ let endOfOptions = false;
1718
+ for (let index = 0; index < args.length; index++) {
1719
+ const arg = args[index];
1720
+ if (endOfOptions || operands > 0) { operands++; continue; }
1721
+ if (arg === "--") { endOfOptions = true; continue; }
1722
+ if (arg === "-") { operands++; continue; }
1723
+ if (arg.startsWith("--") && arg.length > 2) {
1724
+ const name = arg.split("=", 1)[0];
1725
+ const attached = arg.length > name.length;
1726
+ if (UNIQ_LONG_VALUE_FLAGS.has(name)) {
1727
+ if (!attached) index++;
1728
+ continue;
1729
+ }
1730
+ if (UNIQ_LONG_OPTIONAL_FLAGS.has(name) || (UNIQ_LONG_FLAGS.has(name) && !attached)) continue;
1731
+ operands++;
1732
+ continue;
1733
+ }
1734
+ if (arg.startsWith("-")) {
1735
+ const letters = arg.slice(1);
1736
+ let known = true;
1737
+ for (let position = 0; position < letters.length; position++) {
1738
+ if (UNIQ_VALUE_FLAGS.has(letters[position])) {
1739
+ if (position === letters.length - 1) index++;
1740
+ break;
1741
+ }
1742
+ if (!UNIQ_FLAGS.has(letters[position])) { known = false; break; }
1743
+ }
1744
+ if (!known) operands++;
1745
+ continue;
1746
+ }
1747
+ operands++;
1748
+ }
1749
+ return operands <= 1;
1750
+ }
1751
+
1752
+ const DATE_FLAGS = new Set(["u", "R", "j"]);
1753
+ const DATE_VALUE_FLAGS = new Set(["d", "r", "f"]);
1754
+ const DATE_LONG_FLAGS = new Set(["--utc", "--universal", "--rfc-email", "--rfc-2822", "--iso-8601",
1755
+ "--debug", "--help", "--version"]);
1756
+ const DATE_LONG_VALUE_FLAGS = new Set(["--date", "--reference", "--file", "--rfc-3339"]);
1757
+
1758
+ /**
1759
+ * `date` is read-only only when every argument is a known read-only option or a
1760
+ * `+FORMAT` operand. A positional operand sets the system clock
1761
+ * (`date 010100002030`), so it requires review.
1762
+ */
1763
+ function isReadOnlyDate(args: readonly string[]): boolean {
1764
+ for (let index = 0; index < args.length; index++) {
1765
+ const arg = args[index];
1766
+ if (arg.startsWith("+")) continue;
1767
+ if (arg === "--") return args.slice(index + 1).every((operand) => operand.startsWith("+"));
1768
+ if (arg.startsWith("--") && arg.length > 2) {
1769
+ const name = arg.split("=", 1)[0];
1770
+ const attached = arg.length > name.length;
1771
+ // `--file DATEFILE` reads dates from a file, which may be project data.
1772
+ if (name === "--file" || resolveLongOption(name) === "--file") return false;
1773
+ if (DATE_LONG_VALUE_FLAGS.has(name)) {
1774
+ if (!attached) index++;
1775
+ continue;
1776
+ }
1777
+ if (DATE_LONG_FLAGS.has(name)) continue;
1778
+ return false;
1779
+ }
1780
+ if (!arg.startsWith("-") || arg === "-") return false;
1781
+ for (let position = 1; position < arg.length; position++) {
1782
+ const letter = arg[position];
1783
+ if (letter === "I") break;
1784
+ // `-f FILE` / `-fFILE` also reads a date file.
1785
+ if (letter === "f") return false;
1786
+ // `-rFILE` hides the reference file from the word scan; only the separate
1787
+ // `-r FILE` form stays available to the path check.
1788
+ if (letter === "r") {
1789
+ if (position !== arg.length - 1) return false;
1790
+ index++;
1791
+ break;
1792
+ }
1793
+ if (DATE_VALUE_FLAGS.has(letter)) {
1794
+ if (position === arg.length - 1) index++;
1795
+ break;
1796
+ }
1797
+ if (!DATE_FLAGS.has(letter)) return false;
1798
+ }
1799
+ }
1800
+ return true;
1801
+ }
1802
+
1803
+ /**
1804
+ * `jq -f`/`--from-file` runs a filter file and `--run-tests` runs a test file;
1805
+ * either can read the environment or any file it names, so all their spellings
1806
+ * (including attached `-fscript` and `--from-file=script`) need review.
1807
+ */
1808
+ function isReadOnlyJq(args: readonly string[]): boolean {
1809
+ for (const arg of args) {
1810
+ if (arg === "--") break;
1811
+ if (arg.startsWith("--")) {
1812
+ const separator = arg.indexOf("=");
1813
+ const option = separator === -1 ? arg : arg.slice(0, separator);
1814
+ if (option === "--run-tests" || resolveLongOption(option) === "--from-file") return false;
1815
+ continue;
1816
+ }
1817
+ if (shortOption(arg, JQ_SHORT_VALUES)?.letter === "f") return false;
1818
+ }
1819
+ return true;
1820
+ }
1821
+
1822
+ /**
1823
+ * `grep -r`/`-R` and `-d recurse` follow directories instead of reading only
1824
+ * the named operands, so they can dump protected or external trees. `-R` also
1825
+ * follows symlinks. A named protected path still denies before this policy.
1826
+ */
1827
+ function isReadOnlyGrep(args: readonly string[]): boolean {
1828
+ for (let index = 0; index < args.length; index++) {
1829
+ const arg = args[index];
1830
+ if (/[rR]/.test(shortFlags(arg, GREP_SHORT_VALUES))) return false;
1831
+ const parsed = shortOption(arg, GREP_SHORT_VALUES);
1832
+ if (parsed?.letter === "d" && (parsed.attached ?? args[index + 1]) === "recurse") return false;
1833
+ if (!arg.startsWith("--") || arg.length <= 2) continue;
1834
+ const separator = arg.indexOf("=");
1835
+ const option = separator === -1 ? arg : arg.slice(0, separator);
1836
+ if ("--recursive".startsWith(option) || "--dereference-recursive".startsWith(option)) return false;
1837
+ if ("--directories".startsWith(option) &&
1838
+ (separator === -1 ? args[index + 1] : arg.slice(separator + 1)) === "recurse") return false;
1839
+ }
1840
+ return true;
1841
+ }
1842
+
1843
+ /**
1844
+ * `rg --hidden`, `-u`/`--no-ignore` and `-L`/`--follow` reach files the default
1845
+ * ignore rules and the named operands exclude (hidden config, ignored trees,
1846
+ * symlink targets), so those searches need review rather than a low score.
1847
+ */
1848
+ function isReadOnlyRg(args: readonly string[]): boolean {
1849
+ if (hasLongOption(args, ["--pre", "--pre-glob", "--hostname-bin", "--search-zip", "--hidden",
1850
+ "--follow", "--unrestricted", "--no-ignore", "--no-ignore-vcs", "--no-ignore-parent",
1851
+ "--no-ignore-global", "--no-ignore-dot", "--no-ignore-files", "--no-ignore-exclude"])) return false;
1852
+ return !args.some((arg) => /[uLz]/.test(shortFlags(arg, RG_SHORT_VALUES)));
1853
+ }
1854
+
1855
+ /**
1856
+ * `diff -N`/`--new-file` treats absent files as empty and `-r` walks whole
1857
+ * trees, so a directory comparison can dump protected or external files. A
1858
+ * named protected path still denies before this policy.
1859
+ */
1860
+ function isReadOnlyDiff(args: readonly string[]): boolean {
1861
+ if (hasLongOption(args, ["--new-file", "--unidirectional-new-file", "--recursive"])) return false;
1862
+ return !args.some((arg) => /[rN]/.test(shortFlags(arg, PLAIN_SHORT_VALUES.diff ?? {})));
1863
+ }
1864
+
1865
+ const POLICIES: Record<string, (args: readonly string[]) => boolean> = {
1866
+ rg: isReadOnlyRg,
1867
+ // `grep -r`/`-R`/`-d recurse` walks whole trees, including protected and
1868
+ // external directories: the search is no longer a bounded read.
1869
+ grep: isReadOnlyGrep,
1870
+ // `diff -N`/`--new-file` and `-r` dump whole trees, including files that do
1871
+ // not exist on the other side.
1872
+ diff: isReadOnlyDiff,
1873
+ sort: (args) => !hasLongOption(args, ["--output", "--compress-program", "--temporary-directory",
1874
+ "--files0-from"]) && !hasShortOption(args, "oT"),
1875
+ // `wc --files0-from FILE` reads a file of paths.
1876
+ wc: (args) => !hasLongOption(args, ["--files0-from"]),
1877
+ tree: (args) => !hasLongOption(args, ["--output", "--fromfile", "--fromtabfile", "--gitfile"]) &&
1878
+ !hasShortOption(args, "o"),
1879
+ // `file -f/--files-from FILE` reads a list of files; `-C` compiles magic.
1880
+ file: (args) => !hasLongOption(args, ["--compile", "--files-from"]) && !hasShortOption(args, "Cf"),
1881
+ // `--files0-from` and `--exclude-from` read a file of paths; `-X` is the
1882
+ // short form of `--exclude-from`.
1883
+ du: (args) => !hasLongOption(args, ["--files0-from", "--exclude-from"]) && !hasShortOption(args, "X"),
1884
+ // `cache --build` / `--clear` mutate the cache, and `--lessopen` runs the
1885
+ // configured pager preprocessor.
1886
+ bat: (args) => !hasLongOption(args, ["--pager", "--config-file", "--config-dir", "--generate-config-file",
1887
+ "--build", "--clear", "--lessopen"]),
1888
+ // `uniq INPUT OUTPUT` writes OUTPUT; only a single literal operand is read-only.
1889
+ uniq: isReadOnlyUniq,
1890
+ // A bare argument sets the host name; `--` ends options and `-` is an operand.
1891
+ hostname: (args) => {
1892
+ let endOfOptions = false;
1893
+ for (const arg of args) {
1894
+ if (endOfOptions) return false;
1895
+ if (arg === "--") { endOfOptions = true; continue; }
1896
+ if (arg === "-" || !arg.startsWith("-")) return false;
1897
+ }
1898
+ return !hasLongOption(args, ["--file", "--boot"]) && !hasShortOption(args, "Fb");
1899
+ },
1900
+ date: isReadOnlyDate,
1901
+ jq: isReadOnlyJq,
1902
+ };
1903
+
1904
+ const READ_ONLY = new Set(["pwd", "whoami", "id", "groups", "ls", "cat", "head", "tail", "wc", "echo", "printf",
1905
+ "true", ":", "grep", "cut", "tr", "jq", "stat", "du", "df", "which", "type", "basename", "dirname", "realpath",
1906
+ "diff", "test", "sleep", "uname"]);
1907
+
1908
+ function isReadOnlyGit(args: readonly string[]): boolean {
1909
+ if (args.some((arg) =>
1910
+ /^(?:-c|--git-dir|--work-tree|--exec-path|--config-env)(?:=|$)/.test(arg) ||
1911
+ arg === "--ext-diff" || arg === "--exec" || arg.startsWith("--output"))) return false;
1912
+ const subcommand = args[0];
1913
+ if (subcommand === undefined) return false;
1914
+ if (["status", "diff", "log", "show", "rev-parse"].includes(subcommand)) return true;
1915
+ if (subcommand !== "branch") return false;
1916
+ if (args.length === 1) return true;
1917
+ return args.slice(1).every((arg) =>
1918
+ ["--show-current", "--list", "-a", "-r", "-v", "-vv", "--merged", "--no-merged", "--contains", "--points-at", "--format"]
1919
+ .some((safe) => arg === safe || arg.startsWith(`${safe}=`)));
1920
+ }
1921
+
1922
+ function isReadOnlySed(args: readonly string[]): boolean {
1923
+ // One narrowly decoded print script, then literal filenames only. Attached
1924
+ // -e/-f forms are scripts too, not inert filenames.
1925
+ return args[0] === "-n" && args[1] !== undefined && /^\d+(?:,\d+)?p$/.test(args[1]) &&
1926
+ args.slice(2).every((arg) => !arg.startsWith("-"));
1927
+ }
1928
+
1929
+ function isReadOnlyGh(args: readonly string[]): boolean {
1930
+ const [group, subcommand] = args;
1931
+ if (group === "label" && subcommand === "list") return true;
1932
+ if (group === "api") {
1933
+ return !args.some((arg) => arg === "-X" || arg === "--method" || arg.startsWith("-X") ||
1934
+ arg.startsWith("--method=") || ["-f", "--field", "-F", "--raw-field", "--input"].includes(arg));
1935
+ }
1936
+ return new Set(["pr view", "pr list", "pr diff", "pr checks", "issue view", "issue list", "run view",
1937
+ "run list", "repo view", "auth status", "status", "search"]).has(`${group} ${subcommand}`);
1938
+ }
1939
+
1940
+ /**
1941
+ * True only when one parsed simple command is read-only. Callers pass an
1942
+ * already-split segment, so chaining, redirection and wrappers are handled
1943
+ * before this point.
1944
+ *
1945
+ * `globs` marks words that came from an unquoted expansion (`*`, `?`, `[ab]`,
1946
+ * `{a,b}`, `$name`), which Bash can turn into several arguments. A literal flag
1947
+ * policy cannot see what those arguments will be: `sort --out{put,put}=out file`
1948
+ * becomes two `--output=out` options and writes a file, and `uniq *.txt` can
1949
+ * become `uniq a.txt b.txt`. An invocation with an uncertain word is therefore
1950
+ * never read-only. Quoted expansions stay one literal word and are judged as
1951
+ * written, so `uniq '*.txt'` remains a single operand.
1952
+ */
1953
+ export function isReadOnlyInvocation(words: readonly string[], globs: readonly boolean[] = []): boolean {
1954
+ if (globs.some(Boolean)) return false;
1955
+ // Path-qualified binaries may be repo-controlled lookalikes, not system tools.
1956
+ if ((words[0] ?? "").includes("/")) return false;
1957
+ const command = basename(words[0] ?? "");
1958
+ const args = words.slice(1);
1959
+ if (command === "printf") {
1960
+ // Any `-v` option assigns a shell variable, attached or separate. `--` ends
1961
+ // option parsing, so `printf -- -v` keeps `-v` as literal format text.
1962
+ for (const arg of args) {
1963
+ if (arg === "--") break;
1964
+ if (/^-v/.test(arg)) return false;
1965
+ }
1966
+ }
1967
+ // Own properties only: `./constructor` must not resolve to `Object`.
1968
+ if (Object.hasOwn(POLICIES, command)) return POLICIES[command](args);
1969
+ if (READ_ONLY.has(command)) return true;
1970
+ if (command === "git") return isReadOnlyGit(args);
1971
+ if (command === "find")
1972
+ return !args.some((arg) => /^--?(?:fprint|fprint0|fprintf|fls)$/.test(arg) || /^--?files0/.test(arg));
1973
+ if (command === "sed") return isReadOnlySed(args);
1974
+ if (command === "gh") return isReadOnlyGh(args);
1975
+ if (command === "docker") return args[0] === "logs";
1976
+ if (command === "bun")
1977
+ return args[0] === "test" || (args[0] === "run" && (args[1] === "lint" || args[1] === "typecheck"));
1978
+ return false;
1979
+ }