@gotgenes/pi-permission-system 27.1.2 → 27.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
+ ## [27.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.2...pi-permission-system-v27.1.3) (2026-08-29)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** gate commands hosted by declarations, test commands, and assignments ([dfde822](https://github.com/gotgenes/pi-packages/commit/dfde8227d3bf11de5258dcbe376dd0b8b75045e0)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
14
+ * **pi-permission-system:** gate commands inside a for loop's body and word list ([6e96464](https://github.com/gotgenes/pi-packages/commit/6e9646486db849afdf80819150a203d293b3847c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
15
+ * **pi-permission-system:** gate commands inside control-flow bodies and function definitions ([3a2f232](https://github.com/gotgenes/pi-packages/commit/3a2f232d9c12588ea1feacf41cade0e56e66a44b)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
16
+ * **pi-permission-system:** project a command-name substitution's path operands ([9807d44](https://github.com/gotgenes/pi-packages/commit/9807d444b6d81526b01bc542cd4a829a1b2640c3)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** bound the path-slice claim on behavior, not diff footprint ([91ef5c9](https://github.com/gotgenes/pi-packages/commit/91ef5c9f2d0578fb64fe317bcddd3ec7d9418d4c)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
22
+ * **pi-permission-system:** commit the instrument behind Step 4's measurement ([e634c2c](https://github.com/gotgenes/pi-packages/commit/e634c2cf068340cc0deae86648330e44c2741f06)), closes [#742](https://github.com/gotgenes/pi-packages/issues/742)
23
+
8
24
  ## [27.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v27.1.1...pi-permission-system-v27.1.2) (2026-08-29)
9
25
 
10
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "27.1.2",
3
+ "version": "27.1.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -90,6 +90,7 @@
90
90
  "test:watch": "vitest",
91
91
  "verify:public-types": "bash scripts/verify-public-types.sh",
92
92
  "measure:core-coverage": "node scripts/measure-core-coverage.mjs",
93
+ "measure:statement-descent": "node scripts/measure-statement-descent.mjs",
93
94
  "measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
94
95
  "lint:md": "rumdl check *.md docs/**/*.md",
95
96
  "lint": "biome check . && eslint . && pnpm run lint:md"
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  EXECUTION_HOST_TYPES,
3
- forEachNestedExecution,
3
+ forEachExecutionIn,
4
4
  } from "#src/access-intent/bash/nested-execution";
5
5
  import type { TSNode } from "#src/access-intent/bash/parser";
6
6
  import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
@@ -79,7 +79,7 @@ interface UnitScope {
79
79
  /** A top-level command in the current shell, writing no file. */
80
80
  const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
81
81
 
82
- // ── Command enumeration ──────────────────────────────────────────────────────
82
+ // ── Node-type vocabulary ─────────────────────────────────────────────────────
83
83
 
84
84
  /**
85
85
  * Container node types descended into with the enclosing scope unchanged.
@@ -89,6 +89,39 @@ const TOP_LEVEL_SCOPE: UnitScope = { writesViaRedirect: false };
89
89
  */
90
90
  const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
91
91
 
92
+ /**
93
+ * Compound statements: emitted whole, then descended for their statements.
94
+ *
95
+ * The whole emit is what keeps the #306 never-weaker invariant — the commands
96
+ * found inside are additional units, never a replacement.
97
+ *
98
+ * `select` parses as `for_statement` and `until` as `while_statement`, so each
99
+ * pair is one entry.
100
+ */
101
+ const COMPOUND_STATEMENT_TYPES = new Set([
102
+ "if_statement",
103
+ "while_statement",
104
+ "for_statement",
105
+ "c_style_for_statement",
106
+ "case_statement",
107
+ "function_definition",
108
+ "compound_statement",
109
+ "negated_command",
110
+ ]);
111
+
112
+ /**
113
+ * Syntactic groupings inside a compound statement: descended, never emitted.
114
+ *
115
+ * None of these is something anybody runs — a `do_group` is the loop body's
116
+ * punctuation — so emitting one would produce a `do rm $f; done` unit.
117
+ */
118
+ const STATEMENT_GROUP_TYPES = new Set([
119
+ "do_group",
120
+ "case_item",
121
+ "elif_clause",
122
+ "else_clause",
123
+ ]);
124
+
92
125
  /**
93
126
  * Named node types abandoned during command enumeration: they are neither
94
127
  * commands nor able to host one, so nothing in their subtree ever runs.
@@ -104,6 +137,33 @@ const COMMAND_ENUM_DESCEND = new Set(["program", "list", "pipeline"]);
104
137
  */
105
138
  const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
106
139
 
140
+ /**
141
+ * Every node type the enumerator recognizes as a statement.
142
+ *
143
+ * This is the enumerator's third question, beside "is this a command?" and
144
+ * "can this host one?": "is this a *statement*, so that descending an enclosing
145
+ * compound reaches it?" A compound statement's named children are a mix —
146
+ * `for_statement` carries its loop variable and word list, `case_statement` its
147
+ * subject, `function_definition` its name — and descending all of them emits
148
+ * operand words as bash command units, naming `a` as the offending *command* in
149
+ * a prompt. Membership is what {@link descendStatementChildren} filters on.
150
+ */
151
+ const STATEMENT_TYPES = new Set([
152
+ "command",
153
+ "redirected_statement",
154
+ "subshell",
155
+ "declaration_command",
156
+ "variable_assignment",
157
+ "test_command",
158
+ "unset_command",
159
+ "ERROR",
160
+ ...COMMAND_ENUM_DESCEND,
161
+ ...COMPOUND_STATEMENT_TYPES,
162
+ ...STATEMENT_GROUP_TYPES,
163
+ ]);
164
+
165
+ // ── Command enumeration ──────────────────────────────────────────────
166
+
107
167
  /**
108
168
  * Enumerate the command units of a bash program, in source order.
109
169
  *
@@ -114,10 +174,14 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
114
174
  * subshells (`( … )`) — emitting each inner command as its own unit *in
115
175
  * addition to* the enclosing command, since those inner commands really execute
116
176
  * (#306).
117
- * Control-flow bodies and `{ … }` brace groups are emitted whole without
118
- * descending (deferred).
177
+ * A compound statement (control flow, a function definition, a `{ … }` brace
178
+ * group) is emitted whole and then descended for the statements it contains,
179
+ * while its operand words — a loop variable, a word list, a `case` subject, a
180
+ * function's own name — are not commands and are left unemitted. An `ERROR`
181
+ * node is the one exception: its recovered structure is invented rather than
182
+ * observed, so the unparsed blob is emitted whole and never descended (#742).
119
183
  *
120
- * The enclosing command/subshell is always still emitted whole, so adding the
184
+ * The enclosing command/statement is always still emitted whole, so adding the
121
185
  * nested units can only ever produce a more-restrictive decision, never weaker.
122
186
  *
123
187
  * Each emitted command unit has any leading `variable_assignment` prefix
@@ -172,9 +236,33 @@ function collectCommandsInto(
172
236
  return;
173
237
  }
174
238
 
239
+ if (COMPOUND_STATEMENT_TYPES.has(node.type)) {
240
+ out.push(makeUnit(node.text, scope)); // never-weaker whole emit
241
+ descendStatementChildren(node, scope, out);
242
+ return;
243
+ }
244
+
245
+ if (STATEMENT_GROUP_TYPES.has(node.type)) {
246
+ descendStatementChildren(node, scope, out);
247
+ return;
248
+ }
249
+
250
+ if (node.type === "ERROR") {
251
+ // Tree-sitter's error recovery *invents* structure, so the node types
252
+ // inside an ERROR subtree are not evidence that anything runs: descending
253
+ // one turns backtick-quoted prose in an unterminated heredoc into command
254
+ // units. Emit the unparsed blob whole and stop (#742).
255
+ out.push(makeUnit(node.text, scope));
256
+ return;
257
+ }
258
+
175
259
  // Any other named statement (compound_statement `{ … }`, if/while/for/case,
176
260
  // function_definition): emit whole, do not descend — deferred (#306).
261
+ // A declaration, assignment, test, or `unset` still hosts executions that
262
+ // really run (`local x=$(rm y)`, `[[ $(rm x) ]]`), so those are enumerated
263
+ // in addition to the statement (#742).
177
264
  out.push(makeUnit(node.text, scope));
265
+ collectHostedCommands(node, out);
178
266
  }
179
267
 
180
268
  /** The wrapper facts a `command` node's words establish about its unit. */
@@ -291,6 +379,35 @@ function descendCommandChildren(
291
379
  }
292
380
  }
293
381
 
382
+ /**
383
+ * Descend a compound statement's children, enumerating only the ones that are
384
+ * themselves statements.
385
+ *
386
+ * The filter is the whole difference from {@link descendCommandChildren}, whose
387
+ * container types (`program` / `list` / `pipeline` / `redirected_statement` /
388
+ * `subshell`) have nothing but statement children. Here the children are a mix,
389
+ * and a non-statement one is an operand word rather than something that runs.
390
+ *
391
+ * A non-statement child is not abandoned, though: `for f in $(rm x)` hosts a
392
+ * real execution in its word list, which is what the second branch reaches.
393
+ *
394
+ * The scope is relayed unchanged — a compound statement's body runs in the
395
+ * current shell, so a write established by an enclosing `redirected_statement`
396
+ * covers every unit beneath it (#803).
397
+ */
398
+ function descendStatementChildren(
399
+ node: TSNode,
400
+ scope: UnitScope,
401
+ out: BashCommand[],
402
+ ): void {
403
+ for (let i = 0; i < node.childCount; i++) {
404
+ const child = node.child(i);
405
+ if (!child?.isNamed) continue;
406
+ if (STATEMENT_TYPES.has(child.type)) collectCommandsInto(child, scope, out);
407
+ else collectHostedCommands(child, out);
408
+ }
409
+ }
410
+
294
411
  /**
295
412
  * Enumerate the commands of every nested execution context in a subtree, each
296
413
  * tagged with the context it was found in.
@@ -298,9 +415,12 @@ function descendCommandChildren(
298
415
  * The traversal itself lives in `nested-execution.ts` so the bash path surface
299
416
  * shares one definition of what counts as a nested execution (#741); this
300
417
  * function supplies the command-surface interpretation of each one found.
418
+ *
419
+ * `node` may be a context outright or merely host one, so the traversal is the
420
+ * root-inclusive `forEachExecutionIn`.
301
421
  */
302
422
  function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
303
- forEachNestedExecution(node, (contextNode, context) => {
423
+ forEachExecutionIn(node, (contextNode, context) => {
304
424
  // A nested execution starts fresh: an enclosing statement's redirect is
305
425
  // that statement's, not the substitution's, exactly as #807 attributes a
306
426
  // nested command's path tokens to its own command.
@@ -47,6 +47,24 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
47
47
  "heredoc_body",
48
48
  ]);
49
49
 
50
+ /**
51
+ * Visit every execution context `node` *is or contains*, in source order.
52
+ *
53
+ * The root-inclusive question, and the one nearly every consumer asks: a node
54
+ * handed in can be a substitution outright (`> $(cmd)`) or merely host one
55
+ * (`> ${DIR}/$(cmd)`), and both really execute. {@link forEachNestedExecution}
56
+ * answers the strictly-within question instead, which is what a visitor needs
57
+ * once it has already decided to treat a context's interior itself.
58
+ */
59
+ export function forEachExecutionIn(
60
+ node: TSNode,
61
+ visit: (contextNode: TSNode, context: BashCommandContext) => void,
62
+ ): void {
63
+ const context = NESTED_EXECUTION_CONTEXTS.get(node.type);
64
+ if (context) visit(node, context);
65
+ else forEachNestedExecution(node, visit);
66
+ }
67
+
50
68
  /**
51
69
  * Visit every nested execution context in `node`'s subtree, in source order.
52
70
  *
@@ -58,6 +76,10 @@ export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
58
76
  * A substitution can nest under `command_name` (when the whole command is
59
77
  * `$(…)`), under an argument, inside a redirect destination, or inside an
60
78
  * interpolating heredoc body, so the entire subtree is searched.
79
+ *
80
+ * `node` itself is never visited, however it is typed — use
81
+ * {@link forEachExecutionIn} when it may *be* a context rather than merely
82
+ * contain one.
61
83
  */
62
84
  export function forEachNestedExecution(
63
85
  node: TSNode,
@@ -2,8 +2,7 @@ import { basename } from "node:path";
2
2
  import { proveCommandEffect } from "#src/access-intent/bash/command-effects";
3
3
  import {
4
4
  EXECUTION_HOST_TYPES,
5
- forEachNestedExecution,
6
- NESTED_EXECUTION_CONTEXTS,
5
+ forEachExecutionIn,
7
6
  } from "#src/access-intent/bash/nested-execution";
8
7
  import {
9
8
  ARG_NODE_TYPES,
@@ -129,15 +128,12 @@ export function collectRedirectTokens(node: TSNode): PathToken[] {
129
128
  * its prose stays out of the path surface entirely.
130
129
  *
131
130
  * `node` may be a context outright (`> $(cmd)`) or merely contain one
132
- * (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
133
- * subtree, so the first case is checked here.
131
+ * (`> ${DIR}/$(cmd)`), so the traversal is the root-inclusive
132
+ * `forEachExecutionIn`.
134
133
  */
135
134
  function collectHostedExecutionTokens(node: TSNode): PathToken[] {
136
- if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
137
- return collectPathCandidateTokens(node);
138
- }
139
135
  const tokens: PathToken[] = [];
140
- forEachNestedExecution(node, (contextNode) => {
136
+ forEachExecutionIn(node, (contextNode) => {
141
137
  tokens.push(...collectPathCandidateTokens(contextNode));
142
138
  });
143
139
  return tokens;
@@ -201,6 +197,23 @@ function commandArgumentWords(node: TSNode): string[] {
201
197
  return words;
202
198
  }
203
199
 
200
+ /**
201
+ * The children of a `command` node that supply no operand text of their own:
202
+ * its head word, and any env-var prefix assignment.
203
+ *
204
+ * Both are skipped as operands — the head word is not an argument, and a prefix
205
+ * assignment's value is assigned rather than accessed — but either can *host* a
206
+ * substitution that really runs (`$(cat /etc/shadow)`,
207
+ * `FOO=$(cat /etc/shadow) echo hi`), whose own operands are candidates like any
208
+ * other position (ADR 0009's positional invariance). The two walkers below are
209
+ * different state machines and so each carry their own skip, which is why the
210
+ * question is named here once rather than spelled twice (#742).
211
+ */
212
+ const COMMAND_PREFIX_TYPES: ReadonlySet<string> = new Set([
213
+ "command_name",
214
+ "variable_assignment",
215
+ ]);
216
+
204
217
  /**
205
218
  * A long or short option carrying its value inline: one or two leading dashes,
206
219
  * a name containing no `=` or whitespace, then `=` and a non-empty value.
@@ -527,9 +540,11 @@ function collectPatternCommandTokens(
527
540
  const child = node.child(i);
528
541
  if (!child) continue;
529
542
 
530
- // Skip command_name and variable_assignment nodes.
531
- if (child.type === "command_name" || child.type === "variable_assignment")
543
+ if (COMMAND_PREFIX_TYPES.has(child.type)) {
544
+ // Supplies no operand of its own, but may host one that really runs.
545
+ tokens.push(...collectHostedExecutionTokens(child));
532
546
  continue;
547
+ }
533
548
 
534
549
  const isArgNode = ARG_NODE_TYPES.has(child.type);
535
550
  const text = resolveNodeText(child);
@@ -682,12 +697,12 @@ function collectGenericCommandTokens(
682
697
  const child = node.child(i);
683
698
  if (!child) continue;
684
699
 
685
- if (child.type === "command_name") {
686
- seenCommandName = true;
700
+ if (COMMAND_PREFIX_TYPES.has(child.type)) {
701
+ // Supplies no operand of its own, but may host one that really runs.
702
+ if (child.type === "command_name") seenCommandName = true;
703
+ tokens.push(...collectHostedExecutionTokens(child));
687
704
  continue;
688
705
  }
689
- // Skip variable_assignment nodes (FOO=/bar)
690
- if (child.type === "variable_assignment") continue;
691
706
 
692
707
  // If there was no explicit command_name node, the first word-like
693
708
  // child is the command name itself — skip it.