@gotgenes/pi-permission-system 9.0.1 → 9.1.0

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
+ ## [9.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.0.1...pi-permission-system-v9.1.0) (2026-06-02)
9
+
10
+
11
+ ### Features
12
+
13
+ * evaluate nested bash command substitutions and subshells ([#306](https://github.com/gotgenes/pi-packages/issues/306)) ([0e52d64](https://github.com/gotgenes/pi-packages/commit/0e52d645bc2f604b1317781edf48578936e0ae34))
14
+ * surface nested execution context in bash deny and ask messages ([#306](https://github.com/gotgenes/pi-packages/issues/306)) ([9d88543](https://github.com/gotgenes/pi-packages/commit/9d88543c855d16910d71c7e783c451160753c52d))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * document nested bash command evaluation ([#306](https://github.com/gotgenes/pi-packages/issues/306)) ([352e206](https://github.com/gotgenes/pi-packages/commit/352e206ce673ba73d57b1a4dbf24a409adf32e70))
20
+
8
21
  ## [9.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.0.0...pi-permission-system-v9.0.1) (2026-06-01)
9
22
 
10
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "9.0.1",
3
+ "version": "9.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,5 +1,5 @@
1
1
  import { EXTENSION_ID } from "./extension-config";
2
- import type { PermissionCheckResult } from "./types";
2
+ import type { BashCommandContext, PermissionCheckResult } from "./types";
3
3
 
4
4
  // ── Extension attribution tag ──────────────────────────────────────────────
5
5
 
@@ -114,13 +114,54 @@ function buildToolDenyBody(
114
114
  parts.push(`command '${check.command}'`);
115
115
  }
116
116
 
117
- if (check.matchedPattern) {
118
- parts.push(`(matched '${check.matchedPattern}')`);
117
+ const qualifier = matchQualifier(check.matchedPattern, check.commandContext);
118
+ if (qualifier) {
119
+ parts.push(qualifier);
119
120
  }
120
121
 
121
122
  return `${parts.join(" ")}.`;
122
123
  }
123
124
 
125
+ /**
126
+ * Human-readable label for a nested bash execution context, or `undefined` for
127
+ * a current-shell (top-level) command.
128
+ */
129
+ export function describeBashCommandContext(
130
+ context?: BashCommandContext,
131
+ ): string | undefined {
132
+ switch (context) {
133
+ case "command_substitution":
134
+ return "command substitution";
135
+ case "process_substitution":
136
+ return "process substitution";
137
+ case "subshell":
138
+ return "subshell";
139
+ default:
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Build the parenthetical qualifier for a bash decision, folding the matched
146
+ * rule and (for a nested command) its execution context into one clause, e.g.
147
+ * `(matched 'rm *', inside command substitution)`. Returns `""` when neither
148
+ * applies.
149
+ */
150
+ export function matchQualifier(
151
+ matchedPattern?: string,
152
+ context?: BashCommandContext,
153
+ ): string {
154
+ const parts: string[] = [];
155
+ if (matchedPattern) {
156
+ parts.push(`matched '${matchedPattern}'`);
157
+ }
158
+ const label = describeBashCommandContext(context);
159
+ if (label) {
160
+ parts.push(`inside ${label}`);
161
+ }
162
+ return parts.length > 0 ? `(${parts.join(", ")})` : "";
163
+ }
164
+
124
165
  function buildUnavailableBody(ctx: DenialContext): string {
125
166
  switch (ctx.kind) {
126
167
  case "tool": {
@@ -1,4 +1,4 @@
1
- import { BashProgram } from "#src/handlers/gates/bash-program";
1
+ import type { BashCommand } from "#src/handlers/gates/bash-program";
2
2
  import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
3
3
  import type { Rule } from "#src/rule";
4
4
  import type { PermissionCheckResult } from "#src/types";
@@ -11,43 +11,45 @@ type CheckPermissionFn = (
11
11
  sessionRules?: Rule[],
12
12
  ) => PermissionCheckResult;
13
13
 
14
- /** Decompose a bash command into its top-level simple-commands. */
15
- async function decomposeTopLevelCommands(command: string): Promise<string[]> {
16
- return (await BashProgram.parse(command)).topLevelCommands();
17
- }
18
-
19
14
  /**
20
15
  * Resolve the bash command-pattern decision for a (possibly chained) command.
21
16
  *
22
17
  * A bash invocation may be a shell program with several commands joined by
23
18
  * `&&`, `||`, `;`, `|`, `&`, or newlines. Matching the whole string against the
24
19
  * bash patterns lets a denied command ride through on an allowed leading one
25
- * (issue #301). Instead, decompose the command into its top-level simple-commands
26
- * and evaluate each on the `bash` surface, then select the most restrictive
27
- * result (`deny > ask > allow`).
20
+ * (issue #301). Instead, the caller supplies the program's command units (from
21
+ * the shared `BashProgram.commands()` parse) including those nested inside
22
+ * substitutions and subshells (#306); each is evaluated on the `bash` surface
23
+ * and the most restrictive result wins (`deny > ask > allow`).
28
24
  *
29
- * The selected result carries the offending sub-command in `command` and its
30
- * rule in `matchedPattern`, so the prompt, session-approval suggestion, and
31
- * decision event scope to that command.
25
+ * The selected result carries the offending sub-command in `command`, its rule
26
+ * in `matchedPattern`, and the offending command's execution context in
27
+ * `commandContext` (set only for a nested command), so the prompt,
28
+ * session-approval suggestion, and decision event scope to that command.
32
29
  *
33
- * When decomposition yields no top-level commands (an empty command, a comment,
34
- * or a bare compound statement), the whole command is evaluated as before, so
35
- * the surface is never weaker than the previous behavior.
30
+ * When `commands` is empty (an empty command, a comment, or a bare compound
31
+ * statement), the whole `command` is evaluated as before, so the surface is
32
+ * never weaker than the previous behavior.
36
33
  *
37
- * `checkPermission` stays synchronous and single-command; only the decomposition
38
- * is async (tree-sitter). `decompose` is injectable for testing.
34
+ * Pure and synchronous: the (async, tree-sitter) parse happens once in the
35
+ * handler, which passes the decomposed `commands` here.
39
36
  */
40
- export async function resolveBashCommandCheck(
37
+ export function resolveBashCommandCheck(
41
38
  command: string,
39
+ commands: BashCommand[],
42
40
  agentName: string | undefined,
43
41
  sessionRules: Rule[],
44
42
  checkPermission: CheckPermissionFn,
45
- decompose: (command: string) => Promise<string[]> = decomposeTopLevelCommands,
46
- ): Promise<PermissionCheckResult> {
47
- const units = await decompose(command);
48
- const results = units.map((unit) =>
49
- checkPermission("bash", { command: unit }, agentName, sessionRules),
50
- );
43
+ ): PermissionCheckResult {
44
+ const results = commands.map((cmd) => {
45
+ const result = checkPermission(
46
+ "bash",
47
+ { command: cmd.text },
48
+ agentName,
49
+ sessionRules,
50
+ );
51
+ return cmd.context ? { ...result, commandContext: cmd.context } : result;
52
+ });
51
53
  return (
52
54
  pickMostRestrictive(results) ??
53
55
  checkPermission("bash", { command }, agentName, sessionRules)
@@ -3,7 +3,7 @@ import type { Rule } from "#src/rule";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { PermissionCheckResult } from "#src/types";
6
- import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
6
+ import type { BashProgram } from "./bash-program";
7
7
  import { pickMostRestrictive } from "./candidate-check";
8
8
  import type { GateResult } from "./descriptor";
9
9
  import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
@@ -20,26 +20,26 @@ type CheckPermissionFn = (
20
20
  /**
21
21
  * Build a pure descriptor for the bash external-directory permission gate.
22
22
  *
23
- * Extracts paths from a bash command and checks whether any reference
24
- * directories outside the working directory. Returns `null` when the gate
23
+ * Reads the external paths from the injected `BashProgram` and checks whether
24
+ * any reference directories outside the working directory. Returns `null` when the gate
25
25
  * does not apply (tool is not bash, no CWD, or no external paths found).
26
26
  * Returns a `GateBypass` when all paths are allowed (by config or session rule).
27
27
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
28
28
  */
29
- export async function describeBashExternalDirectoryGate(
29
+ export function describeBashExternalDirectoryGate(
30
30
  tcc: ToolCallContext,
31
+ bashProgram: BashProgram | null,
31
32
  checkPermission: CheckPermissionFn,
32
33
  getSessionRuleset: () => Rule[],
33
- ): Promise<GateResult> {
34
+ ): GateResult {
34
35
  if (tcc.toolName !== "bash" || !tcc.cwd) return null;
35
36
 
36
37
  const command = getNonEmptyString(toRecord(tcc.input).command);
37
38
  if (!command) return null;
38
39
 
39
- const externalPaths = await extractExternalPathsFromBashCommand(
40
- command,
41
- tcc.cwd,
42
- );
40
+ if (!bashProgram) return null;
41
+
42
+ const externalPaths = bashProgram.externalPaths(tcc.cwd);
43
43
  if (externalPaths.length === 0) return null;
44
44
 
45
45
  const bashSessionRules = getSessionRuleset();
@@ -3,7 +3,7 @@ import type { Rule } from "#src/rule";
3
3
  import { SessionApproval } from "#src/session-approval";
4
4
  import { deriveApprovalPattern } from "#src/session-rules";
5
5
  import type { PermissionCheckResult } from "#src/types";
6
- import { extractTokensForPathRules } from "./bash-path-extractor";
6
+ import type { BashProgram } from "./bash-program";
7
7
  import { pickMostRestrictive } from "./candidate-check";
8
8
  import type { GateResult } from "./descriptor";
9
9
  import { formatPathAskPrompt } from "./path";
@@ -20,8 +20,8 @@ type CheckPermissionFn = (
20
20
  /**
21
21
  * Build a pure descriptor for the cross-cutting path permission gate (bash).
22
22
  *
23
- * Extracts path-candidate tokens from a bash command using tree-sitter with
24
- * the broader filter (accepts dot-files, relative paths). Evaluates each
23
+ * Reads path-candidate tokens from the injected `BashProgram` (the broader
24
+ * `path`-rule filter, accepting dot-files and relative paths). Evaluates each
25
25
  * token against the `path` permission surface and returns the most
26
26
  * restrictive result.
27
27
  *
@@ -30,17 +30,20 @@ type CheckPermissionFn = (
30
30
  * Returns a `GateBypass` when all tokens are session-covered.
31
31
  * Returns a `GateDescriptor` for the most restrictive token needing a check.
32
32
  */
33
- export async function describeBashPathGate(
33
+ export function describeBashPathGate(
34
34
  tcc: ToolCallContext,
35
+ bashProgram: BashProgram | null,
35
36
  checkPermission: CheckPermissionFn,
36
37
  getSessionRuleset: () => Rule[],
37
- ): Promise<GateResult> {
38
+ ): GateResult {
38
39
  if (tcc.toolName !== "bash") return null;
39
40
 
40
41
  const command = getNonEmptyString(toRecord(tcc.input).command);
41
42
  if (!command) return null;
42
43
 
43
- const tokens = await extractTokensForPathRules(command);
44
+ if (!bashProgram) return null;
45
+
46
+ const tokens = bashProgram.pathTokens();
44
47
  if (tokens.length === 0) return null;
45
48
 
46
49
  // Check each token against path rules with session rules appended.
@@ -10,6 +10,7 @@ import {
10
10
  isSafeSystemPath,
11
11
  normalizePathForComparison,
12
12
  } from "#src/path-utils";
13
+ import type { BashCommandContext } from "#src/types";
13
14
 
14
15
  // ── tree-sitter-bash lazy parser ───────────────────────────────────────────
15
16
 
@@ -21,6 +22,8 @@ interface TSNode {
21
22
  readonly type: string;
22
23
  readonly text: string;
23
24
  readonly childCount: number;
25
+ /** False for anonymous tokens (operators, delimiters); true for named nodes. */
26
+ readonly isNamed: boolean;
24
27
  child(index: number): TSNode | null;
25
28
  }
26
29
 
@@ -55,6 +58,23 @@ function getParser(): Promise<TSParser> {
55
58
 
56
59
  // ── Parsed bash command representation ───────────────────────────────────────
57
60
 
61
+ /**
62
+ * One command-pattern unit of a parsed bash program.
63
+ *
64
+ * Minimal by design — `text` is the simple-command (or whole compound
65
+ * statement) string matched against the bash rules. The type is the stable
66
+ * extension point: #306 adds an execution `context`, #307 adds per-command
67
+ * path candidates and an effective working directory.
68
+ */
69
+ export interface BashCommand {
70
+ readonly text: string;
71
+ /**
72
+ * Execution context for a nested command (substitution or subshell); absent
73
+ * for a current-shell (top-level) command.
74
+ */
75
+ readonly context?: BashCommandContext;
76
+ }
77
+
58
78
  /**
59
79
  * A bash command parsed once into a reusable representation.
60
80
  *
@@ -69,7 +89,7 @@ export class BashProgram {
69
89
  private constructor(
70
90
  private readonly rawTokens: readonly string[],
71
91
  private readonly leadingCdTarget: string | undefined,
72
- private readonly topLevelCommandTexts: readonly string[],
92
+ private readonly commandUnits: readonly BashCommand[],
73
93
  ) {}
74
94
 
75
95
  /**
@@ -88,8 +108,8 @@ export class BashProgram {
88
108
  try {
89
109
  const leadingCdTarget = extractLeadingCdTarget(tree.rootNode);
90
110
  const rawTokens = collectPathCandidateTokens(tree.rootNode);
91
- const topLevelCommandTexts = collectTopLevelCommandTexts(tree.rootNode);
92
- return new BashProgram(rawTokens, leadingCdTarget, topLevelCommandTexts);
111
+ const commandUnits = collectCommands(tree.rootNode);
112
+ return new BashProgram(rawTokens, leadingCdTarget, commandUnits);
93
113
  } finally {
94
114
  tree.delete();
95
115
  }
@@ -102,10 +122,6 @@ export class BashProgram {
102
122
  * paths; does NOT filter by CWD. Returns deduplicated tokens for rule
103
123
  * evaluation.
104
124
  */
105
- // Used by the facades (bash-path-extractor.ts) and tests. Fallow's syntactic
106
- // analysis cannot resolve the static-factory return type (private ctor), so
107
- // it reports a false positive here.
108
- // fallow-ignore-next-line unused-class-member
109
125
  pathTokens(): string[] {
110
126
  const seen = new Set<string>();
111
127
  const result: string[] = [];
@@ -121,17 +137,7 @@ export class BashProgram {
121
137
  }
122
138
 
123
139
  /**
124
- * Deduplicated paths that resolve outside `cwd`.
125
- *
126
- * When the command begins with `cd <dir> && …`, relative candidate paths are
127
- * resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
128
- * mirroring how the shell would resolve them.
129
- */
130
- // Used by the facades (bash-path-extractor.ts) and tests. Fallow's syntactic
131
- // analysis cannot resolve the static-factory return type (private ctor), so
132
- // it reports a false positive here.
133
- /**
134
- * The top-level simple-commands of the chain, in source order.
140
+ * The top-level command-pattern units of the chain, in source order.
135
141
  *
136
142
  * Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
137
143
  * quotes, command substitution, and subshells are respected by the parser and
@@ -143,11 +149,17 @@ export class BashProgram {
143
149
  // syntactic analysis cannot resolve the static-factory return type (private
144
150
  // ctor), so it reports a false positive here.
145
151
  // fallow-ignore-next-line unused-class-member
146
- topLevelCommands(): string[] {
147
- return [...this.topLevelCommandTexts];
152
+ commands(): BashCommand[] {
153
+ return [...this.commandUnits];
148
154
  }
149
155
 
150
- // fallow-ignore-next-line unused-class-member
156
+ /**
157
+ * Deduplicated paths that resolve outside `cwd`.
158
+ *
159
+ * When the command begins with `cd <dir> && …`, relative candidate paths are
160
+ * resolved against `<dir>` (if it stays within CWD) rather than CWD itself,
161
+ * mirroring how the shell would resolve them.
162
+ */
151
163
  externalPaths(cwd: string): string[] {
152
164
  const resolveBase = computeEffectiveResolveBase(this.leadingCdTarget, cwd);
153
165
  const normalizedCwd = normalizePathForComparison(cwd, cwd);
@@ -592,14 +604,12 @@ function collectPathCandidateTokens(node: TSNode): string[] {
592
604
  // which exports classifyTokenAsPathCandidate and classifyTokenAsRuleCandidate
593
605
  // with a shared rejectNonPathToken predicate eliminating the prior clone.
594
606
 
595
- // ── Top-level command enumeration ───────────────────────────────────────────
607
+ // ── Command enumeration ──────────────────────────────────────────────────────
596
608
 
597
609
  /**
598
- * Container node types descended into when enumerating top-level commands.
599
- * A `cd` or `rm` inside a subshell or compound statement is NOT a top-level
600
- * command, so those node types are deliberately absent.
610
+ * Container node types descended into when enumerating command units.
601
611
  */
602
- const TOP_LEVEL_COMMAND_DESCEND = new Set([
612
+ const COMMAND_ENUM_DESCEND = new Set([
603
613
  "program",
604
614
  "list",
605
615
  "pipeline",
@@ -607,18 +617,12 @@ const TOP_LEVEL_COMMAND_DESCEND = new Set([
607
617
  ]);
608
618
 
609
619
  /**
610
- * Node types skipped during top-level command enumeration: chain-operator and
611
- * separator tokens, redirect targets, comments, and heredoc bodies. None of
612
- * these is a command to evaluate.
620
+ * Named node types skipped during command enumeration: redirect targets,
621
+ * comments, and heredoc bodies — none is a command to evaluate. Anonymous
622
+ * tokens (chain operators `&&`/`;`/`|`, substitution and subshell delimiters
623
+ * `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not listed here.
613
624
  */
614
- const TOP_LEVEL_COMMAND_SKIP = new Set([
615
- "&&",
616
- "||",
617
- ";",
618
- "&",
619
- "|",
620
- "|&",
621
- "\n",
625
+ const COMMAND_ENUM_SKIP = new Set([
622
626
  "file_redirect",
623
627
  "heredoc_redirect",
624
628
  "herestring_redirect",
@@ -628,29 +632,105 @@ const TOP_LEVEL_COMMAND_SKIP = new Set([
628
632
  ]);
629
633
 
630
634
  /**
631
- * Collect the text of each top-level simple-command in the program.
635
+ * Nested execution contexts whose interior commands really execute and must be
636
+ * evaluated too: command substitution (`$(…)`, backticks) and process
637
+ * substitution (`<(…)`/`>(…)`). Subshells (`( … )`) are handled separately
638
+ * because they are also emitted whole.
639
+ */
640
+ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
641
+ ["command_substitution", "command_substitution"],
642
+ ["process_substitution", "process_substitution"],
643
+ ]);
644
+
645
+ /**
646
+ * Enumerate the command units of a bash program, in source order.
632
647
  *
633
648
  * Descends container nodes (`program`, `list`, `pipeline`, `redirected_statement`)
634
- * and emits each `command` node's text. Chain-operator tokens and redirect
635
- * targets are skipped. Any other top-level statement node (subshell, compound
636
- * statement, control-flow) is emitted whole without descending, so its inner
637
- * commands are matched as part of the enclosing statement's text rather than
638
- * independently (the top-level scope).
649
+ * and emits each `command` node whole. Additionally descends into the three
650
+ * nested execution contexts command substitution (`$(…)`, backticks), process
651
+ * substitution (`<(…)`/`>(…)`), and subshells (`( )`) emitting each inner
652
+ * command as its own unit *in addition to* the enclosing command, since those
653
+ * inner commands really execute (#306). Control-flow bodies and `{ … }` brace
654
+ * groups are emitted whole without descending (deferred).
655
+ *
656
+ * The enclosing command/subshell is always still emitted whole, so adding the
657
+ * nested units can only ever produce a more-restrictive decision, never weaker.
639
658
  */
640
- function collectTopLevelCommandTexts(node: TSNode): string[] {
641
- if (node.type === "command") return [node.text];
642
- if (TOP_LEVEL_COMMAND_SKIP.has(node.type)) return [];
643
- if (TOP_LEVEL_COMMAND_DESCEND.has(node.type)) {
644
- const texts: string[] = [];
645
- for (let i = 0; i < node.childCount; i++) {
646
- const child = node.child(i);
647
- if (child) texts.push(...collectTopLevelCommandTexts(child));
659
+ function collectCommands(node: TSNode): BashCommand[] {
660
+ const out: BashCommand[] = [];
661
+ collectCommandsInto(node, undefined, out);
662
+ return out;
663
+ }
664
+
665
+ function collectCommandsInto(
666
+ node: TSNode,
667
+ context: BashCommandContext | undefined,
668
+ out: BashCommand[],
669
+ ): void {
670
+ // Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
671
+ // carry no command.
672
+ if (!node.isNamed) return;
673
+ if (COMMAND_ENUM_SKIP.has(node.type)) return;
674
+
675
+ if (node.type === "command") {
676
+ out.push(makeUnit(node.text, context));
677
+ // A command's text already contains any substitution; descend its subtree
678
+ // to ALSO emit the inner commands of command/process substitutions.
679
+ collectSubstitutionCommands(node, out);
680
+ return;
681
+ }
682
+
683
+ if (node.type === "subshell") {
684
+ out.push(makeUnit(node.text, context)); // never-weaker whole emit
685
+ descendCommandChildren(node, "subshell", out);
686
+ return;
687
+ }
688
+
689
+ if (COMMAND_ENUM_DESCEND.has(node.type)) {
690
+ descendCommandChildren(node, context, out);
691
+ return;
692
+ }
693
+
694
+ // Any other named statement (compound_statement `{ … }`, if/while/for/case,
695
+ // function_definition): emit whole, do not descend — deferred (#306).
696
+ out.push(makeUnit(node.text, context));
697
+ }
698
+
699
+ function makeUnit(
700
+ text: string,
701
+ context: BashCommandContext | undefined,
702
+ ): BashCommand {
703
+ return context ? { text, context } : { text };
704
+ }
705
+
706
+ function descendCommandChildren(
707
+ node: TSNode,
708
+ context: BashCommandContext | undefined,
709
+ out: BashCommand[],
710
+ ): void {
711
+ for (let i = 0; i < node.childCount; i++) {
712
+ const child = node.child(i);
713
+ if (child) collectCommandsInto(child, context, out);
714
+ }
715
+ }
716
+
717
+ /**
718
+ * Search a command's subtree for command/process substitutions and enumerate
719
+ * the commands inside them, tagged with the substitution's execution context.
720
+ * A substitution can nest under `command_name` (when the whole command is
721
+ * `$(…)`) or under an argument, so the entire subtree is searched.
722
+ */
723
+ function collectSubstitutionCommands(node: TSNode, out: BashCommand[]): void {
724
+ for (let i = 0; i < node.childCount; i++) {
725
+ const child = node.child(i);
726
+ if (!child) continue;
727
+ const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
728
+ if (nestedContext) {
729
+ descendCommandChildren(child, nestedContext, out);
730
+ } else {
731
+ collectSubstitutionCommands(child, out);
648
732
  }
649
- return texts;
650
733
  }
651
- // Any other named statement node (subshell, compound_statement, if/while/for,
652
- // function_definition, …): emit whole, do not descend.
653
- return [node.text];
654
734
  }
655
735
 
656
736
  // ── Leading cd detection ───────────────────────────────────────────────────
@@ -29,6 +29,7 @@ import {
29
29
  import { resolveBashCommandCheck } from "./gates/bash-command";
30
30
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
31
31
  import { describeBashPathGate } from "./gates/bash-path";
32
+ import { BashProgram } from "./gates/bash-program";
32
33
  import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
33
34
  import { isGateBypass } from "./gates/descriptor";
34
35
  import { describeExternalDirectoryGate } from "./gates/external-directory";
@@ -87,6 +88,14 @@ export class PermissionGateHandler {
87
88
  cwd: ctx.cwd,
88
89
  };
89
90
 
91
+ // Parse the bash command exactly once per tool_call; the three bash gates
92
+ // share this single BashProgram instead of each re-parsing (#308).
93
+ const command = getNonEmptyString(toRecord(tcc.input).command);
94
+ const bashProgram =
95
+ tcc.toolName === "bash" && command
96
+ ? await BashProgram.parse(command)
97
+ : null;
98
+
90
99
  // ── Shared gate adapter closures ─────────────────────────────────────
91
100
  const canConfirm = () => this.session.canPrompt(ctx);
92
101
  const promptPermission = (details: PromptPermissionDetails) =>
@@ -166,19 +175,27 @@ export class PermissionGateHandler {
166
175
  () =>
167
176
  describeBashExternalDirectoryGate(
168
177
  tcc,
178
+ bashProgram,
179
+ checkPermission,
180
+ getSessionRuleset,
181
+ ),
182
+ () =>
183
+ describeBashPathGate(
184
+ tcc,
185
+ bashProgram,
169
186
  checkPermission,
170
187
  getSessionRuleset,
171
188
  ),
172
- () => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
173
- async () => {
189
+ () => {
174
190
  // Bash commands may chain several sub-commands (`a && b`, `a | b`, …);
175
- // evaluate each on the bash surface and select the most restrictive,
176
- // rather than matching the whole program string (#301). Other tools
177
- // evaluate their single input directly.
191
+ // evaluate each unit from the shared parse on the bash surface and
192
+ // select the most restrictive, rather than matching the whole program
193
+ // string (#301). Other tools evaluate their single input directly.
178
194
  const toolCheck =
179
- tcc.toolName === "bash"
180
- ? await resolveBashCommandCheck(
181
- getNonEmptyString(toRecord(tcc.input).command) ?? "",
195
+ tcc.toolName === "bash" && bashProgram
196
+ ? resolveBashCommandCheck(
197
+ command ?? "",
198
+ bashProgram.commands(),
182
199
  tcc.agentName ?? undefined,
183
200
  getSessionRuleset(),
184
201
  checkPermission,
@@ -1,3 +1,4 @@
1
+ import { matchQualifier } from "./denial-messages";
1
2
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
2
3
  import type { ToolPreviewFormatter } from "./tool-preview-formatter";
3
4
  import type { PermissionCheckResult } from "./types";
@@ -36,10 +37,12 @@ export function formatAskPrompt(
36
37
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
37
38
 
38
39
  if (result.toolName === "bash") {
39
- const patternInfo = result.matchedPattern
40
- ? ` (matched '${result.matchedPattern}')`
41
- : "";
42
- return `${subject} requested bash command '${result.command ?? ""}'${patternInfo}. Allow this command?`;
40
+ const qualifier = matchQualifier(
41
+ result.matchedPattern,
42
+ result.commandContext,
43
+ );
44
+ const qualifierInfo = qualifier ? ` ${qualifier}` : "";
45
+ return `${subject} requested bash command '${result.command ?? ""}'${qualifierInfo}. Allow this command?`;
43
46
  }
44
47
 
45
48
  if ((result.source === "mcp" || result.toolName === "mcp") && result.target) {
package/src/types.ts CHANGED
@@ -22,6 +22,15 @@ export interface ScopeConfig {
22
22
  permission?: FlatPermissionConfig;
23
23
  }
24
24
 
25
+ /**
26
+ * Execution context of a bash command nested inside a substitution or subshell.
27
+ * Absent for current-shell (top-level) commands.
28
+ */
29
+ export type BashCommandContext =
30
+ | "command_substitution"
31
+ | "process_substitution"
32
+ | "subshell";
33
+
25
34
  export interface PermissionCheckResult {
26
35
  toolName: string;
27
36
  state: PermissionState;
@@ -31,4 +40,10 @@ export interface PermissionCheckResult {
31
40
  source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
32
41
  /** Which source contributed the winning rule. */
33
42
  origin: RuleOrigin;
43
+ /**
44
+ * Execution context of the offending nested command, when the winning bash
45
+ * unit came from a substitution or subshell. Absent for current-shell
46
+ * (top-level) commands.
47
+ */
48
+ commandContext?: BashCommandContext;
34
49
  }