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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1337 @@
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
+ /**
201
+ * True when an argument names one of the given long options, including the
202
+ * abbreviations GNU option parsing accepts (`sort --out=/tmp/out` means
203
+ * `--output`). Abbreviations fail closed: any prefix that could name a dangerous
204
+ * option requires review rather than approval.
205
+ */
206
+ function hasLongOption(args: readonly string[], names: readonly string[]): boolean {
207
+ return args.some((arg) => {
208
+ const option = arg.split("=", 1)[0];
209
+ if (!option.startsWith("--") || option.length === 2) return false;
210
+ return names.some((name) => name.startsWith(option));
211
+ });
212
+ }
213
+
214
+ function hasShortOption(args: readonly string[], letters: string): boolean {
215
+ return args.some((arg) => arg.startsWith("-") && !arg.startsWith("--") &&
216
+ [...arg.slice(1)].some((character) => letters.includes(character)));
217
+ }
218
+
219
+ function isWorldWritableMode(arg: string): boolean {
220
+ const mode = arg.replace(/^[-+]+/, "");
221
+ return mode === "777" || mode === "0777";
222
+ }
223
+
224
+ /** chmod options, including the BSD flags, that never consume the mode operand. */
225
+ const CHMOD_FLAGS = /^-[RcfvHhLP]+$/;
226
+
227
+ /** The mode operand chmod applies, ignoring options and earlier operands. */
228
+ function chmodMode(args: readonly string[]): string | undefined {
229
+ let end = false;
230
+ for (const arg of args) {
231
+ if (end) return arg;
232
+ if (arg === "--") { end = true; continue; }
233
+ // `--reference` copies a mode from another file, so there is no mode operand.
234
+ if (arg === "--reference" || arg.startsWith("--reference=")) return undefined;
235
+ if (arg.startsWith("--") || CHMOD_FLAGS.test(arg)) continue;
236
+ return arg;
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ /** True when a path names the filesystem root rather than a directory below it. */
242
+ function isFilesystemRoot(target: string): boolean {
243
+ if (!target.startsWith("/")) return false;
244
+ const trimmed = target.replace(/\/+$/, "").replace(/\/\*+$/, "");
245
+ return trimmed === "" || posix.normalize(trimmed) === "/";
246
+ }
247
+
248
+ /**
249
+ * True when `rm` removes the filesystem root recursively. Force is irrelevant:
250
+ * a writable root is already destroyed by `rm -r / --no-preserve-root`.
251
+ *
252
+ * `--` ends option parsing, so every later argument is a target: `rm -- -r /`
253
+ * asks `rm` to remove the files `-r` and `/` without recursion, which fails on
254
+ * the root instead of deleting it, while `rm -r -- /` still denies.
255
+ */
256
+ function isRecursiveRootRemoval(args: readonly string[]): boolean {
257
+ let recursive = false;
258
+ let endOfOptions = false;
259
+ const targets: string[] = [];
260
+ for (const arg of args) {
261
+ if (endOfOptions) { targets.push(arg); continue; }
262
+ if (arg === "--") { endOfOptions = true; continue; }
263
+ if (arg.startsWith("--")) {
264
+ if (arg === "--recursive") recursive = true;
265
+ continue;
266
+ }
267
+ if (arg.startsWith("-") && arg.length > 1) {
268
+ const letters = arg.slice(1);
269
+ if (letters.includes("r") || letters.includes("R")) recursive = true;
270
+ continue;
271
+ }
272
+ targets.push(arg);
273
+ }
274
+ if (!recursive || targets.length === 0) return false;
275
+ return targets.some(isFilesystemRoot);
276
+ }
277
+
278
+ /** Shell options that take no value and may follow `-c` before its script. */
279
+ const SHELL_FLAGS = new Set(["-a", "-b", "-C", "-e", "-f", "-h", "-i", "-k", "-l", "-m", "-n", "-p", "-r", "-s",
280
+ "-t", "-u", "-v", "-x", "-B", "-E", "-H", "-P", "-T", "--login", "--noprofile", "--norc", "--posix",
281
+ "--restricted", "--verbose", "--noediting"]);
282
+ /** Shell options that consume the next word as their value. */
283
+ const SHELL_VALUE_OPTIONS = new Set(["-o", "-O", "--rcfile", "--init-file"]);
284
+ const SHELL_FLAG_LETTERS = "abcCefhiklmnprstuvxBEHPT";
285
+
286
+ /**
287
+ * The script operand that follows the `-c` option at `start`.
288
+ *
289
+ * A shell keeps parsing options after `-c`, so `sh -c -e 'pwd`+newline+`sudo id'`
290
+ * runs the quoted string, not `-e`, and `sh -c -- script` runs `script`. Known
291
+ * flags and their values are skipped; an unknown option could consume the script
292
+ * itself, so its arity is unknowable and the caller must fail closed.
293
+ */
294
+ function shellScript(args: readonly string[], start: number): string | undefined {
295
+ let index = start;
296
+ while (index < args.length) {
297
+ const arg = args[index];
298
+ if (arg === "--") return args[index + 1];
299
+ if (SHELL_VALUE_OPTIONS.has(arg)) { index += 2; continue; }
300
+ if (SHELL_FLAGS.has(arg)) { index++; continue; }
301
+ if (/^-[^-]+$/.test(arg) && [...arg.slice(1)].every((letter) => SHELL_FLAG_LETTERS.includes(letter))) {
302
+ index++;
303
+ continue;
304
+ }
305
+ if (arg.startsWith("-") && arg.length > 1) return undefined;
306
+ return arg;
307
+ }
308
+ return undefined;
309
+ }
310
+
311
+ /** The script a shell or `eval` would run, when invoked non-interactively. */
312
+ function scriptArgument(name: string, args: readonly string[]): string | undefined {
313
+ if (name === "eval") return args.length ? args.join(" ") : undefined;
314
+ for (let index = 0; index < args.length; index++) {
315
+ const arg = args[index];
316
+ if (arg === "--") return undefined;
317
+ if (!arg.startsWith("-") || arg.startsWith("--")) continue;
318
+ if (arg.slice(1).includes("c")) return shellScript(args, index + 1);
319
+ }
320
+ return undefined;
321
+ }
322
+
323
+ /**
324
+ * Removes file-descriptor prefixes (`2` in `2>/dev/null`) that are adjacent to
325
+ * the redirection operator in the source.
326
+ *
327
+ * Only an unquoted, unescaped digit run that starts a word is a descriptor. A
328
+ * numeric operand separated by whitespace (`chmod 777 > /dev/null secret`) is
329
+ * preserved, as is a quoted (`"2">file`) or escaped (`\2>file`) digit argument.
330
+ */
331
+ export function stripDescriptorPrefixes(command: string): string {
332
+ let result = "";
333
+ let boundary = true;
334
+ let index = 0;
335
+ while (index < command.length) {
336
+ const character = command[index];
337
+ if (character === "\\") {
338
+ result += command.slice(index, index + 2);
339
+ boundary = false;
340
+ index += 2;
341
+ continue;
342
+ }
343
+ if (character === "'") {
344
+ const end = command.indexOf("'", index + 1);
345
+ const stop = end === -1 ? command.length : end + 1;
346
+ result += command.slice(index, stop);
347
+ boundary = false;
348
+ index = stop;
349
+ continue;
350
+ }
351
+ // ANSI-C quoting (`$'...'`) is the one quoted form where a backslash escapes
352
+ // the closing quote, so its body needs the same escape handling as `"..."`.
353
+ if (character === "$" && command[index + 1] === "'") {
354
+ let cursor = index + 2;
355
+ while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
356
+ const stop = Math.min(cursor + 1, command.length);
357
+ result += command.slice(index, stop);
358
+ boundary = false;
359
+ index = stop;
360
+ continue;
361
+ }
362
+ if (character === '"') {
363
+ let cursor = index + 1;
364
+ while (cursor < command.length && command[cursor] !== '"') cursor += command[cursor] === "\\" ? 2 : 1;
365
+ const stop = Math.min(cursor + 1, command.length);
366
+ result += command.slice(index, stop);
367
+ boundary = false;
368
+ index = stop;
369
+ continue;
370
+ }
371
+ if (boundary && /\d/.test(character)) {
372
+ let cursor = index;
373
+ while (cursor < command.length && /\d/.test(command[cursor])) cursor++;
374
+ if (command[cursor] === ">" || command[cursor] === "<") {
375
+ // Replace the run with a space instead of deleting it: `&2>` is `&`
376
+ // followed by a redirect, and deleting the `2` would fuse `&` and `>`
377
+ // into the different `&>` operator.
378
+ result += " ";
379
+ index = cursor;
380
+ continue;
381
+ }
382
+ result += command.slice(index, cursor);
383
+ boundary = false;
384
+ index = cursor;
385
+ continue;
386
+ }
387
+ result += character;
388
+ boundary = /[ \t\n;|&()<>]/.test(character);
389
+ index++;
390
+ }
391
+ return result;
392
+ }
393
+
394
+ /**
395
+ * Escapes a `#` that is part of a word so `shell-quote` does not mistake it for
396
+ * the start of a comment.
397
+ *
398
+ * Shell comments begin only at the start of a word. `shell-quote` instead ends
399
+ * the command at any unquoted `#`, which can hide a later command
400
+ * (`echo a#b ; sudo id`). Escaping that literal hash preserves the word text and
401
+ * lets the deny pass see the rest of the line. A real word-start comment is left
402
+ * untouched, and quoted or escaped hashes are already literal.
403
+ */
404
+ export function escapeLiteralHashes(command: string): string {
405
+ let result = "";
406
+ let wordStart = true;
407
+ let quote: "'" | '"' | "$'" | undefined;
408
+ for (let index = 0; index < command.length; index++) {
409
+ const character = command[index];
410
+ if (quote === "'") {
411
+ result += character;
412
+ if (character === "'") quote = undefined;
413
+ continue;
414
+ }
415
+ if (quote === "$'") {
416
+ result += character;
417
+ if (character === "\\") {
418
+ if (index + 1 < command.length) result += command[++index];
419
+ } else if (character === "'") quote = undefined;
420
+ continue;
421
+ }
422
+ if (quote === '"') {
423
+ result += character;
424
+ if (character === "\\") {
425
+ if (index + 1 < command.length) result += command[++index];
426
+ } else if (character === '"') quote = undefined;
427
+ continue;
428
+ }
429
+ if (character === "\\") {
430
+ result += character;
431
+ if (index + 1 < command.length) {
432
+ const escaped = command[++index];
433
+ result += escaped;
434
+ if (escaped !== "\n") wordStart = false;
435
+ } else wordStart = false;
436
+ continue;
437
+ }
438
+ if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
439
+ result += character;
440
+ wordStart = false;
441
+ if (character === "'" || character === '"') quote = character;
442
+ else {
443
+ quote = "$'";
444
+ result += command[++index];
445
+ }
446
+ continue;
447
+ }
448
+ if (character === "#") {
449
+ if (wordStart) {
450
+ result += command.slice(index);
451
+ break;
452
+ }
453
+ result += "\\#";
454
+ continue;
455
+ }
456
+ if (WORD_BREAK.test(character)) {
457
+ result += character;
458
+ wordStart = true;
459
+ continue;
460
+ }
461
+ result += character;
462
+ wordStart = false;
463
+ }
464
+ return result;
465
+ }
466
+
467
+ export interface HeredocMarker { delimiter: string; quoted: boolean; stripTabs: boolean }
468
+
469
+ /**
470
+ * Reads the here-document marker that starts at `<<`, returning the marker and
471
+ * the offset just past its delimiter.
472
+ *
473
+ * Only delimiters this parser decodes exactly are accepted: a bare word of
474
+ * `[A-Za-z0-9_]`, or one fully quoted word. Single quotes keep a backslash
475
+ * literal, so `'E\OF'` decodes to `E\OF`; a double-quoted word decodes `\\`,
476
+ * `\$`, `` \` ``, and `\"`, so the raw text is not the delimiter and any
477
+ * backslash there is unsupported. Escapes, expansion, concatenated words, and
478
+ * an unterminated quote all return undefined, and callers fail closed.
479
+ */
480
+ function heredocMarkerAt(line: string, index: number): { marker: HeredocMarker; end: number } | undefined {
481
+ if (line[index] !== "<" || line[index + 1] !== "<" || line[index + 2] === "<" || line[index - 1] === "<")
482
+ return undefined;
483
+ let cursor = index + 2;
484
+ const stripTabs = line[cursor] === "-";
485
+ if (stripTabs) cursor++;
486
+ while (line[cursor] === " " || line[cursor] === "\t") cursor++;
487
+ let delimiter = "";
488
+ let quoted = false;
489
+ if (line[cursor] === "'" || line[cursor] === '"') {
490
+ const closing = line[cursor];
491
+ quoted = true;
492
+ cursor++;
493
+ let closed = false;
494
+ while (cursor < line.length) {
495
+ const character = line[cursor];
496
+ if (character === closing) { closed = true; cursor++; break; }
497
+ if (closing === '"' && character === "\\") return undefined;
498
+ delimiter += character;
499
+ cursor++;
500
+ }
501
+ if (!closed) return undefined;
502
+ } else {
503
+ while (cursor < line.length && /[A-Za-z0-9_]/.test(line[cursor])) delimiter += line[cursor++];
504
+ }
505
+ if (!delimiter) return undefined;
506
+ // The delimiter word must end here; a quoted run glued to more word text is a
507
+ // concatenation this parser does not decode.
508
+ if (cursor < line.length && !WORD_BREAK.test(line[cursor])) return undefined;
509
+ return { marker: { delimiter, quoted, stripTabs }, end: cursor };
510
+ }
511
+
512
+ interface HeredocScan { markers: HeredocMarker[]; unsupported: boolean }
513
+
514
+ /**
515
+ * Scans one command line for here-document markers, ignoring markers inside
516
+ * quoted strings and comments.
517
+ *
518
+ * `unsupported` reports a marker this parser cannot decode exactly: an escaped
519
+ * or expanded delimiter (`<<\EOF`, `<<$name`), a delimiter word assembled from
520
+ * quoted and unquoted parts (`<<E'OF'`, `<<'EOF'x`), a backslash inside a
521
+ * double-quoted delimiter, or an unterminated quote. A wrong delimiter would
522
+ * move the body boundary, so callers fail closed instead.
523
+ */
524
+ function scanHeredoc(line: string): HeredocScan {
525
+ const markers: HeredocMarker[] = [];
526
+ let unsupported = false;
527
+ let wordStart = true;
528
+ let quote: "'" | '"' | undefined;
529
+ for (let index = 0; index < line.length; index++) {
530
+ const character = line[index];
531
+ if (quote === "'") { if (character === "'") quote = undefined; continue; }
532
+ if (quote === '"') {
533
+ if (character === "\\") { index++; continue; }
534
+ if (character === '"') quote = undefined;
535
+ continue;
536
+ }
537
+ if (character === "'" || character === '"') { quote = character; wordStart = false; continue; }
538
+ if (character === "\\") { index++; wordStart = false; continue; }
539
+ if (character === "#" && wordStart) break;
540
+ if (character === "<") {
541
+ const parsed = heredocMarkerAt(line, index);
542
+ if (parsed === undefined) {
543
+ if (line[index + 1] === "<" && line[index + 2] !== "<" && line[index - 1] !== "<") unsupported = true;
544
+ } else {
545
+ markers.push(parsed.marker);
546
+ index = parsed.end - 1;
547
+ wordStart = false;
548
+ continue;
549
+ }
550
+ }
551
+ wordStart = WORD_BREAK.test(character);
552
+ }
553
+ return { markers, unsupported };
554
+ }
555
+
556
+ /** Reads heredoc markers on one line, ignoring markers inside quoted strings and comments. */
557
+ export function heredocMarkers(line: string): HeredocMarker[] {
558
+ return scanHeredoc(line).markers;
559
+ }
560
+
561
+ /**
562
+ * True when the text contains a here-document operator outside quotes.
563
+ * Heredoc bodies are data, so callers that already located a body scan only
564
+ * the command line.
565
+ */
566
+ export function hasHeredoc(text: string): boolean {
567
+ let quote: "'" | '"' | undefined;
568
+ for (let index = 0; index < text.length; index++) {
569
+ const character = text[index];
570
+ if (quote === "'") { if (character === "'") quote = undefined; continue; }
571
+ if (quote === '"') {
572
+ if (character === "\\") { index++; continue; }
573
+ if (character === '"') quote = undefined;
574
+ continue;
575
+ }
576
+ if (character === "'" || character === '"') { quote = character; continue; }
577
+ if (character === "\\") { index++; continue; }
578
+ if (character === "<" && text[index + 1] === "<" && text[index + 2] !== "<" &&
579
+ text[index - 1] !== "<") return true;
580
+ }
581
+ return false;
582
+ }
583
+
584
+ /**
585
+ * The raw command line that starts at `start`, and the offset of the newline
586
+ * that ends it. Quoted newlines and `\`-newlines stay inside the line; a
587
+ * comment's newline ends it.
588
+ */
589
+ function commandLineAt(command: string, start: number): { line: string; end: number } {
590
+ let quote: "'" | '"' | "$'" | undefined;
591
+ let wordStart = true;
592
+ for (let index = start; index < command.length; index++) {
593
+ const character = command[index];
594
+ if (quote === "'") { if (character === "'") quote = undefined; continue; }
595
+ if (quote === "$'") {
596
+ if (character === "\\") index++;
597
+ else if (character === "'") quote = undefined;
598
+ continue;
599
+ }
600
+ if (quote === '"') {
601
+ if (character === "\\") index++;
602
+ else if (character === '"') quote = undefined;
603
+ continue;
604
+ }
605
+ if (character === "\\") { index++; continue; }
606
+ if (character === "'" || character === '"') { quote = character; wordStart = false; continue; }
607
+ if (character === "$" && command[index + 1] === "'") { quote = "$'"; index++; wordStart = false; continue; }
608
+ if (character === "#" && wordStart) {
609
+ const end = command.indexOf("\n", index);
610
+ return { line: command.slice(start, end === -1 ? command.length : end),
611
+ end: end === -1 ? command.length : end };
612
+ }
613
+ if (character === "\n") return { line: command.slice(start, index), end: index };
614
+ wordStart = WORD_BREAK.test(character);
615
+ }
616
+ return { line: command.slice(start), end: command.length };
617
+ }
618
+
619
+ export interface RawHeredocScript {
620
+ /**
621
+ * Raw logical command lines that are certainly script, through the first header
622
+ * whose boundary cannot be read. Here-document bodies are omitted.
623
+ */
624
+ lines: string[];
625
+ /** True when a raw header's marker or delimiter cannot be placed exactly. */
626
+ ambiguous: boolean;
627
+ }
628
+
629
+ /**
630
+ * Projects the raw source onto its command lines up to the first here-document
631
+ * header this reader cannot decode, skipping the bodies of headers it can.
632
+ *
633
+ * Every header is validated here, not only the first line: a continuation in a
634
+ * later header (`cat <<'A'`+newline+`a`+newline+`A`+newline+`cat <<\`+newline+
635
+ * `'EOF'`) is invisible to a first-line check, and normalization would then join
636
+ * the marker and treat a following `\`-newline body line as script, hiding a
637
+ * command Bash runs. Bodies are copied with the same raw comparison as
638
+ * `logicalLines`, so data is never returned as a script line; an unquoted body
639
+ * whose continuation can move its own terminator stops the scan because the
640
+ * existing projection already asks for it.
641
+ *
642
+ * A `\`-newline in the header, an undecodable marker, or a marker that only
643
+ * appears after normalization ends the certain prefix: the header line itself
644
+ * still runs, but nothing after it can be trusted.
645
+ */
646
+ export function rawHeredocScript(command: string): RawHeredocScript {
647
+ const lines: string[] = [];
648
+ let start = 0;
649
+ while (start < command.length) {
650
+ const { line, end } = commandLineAt(command, start);
651
+ lines.push(line);
652
+ const scan = scanHeredoc(line);
653
+ const rawMarker = hasHeredoc(line);
654
+ const normalizedMarker = hasHeredoc(normalizeContinuations(line));
655
+ if (rawMarker || normalizedMarker) {
656
+ if (line.includes("\\\n") || scan.unsupported) return { lines, ambiguous: true };
657
+ // A `<<` the reader cannot place without a continuation is just as unknown.
658
+ if (!scan.markers.length) return { lines, ambiguous: true };
659
+ const bodies = heredocBodies(command, end + 1, scan.markers);
660
+ if (bodies.unstable) return { lines, ambiguous: false };
661
+ start = bodies.end;
662
+ continue;
663
+ }
664
+ start = end + 1;
665
+ }
666
+ return { lines, ambiguous: false };
667
+ }
668
+
669
+ /**
670
+ * True when any raw here-document header cannot be tracked reliably, so the
671
+ * guard must fail closed instead of normalizing bytes it cannot place.
672
+ *
673
+ * Deleting a `\`-newline is only safe once the marker and its delimiter are
674
+ * visible: a continuation in the header can split `<`+`<`, split the delimiter
675
+ * (`<<`+newline+`EOF`, `<<E`+newline+`OF`), or hide the entire marker
676
+ * (`cat <<`+newline+`'EOF'`). Guessing there can move the body boundary past a
677
+ * trailing command, so these and delimiters the reader cannot decode exactly
678
+ * (escapes, expansion, concatenated words, a backslash inside double quotes)
679
+ * require approval. Every header is checked, not only the first.
680
+ */
681
+ export function hasAmbiguousHeredoc(command: string): boolean {
682
+ return rawHeredocScript(command).ambiguous;
683
+ }
684
+
685
+ interface HeredocCopy {
686
+ text: string;
687
+ end: number;
688
+ /**
689
+ * True when an unquoted body line ends with a backslash. A shell removes that
690
+ * `\`-newline before matching the terminator, so the body can run past the line
691
+ * the raw text compares and the terminator may match somewhere else entirely —
692
+ * including on a line the collector treats as script.
693
+ */
694
+ unstable: boolean;
695
+ }
696
+
697
+ /**
698
+ * Copies here-document bodies verbatim, returning the copied text and the
699
+ * offset where the command script resumes.
700
+ */
701
+ function heredocBodies(command: string, start: number, markers: readonly HeredocMarker[]): HeredocCopy {
702
+ let index = start;
703
+ let text = "";
704
+ let unstable = false;
705
+ for (const marker of markers) {
706
+ let closed = false;
707
+ while (index < command.length) {
708
+ const newline = command.indexOf("\n", index);
709
+ const stop = newline === -1 ? command.length : newline + 1;
710
+ const line = command.slice(index, newline === -1 ? command.length : newline);
711
+ text += command.slice(index, stop);
712
+ index = stop;
713
+ if ((marker.stripTabs ? line.replace(/^\t+/, "") : line) === marker.delimiter) { closed = true; break; }
714
+ // A quoted body keeps its backslashes, so only an unquoted one can move.
715
+ if (!marker.quoted && line.endsWith("\\")) unstable = true;
716
+ }
717
+ if (!closed) break;
718
+ }
719
+ return { text, end: index, unstable };
720
+ }
721
+
722
+ /**
723
+ * Removes `\`-newline continuations so the command is validated and parsed as
724
+ * the shell would run it.
725
+ *
726
+ * A shell deletes a backslash-newline pair while reading a command line, so
727
+ * `sudo echo first \`+newline+`second` is one `sudo` invocation, and a continued
728
+ * flag such as `rm -r`+newline+`f` is the dangerous `-rf`. Removing the pair
729
+ * before quote validation and before the source scans keeps every later check
730
+ * on the same text the shell sees.
731
+ *
732
+ * Continuations are kept wherever the shell keeps them:
733
+ * - single quotes and `$'...'` hold the backslash and the newline as data,
734
+ * - an escaped backslash (`\\`+newline) leaves a real newline that ends the line,
735
+ * - a comment ends at its newline, so a trailing backslash does not continue it,
736
+ * - here-document bodies are data and are copied unchanged.
737
+ */
738
+ export function normalizeContinuations(command: string): string {
739
+ if (!command.includes("\n")) return command;
740
+ let result = "";
741
+ let index = 0;
742
+ let wordStart = true;
743
+ let pending: HeredocMarker[] = [];
744
+ while (index < command.length) {
745
+ const character = command[index];
746
+ if (character === "\\") {
747
+ const next = command[index + 1];
748
+ if (next === "\n") { index += 2; continue; }
749
+ if (next === undefined) { result += character; break; }
750
+ result += character + next;
751
+ wordStart = false;
752
+ index += 2;
753
+ continue;
754
+ }
755
+ if (character === "'") {
756
+ const end = command.indexOf("'", index + 1);
757
+ const stop = end === -1 ? command.length : end + 1;
758
+ result += command.slice(index, stop);
759
+ wordStart = false;
760
+ index = stop;
761
+ continue;
762
+ }
763
+ if (character === "$" && command[index + 1] === "'") {
764
+ let cursor = index + 2;
765
+ while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
766
+ const stop = Math.min(cursor + 1, command.length);
767
+ result += command.slice(index, stop);
768
+ wordStart = false;
769
+ index = stop;
770
+ continue;
771
+ }
772
+ if (character === '"') {
773
+ let cursor = index + 1;
774
+ let quoted = "\"";
775
+ while (cursor < command.length) {
776
+ const inner = command[cursor];
777
+ if (inner === "\\") {
778
+ const next = command[cursor + 1];
779
+ if (next === "\n") { cursor += 2; continue; }
780
+ quoted += next === undefined ? inner : inner + next;
781
+ cursor += next === undefined ? 1 : 2;
782
+ continue;
783
+ }
784
+ quoted += inner;
785
+ cursor++;
786
+ if (inner === '"') break;
787
+ }
788
+ result += quoted;
789
+ wordStart = false;
790
+ index = cursor;
791
+ continue;
792
+ }
793
+ if (character === "#" && wordStart) {
794
+ const end = command.indexOf("\n", index);
795
+ const stop = end === -1 ? command.length : end;
796
+ result += command.slice(index, stop);
797
+ index = stop;
798
+ continue;
799
+ }
800
+ if (character === "<") {
801
+ const parsed = heredocMarkerAt(command, index);
802
+ if (parsed !== undefined) {
803
+ result += command.slice(index, parsed.end);
804
+ pending.push(parsed.marker);
805
+ wordStart = false;
806
+ index = parsed.end;
807
+ continue;
808
+ }
809
+ }
810
+ if (character === "\n") {
811
+ result += character;
812
+ index++;
813
+ wordStart = true;
814
+ if (pending.length) {
815
+ const bodies = heredocBodies(command, index, pending);
816
+ result += bodies.text;
817
+ index = bodies.end;
818
+ pending = [];
819
+ }
820
+ continue;
821
+ }
822
+ result += character;
823
+ wordStart = WORD_BREAK.test(character);
824
+ index++;
825
+ }
826
+ return result;
827
+ }
828
+
829
+ export interface LogicalScript {
830
+ /** One entry per logical command line, in source order; heredoc bodies are omitted. */
831
+ lines: string[];
832
+ /**
833
+ * How many leading entries in `lines` are certainly script. A here-document
834
+ * header this scanner cannot decode, or an unquoted body whose continuation can
835
+ * move its own terminator, ends that prefix: the header line itself still runs,
836
+ * but later entries may be body data.
837
+ */
838
+ certain: number;
839
+ }
840
+
841
+ /**
842
+ * Splits a script into the logical command lines a shell would run.
843
+ *
844
+ * The scan is quote-aware: a newline inside `'...'`, `"..."`, or `$'...'` is part
845
+ * of the word being built, so `sudo echo 'first`+newline+`second'` stays one
846
+ * invocation and a blocked command is visible as a whole instead of becoming two
847
+ * harmless-looking fragments. A `\`-newline keeps an escaped character attached
848
+ * to its word (callers normalize continuations first, so only the escaped form
849
+ * remains here), and a comment ends its own physical line without hiding the next.
850
+ *
851
+ * Here-document bodies are data, not commands: they are skipped, so the `sudo` in
852
+ * `cat <<'EOF'`+newline+`sudo id`+newline+`EOF` is plain text, while a command
853
+ * after the terminator is still analyzed. `certain` reports the prefix of lines
854
+ * whose script/data split is known; callers must not auto-approve past it.
855
+ */
856
+ export function logicalLines(command: string): LogicalScript {
857
+ const lines: string[] = [];
858
+ let certain: number | undefined;
859
+ let start = 0;
860
+ let wordStart = true;
861
+ let quote: "'" | '"' | "$'" | undefined;
862
+ let pending: HeredocMarker[] = [];
863
+ /** Records that everything from the line at `line` onwards may be data. */
864
+ const markUncertain = (line: number) => {
865
+ if (certain === undefined) certain = line + 1;
866
+ };
867
+ /** Ends the current line at `end` and jumps past the bodies of its markers. */
868
+ const endLine = (end: number) => {
869
+ lines.push(command.slice(start, end));
870
+ start = end + 1;
871
+ if (!pending.length) return;
872
+ const bodies = heredocBodies(command, start, pending);
873
+ if (bodies.unstable) markUncertain(lines.length - 1);
874
+ start = bodies.end;
875
+ pending = [];
876
+ };
877
+ for (let index = 0; index < command.length; index++) {
878
+ const character = command[index];
879
+ if (quote === "'") {
880
+ if (character === "'") quote = undefined;
881
+ continue;
882
+ }
883
+ if (quote === "$'") {
884
+ if (character === "\\") index++;
885
+ else if (character === "'") quote = undefined;
886
+ continue;
887
+ }
888
+ if (quote === '"') {
889
+ if (character === "\\") index++;
890
+ else if (character === '"') quote = undefined;
891
+ continue;
892
+ }
893
+ if (character === "\\") {
894
+ if (index + 1 < command.length) {
895
+ if (command[++index] !== "\n") wordStart = false;
896
+ } else wordStart = false;
897
+ continue;
898
+ }
899
+ if (character === "'" || character === '"' || (character === "$" && command[index + 1] === "'")) {
900
+ wordStart = false;
901
+ if (character === "'" || character === '"') quote = character;
902
+ else { quote = "$'"; index++; }
903
+ continue;
904
+ }
905
+ if (character === "#" && wordStart) {
906
+ // A comment runs to the end of the physical line; quotes inside it are text.
907
+ const end = command.indexOf("\n", index);
908
+ if (end === -1) break;
909
+ endLine(end);
910
+ index = start - 1;
911
+ wordStart = true;
912
+ continue;
913
+ }
914
+ if (character === "<") {
915
+ const parsed = heredocMarkerAt(command, index);
916
+ if (parsed !== undefined) {
917
+ pending.push(parsed.marker);
918
+ wordStart = false;
919
+ index = parsed.end - 1;
920
+ continue;
921
+ }
922
+ if (command[index + 1] === "<" && command[index + 2] !== "<" && command[index - 1] !== "<")
923
+ markUncertain(lines.length);
924
+ }
925
+ if (character === "\n") {
926
+ endLine(index);
927
+ // Resume after the bodies, if this line opened any.
928
+ index = start - 1;
929
+ wordStart = true;
930
+ continue;
931
+ }
932
+ wordStart = WORD_BREAK.test(character);
933
+ }
934
+ lines.push(command.slice(start));
935
+ return { lines, certain: certain ?? lines.length };
936
+ }
937
+
938
+ /**
939
+ * Parses the command a shell would run: continuations are removed, descriptor
940
+ * prefixes and midword comment markers are stripped, and the remainder is handed
941
+ * to `shell-quote`. Nested scripts (`sh -c`, `eval`) reach this same parser, so
942
+ * they get the same normalization as a top-level command line.
943
+ *
944
+ * Text with whitespace a shell does not treat as a separator is refused instead
945
+ * of parsed: `shell-quote` splits on JS `\s`, which would move word and heredoc
946
+ * boundaries. Callers treat the throw as unparseable and ask.
947
+ */
948
+ export function parseShell(command: string): ParseEntry[] {
949
+ if (hasNonShellWhitespace(command)) throw new Error("shell whitespace is not supported");
950
+ return parse(escapeLiteralHashes(stripDescriptorPrefixes(normalizeContinuations(command))));
951
+ }
952
+
953
+ /**
954
+ * True when a descriptor close (`>&-` or `<&-`) is immediately followed by more
955
+ * word characters.
956
+ *
957
+ * Bash reads `2>&-sudo id` as closing file descriptor 2 and then running
958
+ * `sudo id`, while shell-quote and other shells read `-sudo` as the redirection
959
+ * word and drop the command. The two readings disagree about which command
960
+ * runs, so ambiguous invocations must not be approved automatically.
961
+ */
962
+ export function hasAmbiguousDescriptorClose(command: string): boolean {
963
+ const delimiter = /[ \t\n;|&()<>]/;
964
+ let boundary = true;
965
+ let index = 0;
966
+ while (index < command.length) {
967
+ const character = command[index];
968
+ if (character === "\\") { index += 2; boundary = false; continue; }
969
+ if (character === "'") {
970
+ const end = command.indexOf("'", index + 1);
971
+ index = end === -1 ? command.length : end + 1;
972
+ boundary = false;
973
+ continue;
974
+ }
975
+ if (character === "$" && command[index + 1] === "'") {
976
+ let cursor = index + 2;
977
+ while (cursor < command.length && command[cursor] !== "'") cursor += command[cursor] === "\\" ? 2 : 1;
978
+ index = Math.min(cursor + 1, command.length);
979
+ boundary = false;
980
+ continue;
981
+ }
982
+ if (character === '"') {
983
+ let cursor = index + 1;
984
+ while (cursor < command.length && command[cursor] !== '"') cursor += command[cursor] === "\\" ? 2 : 1;
985
+ index = Math.min(cursor + 1, command.length);
986
+ boundary = false;
987
+ continue;
988
+ }
989
+ if (boundary && character === "#") {
990
+ const end = command.indexOf("\n", index);
991
+ index = end === -1 ? command.length : end;
992
+ continue;
993
+ }
994
+ if ((character === ">" || character === "<") && command[index + 1] === "&" && command[index + 2] === "-") {
995
+ const next = command[index + 3];
996
+ if (next !== undefined && !delimiter.test(next)) return true;
997
+ }
998
+ boundary = delimiter.test(character);
999
+ index++;
1000
+ }
1001
+ return false;
1002
+ }
1003
+
1004
+ /** Splits a script into simple commands, dropping redirection targets. */
1005
+ function invocations(command: string): string[][] {
1006
+ let tokens;
1007
+ try { tokens = parseShell(command); } catch { return []; }
1008
+ const result: string[][] = [];
1009
+ let words: string[] = [];
1010
+ let skipNext = false;
1011
+ for (const token of tokens) {
1012
+ if (typeof token === "string") {
1013
+ if (!skipNext) words.push(token);
1014
+ skipNext = false;
1015
+ continue;
1016
+ }
1017
+ if ("comment" in token) continue;
1018
+ if ("op" in token) {
1019
+ if (token.op === "glob") {
1020
+ if (!skipNext) words.push(token.pattern);
1021
+ skipNext = false;
1022
+ continue;
1023
+ }
1024
+ if (CONTROL_OPERATORS.has(token.op)) {
1025
+ if (words.length) result.push(words);
1026
+ words = [];
1027
+ skipNext = false;
1028
+ continue;
1029
+ }
1030
+ // A redirection does not end a simple command; only its target is dropped.
1031
+ // Descriptor prefixes were already removed from the source before parsing.
1032
+ if (REDIRECT_OPERATORS.has(token.op)) skipNext = true;
1033
+ }
1034
+ }
1035
+ if (words.length) result.push(words);
1036
+ return result;
1037
+ }
1038
+
1039
+ /** Offset of the first `$'`, `$"`, or `$[` in the normalized text. */
1040
+ function undecodedSyntaxIndex(text: string): number | undefined {
1041
+ let earliest: number | undefined;
1042
+ for (const opener of ["$'", '$"', "$["]) {
1043
+ const index = text.indexOf(opener);
1044
+ if (index !== -1 && (earliest === undefined || index < earliest)) earliest = index;
1045
+ }
1046
+ return earliest;
1047
+ }
1048
+
1049
+ /**
1050
+ * Code whose contents this prefix parse does not project. A line containing one of
1051
+ * these forms is skipped rather than guessed at, because the words inside it are
1052
+ * commands or data that this scan cannot place.
1053
+ */
1054
+ const NESTED_DYNAMIC_SYNTAX = /\$\(|`|[<>]\(/;
1055
+
1056
+ /**
1057
+ * Deny reason visible in the clean prefix before the first syntax this policy
1058
+ * refuses to decode, or undefined when that prefix has no mandatory deny.
1059
+ *
1060
+ * The marker and everything after it are never parsed: an undecoded quote can
1061
+ * move word and line boundaries, and normalization may have assembled it from a
1062
+ * `\`-newline. The prefix is projected with the shared `logicalLines` scan, so
1063
+ * heredoc bodies stay data. A line whose quotes are not balanced at the cut, or
1064
+ * that still contains nested dynamic syntax, is skipped instead of guessed at.
1065
+ * Callers use this before falling back to ask.
1066
+ */
1067
+ export function denyCleanPrefix(normalized: string, depth = 0): string | undefined {
1068
+ const cut = undecodedSyntaxIndex(normalized);
1069
+ if (cut === undefined) return undefined;
1070
+ const { lines, certain } = logicalLines(normalized.slice(0, cut));
1071
+ for (const line of lines.slice(0, certain)) {
1072
+ if (NESTED_DYNAMIC_SYNTAX.test(line) || !BALANCED_QUOTES.test(line)) continue;
1073
+ for (const words of invocations(line)) {
1074
+ const reason = denyInvocation(words, depth);
1075
+ if (reason !== undefined) return reason;
1076
+ }
1077
+ }
1078
+ return undefined;
1079
+ }
1080
+
1081
+ /**
1082
+ * Deny reason for a nested script (`sh -c`, `eval`), or undefined when nothing in
1083
+ * it is a dangerous operation.
1084
+ *
1085
+ * The script is projected onto the commands a shell would actually run, using the
1086
+ * same quote-aware `logicalLines` split as a top-level script: newlines separate
1087
+ * commands only outside quotes, so `sh -c 'pwd`+newline+`sudo id'` still shows the
1088
+ * mandatory deny, while `sh -c 'echo "first`+newline+`second"'` stays one word.
1089
+ * Here-document bodies are data and are never scanned as commands.
1090
+ *
1091
+ * When the split between script and data is unknown — an undecodable header, a
1092
+ * continuation in a raw header, or an unquoted body that can move its own
1093
+ * terminator — only the raw command lines that are certainly script are checked.
1094
+ * The header line always runs: `cat <<\EOF; sudo id` still runs `sudo id`, and a
1095
+ * marker's delimiter is only a redirection target. Every header is validated, not
1096
+ * just the first, because a later continued header has the same boundary problem.
1097
+ * Guessing past an undecodable header could deny quoted here-document data, so it
1098
+ * is left to manual approval.
1099
+ */
1100
+ function denyScript(command: string, depth: number): string | undefined {
1101
+ if (FORK_BOMB.test(command)) return DANGEROUS;
1102
+ const normalized = normalizeContinuations(command);
1103
+ const raw = rawHeredocScript(command);
1104
+ if (raw.ambiguous) {
1105
+ for (const line of raw.lines) {
1106
+ // A line whose own normalization assembles unsupported quoting cannot be
1107
+ // parsed safely; its deny is left to the caller's manual approval.
1108
+ if (hasAssembledUndecodedSyntax(line)) continue;
1109
+ for (const words of invocations(line)) {
1110
+ const reason = denyInvocation(words, depth);
1111
+ if (reason !== undefined) return reason;
1112
+ }
1113
+ }
1114
+ return undefined;
1115
+ }
1116
+ // Normalization can assemble quoting this reader does not decode. The inner
1117
+ // shell could then run a command the projection does not place, so the rest of
1118
+ // the projection stands down; a deny already visible in the clean prefix still
1119
+ // keeps its precedence, and the caller asks when there is none.
1120
+ if (!hasUndecodedSyntax(command) && hasUndecodedSyntax(normalized))
1121
+ return denyCleanPrefix(normalized, depth);
1122
+ const { lines, certain } = logicalLines(normalized);
1123
+ for (const line of lines.slice(0, certain)) {
1124
+ for (const words of invocations(line)) {
1125
+ const reason = denyInvocation(words, depth);
1126
+ if (reason !== undefined) return reason;
1127
+ }
1128
+ }
1129
+ return undefined;
1130
+ }
1131
+
1132
+ /**
1133
+ * Deny reason for one parsed simple command, or undefined when it is not a
1134
+ * dangerous operation. Nested shell scripts are checked recursively.
1135
+ */
1136
+ export function denyInvocation(words: readonly string[], depth = 0): string | undefined {
1137
+ if (depth > MAX_NESTING) return undefined;
1138
+ const executable = resolveExecutable(words);
1139
+ if (executable === undefined) return undefined;
1140
+ const args = words.slice(executable.index + 1);
1141
+ if (executable.name.startsWith("mkfs")) return DANGEROUS;
1142
+ if (DENY_EXECUTABLES.has(executable.name)) return DANGEROUS;
1143
+ if (executable.name === "chmod" && isWorldWritableMode(chmodMode(args) ?? "")) return DANGEROUS;
1144
+ if (executable.name === "rm" && isRecursiveRootRemoval(args)) return DANGEROUS;
1145
+ if (executable.name === "dd" && args.some((arg) => /^of=\/(?:dev|sd|nvme|hd)/.test(arg))) return DANGEROUS;
1146
+ if (SHELLS.has(executable.name) || executable.name === "eval") {
1147
+ const script = scriptArgument(executable.name, args);
1148
+ if (script !== undefined) return denyScript(script, depth + 1);
1149
+ }
1150
+ return undefined;
1151
+ }
1152
+
1153
+ const UNIQ_FLAGS = new Set(["c", "d", "D", "i", "u"]);
1154
+ const UNIQ_VALUE_FLAGS = new Set(["f", "s", "w"]);
1155
+ const UNIQ_LONG_FLAGS = new Set(["--count", "--repeated", "--ignore-case", "--unique", "--zero-terminated",
1156
+ "--help", "--version"]);
1157
+ const UNIQ_LONG_OPTIONAL_FLAGS = new Set(["--all-repeated", "--group"]);
1158
+ const UNIQ_LONG_VALUE_FLAGS = new Set(["--skip-fields", "--skip-chars", "--check-chars"]);
1159
+
1160
+ /**
1161
+ * `uniq INPUT OUTPUT` writes OUTPUT. macOS stops option parsing at the first
1162
+ * operand, so `uniq in.txt -out.txt` writes a file named `-out.txt`; every
1163
+ * argument after an operand is therefore counted as an operand. Unknown leading
1164
+ * flags fail closed, while known flags keep `uniq -f 2 file` read-only.
1165
+ *
1166
+ * An unquoted expansion can turn one operand into several (`uniq *.txt` becomes
1167
+ * `uniq a.txt b.txt` and overwrites the last match), which
1168
+ * `isReadOnlyInvocation` already refuses before any policy runs.
1169
+ */
1170
+ function isReadOnlyUniq(args: readonly string[]): boolean {
1171
+ let operands = 0;
1172
+ let endOfOptions = false;
1173
+ for (let index = 0; index < args.length; index++) {
1174
+ const arg = args[index];
1175
+ if (endOfOptions || operands > 0) { operands++; continue; }
1176
+ if (arg === "--") { endOfOptions = true; continue; }
1177
+ if (arg === "-") { operands++; continue; }
1178
+ if (arg.startsWith("--") && arg.length > 2) {
1179
+ const name = arg.split("=", 1)[0];
1180
+ const attached = arg.length > name.length;
1181
+ if (UNIQ_LONG_VALUE_FLAGS.has(name)) {
1182
+ if (!attached) index++;
1183
+ continue;
1184
+ }
1185
+ if (UNIQ_LONG_OPTIONAL_FLAGS.has(name) || (UNIQ_LONG_FLAGS.has(name) && !attached)) continue;
1186
+ operands++;
1187
+ continue;
1188
+ }
1189
+ if (arg.startsWith("-")) {
1190
+ const letters = arg.slice(1);
1191
+ let known = true;
1192
+ for (let position = 0; position < letters.length; position++) {
1193
+ if (UNIQ_VALUE_FLAGS.has(letters[position])) {
1194
+ if (position === letters.length - 1) index++;
1195
+ break;
1196
+ }
1197
+ if (!UNIQ_FLAGS.has(letters[position])) { known = false; break; }
1198
+ }
1199
+ if (!known) operands++;
1200
+ continue;
1201
+ }
1202
+ operands++;
1203
+ }
1204
+ return operands <= 1;
1205
+ }
1206
+
1207
+ const DATE_FLAGS = new Set(["u", "R", "j"]);
1208
+ const DATE_VALUE_FLAGS = new Set(["d", "r", "f"]);
1209
+ const DATE_LONG_FLAGS = new Set(["--utc", "--universal", "--rfc-email", "--rfc-2822", "--iso-8601",
1210
+ "--debug", "--help", "--version"]);
1211
+ const DATE_LONG_VALUE_FLAGS = new Set(["--date", "--reference", "--file", "--rfc-3339"]);
1212
+
1213
+ /**
1214
+ * `date` is read-only only when every argument is a known read-only option or a
1215
+ * `+FORMAT` operand. A positional operand sets the system clock
1216
+ * (`date 010100002030`), so it requires review.
1217
+ */
1218
+ function isReadOnlyDate(args: readonly string[]): boolean {
1219
+ for (let index = 0; index < args.length; index++) {
1220
+ const arg = args[index];
1221
+ if (arg.startsWith("+")) continue;
1222
+ if (arg === "--") return args.slice(index + 1).every((operand) => operand.startsWith("+"));
1223
+ if (arg.startsWith("--") && arg.length > 2) {
1224
+ const name = arg.split("=", 1)[0];
1225
+ const attached = arg.length > name.length;
1226
+ if (DATE_LONG_VALUE_FLAGS.has(name)) {
1227
+ if (!attached) index++;
1228
+ continue;
1229
+ }
1230
+ if (DATE_LONG_FLAGS.has(name)) continue;
1231
+ return false;
1232
+ }
1233
+ if (!arg.startsWith("-") || arg === "-") return false;
1234
+ for (let position = 1; position < arg.length; position++) {
1235
+ const letter = arg[position];
1236
+ if (letter === "I") break;
1237
+ if (DATE_VALUE_FLAGS.has(letter)) {
1238
+ if (position === arg.length - 1) index++;
1239
+ break;
1240
+ }
1241
+ if (!DATE_FLAGS.has(letter)) return false;
1242
+ }
1243
+ }
1244
+ return true;
1245
+ }
1246
+
1247
+ const POLICIES: Record<string, (args: readonly string[]) => boolean> = {
1248
+ // Flags that read a program from a file, run a command, or write output.
1249
+ rg: (args) => !hasLongOption(args, ["--pre", "--pre-glob", "--hostname-bin", "--search-zip"]) &&
1250
+ !hasShortOption(args, "z"),
1251
+ sort: (args) => !hasLongOption(args, ["--output", "--compress-program", "--temporary-directory"]) &&
1252
+ !hasShortOption(args, "oT"),
1253
+ tree: (args) => !hasLongOption(args, ["--output"]) && !hasShortOption(args, "o"),
1254
+ file: (args) => !hasLongOption(args, ["--compile"]) && !hasShortOption(args, "C"),
1255
+ // `cache --build` / `--clear` mutate the cache, and `--lessopen` runs the
1256
+ // configured pager preprocessor.
1257
+ bat: (args) => !hasLongOption(args, ["--pager", "--config-file", "--config-dir", "--generate-config-file",
1258
+ "--build", "--clear", "--lessopen"]),
1259
+ // `uniq INPUT OUTPUT` writes OUTPUT; only a single literal operand is read-only.
1260
+ uniq: isReadOnlyUniq,
1261
+ // A bare argument sets the host name; `--` ends options and `-` is an operand.
1262
+ hostname: (args) => {
1263
+ let endOfOptions = false;
1264
+ for (const arg of args) {
1265
+ if (endOfOptions) return false;
1266
+ if (arg === "--") { endOfOptions = true; continue; }
1267
+ if (arg === "-" || !arg.startsWith("-")) return false;
1268
+ }
1269
+ return !hasLongOption(args, ["--file", "--boot"]) && !hasShortOption(args, "Fb");
1270
+ },
1271
+ date: isReadOnlyDate,
1272
+ };
1273
+
1274
+ const READ_ONLY = new Set(["pwd", "whoami", "id", "groups", "ls", "cat", "head", "tail", "wc", "echo", "printf",
1275
+ "true", ":", "grep", "cut", "tr", "jq", "stat", "du", "df", "which", "type", "basename", "dirname", "realpath",
1276
+ "diff", "test", "sleep", "uname"]);
1277
+
1278
+ function isReadOnlyGit(args: readonly string[]): boolean {
1279
+ if (args.some((arg) =>
1280
+ /^(?:-c|--git-dir|--work-tree|--exec-path|--config-env)(?:=|$)/.test(arg) ||
1281
+ arg === "--ext-diff" || arg === "--exec" || arg.startsWith("--output"))) return false;
1282
+ const subcommand = args[0];
1283
+ if (subcommand === undefined) return false;
1284
+ if (["status", "diff", "log", "show", "rev-parse"].includes(subcommand)) return true;
1285
+ if (subcommand !== "branch") return false;
1286
+ if (args.length === 1) return true;
1287
+ return args.slice(1).every((arg) =>
1288
+ ["--show-current", "--list", "-a", "-r", "-v", "-vv", "--merged", "--no-merged", "--contains", "--points-at", "--format"]
1289
+ .some((safe) => arg === safe || arg.startsWith(`${safe}=`)));
1290
+ }
1291
+
1292
+ function isReadOnlySed(args: readonly string[]): boolean {
1293
+ if (args.some((arg) => arg === "-i" || arg.startsWith("--in-place") || arg === "-e" ||
1294
+ arg === "--expression" || arg === "-f" || arg === "--file")) return false;
1295
+ return args[0] === "-n" && args[1] !== undefined && /^\d+(?:,\d+)?p$/.test(args[1]);
1296
+ }
1297
+
1298
+ function isReadOnlyGh(args: readonly string[]): boolean {
1299
+ const [group, subcommand] = args;
1300
+ if (group === "label" && subcommand === "list") return true;
1301
+ if (group === "api") {
1302
+ return !args.some((arg) => arg === "-X" || arg === "--method" || arg.startsWith("-X") ||
1303
+ arg.startsWith("--method=") || ["-f", "--field", "-F", "--raw-field", "--input"].includes(arg));
1304
+ }
1305
+ return new Set(["pr view", "pr list", "pr diff", "pr checks", "issue view", "issue list", "run view",
1306
+ "run list", "repo view", "auth status", "status", "search"]).has(`${group} ${subcommand}`);
1307
+ }
1308
+
1309
+ /**
1310
+ * True only when one parsed simple command is read-only. Callers pass an
1311
+ * already-split segment, so chaining, redirection and wrappers are handled
1312
+ * before this point.
1313
+ *
1314
+ * `globs` marks words that came from an unquoted expansion (`*`, `?`, `[ab]`,
1315
+ * `{a,b}`, `$name`), which Bash can turn into several arguments. A literal flag
1316
+ * policy cannot see what those arguments will be: `sort --out{put,put}=out file`
1317
+ * becomes two `--output=out` options and writes a file, and `uniq *.txt` can
1318
+ * become `uniq a.txt b.txt`. An invocation with an uncertain word is therefore
1319
+ * never read-only. Quoted expansions stay one literal word and are judged as
1320
+ * written, so `uniq '*.txt'` remains a single operand.
1321
+ */
1322
+ export function isReadOnlyInvocation(words: readonly string[], globs: readonly boolean[] = []): boolean {
1323
+ if (globs.some(Boolean)) return false;
1324
+ const command = basename(words[0] ?? "");
1325
+ const args = words.slice(1);
1326
+ // Own properties only: `./constructor` must not resolve to `Object`.
1327
+ if (Object.hasOwn(POLICIES, command)) return POLICIES[command](args);
1328
+ if (READ_ONLY.has(command)) return true;
1329
+ if (command === "git") return isReadOnlyGit(args);
1330
+ if (command === "find") return !args.some((arg) => /^-(?:fprint|fprint0|fprintf|fls)$/.test(arg));
1331
+ if (command === "sed") return isReadOnlySed(args);
1332
+ if (command === "gh") return isReadOnlyGh(args);
1333
+ if (command === "docker") return args[0] === "logs";
1334
+ if (command === "bun")
1335
+ return args[0] === "test" || (args[0] === "run" && (args[1] === "lint" || args[1] === "typecheck"));
1336
+ return false;
1337
+ }