24 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 306 | 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(<(…)/>(…)), andsubshell(( … )) when enumerating bash command units, emitting each nested command as an additionalBashCommandin addition to the enclosing command (the never-weaker invariant). - Tag each nested command with its execution
context(the extension point #308 reserved onBashCommand). - 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 becauserm -rf foomatchedrm *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— theBashProgramvalue object.BashProgram.parse()walks the tree-sitter-bash AST once and exposes typed slices:pathTokens(),externalPaths(cwd), andcommands(): BashCommand[]. The command-pattern enumeration lives incollectTopLevelCommandTexts(node), which descends container nodes (program,list,pipeline,redirected_statement), emits eachcommandnode'stext, and emits any other statement node (subshell, compound statement, control-flow) whole without descending.BashCommandis 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 runscheckPermission("bash", { command: unit })for each unit, selects the most restrictive viapickMostRestrictive, and falls back to the wholecommandwhenunitsis empty.src/handlers/permission-gate-handler.ts— parses the bash command once pertool_calland callsresolveBashCommandCheck(command, bashProgram.commands().map((c) => c.text), …).src/types.ts—PermissionCheckResult(the result shape carryingstate,matchedPattern,command,source, …).src/denial-messages.ts—buildToolDenyBodybuilds the bash deny reason fromcheck.command+check.matchedPattern.src/permission-prompts.ts—formatAskPromptbuilds the interactive ask prompt; its bash branch readsresult.command+result.matchedPattern.
AST shapes (verified with a throwaway web-tree-sitter probe; consistent with the #308 retro):
command_substitutionwraps both$(…)and backticks;process_substitutionwraps<(…)/>(…). Both appear as descendants of acommandnode — usually a sibling ofcommand_name, but when the whole command is a substitution ($(a && b)on its own) thecommand_substitutionnests undercommand_name. So the descent must search the entirecommandsubtree, not just its direct argument children.subshellwraps( … )and appears as a statement node (a direct child ofprogram/list, or nested inside a substitution).- Inside any of these, the body is a named
command/list/pipelinenode; 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-codeflags a constructed-but-unread interface field. This is why thecontextfield 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-awaitis on forsrc/; the bash gates andresolveBashCommandCheckare 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:
// 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;
}
// 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:
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.isNamedto 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 addingreadonly isNamed: booleanto the localTSNodeinterface (web-tree-sitter exposes it as a boolean property — verified). - The top-level whole-emit of a
subshellis preserved (existing #301 behavior), then its interior is additionally enumerated — strictly additive, never weaker. command_substitution/process_substitutioninteriors are reached only throughcollectSubstitutionCommands(called from thecommandcase), so each interior is enumerated exactly once — no double emit.- Recursion handles nesting (
echo $( ( rm x ) )) naturally: each level re-enterscollectCommandswith 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):
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):
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:
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 withmatchQualifier(check.matchedPattern, check.commandContext).commandContextis only ever set for bash, so MCP/tool denials are unaffected (the helper returns the same(matched 'P')they produce today).formatAskPromptbash branch (permission-prompts.ts) — replace the localpatternInfowithmatchQualifier(result.matchedPattern, result.commandContext).
Resulting messages:
[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— addexport type BashCommandContext; add optionalcommandContext?: BashCommandContexttoPermissionCheckResult.src/handlers/gates/bash-program.ts:- Add
readonly isNamed: booleanto the localTSNodeinterface. - Import
BashCommandContextfrom#src/types; add optionalcontext?toBashCommand. - Replace
collectTopLevelCommandTexts(node): string[]withcollectCommands(node, context, out)+collectSubstitutionCommands(node, context, out)producingBashCommand[]. - Change the stored field from
topLevelCommandTexts: readonly string[]tocommands: readonly BashCommand[];parse()builds it via the new enumerator;commands()returns it directly (keeps its existing// fallow-ignore-next-line unused-class-membersuppression — still called on an inferred-type value in the handler). - The
NAMED_NON_COMMANDskip set replaces the priorTOP_LEVEL_COMMAND_SKIP(now only the named non-command nodes; anonymous tokens fall out via!isNamed);DESCENDkeepsprogram/list/pipeline/redirected_statement.
- Add
src/handlers/gates/bash-command.ts—resolveBashCommandChecksecond paramunits: string[]→commands: BashCommand[]; attachcommandContextto each per-unit result before selection; importBashCommand.src/handlers/permission-gate-handler.ts— passbashProgram.commands()(drop.map((c) => c.text)).src/denial-messages.ts— adddescribeBashCommandContext+matchQualifier; usematchQualifierinbuildToolDenyBody; importBashCommandContext.src/permission-prompts.ts— bash branch offormatAskPromptusesmatchQualifier; 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 thebash-program.tsslice description (line ~512) to mention the nested-context descent and thecontextfield, and thebash-command.tsdescription (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
- 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 withcontext.bash-command.test.ts— the winning result carriescommandContextwhen a nested unit is the offender.- End-to-end deny in
tool-call.test.tsforecho $(rm -rf foo).
- 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 itandkeeps command substitution inside the enclosing commandnow also list the nested entries (the whole-emit stays as the first element, so the assertions are extended, not replaced).bash-command.test.ts— everyunits: string[]argument becomesBashCommand[](e.g.["cd /p", "npm install pkg"]→[{ text: "cd /p" }, { text: "npm install pkg" }]); the empty-fallback case becomes[].
- Existing tests that stay as-is (genuinely exercise unchanged layers):
bash-program.test.tspathTokens/externalPathsblocks — 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 theextractTokensForPathRules/extractExternalPathsFromBashCommandfacades) — untouched.- Top-level-chain
commands()assertions — unaffected because top-level commands carry nocontext(absent property equalsundefinedundertoEqual). - Non-bash branches of
denial-messages.test.ts/permission-prompts.test.ts—matchQualifierreturns the identical(matched 'P')string for context-free results.
TDD Order
- 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
isNamedfield toTSNode; implementcollectCommands/collectSubstitutionCommandsemitting plain{ text }entries (nocontextmember yet — keepingBashCommandone-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 headlinetool-call.test.tscase mirroring the existingecho start && npm …deny test (echo $(rm -rf foo)withrm *: deny→block: true). - Run
pnpm run check(thecommands()return shape is unchanged — stillBashCommand[]— so the handler's.map((c) => c.text)still compiles). - Commit:
feat: evaluate nested bash command substitutions and subshells (#306).
- Surface:
- 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); addBashCommand.contextand tag nested emits in the enumerator; changeresolveBashCommandCheckto acceptBashCommand[]and attachcommandContext; update the handler call site (single call site — folded in, the type checker requires it); adddescribeBashCommandContext/matchQualifierand wire them intobuildToolDenyBody+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-codestays clean. - Tests:
commands()tags nested entries withcontext;resolveBashCommandCheckreturnscommandContextfor a nested-deny winner and omits it for a top-level winner; deny reason and ask prompt includeinside command substitution/inside subshell; non-bash deny/ask strings unchanged; migratebash-command.test.tsunitsarrays toBashCommand[]. - Run
pnpm run check+ the full suite (sharedPermissionCheckResult+ shared resolver touched). - Commit:
feat: surface nested execution context in bash deny and ask messages (#306).
- Surface:
- green→commit — documentation.
- Update
docs/configuration.md(nested commands are now evaluated; soften the subshell caveat) anddocs/architecture/architecture.md(bash-program / bash-command slice descriptions). - Commit:
docs: document nested bash command evaluation (#306).
- Update
Risks and Mitigations
- Risk: the descent regresses an existing decision.
Mitigation: the change is strictly additive (whole-emit preserved, nested units added) and
pickMostRestrictivecan only move a decision toward more-restrictive; the full suite (incl.tool-call.test.tsand 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.isNamedguard skips every anonymous token; verified against the actual AST with aweb-tree-sitterprobe. - Risk: adding
context/commandContextahead of a reader tripspnpm fallow dead-code. Mitigation: the field and all its readers land in the same commit (step 2); step 1 keepsBashCommandone-field. - Risk:
matchQualifieraccidentally changes MCP/tool/path messages. Mitigation:commandContextis 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-deniedandunavailablebash 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).