@mortz.prk/opencode-auto-mode 1.0.2 → 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.
@@ -197,6 +197,537 @@ export function resolveExecutable(words: readonly string[]): { name: string; ind
197
197
  return index < words.length ? { name: basename(words[index]), index } : undefined;
198
198
  }
199
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
+
200
731
  /**
201
732
  * True when an argument names one of the given long options, including the
202
733
  * abbreviations GNU option parsing accepts (`sort --out=/tmp/out` means
@@ -211,14 +742,27 @@ function hasLongOption(args: readonly string[], names: readonly string[]): boole
211
742
  });
212
743
  }
213
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
+
214
755
  function hasShortOption(args: readonly string[], letters: string): boolean {
215
756
  return args.some((arg) => arg.startsWith("-") && !arg.startsWith("--") &&
216
757
  [...arg.slice(1)].some((character) => letters.includes(character)));
217
758
  }
218
759
 
219
760
  function isWorldWritableMode(arg: string): boolean {
220
- const mode = arg.replace(/^[-+]+/, "");
221
- return mode === "777" || mode === "0777";
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);
222
766
  }
223
767
 
224
768
  /** chmod options, including the BSD flags, that never consume the mode operand. */
@@ -261,7 +805,8 @@ function isRecursiveRootRemoval(args: readonly string[]): boolean {
261
805
  if (endOfOptions) { targets.push(arg); continue; }
262
806
  if (arg === "--") { endOfOptions = true; continue; }
263
807
  if (arg.startsWith("--")) {
264
- if (arg === "--recursive") recursive = true;
808
+ // GNU option parsing accepts `--recursi` for `--recursive`.
809
+ if (isLongOption(arg, "--recursive")) recursive = true;
265
810
  continue;
266
811
  }
267
812
  if (arg.startsWith("-") && arg.length > 1) {
@@ -666,22 +1211,7 @@ export function rawHeredocScript(command: string): RawHeredocScript {
666
1211
  return { lines, ambiguous: false };
667
1212
  }
668
1213
 
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
-
1214
+ /** The copied bodies and the offset where the command script resumes. */
685
1215
  interface HeredocCopy {
686
1216
  text: string;
687
1217
  end: number;
@@ -1138,6 +1668,21 @@ export function denyInvocation(words: readonly string[], depth = 0): string | un
1138
1668
  const executable = resolveExecutable(words);
1139
1669
  if (executable === undefined) return undefined;
1140
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;
1141
1686
  if (executable.name.startsWith("mkfs")) return DANGEROUS;
1142
1687
  if (DENY_EXECUTABLES.has(executable.name)) return DANGEROUS;
1143
1688
  if (executable.name === "chmod" && isWorldWritableMode(chmodMode(args) ?? "")) return DANGEROUS;
@@ -1223,6 +1768,8 @@ function isReadOnlyDate(args: readonly string[]): boolean {
1223
1768
  if (arg.startsWith("--") && arg.length > 2) {
1224
1769
  const name = arg.split("=", 1)[0];
1225
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;
1226
1773
  if (DATE_LONG_VALUE_FLAGS.has(name)) {
1227
1774
  if (!attached) index++;
1228
1775
  continue;
@@ -1234,6 +1781,15 @@ function isReadOnlyDate(args: readonly string[]): boolean {
1234
1781
  for (let position = 1; position < arg.length; position++) {
1235
1782
  const letter = arg[position];
1236
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
+ }
1237
1793
  if (DATE_VALUE_FLAGS.has(letter)) {
1238
1794
  if (position === arg.length - 1) index++;
1239
1795
  break;
@@ -1244,14 +1800,87 @@ function isReadOnlyDate(args: readonly string[]): boolean {
1244
1800
  return true;
1245
1801
  }
1246
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
+
1247
1865
  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"),
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"),
1255
1884
  // `cache --build` / `--clear` mutate the cache, and `--lessopen` runs the
1256
1885
  // configured pager preprocessor.
1257
1886
  bat: (args) => !hasLongOption(args, ["--pager", "--config-file", "--config-dir", "--generate-config-file",
@@ -1269,6 +1898,7 @@ const POLICIES: Record<string, (args: readonly string[]) => boolean> = {
1269
1898
  return !hasLongOption(args, ["--file", "--boot"]) && !hasShortOption(args, "Fb");
1270
1899
  },
1271
1900
  date: isReadOnlyDate,
1901
+ jq: isReadOnlyJq,
1272
1902
  };
1273
1903
 
1274
1904
  const READ_ONLY = new Set(["pwd", "whoami", "id", "groups", "ls", "cat", "head", "tail", "wc", "echo", "printf",
@@ -1290,9 +1920,10 @@ function isReadOnlyGit(args: readonly string[]): boolean {
1290
1920
  }
1291
1921
 
1292
1922
  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]);
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("-"));
1296
1927
  }
1297
1928
 
1298
1929
  function isReadOnlyGh(args: readonly string[]): boolean {
@@ -1321,13 +1952,24 @@ function isReadOnlyGh(args: readonly string[]): boolean {
1321
1952
  */
1322
1953
  export function isReadOnlyInvocation(words: readonly string[], globs: readonly boolean[] = []): boolean {
1323
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;
1324
1957
  const command = basename(words[0] ?? "");
1325
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
+ }
1326
1967
  // Own properties only: `./constructor` must not resolve to `Object`.
1327
1968
  if (Object.hasOwn(POLICIES, command)) return POLICIES[command](args);
1328
1969
  if (READ_ONLY.has(command)) return true;
1329
1970
  if (command === "git") return isReadOnlyGit(args);
1330
- if (command === "find") return !args.some((arg) => /^-(?:fprint|fprint0|fprintf|fls)$/.test(arg));
1971
+ if (command === "find")
1972
+ return !args.some((arg) => /^--?(?:fprint|fprint0|fprintf|fls)$/.test(arg) || /^--?files0/.test(arg));
1331
1973
  if (command === "sed") return isReadOnlySed(args);
1332
1974
  if (command === "gh") return isReadOnlyGh(args);
1333
1975
  if (command === "docker") return args[0] === "logs";