@gotgenes/pi-permission-system 40.1.1 → 40.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/docs/configuration.md +16 -0
- package/package.json +1 -1
- package/src/access-intent/bash/wrapper-analysis.ts +387 -15
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [40.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.2...pi-permission-system-v40.1.3) (2026-10-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** xargs -J and GNU xargs -i/-l no longer earn the pure-reader exemption for the wrong command ([c28e296](https://github.com/gotgenes/pi-packages/commit/c28e29606842def673e7fc4f0b419ee70661736e)), closes [#1053](https://github.com/gotgenes/pi-packages/issues/1053)
|
|
14
|
+
* **pi-permission-system:** xargs reads every option its BSD and GNU manuals list ([e8be7a8](https://github.com/gotgenes/pi-packages/commit/e8be7a8e5be43e729aebe076e75af6ce814d2102)), closes [#1053](https://github.com/gotgenes/pi-packages/issues/1053)
|
|
15
|
+
* **pi-permission-system:** env's chdir, split-string, altpath, and argv0 modes keep the indirection floor ([40a1e1b](https://github.com/gotgenes/pi-packages/commit/40a1e1b6dc19cc90990288a4fce8cb10ea1468c9)), closes [#1053](https://github.com/gotgenes/pi-packages/issues/1053)
|
|
16
|
+
* **pi-permission-system:** doas -a, -C, and -s keep the indirection floor ([0a1dada](https://github.com/gotgenes/pi-packages/commit/0a1dada433d6054b580dc46cbeadaf7531fb84b1)), closes [#1053](https://github.com/gotgenes/pi-packages/issues/1053)
|
|
17
|
+
* **pi-permission-system:** watch and the parallelizers no longer earn the pure-reader exemption ([5e14c70](https://github.com/gotgenes/pi-packages/commit/5e14c70cb5851a43ebfa2b98f111f2dd3176893d)), closes [#1053](https://github.com/gotgenes/pi-packages/issues/1053)
|
|
18
|
+
* **pi-permission-system:** GNU xargs --max-lines no longer earns the pure-reader exemption for the wrong command ([01e943a](https://github.com/gotgenes/pi-packages/commit/01e943ac1c6041491bc4086bc9213fb8a369b74b)), closes [#1053](https://github.com/gotgenes/pi-packages/issues/1053)
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* **pi-permission-system:** document the env, xargs, doas, and command-line wrapper floors ([ef82c63](https://github.com/gotgenes/pi-packages/commit/ef82c6378d00ca6667135ec1e0f574ca952d2372))
|
|
23
|
+
|
|
24
|
+
## [40.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.1...pi-permission-system-v40.1.2) (2026-10-09)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** sudoedit and sudo's clustered or long options no longer earn the pure-reader exemption ([b94d064](https://github.com/gotgenes/pi-packages/commit/b94d064f27e3271a40b157ae311105d14aa83c1b)), closes [#1042](https://github.com/gotgenes/pi-packages/issues/1042)
|
|
30
|
+
* **pi-permission-system:** sudo's shell, login, chdir, and chroot modes keep the indirection floor ([3e36c7b](https://github.com/gotgenes/pi-packages/commit/3e36c7b4090299a147d877d25ea332ad95a8bc0c)), closes [#1042](https://github.com/gotgenes/pi-packages/issues/1042)
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** document the sudo modes that keep the indirection floor ([bd77430](https://github.com/gotgenes/pi-packages/commit/bd774308a50d817b7913713acc963c0f49c635e0))
|
|
35
|
+
|
|
8
36
|
## [40.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.0...pi-permission-system-v40.1.1) (2026-10-08)
|
|
9
37
|
|
|
10
38
|
|
package/docs/configuration.md
CHANGED
|
@@ -1074,6 +1074,7 @@ All four of these must hold, and each is a way the floor's reason could still ap
|
|
|
1074
1074
|
1. The unit is an indirection wrapper.
|
|
1075
1075
|
An `sh -c`/`eval` payload is not one — see below.
|
|
1076
1076
|
2. The command it runs can be established without passing through an inline shell.
|
|
1077
|
+
`watch` and `parallel` hand their whole command line to a shell, and `rush` and `rust-parallel` build theirs from a template, so a unit with any of the four as a layer never qualifies: `watch cat x \; rm y` runs `rm`.
|
|
1077
1078
|
3. That command **proves** a read: a bare-basename core word with no option that withdraws the claim.
|
|
1078
1079
|
So `xargs sort -o /tmp/x` and `xargs find . -delete` stay floored, and so does `xargs ./grep foo`.
|
|
1079
1080
|
4. The enclosing statement provably writes no file through a redirect.
|
|
@@ -1138,6 +1139,21 @@ If you run a permissive `bash` policy and want privilege elevation to prompt reg
|
|
|
1138
1139
|
|
|
1139
1140
|
That rule matches the wrapper's own text, so it is decided before the exemption is ever consulted.
|
|
1140
1141
|
|
|
1142
|
+
`sudo`'s own options are read the way `sudo` reads them, so `sudo -nu cat rm x` runs `rm x`, not a pure reader named `cat`.
|
|
1143
|
+
A `sudo` mode in which the command it names is not what runs as named keeps the floor whatever that command is: `-e`/`--edit` (sudoedit edits its operands as files, as root), `-s`/`--shell` and `-i`/`--login` (a shell runs the operand), and `-D`/`--chdir` and `-R`/`--chroot` (relative operands resolve somewhere the path surfaces do not look).
|
|
1144
|
+
So does `-h`, whose meaning depends on what follows it, and any option `sudo`'s manual does not list; a mode that keeps the floor is approved at the prompt or under `yoloMode`.
|
|
1145
|
+
|
|
1146
|
+
`env`, `xargs`, and `doas` are read the same way, from the BSD and GNU manuals for the first two and `opendoas` for the third, and any option those do not list keeps the floor too.
|
|
1147
|
+
So `xargs -n 1 cat` and `env -u FOO cat x` still resolve by `cat`'s rule, while these keep the floor whatever command they name:
|
|
1148
|
+
|
|
1149
|
+
- `env -C`/`--chdir`, because relative operands resolve somewhere the path surfaces do not look (`env -C /etc cat shadow` reads `/etc/shadow`).
|
|
1150
|
+
- `env -S`/`--split-string`, because the command is split out of one word at run time (`env -Srm cat` runs `rm cat`).
|
|
1151
|
+
- `env -P`, `-a`/`--argv0`, and `--env0-from`, because each chooses which binary the named command is: a search path, the argv0 a multi-call binary dispatches on, or an environment that may set `PATH`.
|
|
1152
|
+
- `xargs -J`, because it puts input in the command's place when the command equals its replacement string.
|
|
1153
|
+
- `doas -C` (it checks a config and runs nothing), `doas -s` (it runs a shell), and OpenBSD's `doas -a`, which `opendoas` does not list.
|
|
1154
|
+
|
|
1155
|
+
GNU `xargs -i`, `-l`, `-e`, and `--max-lines` take a value only when it is attached (`-i{}`, `--max-lines=2`), so `xargs -i rm cat x` runs `rm`, and `xargs` and `doas` run a `NAME=value` word as the command rather than an assignment.
|
|
1156
|
+
|
|
1141
1157
|
#### Which key to actually write
|
|
1142
1158
|
|
|
1143
1159
|
The useful *grants* are `*_read: allow` and the bare sugar key.
|
package/package.json
CHANGED
|
@@ -163,7 +163,10 @@ export function executedUnitOf(
|
|
|
163
163
|
* 2. Unwrapping reached the inner command without passing through an opaque
|
|
164
164
|
* payload. `sh -c '…'` carries an unparsed program whose first word says
|
|
165
165
|
* nothing about the rest of it, which is why this cannot consult
|
|
166
|
-
* {@link executedUnitOf}'s string.
|
|
166
|
+
* {@link executedUnitOf}'s string. A wrapper that hands its whole command
|
|
167
|
+
* line to a shell or builds it from a template
|
|
168
|
+
* ({@link OPAQUE_COMMAND_LINE_WRAPPERS}) is that payload in another shape:
|
|
169
|
+
* `watch cat x \; rm y` runs `rm`, so no layer may be one.
|
|
167
170
|
* 3. The inner command *proves* a read — bare-basename core word, retraction
|
|
168
171
|
* guards applied, so `xargs sort -o /tmp/x` and `xargs find . -delete` are
|
|
169
172
|
* not transparent.
|
|
@@ -184,7 +187,11 @@ export function floorExemptionOf(
|
|
|
184
187
|
// Only the peeled words matter here, so the walk is handed no source span to
|
|
185
188
|
// cut from — the text slice is `executedUnitOf`'s product, not this one's.
|
|
186
189
|
const unwrapped = unwrapIndirection(words, "");
|
|
187
|
-
if (
|
|
190
|
+
if (
|
|
191
|
+
unwrapped.kind === "opaque" ||
|
|
192
|
+
unwrapped.peeled.length === 0 ||
|
|
193
|
+
unwrapped.peeled.some(runsOpaqueCommandLine)
|
|
194
|
+
) {
|
|
188
195
|
return undefined;
|
|
189
196
|
}
|
|
190
197
|
|
|
@@ -197,6 +204,12 @@ export function floorExemptionOf(
|
|
|
197
204
|
: undefined;
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
/** True when a peeled layer's wrapper does not run the words the peel sees. */
|
|
208
|
+
function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
|
|
209
|
+
const name = wrapperName(layer);
|
|
210
|
+
return name !== undefined && OPAQUE_COMMAND_LINE_WRAPPERS.has(name);
|
|
211
|
+
}
|
|
212
|
+
|
|
200
213
|
/**
|
|
201
214
|
* True when every peeled layer only changes *how* the inner command runs, and
|
|
202
215
|
* that command is a literal name the gate can resolve by its own rule.
|
|
@@ -402,10 +415,10 @@ function unquote(text: string): string {
|
|
|
402
415
|
* Index of the word beginning the inner command, or `-1` when the wrapper's own
|
|
403
416
|
* options run out first.
|
|
404
417
|
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
418
|
+
* An exec-conditional wrapper starts immediately after its exec flag. A wrapper
|
|
419
|
+
* with a {@link GETOPT_GRAMMARS} entry is read as getopt reads it, and answers
|
|
420
|
+
* `-1` for any option its grammar refuses; every other wrapper is read by the
|
|
421
|
+
* per-wrapper tables.
|
|
409
422
|
*/
|
|
410
423
|
function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
411
424
|
const name = wrapperName(words);
|
|
@@ -415,6 +428,111 @@ function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
|
415
428
|
const execFlag = execFlagIndex(name, argTexts);
|
|
416
429
|
if (execFlag !== -1) return execFlag + 2;
|
|
417
430
|
|
|
431
|
+
const grammar = GETOPT_GRAMMARS.get(name);
|
|
432
|
+
return grammar === undefined
|
|
433
|
+
? tableInnerIndex(words, name)
|
|
434
|
+
: getoptInnerIndex(words, grammar);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* {@link innerCommandIndex} for a wrapper read as getopt_long reads it, with
|
|
439
|
+
* options permuted no further than the first non-option, as every grammar here
|
|
440
|
+
* does (a `+` optstring, or BSD getopt, which never permutes).
|
|
441
|
+
*
|
|
442
|
+
* Options come first, then — for a wrapper that takes them — the `NAME=value`
|
|
443
|
+
* assignments it passes to the command, then the command. `-1` when an option
|
|
444
|
+
* is refused, unlisted, or an ambiguous abbreviation, or when the words run out
|
|
445
|
+
* first.
|
|
446
|
+
*/
|
|
447
|
+
function getoptInnerIndex(
|
|
448
|
+
words: readonly CommandWord[],
|
|
449
|
+
grammar: GetoptGrammar,
|
|
450
|
+
): number {
|
|
451
|
+
let index = 1;
|
|
452
|
+
while (index < words.length) {
|
|
453
|
+
const word = words[index].text;
|
|
454
|
+
if (word === "--" || (word === "-" && grammar.loneDashEndsOptions)) {
|
|
455
|
+
index++;
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
if (!word.startsWith("-") || word === "-") break;
|
|
459
|
+
const consumed = word.startsWith("--")
|
|
460
|
+
? longOptionWords(word.slice(2), grammar.long)
|
|
461
|
+
: shortClusterWords(word.slice(1), grammar.short);
|
|
462
|
+
if (consumed === undefined) return -1;
|
|
463
|
+
index += consumed;
|
|
464
|
+
}
|
|
465
|
+
while (
|
|
466
|
+
grammar.assignments &&
|
|
467
|
+
index < words.length &&
|
|
468
|
+
isEnvironmentAssignment(words[index].text)
|
|
469
|
+
) {
|
|
470
|
+
index++;
|
|
471
|
+
}
|
|
472
|
+
return index < words.length ? index : -1;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* How many words a short-option cluster spans (`-nu root` is two), or
|
|
477
|
+
* `undefined` when a letter in it is refused or unlisted.
|
|
478
|
+
*
|
|
479
|
+
* A value-taking letter ends the cluster: the rest of the cluster is its value
|
|
480
|
+
* (`-uedward`), or the following word when nothing is left (`-nu root`). An
|
|
481
|
+
* optional-argument letter ends it too, but its value is only ever the rest of
|
|
482
|
+
* the cluster (`-i{}`), never the following word (`-i rm cat x` runs `rm`).
|
|
483
|
+
*/
|
|
484
|
+
function shortClusterWords(
|
|
485
|
+
letters: string,
|
|
486
|
+
table: ReadonlyMap<string, OptionArity>,
|
|
487
|
+
): number | undefined {
|
|
488
|
+
for (let position = 0; position < letters.length; position++) {
|
|
489
|
+
const arity = table.get(letters[position]);
|
|
490
|
+
if (arity === undefined || arity === "refuse") return undefined;
|
|
491
|
+
if (arity === "optional") return 1;
|
|
492
|
+
if (arity === "value") return position + 1 < letters.length ? 1 : 2;
|
|
493
|
+
}
|
|
494
|
+
return 1;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* How many words a long option spans (`--user root` is two, `--user=root`
|
|
499
|
+
* one), or `undefined` when it is refused, unlisted, or ambiguous. A flag or an
|
|
500
|
+
* optional argument (`--replace[=R]`) never takes the following word.
|
|
501
|
+
*/
|
|
502
|
+
function longOptionWords(
|
|
503
|
+
body: string,
|
|
504
|
+
table: ReadonlyMap<string, OptionArity>,
|
|
505
|
+
): number | undefined {
|
|
506
|
+
const equals = body.indexOf("=");
|
|
507
|
+
const name = equals === -1 ? body : body.slice(0, equals);
|
|
508
|
+
const arity = resolveLongOption(name, table);
|
|
509
|
+
if (arity === undefined || arity === "refuse") return undefined;
|
|
510
|
+
return arity === "value" && equals === -1 ? 2 : 1;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* A long option's arity by its exact name, or by the one key it abbreviates.
|
|
515
|
+
*
|
|
516
|
+
* Abbreviations resolve against every key, refusing ones included, as
|
|
517
|
+
* getopt_long does: `--l` is ambiguous between `--list` and `--login`, and
|
|
518
|
+
* resolving against the admitted keys alone would read it as `--list`.
|
|
519
|
+
*/
|
|
520
|
+
function resolveLongOption(
|
|
521
|
+
name: string,
|
|
522
|
+
table: ReadonlyMap<string, OptionArity>,
|
|
523
|
+
): OptionArity | undefined {
|
|
524
|
+
const exact = table.get(name);
|
|
525
|
+
if (exact !== undefined) return exact;
|
|
526
|
+
const matches = [...table.keys()].filter((key) => key.startsWith(name));
|
|
527
|
+
return matches.length === 1 ? table.get(matches[0]) : undefined;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* {@link innerCommandIndex} for a wrapper described by the per-wrapper tables:
|
|
532
|
+
* its value-taking options ({@link VALUE_TAKING_FLAGS}) and whether it takes a
|
|
533
|
+
* leading operand ({@link LEADING_OPERAND_WRAPPERS}).
|
|
534
|
+
*/
|
|
535
|
+
function tableInnerIndex(words: readonly CommandWord[], name: string): number {
|
|
418
536
|
const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
|
|
419
537
|
let operandPending = LEADING_OPERAND_WRAPPERS.has(name);
|
|
420
538
|
let index = 1;
|
|
@@ -535,17 +653,15 @@ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
|
|
|
535
653
|
* Curated per-wrapper options that consume the following word, so skipping a
|
|
536
654
|
* wrapper's own arguments does not mistake an option's value for the inner
|
|
537
655
|
* command. Attached forms (`-I{}`, `--user=root`) need no entry — they are one
|
|
538
|
-
* word.
|
|
539
|
-
*
|
|
656
|
+
* word.
|
|
657
|
+
*
|
|
658
|
+
* This table decides the gate, not only the display: {@link floorExemptionOf}
|
|
659
|
+
* judges the command it locates, so a missing entry can name a pure reader that
|
|
660
|
+
* is really an option's value (GNU `time --out cat rm x`, #1057), and the
|
|
661
|
+
* execution-modifier clause admits value-taking options from it. A wrapper
|
|
662
|
+
* read by a {@link GETOPT_GRAMMARS} entry has no row here.
|
|
540
663
|
*/
|
|
541
664
|
const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
542
|
-
["sudo", new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-r", "-t"])],
|
|
543
|
-
["doas", new Set(["-u", "-C"])],
|
|
544
|
-
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
|
|
545
|
-
[
|
|
546
|
-
"xargs",
|
|
547
|
-
new Set(["-n", "-P", "-I", "-i", "-d", "-E", "-L", "-l", "-s", "-a"]),
|
|
548
|
-
],
|
|
549
665
|
["timeout", new Set(["-s", "-k", "--signal", "--kill-after"])],
|
|
550
666
|
["nice", new Set(["-n", "--adjustment"])],
|
|
551
667
|
["time", new Set(["-o", "-f", "--output", "--format"])],
|
|
@@ -556,6 +672,247 @@ const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
|
556
672
|
|
|
557
673
|
const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
|
|
558
674
|
|
|
675
|
+
/**
|
|
676
|
+
* How a getopt-parsed wrapper treats one of its options: a flag, an option
|
|
677
|
+
* taking a value, an option whose value is optional and so only ever attached
|
|
678
|
+
* (getopt's `::`), or a mode in which the command the peel would name is not
|
|
679
|
+
* the one that runs, which refuses the peel.
|
|
680
|
+
*/
|
|
681
|
+
type OptionArity = "flag" | "value" | "optional" | "refuse";
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* A wrapper's options as getopt_long reads them, keyed by short letter and by
|
|
685
|
+
* full long name. Anything absent refuses, so a missing row costs a prompt and
|
|
686
|
+
* never misplaces the inner command.
|
|
687
|
+
*/
|
|
688
|
+
interface GetoptGrammar {
|
|
689
|
+
readonly short: ReadonlyMap<string, OptionArity>;
|
|
690
|
+
readonly long: ReadonlyMap<string, OptionArity>;
|
|
691
|
+
/**
|
|
692
|
+
* Whether `NAME=value` words after the options are assignments the wrapper
|
|
693
|
+
* passes to the command. `xargs` and `doas` run such a word as the command.
|
|
694
|
+
*/
|
|
695
|
+
readonly assignments: boolean;
|
|
696
|
+
/**
|
|
697
|
+
* Whether a lone `-` is an option that ends the option walk, as `env -`
|
|
698
|
+
* (clear the environment) is; otherwise it is the command, which names
|
|
699
|
+
* nothing.
|
|
700
|
+
*/
|
|
701
|
+
readonly loneDashEndsOptions: boolean;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* `sudo`'s options, verified against `man sudo` (1.9.17p2). The SELinux
|
|
706
|
+
* `-r`/`-t` (`--role`/`--type`) are absent from that page, so they are left
|
|
707
|
+
* unlisted and refuse.
|
|
708
|
+
*
|
|
709
|
+
* Each refusing mode is one where the command the peel would name is not what
|
|
710
|
+
* runs as named: `-e` (sudoedit) opens each operand in an editor and writes it
|
|
711
|
+
* back as root; `-s` and `-i` hand the operand to a shell, which still expands
|
|
712
|
+
* `$`; `-i`, `-D`, and `-R` move where its relative operands resolve, so the
|
|
713
|
+
* path surfaces would judge the wrong file; and `-h` is help alone but a remote
|
|
714
|
+
* host with a value, an arity the page does not settle.
|
|
715
|
+
*/
|
|
716
|
+
const SUDO_GRAMMAR: GetoptGrammar = {
|
|
717
|
+
short: new Map<string, OptionArity>([
|
|
718
|
+
["A", "flag"],
|
|
719
|
+
["B", "flag"],
|
|
720
|
+
["b", "flag"],
|
|
721
|
+
["E", "flag"],
|
|
722
|
+
["H", "flag"],
|
|
723
|
+
["K", "flag"],
|
|
724
|
+
["k", "flag"],
|
|
725
|
+
["l", "flag"],
|
|
726
|
+
["N", "flag"],
|
|
727
|
+
["n", "flag"],
|
|
728
|
+
["P", "flag"],
|
|
729
|
+
["S", "flag"],
|
|
730
|
+
["V", "flag"],
|
|
731
|
+
["v", "flag"],
|
|
732
|
+
["C", "value"],
|
|
733
|
+
["g", "value"],
|
|
734
|
+
["p", "value"],
|
|
735
|
+
["T", "value"],
|
|
736
|
+
["U", "value"],
|
|
737
|
+
["u", "value"],
|
|
738
|
+
["D", "refuse"],
|
|
739
|
+
["e", "refuse"],
|
|
740
|
+
["h", "refuse"],
|
|
741
|
+
["i", "refuse"],
|
|
742
|
+
["R", "refuse"],
|
|
743
|
+
["s", "refuse"],
|
|
744
|
+
]),
|
|
745
|
+
long: new Map<string, OptionArity>([
|
|
746
|
+
["askpass", "flag"],
|
|
747
|
+
["background", "flag"],
|
|
748
|
+
["bell", "flag"],
|
|
749
|
+
["help", "flag"],
|
|
750
|
+
["list", "flag"],
|
|
751
|
+
["no-update", "flag"],
|
|
752
|
+
["non-interactive", "flag"],
|
|
753
|
+
// Its value is attached only (`--preserve-env=list`), which a flag accepts.
|
|
754
|
+
["preserve-env", "flag"],
|
|
755
|
+
["preserve-groups", "flag"],
|
|
756
|
+
["remove-timestamp", "flag"],
|
|
757
|
+
["reset-timestamp", "flag"],
|
|
758
|
+
["set-home", "flag"],
|
|
759
|
+
["stdin", "flag"],
|
|
760
|
+
["validate", "flag"],
|
|
761
|
+
["version", "flag"],
|
|
762
|
+
["close-from", "value"],
|
|
763
|
+
["command-timeout", "value"],
|
|
764
|
+
["group", "value"],
|
|
765
|
+
["other-user", "value"],
|
|
766
|
+
["prompt", "value"],
|
|
767
|
+
["user", "value"],
|
|
768
|
+
["chdir", "refuse"],
|
|
769
|
+
["chroot", "refuse"],
|
|
770
|
+
["edit", "refuse"],
|
|
771
|
+
["host", "refuse"],
|
|
772
|
+
["login", "refuse"],
|
|
773
|
+
["shell", "refuse"],
|
|
774
|
+
]),
|
|
775
|
+
assignments: true,
|
|
776
|
+
loneDashEndsOptions: false,
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* `env`'s options: the union of BSD `env` (optstring `-0C:iP:S:u:v`, no long
|
|
781
|
+
* options) and GNU coreutils 9.12 (`env --help`). No letter has different
|
|
782
|
+
* arities on the two.
|
|
783
|
+
*
|
|
784
|
+
* Each refusing option is one where the command the peel would name is not
|
|
785
|
+
* what runs as named: `-C`/`--chdir` move where its relative operands
|
|
786
|
+
* resolve, so the path surfaces would judge the wrong file; `-S`/
|
|
787
|
+
* `--split-string` split one word into several, so the command is built at
|
|
788
|
+
* run time; and `-P` (a search path), `-a`/`--argv0` (the argv0 a multi-call
|
|
789
|
+
* binary dispatches on), and `--env0-from` (an environment that may set
|
|
790
|
+
* `PATH`) choose which binary the named command is.
|
|
791
|
+
*
|
|
792
|
+
* A lone `-` clears the environment and, as GNU reads it, ends the options;
|
|
793
|
+
* BSD keeps parsing after it, so ending there only ever names less.
|
|
794
|
+
*/
|
|
795
|
+
const ENV_GRAMMAR: GetoptGrammar = {
|
|
796
|
+
short: new Map<string, OptionArity>([
|
|
797
|
+
["0", "flag"],
|
|
798
|
+
["i", "flag"],
|
|
799
|
+
["v", "flag"],
|
|
800
|
+
["u", "value"],
|
|
801
|
+
["a", "refuse"],
|
|
802
|
+
["C", "refuse"],
|
|
803
|
+
["P", "refuse"],
|
|
804
|
+
["S", "refuse"],
|
|
805
|
+
]),
|
|
806
|
+
long: new Map<string, OptionArity>([
|
|
807
|
+
["debug", "flag"],
|
|
808
|
+
["help", "flag"],
|
|
809
|
+
["ignore-environment", "flag"],
|
|
810
|
+
["list-signal-handling", "flag"],
|
|
811
|
+
["null", "flag"],
|
|
812
|
+
["version", "flag"],
|
|
813
|
+
["unset", "value"],
|
|
814
|
+
["block-signal", "optional"],
|
|
815
|
+
["default-signal", "optional"],
|
|
816
|
+
["ignore-signal", "optional"],
|
|
817
|
+
["argv0", "refuse"],
|
|
818
|
+
["chdir", "refuse"],
|
|
819
|
+
["env0-from", "refuse"],
|
|
820
|
+
["split-string", "refuse"],
|
|
821
|
+
]),
|
|
822
|
+
assignments: true,
|
|
823
|
+
loneDashEndsOptions: true,
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* `xargs`'s options: the union of BSD `xargs` (optstring
|
|
828
|
+
* `+0E:I:J:L:n:oP:pR:S:s:rtx`) and GNU findutils 4.9 (`xargs --help`). No
|
|
829
|
+
* letter has different arities on the two, and BSD's long names are a subset
|
|
830
|
+
* of GNU's, so an option one platform rejects only makes it run nothing.
|
|
831
|
+
*
|
|
832
|
+
* GNU's `-i`, `-l`, and `-e` take an optional value, attached only, so
|
|
833
|
+
* `xargs -i rm cat x` runs `rm`. So does `--max-lines`, though its `--help`
|
|
834
|
+
* line reads as required and its short form `-L` does take the next word:
|
|
835
|
+
* `xargs --max-lines echo A B` runs `echo`. BSD's `-J` refuses: it puts input
|
|
836
|
+
* in the utility position when the utility equals its replstr
|
|
837
|
+
* (`xargs -J % % x`).
|
|
838
|
+
*/
|
|
839
|
+
const XARGS_GRAMMAR: GetoptGrammar = {
|
|
840
|
+
short: new Map<string, OptionArity>([
|
|
841
|
+
["0", "flag"],
|
|
842
|
+
["o", "flag"],
|
|
843
|
+
["p", "flag"],
|
|
844
|
+
["r", "flag"],
|
|
845
|
+
["t", "flag"],
|
|
846
|
+
["x", "flag"],
|
|
847
|
+
["a", "value"],
|
|
848
|
+
["d", "value"],
|
|
849
|
+
["E", "value"],
|
|
850
|
+
["I", "value"],
|
|
851
|
+
["L", "value"],
|
|
852
|
+
["n", "value"],
|
|
853
|
+
["P", "value"],
|
|
854
|
+
["R", "value"],
|
|
855
|
+
["S", "value"],
|
|
856
|
+
["s", "value"],
|
|
857
|
+
["e", "optional"],
|
|
858
|
+
["i", "optional"],
|
|
859
|
+
["l", "optional"],
|
|
860
|
+
["J", "refuse"],
|
|
861
|
+
]),
|
|
862
|
+
long: new Map<string, OptionArity>([
|
|
863
|
+
["exit", "flag"],
|
|
864
|
+
["help", "flag"],
|
|
865
|
+
["interactive", "flag"],
|
|
866
|
+
["no-run-if-empty", "flag"],
|
|
867
|
+
["null", "flag"],
|
|
868
|
+
["open-tty", "flag"],
|
|
869
|
+
["show-limits", "flag"],
|
|
870
|
+
["verbose", "flag"],
|
|
871
|
+
["version", "flag"],
|
|
872
|
+
["arg-file", "value"],
|
|
873
|
+
["delimiter", "value"],
|
|
874
|
+
["max-args", "value"],
|
|
875
|
+
["max-chars", "value"],
|
|
876
|
+
["max-procs", "value"],
|
|
877
|
+
["process-slot-var", "value"],
|
|
878
|
+
["eof", "optional"],
|
|
879
|
+
["max-lines", "optional"],
|
|
880
|
+
["replace", "optional"],
|
|
881
|
+
]),
|
|
882
|
+
assignments: false,
|
|
883
|
+
loneDashEndsOptions: false,
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* `doas`'s options, verified against `opendoas`
|
|
888
|
+
* (`usage: doas [-Lns] [-C config] [-u user] command [args]`); it takes no
|
|
889
|
+
* long options and never permutes. OpenBSD's `-a style` is absent there, so
|
|
890
|
+
* it is left unlisted and refuses rather than admitted unverified.
|
|
891
|
+
*
|
|
892
|
+
* `-C` checks a config and runs nothing, so the command it would name never
|
|
893
|
+
* runs as named; `-s` runs a shell.
|
|
894
|
+
*/
|
|
895
|
+
const DOAS_GRAMMAR: GetoptGrammar = {
|
|
896
|
+
short: new Map<string, OptionArity>([
|
|
897
|
+
["L", "flag"],
|
|
898
|
+
["n", "flag"],
|
|
899
|
+
["u", "value"],
|
|
900
|
+
["C", "refuse"],
|
|
901
|
+
["s", "refuse"],
|
|
902
|
+
]),
|
|
903
|
+
long: new Map<string, OptionArity>(),
|
|
904
|
+
assignments: false,
|
|
905
|
+
loneDashEndsOptions: false,
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
/** Wrappers whose inner command is located by a getopt grammar. */
|
|
909
|
+
const GETOPT_GRAMMARS = new Map<string, GetoptGrammar>([
|
|
910
|
+
["sudo", SUDO_GRAMMAR],
|
|
911
|
+
["env", ENV_GRAMMAR],
|
|
912
|
+
["xargs", XARGS_GRAMMAR],
|
|
913
|
+
["doas", DOAS_GRAMMAR],
|
|
914
|
+
]);
|
|
915
|
+
|
|
559
916
|
/**
|
|
560
917
|
* Wrappers that change only *how* the same visible command runs — timing, kill
|
|
561
918
|
* deadline, scheduling, buffering, session — each with the flags (options
|
|
@@ -634,6 +991,21 @@ const RESERVED_WORDS: ReadonlySet<string> = new Set([
|
|
|
634
991
|
*/
|
|
635
992
|
const LEADING_OPERAND_WRAPPERS = new Set(["timeout", "flock"]);
|
|
636
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Wrappers whose executed command line is not the words the peel sees, so a
|
|
996
|
+
* core-looking first word says nothing about what runs. `watch` (without
|
|
997
|
+
* `-x`) passes its words to `sh -c`, and GNU `parallel` runs its command
|
|
998
|
+
* through a shell; both verified, where `watch cat x \; rm y` runs `rm`.
|
|
999
|
+
* `rush` and `rust-parallel` build command lines from templates and have no
|
|
1000
|
+
* local binary to verify how, so they are refused rather than admitted.
|
|
1001
|
+
*/
|
|
1002
|
+
const OPAQUE_COMMAND_LINE_WRAPPERS: ReadonlySet<string> = new Set([
|
|
1003
|
+
"watch",
|
|
1004
|
+
"parallel",
|
|
1005
|
+
"rush",
|
|
1006
|
+
"rust-parallel",
|
|
1007
|
+
]);
|
|
1008
|
+
|
|
637
1009
|
/** Words ending a `find -exec` clause; they belong to `find`, not its command. */
|
|
638
1010
|
const EXEC_TERMINATORS = new Set([";", "+"]);
|
|
639
1011
|
|