@gotgenes/pi-permission-system 30.2.0 → 31.0.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,35 @@ 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
+ ## [31.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.0.0...pi-permission-system-v31.0.1) (2026-09-03)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** consult both path directions for a redirect the parser could not resolve ([0327feb](https://github.com/gotgenes/pi-packages/commit/0327feb9d2cefc47fcebe724ee94cf9949a8749a)), closes [#814](https://github.com/gotgenes/pi-packages/issues/814)
14
+
15
+ ### Documentation
16
+
17
+ * **pi-permission-system:** commit the instrument behind the unresolved-redirect measurement ([757affe](https://github.com/gotgenes/pi-packages/commit/757affeac19400bf7764c9f06fa58bf18ccafc1e)), closes [#814](https://github.com/gotgenes/pi-packages/issues/814)
18
+ * **pi-permission-system:** mark Phase 14 Step 12 complete ([07d23ef](https://github.com/gotgenes/pi-packages/commit/07d23ef6cffb954058ff2f8fe43917a937de437f)), closes [#814](https://github.com/gotgenes/pi-packages/issues/814)
19
+ * **pi-permission-system:** register the unresolved-redirect measurement script ([8203898](https://github.com/gotgenes/pi-packages/commit/8203898bd2dadb62ae72e5bedb579bbaf062cf03)), closes [#814](https://github.com/gotgenes/pi-packages/issues/814)
20
+ * **pi-permission-system:** state the unresolved-redirect residual as a parse fact ([e74c2fc](https://github.com/gotgenes/pi-packages/commit/e74c2fc3c63bf4a18b63334e1fb9b24565fb36b2)), closes [#814](https://github.com/gotgenes/pi-packages/issues/814)
21
+ * **pi-permission-system:** describe the unresolvable-redirect rule as users meet it ([9e57a90](https://github.com/gotgenes/pi-packages/commit/9e57a909e7719fbfa670ad3ec153e5aa2faf1510)), closes [#814](https://github.com/gotgenes/pi-packages/issues/814)
22
+
23
+ ## [31.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v30.2.0...pi-permission-system-v31.0.0) (2026-09-02)
24
+
25
+
26
+ ### Bug Fixes
27
+
28
+ * **pi-permission-system:** **breaking:** gate a path named as a for or select loop operand ([6189d81](https://github.com/gotgenes/pi-packages/commit/6189d81dc48a26d572bacb91a1b2e7c062634ad9)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
29
+ * **pi-permission-system:** **breaking:** gate a path named as a case subject ([dfd2f8d](https://github.com/gotgenes/pi-packages/commit/dfd2f8d682c39dcfe6a903cdeadde18b08a7cc60)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
30
+
31
+ ### Documentation
32
+
33
+ * **pi-permission-system:** commit the instrument behind the statement-operand measurement ([6a708b7](https://github.com/gotgenes/pi-packages/commit/6a708b716f038bf192875a0166ae6a6ab012c3d8)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
34
+ * **pi-permission-system:** register the statement-operand measurement script ([e3e8799](https://github.com/gotgenes/pi-packages/commit/e3e879935c6f395d4b2387a6e30e0791bb8fb765)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
35
+ * **pi-permission-system:** correct the drift note in the operand measurement ([e300934](https://github.com/gotgenes/pi-packages/commit/e300934c0aad34b3bfd127695219250ca6f4613f)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
36
+
8
37
  ## [30.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v30.1.0...pi-permission-system-v30.2.0) (2026-09-02)
9
38
 
10
39
 
@@ -766,6 +766,11 @@ A tool's identity establishes its direction, and on the bash surface a redirect
766
766
  An access whose direction cannot be established consults **both** surfaces and takes the more restrictive answer.
767
767
  That is deliberate: an unproven access is never treated as the narrower one.
768
768
 
769
+ A redirect the parser could not make sense of is unproven for the same reason.
770
+ The read-write open `<>` is the clearest case: `tree-sitter-bash` has no node for it, so neither half of the operator can be trusted to describe the whole, and its destination consults both surfaces rather than the one the surviving half would name.
771
+ The rule is about the parse rather than about `<>`, so it also covers a redirect that is itself well-formed but sits beside something the parser could not read: in `cat $(( > out.txt`, the `> out.txt` consults both surfaces too.
772
+ That is deliberate — a command nobody could parse is the last place to assume a file is only being read — and it does not reach past the neighbour, so a redirect in a later statement keeps its proof.
773
+
769
774
  Attribution is per **token**, not per command, so one invocation can do both: in `cat notes.md > /backup/notes.md`, `notes.md` is a read and `/backup/notes.md` is a write.
770
775
  A redirect operator's proof is absolute — it overrides whatever the command in front of it proved, because `> out.txt` writes `out.txt` however read-only that command is.
771
776
  When the same path is reached twice with disagreeing directions (`cat a.txt > a.txt`), the two fold to unproven, which consults both surfaces.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "30.2.0",
3
+ "version": "31.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -91,6 +91,8 @@
91
91
  "verify:public-types": "bash scripts/verify-public-types.sh",
92
92
  "measure:core-coverage": "node scripts/measure-core-coverage.mjs",
93
93
  "measure:statement-descent": "node scripts/measure-statement-descent.mjs",
94
+ "measure:statement-operands": "node scripts/measure-statement-operands.mjs",
95
+ "measure:unresolved-redirects": "node scripts/measure-unresolved-redirects.mjs",
94
96
  "measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
95
97
  "lint:md": "rumdl check *.md docs/**/*.md",
96
98
  "lint": "biome check . && eslint . && pnpm run lint:md"
@@ -4,6 +4,11 @@ import { memoizeAsyncWithRetry } from "#src/async-cache";
4
4
  /**
5
5
  * Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
6
6
  * Defined locally so callers do not need to import web-tree-sitter types.
7
+ *
8
+ * The last two members are the parse's own health, which every other member
9
+ * describes a *successful* parse's structure. They exist for
10
+ * {@link parseUnresolvedAt} and are read nowhere else — see its doc comment for
11
+ * why that boundary matters.
7
12
  */
8
13
  export interface TSNode {
9
14
  readonly type: string;
@@ -13,9 +18,49 @@ export interface TSNode {
13
18
  readonly childCount: number;
14
19
  /** False for anonymous tokens (operators, delimiters); true for named nodes. */
15
20
  readonly isNamed: boolean;
21
+ /** True when this node is an error or missing token, or contains one. */
22
+ readonly hasError: boolean;
23
+ /** The node immediately before this one under the same parent, named or not. */
24
+ readonly previousSibling: TSNode | null;
16
25
  child(index: number): TSNode | null;
17
26
  }
18
27
 
28
+ /**
29
+ * Whether tree-sitter failed to resolve the syntax at `node`.
30
+ *
31
+ * Error recovery disposes of text it cannot attach in one of two places, and
32
+ * which one it picks depends on what follows. The read-write open `<>`, which
33
+ * `tree-sitter-bash` 0.25.1 has no node for, shows both: `cat <> rw.txt` keeps
34
+ * the discarded `>` as an `ERROR` *child* of the redirect, while
35
+ * `cat <> ~/rw.txt` strands the `<` as an `ERROR` *sibling* ahead of a redirect
36
+ * that is otherwise indistinguishable from a genuine `> ~/rw.txt`. A reader
37
+ * that consults only the node's own subtree sees the first and not the second.
38
+ *
39
+ * The immediate predecessor, rather than the enclosing statement, is what makes
40
+ * the answer per-redirect: in `cat a > out.txt <> ~/rw.txt` the statement has
41
+ * an error but its first redirect is a fully resolved write, and condemning it
42
+ * would forfeit a proof the parse really did establish.
43
+ *
44
+ * The question is about the parse, not about `<>`, so the population is wider
45
+ * than the form that exposed it: `cat $(( > out.txt` and `echo ) > out.txt`
46
+ * both carry a perfectly good `> out.txt` whose predecessor failed for an
47
+ * unrelated reason, and both go unproven. That is the accepted cost, and it is
48
+ * the same shape as the only real occurrence measured across 5000+ logged
49
+ * commands — `git commit -F - <<'MSG' 2>&1 | tail -4`, valid bash the grammar
50
+ * cannot parse (ADR 0013's 2026-08-29 amendment), where the demoted token
51
+ * belongs to no `<>` either. Over-refusing costs a prompt; under-refusing hands
52
+ * a write to a read grant.
53
+ *
54
+ * This is the one place {@link TSNode.hasError} and
55
+ * {@link TSNode.previousSibling} are read. Keeping the lateral navigation here
56
+ * is deliberate: recovering-parser behavior is a fact about tree-sitter rather
57
+ * than about any construct, so a caller asks this question instead of
58
+ * hand-rolling a sibling walk of its own.
59
+ */
60
+ export function parseUnresolvedAt(node: TSNode): boolean {
61
+ return node.hasError || (node.previousSibling?.hasError ?? false);
62
+ }
63
+
19
64
  /**
20
65
  * Minimal subset of web-tree-sitter's Parser used by this module.
21
66
  */
@@ -1,6 +1,6 @@
1
1
  import { redirectDestinationEffect } from "#src/access-intent/bash/command-effects";
2
- import type { TSNode } from "#src/access-intent/bash/parser";
3
- import type { TokenEffect } from "#src/access-intent/effect";
2
+ import { parseUnresolvedAt, type TSNode } from "#src/access-intent/bash/parser";
3
+ import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
4
4
 
5
5
  /**
6
6
  * What a redirect node in the parse tree proves.
@@ -17,6 +17,11 @@ import type { TokenEffect } from "#src/access-intent/effect";
17
17
  * whether it is safe to *remove* the wrapper floor, so it answers with a
18
18
  * refusal: anything it cannot resolve counts against the exemption (#803).
19
19
  * One reader of the node keeps the two from drifting on what a redirect is.
20
+ *
21
+ * The two burdens meet at one fact: whether the parse resolved at all. Both
22
+ * answers ask `parseUnresolvedAt`, so a syntax form the grammar could not
23
+ * handle cannot be a proof to one caller and a resolvable read to the other
24
+ * (#814).
20
25
  */
21
26
 
22
27
  /**
@@ -26,15 +31,28 @@ import type { TokenEffect } from "#src/access-intent/effect";
26
31
  * `>&` and `<&` are the two operators that may name either a file descriptor
27
32
  * (`2>&1`) or a real file (`cmd >& out`); the destination node's type is the
28
33
  * parse-tree fact that tells them apart.
34
+ *
35
+ * A redirect the parse could not resolve proves nothing — ADR 0013 §10's base
36
+ * case, which consults both directional surfaces. Reading a proof off whichever
37
+ * operator survived error recovery made `cat <> rw.txt` a read and
38
+ * `cat <> ~/rw.txt` a write, so one command's answer was a function of its
39
+ * filename, and the read half was a fail-open on a destination the shell may
40
+ * truncate (#814).
41
+ *
42
+ * The demotion applies to a *proof*, never to the `null`: a descriptor
43
+ * duplication names no file whatever the operator around it did, so demoting
44
+ * first would emit a descriptor number as a path candidate.
29
45
  */
30
46
  export function redirectEffectForDestination(
31
47
  redirect: TSNode,
32
48
  destination: TSNode,
33
49
  ): TokenEffect | null {
34
- return redirectDestinationEffect(
50
+ const proven = redirectDestinationEffect(
35
51
  redirectOperatorOf(redirect),
36
52
  DESCRIPTOR_NODE_TYPES.has(destination.type),
37
53
  );
54
+ if (proven === null) return null;
55
+ return parseUnresolvedAt(redirect) ? UNPROVEN_EFFECT : proven;
38
56
  }
39
57
 
40
58
  /**
@@ -49,20 +67,24 @@ export function redirectEffectForDestination(
49
67
  * shapes least visible to every other surface — the path projection does not
50
68
  * collect them either (#609).
51
69
  *
52
- * Only two things clear it: a descriptor duplication (`2>&1`), which names no
53
- * file, and an operator that proves a read reading a file alongside a pure
54
- * reader leaves it a pure reader.
70
+ * An unresolved parse is refused up front rather than left to the loop. The
71
+ * loop would usually reach the same answer — a demoted destination is unproven,
72
+ * which is not a read — but `cat <>&1` parses to a redirect whose only children
73
+ * are the operator and a descriptor, so the loop finds nothing to refuse on and
74
+ * clears the exemption for a form nobody understood (#814).
75
+ *
76
+ * Past that, only two things clear it: a descriptor duplication (`2>&1`), which
77
+ * names no file, and an operator that proves a read — reading a file alongside
78
+ * a pure reader leaves it a pure reader.
55
79
  */
56
80
  export function redirectMayWriteFile(redirect: TSNode): boolean {
81
+ if (parseUnresolvedAt(redirect)) return true;
57
82
  for (let i = 0; i < redirect.childCount; i++) {
58
83
  const child = redirect.child(i);
59
84
  // The operator itself is the redirect's only unnamed child.
60
85
  if (!child?.isNamed) continue;
61
86
  // A source or duplicated descriptor (`2`, `&1`) names no file.
62
87
  if (DESCRIPTOR_NODE_TYPES.has(child.type)) continue;
63
- // A shape the grammar could not resolve (`<>` degrades to one) says nothing
64
- // about direction, so it cannot clear the check.
65
- if (child.type === "ERROR") return true;
66
88
  if (redirectEffectForDestination(redirect, child)?.effect !== "read") {
67
89
  return true;
68
90
  }
@@ -11,7 +11,7 @@ import {
11
11
  } from "#src/access-intent/bash/node-text";
12
12
  import type { TSNode } from "#src/access-intent/bash/parser";
13
13
  import { redirectEffectForDestination } from "#src/access-intent/bash/redirect-analysis";
14
- import type { TokenEffect } from "#src/access-intent/effect";
14
+ import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
15
15
 
16
16
  /**
17
17
  * A collected path-candidate token paired with the effect its position proved.
@@ -29,7 +29,8 @@ export interface PathToken {
29
29
 
30
30
  /**
31
31
  * Recursively visit the AST and collect resolved text of nodes that
32
- * represent command arguments or redirect destinations.
32
+ * represent command arguments, redirect destinations, or a statement's own
33
+ * path operands.
33
34
  *
34
35
  * Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
35
36
  * still descends an execution host for the commands it hosts — an interpolating
@@ -46,6 +47,12 @@ export interface PathToken {
46
47
  export function collectPathCandidateTokens(node: TSNode): PathToken[] {
47
48
  if (node.type === "command") return collectCommandTokens(node);
48
49
  if (node.type === "file_redirect") return collectRedirectTokens(node);
50
+ if (node.type === "for_statement") {
51
+ return collectStatementOperandTokens(node, "after-in");
52
+ }
53
+ if (node.type === "case_statement") {
54
+ return collectStatementOperandTokens(node, "before-in");
55
+ }
49
56
  if (EXECUTION_HOST_TYPES.has(node.type)) {
50
57
  return collectHostedExecutionTokens(node);
51
58
  }
@@ -139,6 +146,70 @@ function collectHostedExecutionTokens(node: TSNode): PathToken[] {
139
146
  return tokens;
140
147
  }
141
148
 
149
+ /**
150
+ * Which side of a statement's `in` keyword carries its path operands.
151
+ *
152
+ * A `for`/`select` word list follows `in`; a `case` subject precedes it.
153
+ */
154
+ type OperandSide = "before-in" | "after-in";
155
+
156
+ /**
157
+ * Collect the tokens of a statement that names its own path operands, rather
158
+ * than reaching them through a command.
159
+ *
160
+ * A path in a `for`/`select` word list or a `case` subject is a child of the
161
+ * statement node, so the command and redirect collectors never see it and the
162
+ * loop body cannot recover it — `for f in /etc/shadow; do cat $f; done` carries
163
+ * the literal only here, and ADR 0009 declines to resolve the body's `$f`
164
+ * (#839).
165
+ *
166
+ * The two statements ask one question with one parameter — which side of the
167
+ * anonymous `in` keyword is the operand side — so the walk is named here once
168
+ * rather than spelled twice, as `COMMAND_PREFIX_TYPES` is for the two command
169
+ * walkers.
170
+ *
171
+ * Three properties carry the design:
172
+ *
173
+ * 1. A non-operand child falls through to the ordinary recursion, not to
174
+ * nothing. That is what keeps the `do_group` reaching the loop body's
175
+ * commands; searching it for hosted executions alone would silently drop
176
+ * every ordinary body command.
177
+ * 2. An operand-side child outside {@link ARG_NODE_TYPES} falls through the
178
+ * same way, so a bare substitution in the word list is descended for its
179
+ * command as before and its operands keep that command's own attribution
180
+ * (#807) instead of the statement's.
181
+ * 3. An operand-side argument node is read *and* searched for hosted
182
+ * executions, since a `concatenation` can be both — the pairing
183
+ * {@link collectRedirectTokens} already performs on a destination.
184
+ *
185
+ * The token carries {@link UNPROVEN_EFFECT}: no command word owns it and no
186
+ * redirect operator names it, so neither proof source can speak and the gates
187
+ * consult both directional surfaces.
188
+ */
189
+ function collectStatementOperandTokens(
190
+ node: TSNode,
191
+ operandSide: OperandSide,
192
+ ): PathToken[] {
193
+ const tokens: PathToken[] = [];
194
+ let seenIn = false;
195
+ for (let i = 0; i < node.childCount; i++) {
196
+ const child = node.child(i);
197
+ if (!child) continue;
198
+ if (!child.isNamed) {
199
+ if (child.type === "in") seenIn = true;
200
+ continue;
201
+ }
202
+ const side: OperandSide = seenIn ? "after-in" : "before-in";
203
+ if (side !== operandSide || !ARG_NODE_TYPES.has(child.type)) {
204
+ tokens.push(...collectPathCandidateTokens(child));
205
+ continue;
206
+ }
207
+ tokens.push({ token: resolveNodeText(child), effect: UNPROVEN_EFFECT });
208
+ tokens.push(...collectHostedExecutionTokens(child));
209
+ }
210
+ return tokens;
211
+ }
212
+
142
213
  /**
143
214
  * Extract the command name from a `command` node.
144
215
  * Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined