@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.
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
*
|
|
118
|
-
*
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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)`)
|
|
133
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
|
686
|
-
|
|
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.
|