---
issue: 306
issue_title: "Evaluate commands inside command substitution and subshells against the permission rules"
---

# Evaluate nested bash commands (command substitution, process substitution, subshells)

## Problem Statement

Issue #301 made the bash command-pattern gate split a chain on its top-level operators (`&&`, `||`, `;`, `|`, `&`, newlines) and evaluate each simple-command independently with most-restrictive-wins.
It deliberately did **not** recurse into command substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), or subshells (`( … )`).
As a result, a command nested inside one of those constructs is invisible to the gate: `echo $(rm -rf foo)` is enumerated as the single unit `echo $(rm -rf foo)`, whose command name is `echo`, so a `"rm *": "deny"` rule never fires even though the inner `rm -rf foo` really executes.

This issue closes that hole.
Commands inside those three constructs should be evaluated against the bash rules too, combined with the existing `deny > ask > allow` precedence, so a denied inner command blocks the whole invocation.

## Goals

- Descend into `command_substitution` (covers `$(…)` and backticks), `process_substitution` (`<(…)`/`>(…)`), and `subshell` (`( … )`) when enumerating bash command units, emitting each nested command as an additional `BashCommand` **in addition to** the enclosing command (the never-weaker invariant).
- Tag each nested command with its execution `context` (the extension point #308 reserved on `BashCommand`).
- Surface the execution context in the bash deny reason and the interactive ask prompt, so a denied nested command explains why (e.g. an `echo …` invocation was blocked because `rm -rf foo` matched `rm *` **inside command substitution**).
- Preserve every existing decision: more command units can only ever produce a more-restrictive result, never weaker.

## Non-Goals

- Per-command path candidates or effective-cwd projection for the external-directory / bash-path guards (#307).
  The execution-context tag added here is scoped to the **command-pattern** surface only; the path surfaces keep their current flat-path messages until #307 introduces per-command path provenance.
- Descending into control-flow bodies (`if` / `while` / `for` / `case`) or function definitions — a larger surface the issue explicitly defers.
  These statement nodes continue to be emitted whole without descent.
- Arithmetic expansion `$((…))`, parameter expansion `${…}`, `$var` — these do not execute commands and are correctly never descended.
- Command substitution nested inside heredoc bodies — heredoc bodies are skipped during command enumeration (a pre-existing best-effort limitation; see Risks).
- Unifying the synchronous advisory `checkPermission` / RPC path with the gate's decomposed fidelity (#309).
- Defeating obfuscation (`$(echo rm) -rf /`, `eval "$VAR"`, base64-decode pipelines).
  This remains a best-effort textual/glob heuristic, not a sandbox; the goal is to close the common, obvious hole.

## Background

The relevant modules (all under `packages/pi-permission-system/`):

- `src/handlers/gates/bash-program.ts` — the `BashProgram` value object.
  `BashProgram.parse()` walks the tree-sitter-bash AST once and exposes typed slices: `pathTokens()`, `externalPaths(cwd)`, and `commands(): BashCommand[]`.
  The command-pattern enumeration lives in `collectTopLevelCommandTexts(node)`, which descends container nodes (`program`, `list`, `pipeline`, `redirected_statement`), emits each `command` node's `text`, and emits any other statement node (subshell, compound statement, control-flow) **whole without descending**.
  `BashCommand` is currently a one-field type (`{ text }`), introduced by #308 precisely as the stable extension point this issue extends.
- `src/handlers/gates/bash-command.ts` — `resolveBashCommandCheck(command, units, agentName, sessionRules, checkPermission)`.
  A pure combiner: it runs `checkPermission("bash", { command: unit })` for each unit, selects the most restrictive via `pickMostRestrictive`, and falls back to the whole `command` when `units` is empty.
- `src/handlers/permission-gate-handler.ts` — parses the bash command once per `tool_call` and calls `resolveBashCommandCheck(command, bashProgram.commands().map((c) => c.text), …)`.
- `src/types.ts` — `PermissionCheckResult` (the result shape carrying `state`, `matchedPattern`, `command`, `source`, …).
- `src/denial-messages.ts` — `buildToolDenyBody` builds the bash deny reason from `check.command` + `check.matchedPattern`.
- `src/permission-prompts.ts` — `formatAskPrompt` builds the interactive ask prompt; its bash branch reads `result.command` + `result.matchedPattern`.

AST shapes (verified with a throwaway `web-tree-sitter` probe; consistent with the #308 retro):

- `command_substitution` wraps both `$(…)` and backticks; `process_substitution` wraps `<(…)`/`>(…)`.
  Both appear as descendants of a `command` node — usually a sibling of `command_name`, but when the **whole** command is a substitution (`$(a && b)` on its own) the `command_substitution` nests **under** `command_name`.
  So the descent must search the entire `command` subtree, not just its direct argument children.
- `subshell` wraps `( … )` and appears as a statement node (a direct child of `program`/`list`, or nested inside a substitution).
- Inside any of these, the body is a named `command` / `list` / `pipeline` node; the delimiter tokens (`$(`, `)`, `` ` ``, `(`, `<(`, `>(`) and chain operators (`&&`, `;`, `|`, …) are **anonymous** nodes (`node.isNamed === false`).

Constraints from `AGENTS.md` / package skill that apply:

- Default to least privilege; more units → more restrictive is the safe direction.
- Treat any declared field not read at runtime as a maintenance trap; `pnpm fallow dead-code` flags a constructed-but-unread interface field.
  This is why the `context` field is added **together with** its consumers in a single commit, not ahead of them.
- Keep schema, example config, loader, and docs aligned — but note this change adds **no** config field (it is a matching-engine change), so only prose docs change.
- `@typescript-eslint/require-await` is on for `src/`; the bash gates and `resolveBashCommandCheck` are already synchronous (#308) and stay so.

## Design Overview

### The execution-context model

Add a small union to `src/types.ts` and an optional field to `BashCommand`:

```typescript
// src/types.ts
export type BashCommandContext =
  | "command_substitution"
  | "process_substitution"
  | "subshell";

export interface PermissionCheckResult {
  // …existing fields…
  /** Execution context of the offending nested command, when the winning
   *  bash unit came from a substitution or subshell. Absent for current-shell
   *  (top-level) commands. */
  commandContext?: BashCommandContext;
}
```

```typescript
// src/handlers/gates/bash-program.ts
import type { BashCommandContext } from "#src/types";

export interface BashCommand {
  readonly text: string;
  /** Set for a nested command; absent for a current-shell (top-level) command. */
  readonly context?: BashCommandContext;
}
```

`context` is **optional** and **absent** for top-level commands.
This keeps the existing `commands()` assertions for top-level chains green (`toEqual` treats an absent property and `undefined` as equal), confining churn to the new nested cases.
The union deliberately has no `"top-level"` member: a current-shell command carries no context, so the result's `commandContext` stays `undefined` and existing whole-result assertions across the suite are unaffected.

`BashCommandContext` lives in `types.ts` (not the gate module) so `PermissionCheckResult` stays self-contained; the gate and the presentation modules import it from `#src/types`, the same direction they already depend.

### The enumeration descent

Replace `collectTopLevelCommandTexts(node): string[]` with a context-aware enumerator that produces `BashCommand[]` directly.
Two mutually-recursive helpers:

```text
collectCommands(node, context, out):
  if !node.isNamed: return                         // anonymous tokens: $( ) ` ( <( && ; | …
  if NAMED_NON_COMMAND.has(node.type): return      // file_redirect, heredoc_*, comment
  if node.type === "command":
    out.push({ text: node.text, ...(context && { context }) })
    collectSubstitutionCommands(node, context, out)  // descend args for $(…)/`…`/<(…)
    return
  if node.type === "subshell":
    out.push({ text: node.text, ...(context && { context }) })  // never-weaker whole emit
    for child of node: collectCommands(child, "subshell", out)  // descend interior
    return
  if DESCEND.has(node.type):                        // program, list, pipeline, redirected_statement
    for child of node: collectCommands(child, context, out)
    return
  // any other named statement (compound_statement {…}, if/while/for/case,
  // function_definition): emit whole, do NOT descend — deferred (#306 non-goal)
  out.push({ text: node.text, ...(context && { context }) })

collectSubstitutionCommands(node, context, out):
  for child of node:
    if child.type === "command_substitution":
      for inner of child: collectCommands(inner, "command_substitution", out)
    else if child.type === "process_substitution":
      for inner of child: collectCommands(inner, "process_substitution", out)
    else:
      collectSubstitutionCommands(child, context, out)  // keep searching the subtree
```

Key points:

- Using `node.isNamed` to skip anonymous nodes is what makes the descent robust: it auto-skips every delimiter and operator token (`$(`, `)`, `` ` ``, `(`, `&&`, `;`, `|`, …) without enumerating fragile token-type strings.
  This requires adding `readonly isNamed: boolean` to the local `TSNode` interface (web-tree-sitter exposes it as a boolean property — verified).
- The top-level whole-emit of a `subshell` is preserved (existing #301 behavior), then its interior is additionally enumerated — strictly additive, never weaker.
- `command_substitution` / `process_substitution` interiors are reached **only** through `collectSubstitutionCommands` (called from the `command` case), so each interior is enumerated exactly once — no double emit.
- Recursion handles nesting (`echo $( ( rm x ) )`) naturally: each level re-enters `collectCommands` with the inner context.

### Resolver: attach the winning context

`resolveBashCommandCheck` changes its second parameter from `units: string[]` to `commands: BashCommand[]` (it now needs both `text` and `context`, so it takes the object — ISP-clean, the type carries exactly the two fields it reads):

```typescript
export function resolveBashCommandCheck(
  command: string,
  commands: BashCommand[],
  agentName: string | undefined,
  sessionRules: Rule[],
  checkPermission: CheckPermissionFn,
): PermissionCheckResult {
  const results = commands.map((cmd) => {
    const result = checkPermission("bash", { command: cmd.text }, agentName, sessionRules);
    return cmd.context ? { ...result, commandContext: cmd.context } : result;
  });
  return (
    pickMostRestrictive(results) ??
    checkPermission("bash", { command }, agentName, sessionRules)
  );
}
```

The winner naturally carries its own context because the context is attached before selection.
The handler drops the `.map((c) => c.text)` and passes `bashProgram.commands()` directly.

Consumer call site (handler, ~3 lines — confirms Tell-Don't-Ask: the handler hands the whole `BashCommand[]` to the resolver and asks nothing about individual commands):

```typescript
const toolCheck =
  tcc.toolName === "bash" && bashProgram
    ? resolveBashCommandCheck(command ?? "", bashProgram.commands(), tcc.agentName ?? undefined, getSessionRuleset(), checkPermission)
    : checkPermission(tcc.toolName, tcc.input, tcc.agentName ?? undefined, getSessionRuleset());
```

### Message surfacing

Add a presentation helper in `src/denial-messages.ts`, exported for reuse:

```typescript
export function describeBashCommandContext(context?: BashCommandContext): string | undefined {
  switch (context) {
    case "command_substitution": return "command substitution";
    case "process_substitution": return "process substitution";
    case "subshell": return "subshell";
    default: return undefined;
  }
}

// Fold matched-pattern and context into one parenthetical.
export function matchQualifier(matchedPattern?: string, context?: BashCommandContext): string {
  const parts: string[] = [];
  if (matchedPattern) parts.push(`matched '${matchedPattern}'`);
  const label = describeBashCommandContext(context);
  if (label) parts.push(`inside ${label}`);
  return parts.length ? ` (${parts.join(", ")})` : "";
}
```

Use it in two places (the user's chosen scope — deny reason + ask prompt):

- `buildToolDenyBody` (`denial-messages.ts`) — replace the standalone `(matched 'P')` part with `matchQualifier(check.matchedPattern, check.commandContext)`.
  `commandContext` is only ever set for bash, so MCP/tool denials are unaffected (the helper returns the same `(matched 'P')` they produce today).
- `formatAskPrompt` bash branch (`permission-prompts.ts`) — replace the local `patternInfo` with `matchQualifier(result.matchedPattern, result.commandContext)`.

Resulting messages:

```text
[pi-permission-system] Current agent is not permitted to run 'bash' command 'rm -rf foo' (matched 'rm *', inside command substitution).
Current agent requested bash command 'rm -rf foo' (matched 'rm *', inside command substitution). Allow this command?
```

The `user-denied` and `unavailable` bash bodies are deliberately left unchanged — they describe the user's own action / a no-UI condition, where "why it matched" adds little; the high-value surfaces are the policy-deny reason and the interactive ask prompt.

### Edge cases

| Input                     | Enumerated units (text → context)                                              | Decision with `rm *: deny`, `echo *: allow` |
| ------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
| `echo $(rm -rf foo)`      | `echo $(rm -rf foo)` → —, `rm -rf foo` → command_substitution                  | deny                                        |
| `` echo `rm x` ``         | `` echo `rm x` `` → —, `rm x` → command_substitution                           | deny                                        |
| `diff <(cat /etc/shadow)` | `diff <(cat /etc/shadow)` → —, `cat /etc/shadow` → process_substitution        | (per `cat`/`diff` rules)                    |
| `( rm -rf foo )`          | `( rm -rf foo )` → —, `rm -rf foo` → subshell                                  | deny                                        |
| `( cd /t && rm x )`       | `( cd /t && rm x )` → —, `cd /t` → subshell, `rm x` → subshell                 | deny                                        |
| `echo $( ( rm x ) )`      | `echo $( ( rm x ) )` → —, `( rm x )` → command_substitution, `rm x` → subshell | deny                                        |
| `echo $(echo safe)`       | `echo $(echo safe)` → —, `echo safe` → command_substitution                    | allow (never-weaker holds)                  |

## Module-Level Changes

- `src/types.ts` — add `export type BashCommandContext`; add optional `commandContext?: BashCommandContext` to `PermissionCheckResult`.
- `src/handlers/gates/bash-program.ts`:
  - Add `readonly isNamed: boolean` to the local `TSNode` interface.
  - Import `BashCommandContext` from `#src/types`; add optional `context?` to `BashCommand`.
  - Replace `collectTopLevelCommandTexts(node): string[]` with `collectCommands(node, context, out)` + `collectSubstitutionCommands(node, context, out)` producing `BashCommand[]`.
  - Change the stored field from `topLevelCommandTexts: readonly string[]` to `commands: readonly BashCommand[]`; `parse()` builds it via the new enumerator; `commands()` returns it directly (keeps its existing `// fallow-ignore-next-line unused-class-member` suppression — still called on an inferred-type value in the handler).
  - The `NAMED_NON_COMMAND` skip set replaces the prior `TOP_LEVEL_COMMAND_SKIP` (now only the named non-command nodes; anonymous tokens fall out via `!isNamed`); `DESCEND` keeps `program`/`list`/`pipeline`/`redirected_statement`.
- `src/handlers/gates/bash-command.ts` — `resolveBashCommandCheck` second param `units: string[]` → `commands: BashCommand[]`; attach `commandContext` to each per-unit result before selection; import `BashCommand`.
- `src/handlers/permission-gate-handler.ts` — pass `bashProgram.commands()` (drop `.map((c) => c.text)`).
- `src/denial-messages.ts` — add `describeBashCommandContext` + `matchQualifier`; use `matchQualifier` in `buildToolDenyBody`; import `BashCommandContext`.
- `src/permission-prompts.ts` — bash branch of `formatAskPrompt` uses `matchQualifier`; import the helper from `#src/denial-messages` (one-way import, no cycle).
- `docs/configuration.md` — rewrite the "matched as part of their enclosing command rather than evaluated independently" sentence (line ~194) to state that nested commands in substitutions/subshells **are** now evaluated; soften the line ~380 "subshells … are not parsed" caveat to note subshells/substitutions are parsed for the command-pattern surface (path/cwd resolution into them remains future work, #307).
- `docs/architecture/architecture.md` — update the `bash-program.ts` slice description (line ~512) to mention the nested-context descent and the `context` field, and the `bash-command.ts` description (line ~514) to note the context-tagged result.

No schema, example config, or loader changes: this is a matching-engine change with no new config field.
The package skill (`.pi/skills/package-pi-permission-system/SKILL.md`) does not reference `BashCommand` / the enumerator, so no skill update is needed.

## Test Impact Analysis

1. New unit tests the descent enables (previously impossible — the gate could not see nested commands):
   - `bash-program.test.ts` — `commands()` now returns nested entries for `$(…)`, backticks, `<(…)`, `( … )`, chains inside subshells, and nested-in-nested, each tagged with `context`.
   - `bash-command.test.ts` — the winning result carries `commandContext` when a nested unit is the offender.
   - End-to-end deny in `tool-call.test.ts` for `echo $(rm -rf foo)`.
2. Existing tests that change (behavior shifts from "whole-emit only" to "whole-emit + descend"):
   - `bash-program.test.ts` — two assertions update: `emits a subshell whole without descending into it` and `keeps command substitution inside the enclosing command` now also list the nested entries (the whole-emit stays as the first element, so the assertions are extended, not replaced).
   - `bash-command.test.ts` — every `units: string[]` argument becomes `BashCommand[]` (e.g. `["cd /p", "npm install pkg"]` → `[{ text: "cd /p" }, { text: "npm install pkg" }]`); the empty-fallback case becomes `[]`.
3. Existing tests that stay as-is (genuinely exercise unchanged layers):
   - `bash-program.test.ts` `pathTokens` / `externalPaths` blocks — the path slices are untouched (#307 territory); they already walk into nested contexts for path candidates.
   - `bash-external-directory.test.ts` (~1000-line characterization suite via the `extractTokensForPathRules` / `extractExternalPathsFromBashCommand` facades) — untouched.
   - Top-level-chain `commands()` assertions — unaffected because top-level commands carry no `context` (absent property equals `undefined` under `toEqual`).
   - Non-bash branches of `denial-messages.test.ts` / `permission-prompts.test.ts` — `matchQualifier` returns the identical `(matched 'P')` string for context-free results.

## TDD Order

1. red→green→commit — **enumeration descent (the security fix), no context field yet.**
   - Surface: `bash-program.test.ts` (`commands()`) + `tool-call.test.ts` (end-to-end).
   - Add the `isNamed` field to `TSNode`; implement `collectCommands` / `collectSubstitutionCommands` emitting plain `{ text }` entries (no `context` member yet — keeping `BashCommand` one-field avoids a fallow-flagged unread field in this commit).
   - Tests: nested `$(…)`, backticks, `<(…)`, bare subshell, chain-in-subshell, nested-in-nested all enumerate the inner commands; update the two changed subshell/substitution assertions; never-weaker case (`echo $(echo safe)` stays allow); add the headline `tool-call.test.ts` case mirroring the existing `echo start && npm …` deny test (`echo $(rm -rf foo)` with `rm *: deny` → `block: true`).
   - Run `pnpm run check` (the `commands()` return shape is unchanged — still `BashCommand[]` — so the handler's `.map((c) => c.text)` still compiles).
   - Commit: `feat: evaluate nested bash command substitutions and subshells (#306)`.
2. red→green→commit — **execution-context tag + message surfacing (added with its consumers in one commit).**
   - Surface: `bash-program.test.ts`, `bash-command.test.ts`, `denial-messages.test.ts`, `permission-prompts.test.ts`.
   - Add `BashCommandContext` + `PermissionCheckResult.commandContext` (`types.ts`); add `BashCommand.context` and tag nested emits in the enumerator; change `resolveBashCommandCheck` to accept `BashCommand[]` and attach `commandContext`; update the handler call site (single call site — folded in, the type checker requires it); add `describeBashCommandContext` / `matchQualifier` and wire them into `buildToolDenyBody` + `formatAskPrompt`.
   - This is the cohesive "field + consumer together" commit: the field is read (resolver → result → both message builders) in the same commit it is introduced, so `pnpm fallow dead-code` stays clean.
   - Tests: `commands()` tags nested entries with `context`; `resolveBashCommandCheck` returns `commandContext` for a nested-deny winner and omits it for a top-level winner; deny reason and ask prompt include `inside command substitution` / `inside subshell`; non-bash deny/ask strings unchanged; migrate `bash-command.test.ts` `units` arrays to `BashCommand[]`.
   - Run `pnpm run check` + the full suite (shared `PermissionCheckResult` + shared resolver touched).
   - Commit: `feat: surface nested execution context in bash deny and ask messages (#306)`.
3. green→commit — **documentation.**
   - Update `docs/configuration.md` (nested commands are now evaluated; soften the subshell caveat) and `docs/architecture/architecture.md` (bash-program / bash-command slice descriptions).
   - Commit: `docs: document nested bash command evaluation (#306)`.

## Risks and Mitigations

- Risk: the descent regresses an existing decision.
  Mitigation: the change is strictly additive (whole-emit preserved, nested units added) and `pickMostRestrictive` can only move a decision toward more-restrictive; the full suite (incl.
  `tool-call.test.ts` and the manager harness) runs in step 2, and the never-weaker case is asserted in step 1.
- Risk: anonymous delimiter tokens (`$(`, `)`, `(`) get emitted as spurious command units.
  Mitigation: the `!node.isNamed` guard skips every anonymous token; verified against the actual AST with a `web-tree-sitter` probe.
- Risk: adding `context` / `commandContext` ahead of a reader trips `pnpm fallow dead-code`.
  Mitigation: the field and all its readers land in the same commit (step 2); step 1 keeps `BashCommand` one-field.
- Risk: `matchQualifier` accidentally changes MCP/tool/path messages.
  Mitigation: `commandContext` is only ever set for bash; the helper returns the byte-identical `(matched 'P')` string for context-free results, asserted by the unchanged non-bash message tests.
- Risk: obfuscation and heredoc-embedded substitutions still evade matching.
  Mitigation: out of scope by design — documented as a known best-effort caveat in `configuration.md`, consistent with the issue's framing.

## Open Questions

- Should the `user-denied` and `unavailable` bash bodies also carry the context label for full consistency?
  Deferred: scoped out per the chosen "deny reason + ask prompt" surface; trivial to add later if the asymmetry proves confusing.
- Should command substitution inside an unquoted heredoc body be evaluated?
  Deferred: heredoc bodies are skipped during enumeration today; revisit only if a concrete bypass is reported (best-effort heuristic).
