@gotgenes/pi-permission-system 40.1.2 → 40.1.4

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,36 @@ 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.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.3...pi-permission-system-v40.1.4) (2026-10-10)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** scope a wrapper's session approval to its inner command ([97f805f](https://github.com/gotgenes/pi-packages/commit/97f805fc7f07be93ef0fc054310755bddabada24)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
14
+ * **pi-permission-system:** keep an xargs replacement template exact in session approvals ([b4a4d06](https://github.com/gotgenes/pi-packages/commit/b4a4d0648c9cf4e873a7679e8f701925232cd5aa)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
15
+ * **pi-permission-system:** keep a wrapped command exact when a word holds a non-breaking space ([f16aa96](https://github.com/gotgenes/pi-packages/commit/f16aa968dbeed76b23967838b88fff0d18e93cde)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** describe wrapper session-approval suggestions ([a999c58](https://github.com/gotgenes/pi-packages/commit/a999c585e60c8d4d8836de744788440712c9d099)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
20
+ * **pi-permission-system:** note that a bare inline-shell payload keeps its exact session pattern ([3d53393](https://github.com/gotgenes/pi-packages/commit/3d53393c9da27c74c900902d2f0ec2453df1055d)), closes [#1055](https://github.com/gotgenes/pi-packages/issues/1055), closes [#1054](https://github.com/gotgenes/pi-packages/issues/1054)
21
+
22
+ ## [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)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **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)
28
+ * **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)
29
+ * **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)
30
+ * **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)
31
+ * **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)
32
+ * **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)
33
+
34
+ ### Documentation
35
+
36
+ * **pi-permission-system:** document the env, xargs, doas, and command-line wrapper floors ([ef82c63](https://github.com/gotgenes/pi-packages/commit/ef82c6378d00ca6667135ec1e0f574ca952d2372))
37
+
8
38
  ## [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
39
 
10
40
 
@@ -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.
@@ -87,6 +87,31 @@ The arity table covers common CLI tools including git, npm/pnpm/yarn/bun, docker
87
87
  To add an entry, open `src/access-intent/bash/bash-arity.ts` and add a key/arity pair to the `ARITY` object.
88
88
  Put the most specific multi-word prefix first (e.g. `"npm run": 3`) before the shorter fallback (`"npm": 2`).
89
89
 
90
+ ### Wrapped commands
91
+
92
+ A command behind an indirection wrapper (`sudo`, `env`, `xargs`, `nohup`, `timeout`, `nice`, and the rest of the wrapper vocabulary) is approved by its inner command, not by the wrapper.
93
+ A session approval skips the wrapper's floor, so the suggestion keeps every wrapper word literally (options, operands, nested wrappers) and lets only the inner command's arguments vary.
94
+
95
+ | Example command | Suggested pattern |
96
+ | ------------------------------------ | ----------------------------- |
97
+ | `nohup mytool argument` | `nohup mytool *` |
98
+ | `sudo aws s3 rm s3://bucket` | `sudo aws s3 rm *` |
99
+ | `env FOO=bar mytool argument` | `env FOO=bar mytool *` |
100
+ | `timeout 5 mytool argument` | `timeout 5 mytool *` |
101
+ | `sudo -u root nohup mytool argument` | `sudo -u root nohup mytool *` |
102
+
103
+ A wrapped pattern always puts a space before its wildcard, so approving `nohup mytool` covers `nohup mytool` and `nohup mytool <args>` but not `nohup mytool-other`.
104
+
105
+ When the inner command cannot be established from the words alone, the suggestion is the whole command with no wildcard:
106
+
107
+ - Shell syntax in any word: quoting, `$` expansion, a glob, `~`, grouping, or an operator (`sudo mytool $HOME`).
108
+ - An inline shell payload, bare or wrapped (`bash -c …`, `eval …`, `sudo bash -c …`), or a wrapper that hands its command line to a shell or template (`watch`, `parallel`, `rush`, `rust-parallel`).
109
+ - A wrapper mode in which the peeled command is not what runs (`sudo -s`, `env -C`), an option the wrapper's table does not admit (`timeout --sig KILL 5 …`, `flock -w 5 …`), or a `find`/`fd` exec clause.
110
+ - An `xargs` replacement option (`-I`, `-i`, `--replace`), since standard input then rewrites the inner command's arguments (`xargs -I X nohup X first`).
111
+ - Wrappers nested deeper than the gate peels.
112
+
113
+ This changes only the suggested pattern; the wrapper floors and their exemptions are unchanged.
114
+
90
115
  ## Review Log Entries
91
116
 
92
117
  The review log records session approval decisions:
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.4",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -3,10 +3,11 @@
3
3
  * what it actually runs, and whether its floor still has a reason to hold.
4
4
  *
5
5
  * Pure and word-based; the AST walk that produces the words lives in
6
- * `command-enumeration.ts`. The three questions live here together
7
- * deliberately: the shape that floors a unit to `ask`, the shape that names its
8
- * inner command, and the shape that exempts it must agree, and separate
9
- * classifiers over the same vocabulary would drift.
6
+ * `command-enumeration.ts`. The questions live here together deliberately:
7
+ * the shape that floors a unit to `ask`, the shape that names its inner
8
+ * command, the shape that exempts it, and how far a session approval may
9
+ * generalize past it must agree, and separate classifiers over the same
10
+ * vocabulary would drift.
10
11
  */
11
12
 
12
13
  import type { FloorExemption } from "#src/types";
@@ -163,7 +164,10 @@ export function executedUnitOf(
163
164
  * 2. Unwrapping reached the inner command without passing through an opaque
164
165
  * payload. `sh -c '…'` carries an unparsed program whose first word says
165
166
  * nothing about the rest of it, which is why this cannot consult
166
- * {@link executedUnitOf}'s string.
167
+ * {@link executedUnitOf}'s string. A wrapper that hands its whole command
168
+ * line to a shell or builds it from a template
169
+ * ({@link OPAQUE_COMMAND_LINE_WRAPPERS}) is that payload in another shape:
170
+ * `watch cat x \; rm y` runs `rm`, so no layer may be one.
167
171
  * 3. The inner command *proves* a read — bare-basename core word, retraction
168
172
  * guards applied, so `xargs sort -o /tmp/x` and `xargs find . -delete` are
169
173
  * not transparent.
@@ -184,7 +188,11 @@ export function floorExemptionOf(
184
188
  // Only the peeled words matter here, so the walk is handed no source span to
185
189
  // cut from — the text slice is `executedUnitOf`'s product, not this one's.
186
190
  const unwrapped = unwrapIndirection(words, "");
187
- if (unwrapped.kind === "opaque" || unwrapped.peeled.length === 0) {
191
+ if (
192
+ unwrapped.kind === "opaque" ||
193
+ unwrapped.peeled.length === 0 ||
194
+ unwrapped.peeled.some(runsOpaqueCommandLine)
195
+ ) {
188
196
  return undefined;
189
197
  }
190
198
 
@@ -197,6 +205,12 @@ export function floorExemptionOf(
197
205
  : undefined;
198
206
  }
199
207
 
208
+ /** True when a peeled layer's wrapper does not run the words the peel sees. */
209
+ function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
210
+ const name = wrapperName(layer);
211
+ return name !== undefined && OPAQUE_COMMAND_LINE_WRAPPERS.has(name);
212
+ }
213
+
200
214
  /**
201
215
  * True when every peeled layer only changes *how* the inner command runs, and
202
216
  * that command is a literal name the gate can resolve by its own rule.
@@ -232,8 +246,16 @@ function onlyModifiesExecution(
232
246
  peeled: readonly (readonly CommandWord[])[],
233
247
  inner: readonly CommandWord[],
234
248
  ): boolean {
249
+ return peeled.every(isAdmittedModifierLayer) && reachedLiteralCommand(inner);
250
+ }
251
+
252
+ /**
253
+ * True when a peel ended at an ordinary command spelled as a literal name: not
254
+ * a wrapper it could not see past, and not shell syntax standing where the
255
+ * command name should be.
256
+ */
257
+ function reachedLiteralCommand(inner: readonly CommandWord[]): boolean {
235
258
  return (
236
- peeled.every(isAdmittedModifierLayer) &&
237
259
  classifyWrapperWords(inner) === undefined &&
238
260
  isLiteralCommandName(inner.at(0)?.text ?? "")
239
261
  );
@@ -296,6 +318,90 @@ function isLiteralCommandName(text: string): boolean {
296
318
  return LITERAL_COMMAND_NAME.test(text) && !RESERVED_WORDS.has(text);
297
319
  }
298
320
 
321
+ // ── Session patterns ─────────────────────────────────────────────────────────
322
+
323
+ /**
324
+ * Where the inner command starts for a session-approval pattern: `0` for an
325
+ * ordinary command, the count of peeled wrapper words when every layer may be
326
+ * generalized past, or `null` to keep the whole command.
327
+ *
328
+ * A session grant skips the wrapper floor, so the pattern built from this
329
+ * index is the grant's whole width: everything before the index stays literal,
330
+ * and only the inner command's arguments may vary. Each `null` is a shape where
331
+ * a pattern ending in a wildcard could name a command the human never saw —
332
+ * shell syntax the whitespace split cannot read, an opaque payload, a peel
333
+ * that stopped short, or a layer {@link generalizesLayer} refuses.
334
+ *
335
+ * The count is summed over the peeled layers rather than taken from the inner
336
+ * command's length, since the walk trims an exec terminator off the end.
337
+ */
338
+ export function sessionCommandIndex(tokens: readonly string[]): number | null {
339
+ const words = literalWordsOf(tokens);
340
+ if (classifyWrapperWords(words) === undefined) return 0;
341
+ if (words.some((word) => word.computed)) return null;
342
+ const unwrapped = unwrapIndirection(words, "");
343
+ if (unwrapped.kind === "opaque") return null;
344
+ if (!unwrapped.peeled.every(generalizesLayer)) return null;
345
+ if (!reachedLiteralCommand(unwrapped.words)) return null;
346
+ return unwrapped.peeled.reduce((count, layer) => count + layer.length, 0);
347
+ }
348
+
349
+ /**
350
+ * True when a session pattern may keep a peeled layer literal and vary what
351
+ * follows its inner command.
352
+ *
353
+ * A getopt-read wrapper's peel already refuses an unlisted or mode-changing
354
+ * option. A table-read wrapper skips an unknown option as a flag, so it is
355
+ * admitted only by the floor exemption's own admission or with no option at
356
+ * all; that also refuses a `find`/`fd` exec flag, after which `find`'s own
357
+ * options may follow the inner command.
358
+ */
359
+ function generalizesLayer(layer: readonly CommandWord[]): boolean {
360
+ const name = wrapperName(layer);
361
+ if (name === undefined || runsOpaqueCommandLine(layer)) return false;
362
+ if (name === "xargs") return !mayReplaceArguments(layer);
363
+ if (GETOPT_GRAMMARS.has(name)) return true;
364
+ return (
365
+ isAdmittedModifierLayer(layer) ||
366
+ layer.slice(1).every((word) => !word.text.startsWith("-"))
367
+ );
368
+ }
369
+
370
+ /**
371
+ * True when an `xargs` layer may carry a replacement option (`-I`, `-i`,
372
+ * `--replace`), which lets standard input rewrite the inner command's
373
+ * arguments, a nested wrapper's executable included.
374
+ *
375
+ * Deliberately wider than the grammar: any short cluster holding `I` or `i`
376
+ * and any long option opening `--r` counts, since a false positive only keeps
377
+ * the whole command.
378
+ */
379
+ function mayReplaceArguments(layer: readonly CommandWord[]): boolean {
380
+ return layer
381
+ .slice(1)
382
+ .some(({ text }) => /^-[^-]*[Ii]/.test(text) || text.startsWith("--r"));
383
+ }
384
+
385
+ /**
386
+ * A command's whitespace tokens as command words, each marked `computed` when
387
+ * it carries shell syntax the split cannot read: quoting, expansion, a glob,
388
+ * grouping, an operator, or a comment.
389
+ */
390
+ function literalWordsOf(tokens: readonly string[]): CommandWord[] {
391
+ let offset = 0;
392
+ return tokens.map((text) => {
393
+ const word: CommandWord = {
394
+ text,
395
+ value: text,
396
+ computed: SHELL_SYNTAX.test(text),
397
+ mayLeadWithDash: text.startsWith("-"),
398
+ offset,
399
+ };
400
+ offset += text.length + 1;
401
+ return word;
402
+ });
403
+ }
404
+
299
405
  // ── Unwrapping ───────────────────────────────────────────────────────────────
300
406
 
301
407
  /**
@@ -423,11 +529,13 @@ function innerCommandIndex(words: readonly CommandWord[]): number {
423
529
 
424
530
  /**
425
531
  * {@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).
532
+ * options permuted no further than the first non-option, as every grammar here
533
+ * does (a `+` optstring, or BSD getopt, which never permutes).
427
534
  *
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.
535
+ * Options come first, then — for a wrapper that takes them — the `NAME=value`
536
+ * assignments it passes to the command, then the command. `-1` when an option
537
+ * is refused, unlisted, or an ambiguous abbreviation, or when the words run out
538
+ * first.
431
539
  */
432
540
  function getoptInnerIndex(
433
541
  words: readonly CommandWord[],
@@ -436,7 +544,7 @@ function getoptInnerIndex(
436
544
  let index = 1;
437
545
  while (index < words.length) {
438
546
  const word = words[index].text;
439
- if (word === "--") {
547
+ if (word === "--" || (word === "-" && grammar.loneDashEndsOptions)) {
440
548
  index++;
441
549
  break;
442
550
  }
@@ -447,7 +555,11 @@ function getoptInnerIndex(
447
555
  if (consumed === undefined) return -1;
448
556
  index += consumed;
449
557
  }
450
- while (index < words.length && isEnvironmentAssignment(words[index].text)) {
558
+ while (
559
+ grammar.assignments &&
560
+ index < words.length &&
561
+ isEnvironmentAssignment(words[index].text)
562
+ ) {
451
563
  index++;
452
564
  }
453
565
  return index < words.length ? index : -1;
@@ -458,7 +570,9 @@ function getoptInnerIndex(
458
570
  * `undefined` when a letter in it is refused or unlisted.
459
571
  *
460
572
  * 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`).
573
+ * (`-uedward`), or the following word when nothing is left (`-nu root`). An
574
+ * optional-argument letter ends it too, but its value is only ever the rest of
575
+ * the cluster (`-i{}`), never the following word (`-i rm cat x` runs `rm`).
462
576
  */
463
577
  function shortClusterWords(
464
578
  letters: string,
@@ -467,6 +581,7 @@ function shortClusterWords(
467
581
  for (let position = 0; position < letters.length; position++) {
468
582
  const arity = table.get(letters[position]);
469
583
  if (arity === undefined || arity === "refuse") return undefined;
584
+ if (arity === "optional") return 1;
470
585
  if (arity === "value") return position + 1 < letters.length ? 1 : 2;
471
586
  }
472
587
  return 1;
@@ -474,7 +589,8 @@ function shortClusterWords(
474
589
 
475
590
  /**
476
591
  * How many words a long option spans (`--user root` is two, `--user=root`
477
- * one), or `undefined` when it is refused, unlisted, or ambiguous.
592
+ * one), or `undefined` when it is refused, unlisted, or ambiguous. A flag or an
593
+ * optional argument (`--replace[=R]`) never takes the following word.
478
594
  */
479
595
  function longOptionWords(
480
596
  body: string,
@@ -634,17 +750,11 @@ const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
634
750
  *
635
751
  * This table decides the gate, not only the display: {@link floorExemptionOf}
636
752
  * 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.
753
+ * is really an option's value (GNU `time --out cat rm x`, #1057), and the
754
+ * execution-modifier clause admits value-taking options from it. A wrapper
755
+ * read by a {@link GETOPT_GRAMMARS} entry has no row here.
640
756
  */
641
757
  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
758
  ["timeout", new Set(["-s", "-k", "--signal", "--kill-after"])],
649
759
  ["nice", new Set(["-n", "--adjustment"])],
650
760
  ["time", new Set(["-o", "-f", "--output", "--format"])],
@@ -657,10 +767,11 @@ const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
657
767
 
658
768
  /**
659
769
  * 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
770
+ * taking a value, an option whose value is optional and so only ever attached
771
+ * (getopt's `::`), or a mode in which the command the peel would name is not
661
772
  * the one that runs, which refuses the peel.
662
773
  */
663
- type OptionArity = "flag" | "value" | "refuse";
774
+ type OptionArity = "flag" | "value" | "optional" | "refuse";
664
775
 
665
776
  /**
666
777
  * A wrapper's options as getopt_long reads them, keyed by short letter and by
@@ -670,6 +781,17 @@ type OptionArity = "flag" | "value" | "refuse";
670
781
  interface GetoptGrammar {
671
782
  readonly short: ReadonlyMap<string, OptionArity>;
672
783
  readonly long: ReadonlyMap<string, OptionArity>;
784
+ /**
785
+ * Whether `NAME=value` words after the options are assignments the wrapper
786
+ * passes to the command. `xargs` and `doas` run such a word as the command.
787
+ */
788
+ readonly assignments: boolean;
789
+ /**
790
+ * Whether a lone `-` is an option that ends the option walk, as `env -`
791
+ * (clear the environment) is; otherwise it is the command, which names
792
+ * nothing.
793
+ */
794
+ readonly loneDashEndsOptions: boolean;
673
795
  }
674
796
 
675
797
  /**
@@ -743,11 +865,145 @@ const SUDO_GRAMMAR: GetoptGrammar = {
743
865
  ["login", "refuse"],
744
866
  ["shell", "refuse"],
745
867
  ]),
868
+ assignments: true,
869
+ loneDashEndsOptions: false,
870
+ };
871
+
872
+ /**
873
+ * `env`'s options: the union of BSD `env` (optstring `-0C:iP:S:u:v`, no long
874
+ * options) and GNU coreutils 9.12 (`env --help`). No letter has different
875
+ * arities on the two.
876
+ *
877
+ * Each refusing option is one where the command the peel would name is not
878
+ * what runs as named: `-C`/`--chdir` move where its relative operands
879
+ * resolve, so the path surfaces would judge the wrong file; `-S`/
880
+ * `--split-string` split one word into several, so the command is built at
881
+ * run time; and `-P` (a search path), `-a`/`--argv0` (the argv0 a multi-call
882
+ * binary dispatches on), and `--env0-from` (an environment that may set
883
+ * `PATH`) choose which binary the named command is.
884
+ *
885
+ * A lone `-` clears the environment and, as GNU reads it, ends the options;
886
+ * BSD keeps parsing after it, so ending there only ever names less.
887
+ */
888
+ const ENV_GRAMMAR: GetoptGrammar = {
889
+ short: new Map<string, OptionArity>([
890
+ ["0", "flag"],
891
+ ["i", "flag"],
892
+ ["v", "flag"],
893
+ ["u", "value"],
894
+ ["a", "refuse"],
895
+ ["C", "refuse"],
896
+ ["P", "refuse"],
897
+ ["S", "refuse"],
898
+ ]),
899
+ long: new Map<string, OptionArity>([
900
+ ["debug", "flag"],
901
+ ["help", "flag"],
902
+ ["ignore-environment", "flag"],
903
+ ["list-signal-handling", "flag"],
904
+ ["null", "flag"],
905
+ ["version", "flag"],
906
+ ["unset", "value"],
907
+ ["block-signal", "optional"],
908
+ ["default-signal", "optional"],
909
+ ["ignore-signal", "optional"],
910
+ ["argv0", "refuse"],
911
+ ["chdir", "refuse"],
912
+ ["env0-from", "refuse"],
913
+ ["split-string", "refuse"],
914
+ ]),
915
+ assignments: true,
916
+ loneDashEndsOptions: true,
917
+ };
918
+
919
+ /**
920
+ * `xargs`'s options: the union of BSD `xargs` (optstring
921
+ * `+0E:I:J:L:n:oP:pR:S:s:rtx`) and GNU findutils 4.9 (`xargs --help`). No
922
+ * letter has different arities on the two, and BSD's long names are a subset
923
+ * of GNU's, so an option one platform rejects only makes it run nothing.
924
+ *
925
+ * GNU's `-i`, `-l`, and `-e` take an optional value, attached only, so
926
+ * `xargs -i rm cat x` runs `rm`. So does `--max-lines`, though its `--help`
927
+ * line reads as required and its short form `-L` does take the next word:
928
+ * `xargs --max-lines echo A B` runs `echo`. BSD's `-J` refuses: it puts input
929
+ * in the utility position when the utility equals its replstr
930
+ * (`xargs -J % % x`).
931
+ */
932
+ const XARGS_GRAMMAR: GetoptGrammar = {
933
+ short: new Map<string, OptionArity>([
934
+ ["0", "flag"],
935
+ ["o", "flag"],
936
+ ["p", "flag"],
937
+ ["r", "flag"],
938
+ ["t", "flag"],
939
+ ["x", "flag"],
940
+ ["a", "value"],
941
+ ["d", "value"],
942
+ ["E", "value"],
943
+ ["I", "value"],
944
+ ["L", "value"],
945
+ ["n", "value"],
946
+ ["P", "value"],
947
+ ["R", "value"],
948
+ ["S", "value"],
949
+ ["s", "value"],
950
+ ["e", "optional"],
951
+ ["i", "optional"],
952
+ ["l", "optional"],
953
+ ["J", "refuse"],
954
+ ]),
955
+ long: new Map<string, OptionArity>([
956
+ ["exit", "flag"],
957
+ ["help", "flag"],
958
+ ["interactive", "flag"],
959
+ ["no-run-if-empty", "flag"],
960
+ ["null", "flag"],
961
+ ["open-tty", "flag"],
962
+ ["show-limits", "flag"],
963
+ ["verbose", "flag"],
964
+ ["version", "flag"],
965
+ ["arg-file", "value"],
966
+ ["delimiter", "value"],
967
+ ["max-args", "value"],
968
+ ["max-chars", "value"],
969
+ ["max-procs", "value"],
970
+ ["process-slot-var", "value"],
971
+ ["eof", "optional"],
972
+ ["max-lines", "optional"],
973
+ ["replace", "optional"],
974
+ ]),
975
+ assignments: false,
976
+ loneDashEndsOptions: false,
977
+ };
978
+
979
+ /**
980
+ * `doas`'s options, verified against `opendoas`
981
+ * (`usage: doas [-Lns] [-C config] [-u user] command [args]`); it takes no
982
+ * long options and never permutes. OpenBSD's `-a style` is absent there, so
983
+ * it is left unlisted and refuses rather than admitted unverified.
984
+ *
985
+ * `-C` checks a config and runs nothing, so the command it would name never
986
+ * runs as named; `-s` runs a shell.
987
+ */
988
+ const DOAS_GRAMMAR: GetoptGrammar = {
989
+ short: new Map<string, OptionArity>([
990
+ ["L", "flag"],
991
+ ["n", "flag"],
992
+ ["u", "value"],
993
+ ["C", "refuse"],
994
+ ["s", "refuse"],
995
+ ]),
996
+ long: new Map<string, OptionArity>(),
997
+ assignments: false,
998
+ loneDashEndsOptions: false,
746
999
  };
747
1000
 
748
1001
  /** Wrappers whose inner command is located by a getopt grammar. */
749
1002
  const GETOPT_GRAMMARS = new Map<string, GetoptGrammar>([
750
1003
  ["sudo", SUDO_GRAMMAR],
1004
+ ["env", ENV_GRAMMAR],
1005
+ ["xargs", XARGS_GRAMMAR],
1006
+ ["doas", DOAS_GRAMMAR],
751
1007
  ]);
752
1008
 
753
1009
  /**
@@ -796,6 +1052,12 @@ const WRITING_OPTIONS = new Map<string, ReadonlySet<string>>([
796
1052
  */
797
1053
  const LITERAL_COMMAND_NAME = /^[A-Za-z0-9_./+@%,:][A-Za-z0-9_./+@%,:-]*$/;
798
1054
 
1055
+ /**
1056
+ * A character that makes a whitespace token something other than the literal
1057
+ * word a program receives.
1058
+ */
1059
+ const SHELL_SYNTAX = /['"\\$`|&;<>(){}[\]*?!~#]/;
1060
+
799
1061
  /** Bash reserved words, which open syntax rather than name a command. */
800
1062
  const RESERVED_WORDS: ReadonlySet<string> = new Set([
801
1063
  "!",
@@ -828,6 +1090,21 @@ const RESERVED_WORDS: ReadonlySet<string> = new Set([
828
1090
  */
829
1091
  const LEADING_OPERAND_WRAPPERS = new Set(["timeout", "flock"]);
830
1092
 
1093
+ /**
1094
+ * Wrappers whose executed command line is not the words the peel sees, so a
1095
+ * core-looking first word says nothing about what runs. `watch` (without
1096
+ * `-x`) passes its words to `sh -c`, and GNU `parallel` runs its command
1097
+ * through a shell; both verified, where `watch cat x \; rm y` runs `rm`.
1098
+ * `rush` and `rust-parallel` build command lines from templates and have no
1099
+ * local binary to verify how, so they are refused rather than admitted.
1100
+ */
1101
+ const OPAQUE_COMMAND_LINE_WRAPPERS: ReadonlySet<string> = new Set([
1102
+ "watch",
1103
+ "parallel",
1104
+ "rush",
1105
+ "rust-parallel",
1106
+ ]);
1107
+
831
1108
  /** Words ending a `find -exec` clause; they belong to `find`, not its command. */
832
1109
  const EXEC_TERMINATORS = new Set([";", "+"]);
833
1110
 
@@ -2,6 +2,7 @@ import {
2
2
  prefix,
3
3
  stripBashCommentLines,
4
4
  } from "#src/access-intent/bash/bash-arity";
5
+ import { sessionCommandIndex } from "#src/access-intent/bash/wrapper-analysis";
5
6
  import {
6
7
  type CapabilityDirection,
7
8
  PATH_BEARING_TOOLS,
@@ -30,6 +31,10 @@ export interface SessionApprovalSuggestion {
30
31
  * - Arity prefix covers all tokens: trailing wildcard (`npm run build*`).
31
32
  * - Arity prefix shorter than token list: space + wildcard (`git checkout *`).
32
33
  * - Unknown command: first token + space wildcard (`mytool *`).
34
+ * - Recognized wrapper: its words, then the inner command's arity prefix,
35
+ * then a space wildcard (`sudo -u root nohup mytool *`).
36
+ * - Wrapper whose inner command cannot be proven: the exact command, with no
37
+ * wildcard (`watch mytool x`).
33
38
  */
34
39
  export function suggestBashPattern(command: string): string {
35
40
  const trimmed = command.trim();
@@ -40,6 +45,12 @@ export function suggestBashPattern(command: string): string {
40
45
  if (!stripped) return "";
41
46
  const tokens = stripped.split(/\s+/);
42
47
  if (tokens.length === 1) return stripped;
48
+ // A wrapper's words are split only where bash splits them: `\s` also splits
49
+ // on a non-breaking space or `\r`, which would read `rm<NBSP>-rf` as `rm`.
50
+ const words = stripped.split(BASH_BLANKS);
51
+ const commandIndex = sessionCommandIndex(words);
52
+ if (commandIndex === null) return stripped;
53
+ if (commandIndex > 0) return wrappedPattern(words, commandIndex);
43
54
  const meaningful = prefix(tokens);
44
55
  if (meaningful.length >= tokens.length) {
45
56
  return `${stripped}*`;
@@ -47,6 +58,28 @@ export function suggestBashPattern(command: string): string {
47
58
  return `${meaningful.join(" ")} *`;
48
59
  }
49
60
 
61
+ /** The blanks bash splits a command's words on. */
62
+ const BASH_BLANKS = /[ \t\n]+/;
63
+
64
+ /**
65
+ * The wrapper words kept literally, then the inner command's arity prefix.
66
+ *
67
+ * The wildcard always follows a space, even when the arity prefix covers every
68
+ * token, so approving `nohup mytool` never approves `nohup mytool-other`; the
69
+ * matcher treats a trailing space and wildcard as optional, so the bare
70
+ * command is still covered.
71
+ */
72
+ function wrappedPattern(
73
+ tokens: readonly string[],
74
+ commandIndex: number,
75
+ ): string {
76
+ const meaningful = [
77
+ ...tokens.slice(0, commandIndex),
78
+ ...prefix(tokens.slice(commandIndex)),
79
+ ];
80
+ return `${meaningful.join(" ")} *`;
81
+ }
82
+
50
83
  /**
51
84
  * Suggest an MCP session-approval pattern from a resolved target string.
52
85
  *