@gotgenes/pi-permission-system 32.0.3 → 32.0.4

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,19 @@ 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
+ ## [32.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.3...pi-permission-system-v32.0.4) (2026-09-16)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** evaluate bash rules against a command a partial parse dropped ([28cedc4](https://github.com/gotgenes/pi-packages/commit/28cedc45dd4a11ee9189f41ee661a051d2b13936)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
14
+ * **pi-permission-system:** gate the paths a command dropped by a partial parse reads ([6f1d032](https://github.com/gotgenes/pi-packages/commit/6f1d0328f8151da8716641f0ee0c25341699cd75)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
15
+ * **pi-permission-system:** keep the whole-command deny reachable when a salvage recovers the only units ([ab91a66](https://github.com/gotgenes/pi-packages/commit/ab91a660eafb879a3370104cb6f68353041a7cbb)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
16
+
17
+ ### Documentation
18
+
19
+ * **pi-permission-system:** record the salvage of a bash command a partial parse dropped ([17a3684](https://github.com/gotgenes/pi-packages/commit/17a3684f182467c19e1ca56664e1adf900980f11))
20
+
8
21
  ## [32.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.2...pi-permission-system-v32.0.3) (2026-09-15)
9
22
 
10
23
 
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 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`)
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`); where a partial parse failure's own region re-parses cleanly on its own, the commands and paths it holds are recovered and gated rather than merely prompted for
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
@@ -429,11 +429,13 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
429
429
  - If the permission gate throws an internal error (for example a transient tree-sitter parser-init failure), the tool call is **blocked** rather than passed ungated, and a `gate_error` entry is written to the review log naming the failure.
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
+ That whole-command check runs whenever the parse itself matched nothing, including when the recovery below went on to recover a command from the wreckage, so a rule naming the command in context (`"* rm -rf *"`) is still consulted.
432
433
  An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
433
434
  - A command the parser could only *partly* resolve is floored the same way (the synthetic `<unparsed-bash-subtree>` pattern in the review log).
434
435
  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
+ The prompt names the **whole** command rather than the unit, because the fragment that did parse is not what you need to see.
436
437
  A statement beside the failed one keeps its own rule.
438
+ Where the unresolved region's own text parses cleanly on its own, the commands and paths inside it are recovered and gated too, so a `deny` covering one of them still denies rather than prompting — a region whose own text does not re-parse is left to the floor, since error recovery invents the structure inside one and inventions do not re-parse.
437
439
  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.
438
440
  - 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).
439
441
  An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "32.0.3",
3
+ "version": "32.0.4",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -127,13 +127,31 @@ export class BashPathResolver {
127
127
  * itself is added to the external paths when it resolves outside the cwd.
128
128
  * Containment is always measured against the session cwd baked into the
129
129
  * normalizer, so a `workdir` outside the cwd does not widen the sandbox.
130
+ *
131
+ * `salvagedRoots` are regions the primary parse could not resolve, re-parsed
132
+ * cleanly on their own (`unresolved-salvage.ts`, #875). Their candidates are
133
+ * collected into the same array before projection runs, so a path both a
134
+ * salvaged region and the primary parse name folds to one entry rather than
135
+ * showing twice in the prompt. Each is walked under the **unknown** base: a
136
+ * fragment carries no record of the `cd` in force where it sat, and
137
+ * resolving `cat rel.txt` against the session cwd after `cd /outside` would
138
+ * name a different file than the one that runs — a rule for that other path
139
+ * could then allow this access. #393's unknown base declines the claim
140
+ * instead, keeping an absolute token literal-only and unconditionally
141
+ * external while a relative one is not projected at all.
130
142
  */
131
- resolve(rootNode: TSNode): ResolvedBashPaths {
143
+ resolve(
144
+ rootNode: TSNode,
145
+ salvagedRoots: readonly TSNode[] = [],
146
+ ): ResolvedBashPaths {
132
147
  const initialBase =
133
148
  this.workdir === undefined
134
149
  ? CWD_BASE
135
150
  : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
136
151
  const candidates = this.collectPathCandidates(rootNode, initialBase);
152
+ for (const salvaged of salvagedRoots) {
153
+ this.walkForCandidates(salvaged, UNKNOWN_BASE, candidates);
154
+ }
137
155
  return {
138
156
  externalAccesses: this.withWorkdirExternal(
139
157
  this.projectExternalPaths(candidates),
@@ -57,6 +57,19 @@ export interface BashCommand {
57
57
  * 0013 §10's fail-closed base case (#840).
58
58
  */
59
59
  readonly parseUnresolved?: true;
60
+ /**
61
+ * Set when this unit came from a region re-parsed out of a subtree the
62
+ * primary parse could not resolve, rather than from the primary parse
63
+ * itself (#875).
64
+ *
65
+ * Narrower than {@link parseUnresolved}, which a primary unit also carries
66
+ * when its enclosing statement failed. The verdict fold needs the
67
+ * distinction to tell whether the *primary* parse found anything: when it
68
+ * found nothing, the whole command string is the only surface an explicit
69
+ * `deny` can reach (#452), and salvaging a unit must not make that check
70
+ * unreachable.
71
+ */
72
+ readonly salvaged?: true;
60
73
  }
61
74
 
62
75
  /**
@@ -83,12 +96,34 @@ interface UnitScope {
83
96
  * resolve, so every unit beneath it is floored rather than trusted (#840).
84
97
  */
85
98
  readonly parseUnresolved: boolean;
99
+ /**
100
+ * True when the walk started from a salvaged region rather than the primary
101
+ * parse tree (#875). Relayed unchanged, including into nested executions:
102
+ * everything found inside a salvaged region is salvaged.
103
+ */
104
+ readonly salvaged: boolean;
86
105
  }
87
106
 
88
107
  /** A top-level command in the current shell, writing no file, fully parsed. */
89
108
  const TOP_LEVEL_SCOPE: UnitScope = {
90
109
  writesViaRedirect: false,
91
110
  parseUnresolved: false,
111
+ salvaged: false,
112
+ };
113
+
114
+ /**
115
+ * The scope a salvaged region's own units run under.
116
+ *
117
+ * Marked unresolved because the region reached the salvage only by failing in
118
+ * the primary parse, so the verdict fold floors what it recovers rather than
119
+ * trusting it. `writesViaRedirect` starts false for the same reason
120
+ * {@link collectHostedCommands} resets it: a redirect established outside the
121
+ * region is the enclosing statement's, not the region's.
122
+ */
123
+ const SALVAGED_SCOPE: UnitScope = {
124
+ writesViaRedirect: false,
125
+ parseUnresolved: true,
126
+ salvaged: true,
92
127
  };
93
128
 
94
129
  // ── Node-type vocabulary ─────────────────────────────────────────────────────
@@ -212,6 +247,22 @@ export function collectCommands(node: TSNode): BashCommand[] {
212
247
  return out;
213
248
  }
214
249
 
250
+ /**
251
+ * Enumerate the command units of a region the primary parse could not resolve,
252
+ * re-parsed cleanly on its own (`unresolved-salvage.ts`, #875).
253
+ *
254
+ * The same walk as {@link collectCommands}, differing only in the scope it
255
+ * starts from: every unit is marked {@link BashCommand.parseUnresolved}, so a
256
+ * command the primary parse dropped is matched against the bash rules — an
257
+ * explicit `deny` fires — while its `allow` is still floored to `ask` by the
258
+ * verdict fold (#840).
259
+ */
260
+ export function collectSalvagedCommands(node: TSNode): BashCommand[] {
261
+ const out: BashCommand[] = [];
262
+ collectCommandsInto(node, SALVAGED_SCOPE, out);
263
+ return out;
264
+ }
265
+
215
266
  function collectCommandsInto(
216
267
  node: TSNode,
217
268
  inherited: UnitScope,
@@ -228,7 +279,7 @@ function collectCommandsInto(
228
279
  out.push(makeCommandUnit(node, scope));
229
280
  // A command's text already contains any substitution; descend its subtree
230
281
  // to ALSO emit the inner commands of command/process substitutions.
231
- collectHostedCommands(node, out);
282
+ collectHostedCommands(node, scope, out);
232
283
  return;
233
284
  }
234
285
 
@@ -240,7 +291,7 @@ function collectCommandsInto(
240
291
  if (EXECUTION_HOST_TYPES.has(node.type)) {
241
292
  // Not a command itself, but its subtree can host one that really runs
242
293
  // (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
243
- collectHostedCommands(node, out);
294
+ collectHostedCommands(node, scope, out);
244
295
  return;
245
296
  }
246
297
 
@@ -281,7 +332,7 @@ function collectCommandsInto(
281
332
  // really run (`local x=$(rm y)`, `[[ $(rm x) ]]`), so those are enumerated
282
333
  // in addition to the statement (#742).
283
334
  out.push(makeUnit(node.text, scope));
284
- collectHostedCommands(node, out);
335
+ collectHostedCommands(node, scope, out);
285
336
  }
286
337
 
287
338
  /**
@@ -328,9 +379,10 @@ function makeUnit(
328
379
  executedUnit === undefined ? flagged : { ...flagged, executedUnit };
329
380
  const exempted =
330
381
  floorExemption === undefined ? named : { ...named, floorExemption };
331
- return scope.parseUnresolved
382
+ const marked: BashCommand = scope.parseUnresolved
332
383
  ? { ...exempted, parseUnresolved: true }
333
384
  : exempted;
385
+ return scope.salvaged ? { ...marked, salvaged: true } : marked;
334
386
  }
335
387
 
336
388
  /**
@@ -450,7 +502,7 @@ function descendStatementChildren(
450
502
  const child = node.child(i);
451
503
  if (!child?.isNamed) continue;
452
504
  if (STATEMENT_TYPES.has(child.type)) collectCommandsInto(child, scope, out);
453
- else collectHostedCommands(child, out);
505
+ else collectHostedCommands(child, scope, out);
454
506
  }
455
507
  }
456
508
 
@@ -465,7 +517,11 @@ function descendStatementChildren(
465
517
  * `node` may be a context outright or merely host one, so the traversal is the
466
518
  * root-inclusive `forEachExecutionIn`.
467
519
  */
468
- function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
520
+ function collectHostedCommands(
521
+ node: TSNode,
522
+ scope: UnitScope,
523
+ out: BashCommand[],
524
+ ): void {
469
525
  forEachExecutionIn(node, (contextNode, context) => {
470
526
  // A nested execution starts fresh: an enclosing statement's redirect is
471
527
  // that statement's, not the substitution's, exactly as #807 attributes a
@@ -475,7 +531,12 @@ function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
475
531
  // units carry the mark regardless, so the verdict is unchanged (#840).
476
532
  descendCommandChildren(
477
533
  contextNode,
478
- { context, writesViaRedirect: false, parseUnresolved: false },
534
+ {
535
+ context,
536
+ writesViaRedirect: false,
537
+ parseUnresolved: false,
538
+ salvaged: scope.salvaged,
539
+ },
479
540
  out,
480
541
  );
481
542
  });
@@ -79,16 +79,32 @@ export function parseUnresolvedAt(node: TSNode): boolean {
79
79
  * fact about redirects, not about statements: a statement whose *predecessor*
80
80
  * failed is not itself unparsed, and borrowing the wider predicate here would
81
81
  * condemn every statement following a failed one.
82
+ *
83
+ * `unresolved-salvage.ts` asks the same question twice over: to locate the
84
+ * innermost region worth re-parsing, and to refuse the re-parse's own result
85
+ * when it failed too (#875).
82
86
  */
83
87
  export function parseUnresolvedWithin(node: TSNode): boolean {
84
88
  return node.hasError;
85
89
  }
86
90
 
87
91
  /**
88
- * Minimal subset of web-tree-sitter's Parser used by this module.
92
+ * The one parse capability a consumer needs to re-parse a fragment of a
93
+ * command on its own.
94
+ *
95
+ * Narrower than {@link TSParser} on purpose: that interface also carries the
96
+ * parser's own `delete()`, which destroys the process-wide memoized parser for
97
+ * every later command. A consumer re-parsing a fragment has no business
98
+ * holding that, so it takes this instead (`unresolved-salvage.ts`, #875).
89
99
  */
90
- interface TSParser {
100
+ export interface BashReparser {
91
101
  parse(input: string): { rootNode: TSNode; delete(): void } | null;
102
+ }
103
+
104
+ /**
105
+ * Minimal subset of web-tree-sitter's Parser used by this module.
106
+ */
107
+ interface TSParser extends BashReparser {
92
108
  delete(): void;
93
109
  }
94
110
 
@@ -4,8 +4,13 @@ import {
4
4
  BashPathResolver,
5
5
  type BashPathRuleCandidate,
6
6
  } from "./bash-path-resolver";
7
- import { type BashCommand, collectCommands } from "./command-enumeration";
7
+ import {
8
+ type BashCommand,
9
+ collectCommands,
10
+ collectSalvagedCommands,
11
+ } from "./command-enumeration";
8
12
  import { getParser } from "./parser";
13
+ import { withSalvagedRoots } from "./unresolved-salvage";
9
14
 
10
15
  export type { BashCommand, BashExternalPath, BashPathRuleCandidate };
11
16
 
@@ -56,16 +61,21 @@ export class BashProgram {
56
61
  if (!tree) return new BashProgram(command, [], [], []);
57
62
 
58
63
  try {
59
- const { externalAccesses, ruleCandidates } = new BashPathResolver(
60
- normalizer,
61
- options?.workdir,
62
- ).resolve(tree.rootNode);
63
- return new BashProgram(
64
- command,
65
- collectCommands(tree.rootNode),
66
- externalAccesses,
67
- ruleCandidates,
68
- );
64
+ return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
65
+ const { externalAccesses, ruleCandidates } = new BashPathResolver(
66
+ normalizer,
67
+ options?.workdir,
68
+ ).resolve(tree.rootNode, salvaged);
69
+ return new BashProgram(
70
+ command,
71
+ [
72
+ ...collectCommands(tree.rootNode),
73
+ ...salvaged.flatMap(collectSalvagedCommands),
74
+ ],
75
+ externalAccesses,
76
+ ruleCandidates,
77
+ );
78
+ });
69
79
  } finally {
70
80
  tree.delete();
71
81
  }
@@ -1,5 +1,10 @@
1
- import { type BashCommand, collectCommands } from "./command-enumeration";
1
+ import {
2
+ type BashCommand,
3
+ collectCommands,
4
+ collectSalvagedCommands,
5
+ } from "./command-enumeration";
2
6
  import { getWarmBashParser } from "./parser";
7
+ import { withSalvagedRoots } from "./unresolved-salvage";
3
8
 
4
9
  /**
5
10
  * Synchronously enumerate the command-pattern units of a bash command using the
@@ -21,7 +26,10 @@ export function parseBashCommandsSync(command: string): BashCommand[] | null {
21
26
  const tree = parser.parse(command);
22
27
  if (!tree) return [];
23
28
  try {
24
- return collectCommands(tree.rootNode);
29
+ return withSalvagedRoots(tree.rootNode, parser, (salvaged) => [
30
+ ...collectCommands(tree.rootNode),
31
+ ...salvaged.flatMap(collectSalvagedCommands),
32
+ ]);
25
33
  } finally {
26
34
  tree.delete();
27
35
  }
@@ -0,0 +1,97 @@
1
+ import {
2
+ type BashReparser,
3
+ parseUnresolvedWithin,
4
+ type TSNode,
5
+ } from "./parser";
6
+
7
+ /**
8
+ * Run `use` over the roots of every region the primary parse could not resolve
9
+ * but which re-parses cleanly on its own, then delete the trees it created.
10
+ *
11
+ * `tree-sitter-bash` 0.25.1 cannot parse a heredoc redirect combined with
12
+ * `2>&1` **and** a pipe, though each pairing alone is fine. Its recovery hangs
13
+ * an `ERROR` node holding only the `|` under `heredoc_redirect → file_redirect`
14
+ * and leaves the piped command's words as plain siblings of it — and
15
+ * `heredoc_redirect` is an execution host, descended for the substitutions it
16
+ * may carry and never read for text. So `git commit -F - <<'MSG' 2>&1 | rm -rf
17
+ * /tmp/x` enumerates `git commit -F` and nothing else, and a configured
18
+ * `bash: {"rm -rf *": "deny"}` is never evaluated against a command that
19
+ * really runs (#875).
20
+ *
21
+ * Re-parsing the dropped region's own source text recovers it, because the
22
+ * grammar gap is in the *combination* — `2>&1 | rm -rf /tmp/x` parses
23
+ * perfectly on its own.
24
+ *
25
+ * The roots are handed to a callback rather than returned because each belongs
26
+ * to a tree that must outlive its use and be released afterwards, exactly as
27
+ * the primary parse's caller already does for its own tree.
28
+ *
29
+ * Salvaging is purely additive: a caller enumerates these roots *in addition
30
+ * to* the primary one, so the result can only ever be more restrictive.
31
+ */
32
+ export function withSalvagedRoots<T>(
33
+ primary: TSNode,
34
+ reparser: BashReparser,
35
+ use: (salvaged: readonly TSNode[]) => T,
36
+ ): T {
37
+ const trees: { rootNode: TSNode; delete(): void }[] = [];
38
+ try {
39
+ for (const candidate of unresolvedRegionsWithin(primary)) {
40
+ const tree = reparser.parse(candidate.text);
41
+ if (!tree) continue;
42
+ // The whole safety argument: tree-sitter's error recovery *invents* the
43
+ // structure inside an unresolved region (#742), and invented structure
44
+ // does not re-parse. Without this check `cat <> rw.txt` salvages a
45
+ // command unit whose text is `">"`, matched against the bash rules like
46
+ // any real command.
47
+ if (parseUnresolvedWithin(tree.rootNode)) {
48
+ tree.delete();
49
+ continue;
50
+ }
51
+ trees.push(tree);
52
+ }
53
+ return use(trees.map(({ rootNode }) => rootNode));
54
+ } finally {
55
+ for (const tree of trees) tree.delete();
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The innermost nodes beneath `root` whose subtree the parser could not
61
+ * resolve, in source order.
62
+ *
63
+ * Three exclusions shape the answer.
64
+ *
65
+ * An `ERROR` node is never a candidate and is never descended in search of
66
+ * one: its interior is recovery's invention rather than anything observed
67
+ * (#742), so the region worth re-parsing is the node that *holds* it.
68
+ *
69
+ * Only the innermost such node is offered. An enclosing statement reports the
70
+ * error too, and its text re-parses to the same failure, so offering it
71
+ * salvages nothing while burying the fragment that would have worked.
72
+ *
73
+ * `root` itself is never a candidate, for the same reason taken to its limit:
74
+ * re-parsing the whole source reproduces the whole failure by construction.
75
+ */
76
+ function unresolvedRegionsWithin(root: TSNode): TSNode[] {
77
+ const found: TSNode[] = [];
78
+ collectInnermostUnresolved(root, root, found);
79
+ return found;
80
+ }
81
+
82
+ function collectInnermostUnresolved(
83
+ node: TSNode,
84
+ root: TSNode,
85
+ found: TSNode[],
86
+ ): void {
87
+ if (!parseUnresolvedWithin(node)) return;
88
+ const before = found.length;
89
+ for (let i = 0; i < node.childCount; i++) {
90
+ const child = node.child(i);
91
+ if (child && child.type !== "ERROR") {
92
+ collectInnermostUnresolved(child, root, found);
93
+ }
94
+ }
95
+ const foundDeeper = found.length > before;
96
+ if (!foundDeeper && node !== root && node.type !== "ERROR") found.push(node);
97
+ }
@@ -30,15 +30,19 @@ import type { PermissionCheckResult } from "#src/types";
30
30
  * from riding a permissive rule; an explicit `deny`/`ask` on the wrapper is left
31
31
  * untouched (`deny > ask > allow`).
32
32
  *
33
- * When `commands` is empty there are two cases. A trivially-empty command (an
34
- * empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
35
- * so the whole `command` is resolved as before. A non-empty command that parsed
36
- * to zero command units (a parse anomaly or an opaque program) fails closed to
37
- * a synthetic `ask` so a permissive top-level `*` cannot silently allow an
33
+ * A trivially-empty command (an empty, whitespace-only, or comment-only line)
34
+ * has genuinely nothing to gate, so the whole `command` is resolved as before.
35
+ *
36
+ * When the *primary* parse matched nothing, the whole command string is the
37
+ * only surface an explicit `deny` can reach, so it is resolved first and a
38
+ * `deny` covering it denies outright rather than being masked into an
39
+ * approvable prompt (#712). With no units at all the result also fails closed
40
+ * to a synthetic `ask`, so a permissive top-level `*` cannot silently allow an
38
41
  * unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
39
- * the empty-parse path) — #452. The whole command is still resolved first so an
40
- * explicit `deny` covering it denies outright rather than being masked into an
41
- * approvable prompt (#712).
42
+ * the empty-parse path) — #452. A command whose units are *all* salvaged
43
+ * (#875) takes the same whole-string check, because its primary parse matched
44
+ * nothing either; only the synthetic `ask` is skipped, since the recovered
45
+ * units now carry the verdict.
42
46
  *
43
47
  * A *partial* parse failure is the other half of that clause: the units the
44
48
  * recovery produced are enumerated normally, and any one the enumerator marked
@@ -70,22 +74,32 @@ export function resolveBashCommandCheck(
70
74
  agentName: string | undefined,
71
75
  resolver: ScopedPermissionResolver,
72
76
  ): PermissionCheckResult {
73
- if (commands.length === 0) {
74
- if (isTriviallyEmptyCommand(command)) {
75
- return resolveOnBashSurface(command, agentName, resolver);
76
- }
77
+ if (isTriviallyEmptyCommand(command)) {
78
+ return resolveOnBashSurface(command, agentName, resolver);
79
+ }
80
+
81
+ if (!commands.some((cmd) => cmd.salvaged !== true)) {
82
+ // The primary parse matched nothing, so the whole command string is the
83
+ // only surface an explicit `deny` can reach (#452, #712) — a rule naming
84
+ // the command in context (`"* rm -rf *"`) matches the string and not the
85
+ // fragment. This runs whether or not the salvage went on to recover units
86
+ // from the wreckage: `> f <<'M' 2>&1 | rm -rf /tmp/x` has zero primary
87
+ // units and one salvaged one, and keying the check on the combined list
88
+ // would silently drop a `deny` the pre-salvage gate reached (#875).
77
89
  const whole = resolveOnBashSurface(command, agentName, resolver);
78
90
  if (whole.state === "deny") {
79
91
  return whole;
80
92
  }
81
- return {
82
- state: "ask",
83
- toolName: "bash",
84
- source: "bash",
85
- origin: "builtin",
86
- command,
87
- matchedPattern: "<unparseable-bash-command>",
88
- };
93
+ if (commands.length === 0) {
94
+ return {
95
+ state: "ask",
96
+ toolName: "bash",
97
+ source: "bash",
98
+ origin: "builtin",
99
+ command,
100
+ matchedPattern: "<unparseable-bash-command>",
101
+ };
102
+ }
89
103
  }
90
104
 
91
105
  const results = commands.map((cmd) =>