@gotgenes/pi-permission-system 40.1.3 → 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,20 @@ 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
+
8
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)
9
23
 
10
24
 
@@ -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.3",
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";
@@ -245,8 +246,16 @@ function onlyModifiesExecution(
245
246
  peeled: readonly (readonly CommandWord[])[],
246
247
  inner: readonly CommandWord[],
247
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 {
248
258
  return (
249
- peeled.every(isAdmittedModifierLayer) &&
250
259
  classifyWrapperWords(inner) === undefined &&
251
260
  isLiteralCommandName(inner.at(0)?.text ?? "")
252
261
  );
@@ -309,6 +318,90 @@ function isLiteralCommandName(text: string): boolean {
309
318
  return LITERAL_COMMAND_NAME.test(text) && !RESERVED_WORDS.has(text);
310
319
  }
311
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
+
312
405
  // ── Unwrapping ───────────────────────────────────────────────────────────────
313
406
 
314
407
  /**
@@ -959,6 +1052,12 @@ const WRITING_OPTIONS = new Map<string, ReadonlySet<string>>([
959
1052
  */
960
1053
  const LITERAL_COMMAND_NAME = /^[A-Za-z0-9_./+@%,:][A-Za-z0-9_./+@%,:-]*$/;
961
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
+
962
1061
  /** Bash reserved words, which open syntax rather than name a command. */
963
1062
  const RESERVED_WORDS: ReadonlySet<string> = new Set([
964
1063
  "!",
@@ -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
  *