@gotgenes/pi-permission-system 40.0.2 → 40.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ 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.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.0...pi-permission-system-v40.1.1) (2026-10-08)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** commands inside time ( … ) are gated on their own rules ([0b77df6](https://github.com/gotgenes/pi-packages/commit/0b77df61d03c31e394cf68971df0e526b44a1abd)), closes [#1027](https://github.com/gotgenes/pi-packages/issues/1027)
14
+ * **pi-permission-system:** a cd inside time ( … ) resolves the paths after it in that subshell ([6994e15](https://github.com/gotgenes/pi-packages/commit/6994e15042e2ec6cb9e495973e0407d80fa2ff25)), closes [#1027](https://github.com/gotgenes/pi-packages/issues/1027)
15
+
16
+ ### Documentation
17
+
18
+ * **pi-permission-system:** document the timed-subshell descent and mark #1027 complete ([8337f54](https://github.com/gotgenes/pi-packages/commit/8337f5494ddd7a6c84561e3092700112e1de78a8))
19
+
20
+ ## [40.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.0.2...pi-permission-system-v40.1.0) (2026-10-08)
21
+
22
+
23
+ ### Features
24
+
25
+ * **pi-permission-system:** sudo -n true and other shell no-ops behind a wrapper resolve by their own rule ([a0889ee](https://github.com/gotgenes/pi-packages/commit/a0889ee49c3ea217bb534339c12dc6c470020429)), closes [#1039](https://github.com/gotgenes/pi-packages/issues/1039)
26
+
27
+ ### Documentation
28
+
29
+ * **pi-permission-system:** document shell no-ops in the pure-reader core ([#1039](https://github.com/gotgenes/pi-packages/issues/1039)) ([de296e0](https://github.com/gotgenes/pi-packages/commit/de296e0306a022b19fc5a8584e7c74841a35e69f))
30
+
8
31
  ## [40.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.0.1...pi-permission-system-v40.0.2) (2026-10-06)
9
32
 
10
33
 
@@ -1009,7 +1009,7 @@ A path token owned by one of them consults the `_read` surface alone:
1009
1009
 
1010
1010
  <!-- BEGIN PURE_READER_CORE -->
1011
1011
 
1012
- `awk`, `basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sed`, `sort`, `stat`, `tail`, `wc`, `which`
1012
+ `:`, `awk`, `basename`, `cat`, `cd`, `diff`, `dirname`, `echo`, `egrep`, `false`, `fd`, `fgrep`, `find`, `grep`, `head`, `ls`, `pwd`, `realpath`, `rg`, `sed`, `sort`, `stat`, `tail`, `true`, `wc`, `which`
1013
1013
 
1014
1014
  <!-- END PURE_READER_CORE -->
1015
1015
 
@@ -1079,7 +1079,7 @@ All four of these must hold, and each is a way the floor's reason could still ap
1079
1079
  4. The enclosing statement provably writes no file through a redirect.
1080
1080
  A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
1081
1081
 
1082
- So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, and `find . -exec sh -c '…' \;` still prompt.
1082
+ So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, `find . -name '*.ts' -exec cat {} +`, and the no-op probe `sudo -n true` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, and `find . -exec sh -c '…' \;` still prompt.
1083
1083
 
1084
1084
  Three things this does **not** change:
1085
1085
 
@@ -1107,7 +1107,8 @@ All four of these must hold, and each keeps the decision about the command that
1107
1107
  The admitted options are `time -p`/`-l`/`-h`/`-f`; `timeout -s`/`-k`/`-f`/`-p`/`-v` and their long forms; `nice -n`/`--adjustment`; and `stdbuf -i`/`-o`/`-e` and their long forms.
1108
1108
  3. The command it runs is not itself a wrapper or an inline shell, so `timeout 5 bash -c '…'` stays floored.
1109
1109
  4. The command it runs is named literally.
1110
- `time ( … )` and `time { …; }` stay floored: the parser reads the group as `time`'s arguments, so the commands inside are not yet gated on their own rules.
1110
+ `time ( … )` is decided by the commands inside it, each on its own rule, so `time (rm -rf /tmp/x)` reaches an `rm *` deny.
1111
+ `time { …; }` stays floored: the parser reads the group as `time`'s arguments, so the commands inside are not yet gated on their own rules.
1111
1112
 
1112
1113
  So under `bash: {"*": "allow", "git push *": "deny"}`, `time pnpm run lint >/tmp/lint.txt 2>&1` is allowed and `timeout 60 git push --force` is denied, exactly as without the wrapper.
1113
1114
  No redirect refusal applies here: the redirect destination goes through `path` and `external_directory` as it does for the bare command, and a destination the parse cannot resolve (`> $OUT`) is unprojected for both forms alike.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "40.0.2",
3
+ "version": "40.1.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,7 @@ import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalizatio
8
8
  import type { PathNormalizer } from "#src/path/path-normalizer";
9
9
  import { isSafeSystemPath } from "#src/path/safe-system-paths";
10
10
  import type { ArgumentSpeller } from "./command-enumeration";
11
+ import { timedSubshellOf } from "./nested-execution";
11
12
  import {
12
13
  ARG_NODE_TYPES,
13
14
  SKIP_SUBTREE_TYPES,
@@ -238,6 +239,8 @@ export class BashPathResolver {
238
239
  * does not update the running directory; subshell and brace-group interiors
239
240
  * inherit the enclosing base without folding their own `cd`s (a conservative
240
241
  * first tier).
242
+ * A subshell timed by `time`, which the grammar reads as the argument of a
243
+ * command named `time`, is walked as a bare subshell is.
241
244
  */
242
245
  private collectPathCandidates(
243
246
  rootNode: TSNode,
@@ -263,9 +266,14 @@ export class BashPathResolver {
263
266
  case "list":
264
267
  case "redirected_statement":
265
268
  return this.walkCurrentShellSequence(node, base, out);
266
- case "command":
269
+ case "command": {
270
+ const timed = timedSubshellOf(node);
271
+ if (timed !== null) {
272
+ return this.walkTimedSubshell(node, timed, base, out);
273
+ }
267
274
  tagTokens(collectCommandTokens(node, this.words), base, out);
268
275
  return this.foldCd(node, base);
276
+ }
269
277
  case "pipeline":
270
278
  // tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
271
279
  // first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as
@@ -294,6 +302,30 @@ export class BashPathResolver {
294
302
  }
295
303
  }
296
304
 
305
+ /**
306
+ * Walk `time ( … )`, returning the base in force after it, which is `base`.
307
+ *
308
+ * The subshell is walked as a subshell, so its `cd`s fold for the commands
309
+ * after them inside it and reset on exit. Every other named child but the
310
+ * `time` name, such as a redirect the command hosts (`2>err time ( … )`),
311
+ * is collected against `base`.
312
+ */
313
+ private walkTimedSubshell(
314
+ command: TSNode,
315
+ subshell: TSNode,
316
+ base: EffectiveBase,
317
+ out: PathCandidate[],
318
+ ): EffectiveBase {
319
+ for (let i = 0; i < command.childCount; i++) {
320
+ const child = command.child(i);
321
+ if (!child?.isNamed || child.type === "command_name") continue;
322
+ if (child.startIndex === subshell.startIndex) continue;
323
+ tagTokens(collectPathCandidateTokens(child, this.words), base, out);
324
+ }
325
+ this.walkForCandidates(subshell, base, out);
326
+ return base;
327
+ }
328
+
297
329
  /**
298
330
  * Fold a current-shell sequence (`program` / `list` / `redirected_statement`):
299
331
  * thread the effective base left-to-right through the children so a `cd`
@@ -155,6 +155,11 @@ function coreAdmissions(): readonly CoreAdmission[] {
155
155
  reason:
156
156
  "No filesystem write: `echo` writes to stdout (a redirect destination is the syntax proof's job, not `echo`'s); `cd` reads a directory to enter it",
157
157
  },
158
+ {
159
+ words: ["true", "false", ":"],
160
+ reason:
161
+ "No-ops: ignore their operands and touch no file; GNU's `--help`/`--version` print to stdout only",
162
+ },
158
163
  {
159
164
  words: ["find", "fd", "sort"],
160
165
  reason:
@@ -1,5 +1,10 @@
1
1
  import type { BashCommandContext, FloorExemption } from "#src/types";
2
- import { EXECUTION_HOST_TYPES, forEachExecutionIn } from "./nested-execution";
2
+ import {
3
+ commandWordNodes,
4
+ EXECUTION_HOST_TYPES,
5
+ forEachExecutionIn,
6
+ timedSubshellOf,
7
+ } from "./nested-execution";
3
8
  import type { WordReader } from "./node-text";
4
9
  import { parseUnresolvedWithin } from "./parse-health";
5
10
  import type { TSNode } from "./parser";
@@ -269,7 +274,8 @@ const STATEMENT_TYPES = new Set([
269
274
  * substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and
270
275
  * subshells (`( … )`) — emitting each inner command as its own unit *in
271
276
  * addition to* the enclosing command, since those inner commands really execute
272
- * (#306).
277
+ * (#306). A subshell timed by `time`, which the grammar reads as the argument
278
+ * of a command named `time`, is descended the same way.
273
279
  * A compound statement (control flow, a function definition, a `{ … }` brace
274
280
  * group) is emitted whole and then descended for the statements it contains,
275
281
  * while its operand words — a loop variable, a word list, a `case` subject, a
@@ -363,6 +369,11 @@ function collectCommandsInto(
363
369
  const scope = unresolvedScope(node, inherited);
364
370
 
365
371
  if (node.type === "command") {
372
+ const timed = timedSubshellOf(node);
373
+ if (timed !== null) {
374
+ collectTimedSubshell(node, timed, scope, out);
375
+ return;
376
+ }
366
377
  out.push(makeCommandUnit(node, scope));
367
378
  // A command's text already contains any substitution; descend its subtree
368
379
  // to ALSO emit the inner commands of command/process substitutions.
@@ -422,6 +433,40 @@ function collectCommandsInto(
422
433
  collectHostedCommands(node, scope, out);
423
434
  }
424
435
 
436
+ /**
437
+ * Enumerate `time ( … )`: the `time` unit whole, then the subshell's commands
438
+ * as units of their own, exactly as a bare subshell's are.
439
+ *
440
+ * The `time` unit is the never-weaker whole emit, and it is exempt from the
441
+ * wrapper floor: the floor exists because a wrapper hides the command that
442
+ * should be gated, and every command in this subshell is a unit gated on its
443
+ * own rules. It resolves by its executed unit, the subshell's text, which is
444
+ * how the bare subshell's whole unit resolves.
445
+ *
446
+ * Substitutions the command hosts outside the subshell (in a redirect) are
447
+ * still enumerated, and the subshell is left to the descent so a substitution
448
+ * inside it is emitted once.
449
+ */
450
+ function collectTimedSubshell(
451
+ node: TSNode,
452
+ subshell: TSNode,
453
+ scope: UnitScope,
454
+ out: BashCommand[],
455
+ ): void {
456
+ out.push(makeCommandUnit(node, scope, "execution-modifier"));
457
+ for (let i = 0; i < node.childCount; i++) {
458
+ const child = node.child(i);
459
+ if (child && child.startIndex !== subshell.startIndex) {
460
+ collectHostedCommands(child, scope, out);
461
+ }
462
+ }
463
+ descendCommandChildren(
464
+ subshell,
465
+ { ...redirectedScope(node, scope), context: "subshell" },
466
+ out,
467
+ );
468
+ }
469
+
425
470
  /**
426
471
  * The scope `node`'s own subtree establishes, marking it unresolved when
427
472
  * tree-sitter could not parse a region within it (#840).
@@ -488,7 +533,11 @@ function makeUnit(
488
533
  * before or between the words (`>/tmp/o xargs grep foo`) writes a file as
489
534
  * surely as one on the enclosing statement.
490
535
  */
491
- function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
536
+ function makeCommandUnit(
537
+ node: TSNode,
538
+ scope: UnitScope,
539
+ floorExemption?: FloorExemption,
540
+ ): BashCommand {
492
541
  const { text, words, argumentSpelling } = readCommandUnit(node, scope);
493
542
  return makeUnit(text, scope, {
494
543
  spellings: distinctSpellings(text, [
@@ -497,7 +546,8 @@ function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
497
546
  ]),
498
547
  wrapperKind: classifyWrapperWords(words),
499
548
  executedUnit: executedUnitOf(text, words) ?? undefined,
500
- floorExemption: floorExemptionOf(words, redirectedScope(node, scope)),
549
+ floorExemption:
550
+ floorExemption ?? floorExemptionOf(words, redirectedScope(node, scope)),
501
551
  });
502
552
  }
503
553
 
@@ -626,28 +676,6 @@ function gapBetween(
626
676
  );
627
677
  }
628
678
 
629
- /**
630
- * The nodes {@link readCommandUnit} reports words for, in the same order: every
631
- * named child except a prefix assignment and a hosted redirect.
632
- *
633
- * Split out so a consumer that needs a *node* rather than a word (the log's
634
- * command masker, which offsets a re-parse by the payload node's `startIndex`)
635
- * walks the identical filtered list. Two walks over the same children with the
636
- * same filter, written twice, is how the two come to disagree about which word
637
- * is at which index.
638
- */
639
- function commandWordNodes(node: TSNode): TSNode[] {
640
- const nodes: TSNode[] = [];
641
- for (let i = 0; i < node.childCount; i++) {
642
- const child = node.child(i);
643
- if (!child?.isNamed) continue;
644
- if (child.type === "variable_assignment") continue;
645
- if (REDIRECT_NODE_TYPES.has(child.type)) continue;
646
- nodes.push(child);
647
- }
648
- return nodes;
649
- }
650
-
651
679
  /** The redirects a `command` node hosts among its own children. */
652
680
  function hostedRedirects(node: TSNode): TSNode[] {
653
681
  const redirects: TSNode[] = [];
@@ -1,5 +1,6 @@
1
1
  import type { BashCommandContext } from "#src/types";
2
2
  import type { TSNode } from "./parser";
3
+ import { REDIRECT_NODE_TYPES } from "./redirect-analysis";
3
4
 
4
5
  /**
5
6
  * AST node types whose interior commands really execute when the shell runs the
@@ -47,6 +48,50 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
47
48
  "heredoc_body",
48
49
  ]);
49
50
 
51
+ /**
52
+ * The word nodes of a `command` node, in source order: every named child except
53
+ * a prefix assignment and a hosted redirect.
54
+ *
55
+ * Shared so every consumer that reads a command's words, whether as words (the
56
+ * command enumerator's unit text) or as nodes (the log's command masker, which
57
+ * offsets a re-parse by the payload node's `startIndex`), walks the identical
58
+ * filtered list. Two walks over the same children with the same filter,
59
+ * written twice, is how the two come to disagree about which word is at which
60
+ * index.
61
+ */
62
+ export function commandWordNodes(node: TSNode): TSNode[] {
63
+ const nodes: TSNode[] = [];
64
+ for (let i = 0; i < node.childCount; i++) {
65
+ const child = node.child(i);
66
+ if (!child?.isNamed) continue;
67
+ if (child.type === "variable_assignment") continue;
68
+ if (REDIRECT_NODE_TYPES.has(child.type)) continue;
69
+ nodes.push(child);
70
+ }
71
+ return nodes;
72
+ }
73
+
74
+ /**
75
+ * The subshell a `command` node times, or `null` when it is not `time ( … )`.
76
+ *
77
+ * `tree-sitter-bash` has no `time` keyword, so `time (rm x)` parses as a
78
+ * command named `time` whose only argument is a `subshell`. Bash runs that
79
+ * subshell's commands as surely as a bare `( … )`'s, so both bash surfaces read
80
+ * the shape through this one recognizer rather than each re-deciding it.
81
+ *
82
+ * The words must be exactly a `time` spelled literally (a quoted `"time"` is
83
+ * not the keyword) and one subshell. Anything between them (`time -p ( … )`,
84
+ * whose `-p` the grammar cannot place) leaves the shape unrecognized, so it
85
+ * keeps whatever floor the enumerator gives it.
86
+ */
87
+ export function timedSubshellOf(command: TSNode): TSNode | null {
88
+ const words = commandWordNodes(command);
89
+ if (words.length !== 2) return null;
90
+ const [name, argument] = words;
91
+ if (argument.type !== "subshell") return null;
92
+ return name.type === "command_name" && name.text === "time" ? argument : null;
93
+ }
94
+
50
95
  /**
51
96
  * Visit every execution context `node` *is or contains*, in source order.
52
97
  *
@@ -223,7 +223,10 @@ export function floorExemptionOf(
223
223
  * past.
224
224
  * 4. The inner head is a literal command name. The grammar has no `time`
225
225
  * keyword, so `time { …; }` and `time ( … )` reach here with shell syntax
226
- * where the command name should be.
226
+ * where the command name should be. A word list cannot tell whether that
227
+ * syntax was enumerated, so both keep the floor here; the command
228
+ * enumerator, which descends a `time ( … )` subshell, exempts that shape
229
+ * itself.
227
230
  */
228
231
  function onlyModifiesExecution(
229
232
  peeled: readonly (readonly CommandWord[])[],