@gotgenes/pi-permission-system 25.2.1 → 25.2.2

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,21 @@ 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
+ ## [25.2.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.1...pi-permission-system-v25.2.2) (2026-08-15)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** gate commands hosted in bash redirect targets ([1015bb8](https://github.com/gotgenes/pi-packages/commit/1015bb879234d1d96595b9f995ca88037588e16f)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
14
+ * **pi-permission-system:** gate commands hosted in interpolating heredoc bodies ([48978d2](https://github.com/gotgenes/pi-packages/commit/48978d2016c67aa90f291db55d98908ba930f0d1)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
15
+ * **pi-permission-system:** project path operands of heredoc-hosted nested commands ([8e2fbee](https://github.com/gotgenes/pi-packages/commit/8e2fbee404677d02c7cc565cf23d1e778994d5f0)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
16
+ * **pi-permission-system:** project path operands of redirect-hosted nested commands ([12164f3](https://github.com/gotgenes/pi-packages/commit/12164f3b61b6a4b50281d84b6d741a3736516991)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** document hosted nested-command evaluation ([f24b338](https://github.com/gotgenes/pi-packages/commit/f24b338a42855f96038fdf2efdfe440cef8501c8)), closes [#741](https://github.com/gotgenes/pi-packages/issues/741)
22
+
8
23
  ## [25.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.0...pi-permission-system-v25.2.1) (2026-08-15)
9
24
 
10
25
 
@@ -328,6 +328,11 @@ Quotes are respected (an operator inside `'…'` or `"…"` does not split the c
328
328
  Commands nested inside command substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and subshells (`( … )`) are evaluated against the bash patterns too, in addition to their enclosing command — since those inner commands really execute.
329
329
  So `echo $(rm -rf foo)` evaluates both `echo $(rm -rf foo)` and the inner `rm -rf foo`; if `rm *` is denied, the whole invocation is denied.
330
330
  The deny reason and the approval prompt note the nested origin (e.g. `inside command substitution`).
331
+
332
+ This holds wherever the substitution appears, not only in argument position.
333
+ A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat <<EOF` with `$(rm e)` in the body) are evaluated the same way.
334
+ A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its body is literal text and nothing in it is evaluated as a command.
335
+ The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`.
331
336
  Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text.
332
337
 
333
338
  A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "25.2.1",
3
+ "version": "25.2.2",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,3 +1,7 @@
1
+ import {
2
+ EXECUTION_HOST_TYPES,
3
+ forEachNestedExecution,
4
+ } from "#src/access-intent/bash/nested-execution";
1
5
  import type { TSNode } from "#src/access-intent/bash/parser";
2
6
  import type { BashCommandContext } from "#src/types";
3
7
 
@@ -49,32 +53,19 @@ const COMMAND_ENUM_DESCEND = new Set([
49
53
  ]);
50
54
 
51
55
  /**
52
- * Named node types skipped during command enumeration: redirect targets,
53
- * comments, and heredoc bodies none is a command to evaluate.
56
+ * Named node types abandoned during command enumeration: they are neither
57
+ * commands nor able to host one, so nothing in their subtree ever runs.
58
+ *
59
+ * A redirect and a heredoc body are deliberately NOT listed here. Neither is a
60
+ * command, but each can host a substitution that really executes, so both are
61
+ * {@link EXECUTION_HOST_TYPES} members instead — conflating the two questions
62
+ * ("is this a command?" and "can this host one?") is the bypass #741 fixed.
63
+ *
54
64
  * Anonymous tokens (chain operators `&&`/`;`/`|`, substitution and subshell
55
65
  * delimiters `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not
56
66
  * listed here.
57
67
  */
58
- const COMMAND_ENUM_SKIP = new Set([
59
- "file_redirect",
60
- "heredoc_redirect",
61
- "herestring_redirect",
62
- "comment",
63
- "heredoc_body",
64
- "heredoc_end",
65
- ]);
66
-
67
- /**
68
- * Nested execution contexts whose interior commands really execute and must be
69
- * evaluated too: command substitution (`$(…)`, backticks) and process
70
- * substitution (`<(…)`/`>(…)`).
71
- * Subshells (`( … )`) are handled separately because they are also emitted
72
- * whole.
73
- */
74
- const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
75
- ["command_substitution", "command_substitution"],
76
- ["process_substitution", "process_substitution"],
77
- ]);
68
+ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
78
69
 
79
70
  /**
80
71
  * Enumerate the command units of a bash program, in source order.
@@ -119,7 +110,14 @@ function collectCommandsInto(
119
110
  );
120
111
  // A command's text already contains any substitution; descend its subtree
121
112
  // to ALSO emit the inner commands of command/process substitutions.
122
- collectSubstitutionCommands(node, out);
113
+ collectHostedCommands(node, out);
114
+ return;
115
+ }
116
+
117
+ if (EXECUTION_HOST_TYPES.has(node.type)) {
118
+ // Not a command itself, but its subtree can host one that really runs
119
+ // (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
120
+ collectHostedCommands(node, out);
123
121
  return;
124
122
  }
125
123
 
@@ -297,20 +295,15 @@ function descendCommandChildren(
297
295
  }
298
296
 
299
297
  /**
300
- * Search a command's subtree for command/process substitutions and enumerate
301
- * the commands inside them, tagged with the substitution's execution context.
302
- * A substitution can nest under `command_name` (when the whole command is
303
- * `$(…)`) or under an argument, so the entire subtree is searched.
298
+ * Enumerate the commands of every nested execution context in a subtree, each
299
+ * tagged with the context it was found in.
300
+ *
301
+ * The traversal itself lives in `nested-execution.ts` so the bash path surface
302
+ * shares one definition of what counts as a nested execution (#741); this
303
+ * function supplies the command-surface interpretation of each one found.
304
304
  */
305
- function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
306
- for (let i = 0; i < node.childCount; i++) {
307
- const child = node.child(i);
308
- if (!child) continue;
309
- const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
310
- if (nestedContext) {
311
- descendCommandChildren(child, nestedContext, out);
312
- } else {
313
- collectSubstitutionCommands(child, out);
314
- }
315
- }
305
+ function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
306
+ forEachNestedExecution(node, (contextNode, context) => {
307
+ descendCommandChildren(contextNode, context, out);
308
+ });
316
309
  }
@@ -0,0 +1,76 @@
1
+ import type { TSNode } from "#src/access-intent/bash/parser";
2
+ import type { BashCommandContext } from "#src/types";
3
+
4
+ /**
5
+ * AST node types whose interior commands really execute when the shell runs the
6
+ * program: command substitution (`$(…)`, backticks) and process substitution
7
+ * (`<(…)`/`>(…)`).
8
+ *
9
+ * Subshells (`( … )`) are deliberately absent — a subshell is also a command
10
+ * unit in its own right, so the command enumerator emits it whole and descends
11
+ * it separately rather than treating it as a pure nesting wrapper.
12
+ *
13
+ * This map is the single vocabulary shared by the bash command surface and the
14
+ * bash path surface, so the two cannot disagree about what counts as a nested
15
+ * execution (#741).
16
+ */
17
+ export const NESTED_EXECUTION_CONTEXTS: ReadonlyMap<
18
+ string,
19
+ BashCommandContext
20
+ > = new Map([
21
+ ["command_substitution", "command_substitution"],
22
+ ["process_substitution", "process_substitution"],
23
+ ] satisfies [string, BashCommandContext][]);
24
+
25
+ /**
26
+ * AST node types that are neither commands nor argument values themselves, but
27
+ * whose subtree can host a nested execution context that really runs.
28
+ *
29
+ * A redirect destination is the motivating case: tree-sitter-bash parses
30
+ * `echo hi > $(rm x)` with the `file_redirect` as a *sibling* of the `command`,
31
+ * so a consumer that abandons the redirect never sees the substitution inside
32
+ * it — the bypass #741 fixed.
33
+ *
34
+ * An interpolating heredoc body is the second case: `cat <<EOF` with `$(rm e)`
35
+ * in the body really runs `rm e`. Quoting needs no special handling here —
36
+ * tree-sitter-bash emits a `command_substitution` node under `heredoc_body`
37
+ * only for a bare `<<EOF`, never for `<<'EOF'` or `<<"EOF"`, so the parser
38
+ * already encodes the interpolation rule.
39
+ *
40
+ * Membership means "do not read this subtree's own text, but do descend it for
41
+ * executions"; each consumer keeps its own handling of the destination tokens.
42
+ */
43
+ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
44
+ "file_redirect",
45
+ "heredoc_redirect",
46
+ "herestring_redirect",
47
+ "heredoc_body",
48
+ ]);
49
+
50
+ /**
51
+ * Visit every nested execution context in `node`'s subtree, in source order.
52
+ *
53
+ * The walk does not descend *past* a context it finds: `visit` receives the
54
+ * context node itself and decides how to treat its interior (the command
55
+ * enumerator enumerates commands there; the path collector collects operand
56
+ * tokens), which keeps recursion policy with the consumer that understands it.
57
+ *
58
+ * A substitution can nest under `command_name` (when the whole command is
59
+ * `$(…)`), under an argument, inside a redirect destination, or inside an
60
+ * interpolating heredoc body, so the entire subtree is searched.
61
+ */
62
+ export function forEachNestedExecution(
63
+ node: TSNode,
64
+ visit: (contextNode: TSNode, context: BashCommandContext) => void,
65
+ ): void {
66
+ for (let i = 0; i < node.childCount; i++) {
67
+ const child = node.child(i);
68
+ if (!child) continue;
69
+ const context = NESTED_EXECUTION_CONTEXTS.get(child.type);
70
+ if (context) {
71
+ visit(child, context);
72
+ } else {
73
+ forEachNestedExecution(child, visit);
74
+ }
75
+ }
76
+ }
@@ -2,8 +2,13 @@ import type { TSNode } from "#src/access-intent/bash/parser";
2
2
  import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
3
3
 
4
4
  /**
5
- * Node types whose subtrees must never be descended into for
6
- * path extraction — their text content is not a command argument.
5
+ * Node types whose text content is never a command argument, so no path
6
+ * candidate is ever read from it.
7
+ *
8
+ * This governs the subtree's *text*, not whether it is visited at all: an
9
+ * interpolating `heredoc_body` is also an execution host, so it is still
10
+ * descended for the commands it runs while its prose stays out of the path
11
+ * surface (#741). See `EXECUTION_HOST_TYPES` in `nested-execution.ts`.
7
12
  */
8
13
  export const SKIP_SUBTREE_TYPES = new Set([
9
14
  "heredoc_body",
@@ -1,4 +1,9 @@
1
1
  import { basename } from "node:path";
2
+ import {
3
+ EXECUTION_HOST_TYPES,
4
+ forEachNestedExecution,
5
+ NESTED_EXECUTION_CONTEXTS,
6
+ } from "#src/access-intent/bash/nested-execution";
2
7
  import {
3
8
  ARG_NODE_TYPES,
4
9
  resolveNodeText,
@@ -12,7 +17,12 @@ import type { TSNode } from "#src/access-intent/bash/parser";
12
17
  * Recursively visit the AST and collect resolved text of nodes that
13
18
  * represent command arguments or redirect destinations.
14
19
  *
15
- * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
20
+ * Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
21
+ * still descends an execution host for the commands it hosts — an interpolating
22
+ * heredoc body runs its substitution even though its prose is never an operand
23
+ * (#741). That is why the {@link EXECUTION_HOST_TYPES} branch sits above the
24
+ * {@link SKIP_SUBTREE_TYPES} check: `heredoc_body` is in both sets, and the
25
+ * host reading is the one that must win.
16
26
  *
17
27
  * For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
18
28
  * argument skipping to avoid collecting inline patterns/scripts
@@ -20,9 +30,12 @@ import type { TSNode } from "#src/access-intent/bash/parser";
20
30
  * arguments generically.
21
31
  */
22
32
  export function collectPathCandidateTokens(node: TSNode): string[] {
23
- if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
24
33
  if (node.type === "command") return collectCommandTokens(node);
25
34
  if (node.type === "file_redirect") return collectRedirectTokens(node);
35
+ if (EXECUTION_HOST_TYPES.has(node.type)) {
36
+ return collectHostedExecutionTokens(node);
37
+ }
38
+ if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
26
39
 
27
40
  const tokens: string[] = [];
28
41
  for (let i = 0; i < node.childCount; i++) {
@@ -50,6 +63,15 @@ export function collectCommandTokens(node: TSNode): string[] {
50
63
 
51
64
  /**
52
65
  * Collect redirect-destination tokens from a `file_redirect` node.
66
+ *
67
+ * The destination itself is an argument value (`> out.txt`), but it can also
68
+ * host a command that really runs (`> $(cat /etc/shadow)`, `< <(cmd)`), whose
69
+ * own operands are path candidates too — so each child is both read for its
70
+ * text and searched for nested executions (#741).
71
+ *
72
+ * Both passes are needed: a substitution can be the destination outright, or be
73
+ * concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
74
+ * argument node.
53
75
  */
54
76
  export function collectRedirectTokens(node: TSNode): string[] {
55
77
  const tokens: string[] = [];
@@ -59,10 +81,33 @@ export function collectRedirectTokens(node: TSNode): string[] {
59
81
  if (ARG_NODE_TYPES.has(child.type)) {
60
82
  tokens.push(resolveNodeText(child));
61
83
  }
84
+ tokens.push(...collectHostedExecutionTokens(child));
62
85
  }
63
86
  return tokens;
64
87
  }
65
88
 
89
+ /**
90
+ * Collect the path-candidate tokens of every command nested inside `node`'s
91
+ * execution contexts, reading none of the host subtree's own text.
92
+ *
93
+ * This is what lets a heredoc body contribute its substitution's operands while
94
+ * its prose stays out of the path surface entirely.
95
+ *
96
+ * `node` may be a context outright (`> $(cmd)`) or merely contain one
97
+ * (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
98
+ * subtree, so the first case is checked here.
99
+ */
100
+ function collectHostedExecutionTokens(node: TSNode): string[] {
101
+ if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
102
+ return collectPathCandidateTokens(node);
103
+ }
104
+ const tokens: string[] = [];
105
+ forEachNestedExecution(node, (contextNode) => {
106
+ tokens.push(...collectPathCandidateTokens(contextNode));
107
+ });
108
+ return tokens;
109
+ }
110
+
66
111
  /**
67
112
  * Extract the command name from a `command` node.
68
113
  * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined