@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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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": "
|
|
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
|
|
3
|
-
import type
|
|
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
|
-
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
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
|
|
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
|