@gotgenes/pi-permission-system 40.1.2 → 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 CHANGED
@@ -5,6 +5,22 @@ 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
+
8
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)
9
25
 
10
26
 
@@ -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.
@@ -1142,6 +1143,17 @@ That rule matches the wrapper's own text, so it is decided before the exemption
1142
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).
1143
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`.
1144
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
+
1145
1157
  #### Which key to actually write
1146
1158
 
1147
1159
  The useful *grants* are `*_read: allow` and the bare sugar key.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "40.1.2",
3
+ "version": "40.1.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 (unwrapped.kind === "opaque" || unwrapped.peeled.length === 0) {
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.
@@ -423,11 +436,13 @@ function innerCommandIndex(words: readonly CommandWord[]): number {
423
436
 
424
437
  /**
425
438
  * {@link innerCommandIndex} for a wrapper read as getopt_long reads it, with
426
- * options permuted no further than the first non-option (sudo's `+` optstring).
439
+ * options permuted no further than the first non-option, as every grammar here
440
+ * does (a `+` optstring, or BSD getopt, which never permutes).
427
441
  *
428
- * Options come first, then the `NAME=value` assignments the wrapper passes to
429
- * the command, then the command. `-1` when an option is refused, unlisted, or
430
- * an ambiguous abbreviation, or when the words run out first.
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.
431
446
  */
432
447
  function getoptInnerIndex(
433
448
  words: readonly CommandWord[],
@@ -436,7 +451,7 @@ function getoptInnerIndex(
436
451
  let index = 1;
437
452
  while (index < words.length) {
438
453
  const word = words[index].text;
439
- if (word === "--") {
454
+ if (word === "--" || (word === "-" && grammar.loneDashEndsOptions)) {
440
455
  index++;
441
456
  break;
442
457
  }
@@ -447,7 +462,11 @@ function getoptInnerIndex(
447
462
  if (consumed === undefined) return -1;
448
463
  index += consumed;
449
464
  }
450
- while (index < words.length && isEnvironmentAssignment(words[index].text)) {
465
+ while (
466
+ grammar.assignments &&
467
+ index < words.length &&
468
+ isEnvironmentAssignment(words[index].text)
469
+ ) {
451
470
  index++;
452
471
  }
453
472
  return index < words.length ? index : -1;
@@ -458,7 +477,9 @@ function getoptInnerIndex(
458
477
  * `undefined` when a letter in it is refused or unlisted.
459
478
  *
460
479
  * A value-taking letter ends the cluster: the rest of the cluster is its value
461
- * (`-uedward`), or the following word when nothing is left (`-nu root`).
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`).
462
483
  */
463
484
  function shortClusterWords(
464
485
  letters: string,
@@ -467,6 +488,7 @@ function shortClusterWords(
467
488
  for (let position = 0; position < letters.length; position++) {
468
489
  const arity = table.get(letters[position]);
469
490
  if (arity === undefined || arity === "refuse") return undefined;
491
+ if (arity === "optional") return 1;
470
492
  if (arity === "value") return position + 1 < letters.length ? 1 : 2;
471
493
  }
472
494
  return 1;
@@ -474,7 +496,8 @@ function shortClusterWords(
474
496
 
475
497
  /**
476
498
  * How many words a long option spans (`--user root` is two, `--user=root`
477
- * one), or `undefined` when it is refused, unlisted, or ambiguous.
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.
478
501
  */
479
502
  function longOptionWords(
480
503
  body: string,
@@ -634,17 +657,11 @@ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
634
657
  *
635
658
  * This table decides the gate, not only the display: {@link floorExemptionOf}
636
659
  * judges the command it locates, so a missing entry can name a pure reader that
637
- * is really an option's value (`xargs -J cat rm x`), and the execution-modifier
638
- * clause admits value-taking options from it. A wrapper read by a
639
- * {@link GETOPT_GRAMMARS} entry has no row here.
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.
640
663
  */
641
664
  const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
642
- ["doas", new Set(["-u", "-C"])],
643
- ["env", new Set(["-u", "-C", "--unset", "--chdir"])],
644
- [
645
- "xargs",
646
- new Set(["-n", "-P", "-I", "-i", "-d", "-E", "-L", "-l", "-s", "-a"]),
647
- ],
648
665
  ["timeout", new Set(["-s", "-k", "--signal", "--kill-after"])],
649
666
  ["nice", new Set(["-n", "--adjustment"])],
650
667
  ["time", new Set(["-o", "-f", "--output", "--format"])],
@@ -657,10 +674,11 @@ const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
657
674
 
658
675
  /**
659
676
  * How a getopt-parsed wrapper treats one of its options: a flag, an option
660
- * taking a value, or a mode in which the command the peel would name is not
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
661
679
  * the one that runs, which refuses the peel.
662
680
  */
663
- type OptionArity = "flag" | "value" | "refuse";
681
+ type OptionArity = "flag" | "value" | "optional" | "refuse";
664
682
 
665
683
  /**
666
684
  * A wrapper's options as getopt_long reads them, keyed by short letter and by
@@ -670,6 +688,17 @@ type OptionArity = "flag" | "value" | "refuse";
670
688
  interface GetoptGrammar {
671
689
  readonly short: ReadonlyMap<string, OptionArity>;
672
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;
673
702
  }
674
703
 
675
704
  /**
@@ -743,11 +772,145 @@ const SUDO_GRAMMAR: GetoptGrammar = {
743
772
  ["login", "refuse"],
744
773
  ["shell", "refuse"],
745
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,
746
906
  };
747
907
 
748
908
  /** Wrappers whose inner command is located by a getopt grammar. */
749
909
  const GETOPT_GRAMMARS = new Map<string, GetoptGrammar>([
750
910
  ["sudo", SUDO_GRAMMAR],
911
+ ["env", ENV_GRAMMAR],
912
+ ["xargs", XARGS_GRAMMAR],
913
+ ["doas", DOAS_GRAMMAR],
751
914
  ]);
752
915
 
753
916
  /**
@@ -828,6 +991,21 @@ const RESERVED_WORDS: ReadonlySet<string> = new Set([
828
991
  */
829
992
  const LEADING_OPERAND_WRAPPERS = new Set(["timeout", "flock"]);
830
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
+
831
1009
  /** Words ending a `find -exec` clause; they belong to `find`, not its command. */
832
1010
  const EXEC_TERMINATORS = new Set([";", "+"]);
833
1011