@gotgenes/pi-permission-system 31.0.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,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
+ ## [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
+
8
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)
9
24
 
10
25
 
@@ -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": "31.0.0",
3
+ "version": "31.0.1",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -92,6 +92,7 @@
92
92
  "measure:core-coverage": "node scripts/measure-core-coverage.mjs",
93
93
  "measure:statement-descent": "node scripts/measure-statement-descent.mjs",
94
94
  "measure:statement-operands": "node scripts/measure-statement-operands.mjs",
95
+ "measure:unresolved-redirects": "node scripts/measure-unresolved-redirects.mjs",
95
96
  "measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
96
97
  "lint:md": "rumdl check *.md docs/**/*.md",
97
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
  }