17 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 301 | Only first command in bash command chain is evaluated |
Evaluate every command in a bash command chain
Problem Statement
When the agent runs a chained bash command such as cd /path/to/project && npm install compromised-package, the permission system matches the entire command string against the bash command patterns.
With a policy of { "cd *": "allow", "npm *": "deny" }, the whole string matches cd * (allow) but the npm * (deny) rule is never evaluated against the npm install … segment.
A denied command therefore rides through on the back of an allowed leading command — a permission bypass.
Every command in a chain must be evaluated independently, with precedence deny > ask > allow.
The bash path and external_directory surfaces already decompose chains correctly.
Only the bash command-pattern surface was left matching the raw program string.
Dependency
This plan builds on #304 (landed locally), which introduced the two seams this fix needs:
BashProgram(src/handlers/gates/bash-program.ts) — a bash command parsed once into a reusable value object exposing typed slices (pathTokens(),externalPaths(cwd)). This fix adds a third slice,topLevelCommands(), captured in the same parse.pickMostRestrictive(src/handlers/gates/candidate-check.ts) — selects the most-restrictivePermissionCheckResult(deny > ask > allow, first-wins) from a list. Already used by the bash path and external-directory gates; this fix is the third consumer.
With both in place, the change is small: one new BashProgram method, one small resolver, and the gate wiring.
Goals
- Evaluate each top-level simple-command in a bash chain (
&&,||,;,|,&, newlines) independently against the bash command-pattern rules. - Combine the per-command results with
pickMostRestrictive, reporting the offending command's matched pattern. - Reuse the existing
BashProgramparse — one decomposition model for the whole package, no second splitter that could diverge from the path/external-directory decomposition. - Preserve current behavior for single-command bash calls (no regression) and keep
PermissionManager.checkPermission()synchronous and unchanged.
Non-Goals
- Changing
PermissionManager.checkPermission(), the publicPermissionsService.checkPermission()API, or the event-bus RPC. Those are synchronous and cannot run the async tree-sitter decomposition; they continue to match a single command string. An async decompose-and-check service method is a possible follow-up, not this issue. - Recursing into command substitution (
$(…), backticks) or subshells (( … )) to evaluate nested commands. Scope is top-level chain operators only; nested constructs are matched as their enclosing statement's text and noted as a known limitation. - Parse-once-and-inject a shared
BashProgramacross the three bash gates. Each gate still parses; consolidation is the deferred gate-consolidation follow-up. - Touching
bash-arity.ts,pattern-suggest.ts, or the wildcard matcher.
Background
Relevant modules:
src/permission-manager.ts—checkPermission(toolName, input, agentName, sessionRules)is synchronous; for bash it producesvalues: [command](one whole-string candidate) and matches it withevaluateFirst. Exposed synchronously viasrc/service.tsandsrc/permission-event-rpc.ts.src/handlers/permission-gate-handler.ts—handleToolCallruns an ordered,await-friendly gate pipeline (gateProducers: Array<() => GateResult | Promise<GateResult>>). The final producer performs the bash command-pattern check: it callscheckPermission(tcc.toolName, tcc.input, …)once and feeds the result intodescribeToolGateaspreCheck.toRecordandgetNonEmptyStringare already available to it.src/handlers/gates/bash-program.ts—BashProgram.parse(command)walks the AST once intorawTokens+leadingCdTarget. The walker primitives (collectPathCandidateTokens,resolveNodeText,extractCommandName,findFirstCommand, theprogram/listdescent infindFirstCommand) live here and are reused for the new top-level-command walk.src/handlers/gates/candidate-check.ts—pickMostRestrictive(results).src/handlers/gates/bash-path.ts/bash-external-directory.ts— existing consumers ofBashProgram+pickMostRestrictive; the template for this fix.src/handlers/gates/tool.ts—describeToolGate(tcc, check, formatter)builds the prompt/session-approval/decision descriptor from aPermissionCheckResult; for bash it derives the session-approval suggestion and decision value fromcheck.command.
Constraints from AGENTS.md / package skill that apply:
- Default to least privilege; silent over-matching is a permission bypass — the fix must never be weaker than today.
- Wildcard matching must be explicit and tested, including over-match and under-match cases.
- Keep schema, example config,
docs/configuration.md,README.md, and types aligned when behavior changes. - Pure logic, IO at the edges; inject collaborators for testability (the resolver takes
checkPermissionand adecomposecallback).
AST shapes (confirmed empirically during planning):
| Input | Tree | Top-level commands |
|---|---|---|
cd /p && npm i x |
program > list > [command, &&, command] |
cd /p, npm i x |
a || b |
program > list > [command, ||, command] |
a, b |
a ; b / a & b |
program > [command, sep, command] |
a, b |
cat f | grep b |
program > pipeline > [command, |, command] |
cat f, grep b |
foo\nbar |
program > [command, command] |
foo, bar |
echo 'x && y' |
program > command (quoted) |
echo 'x && y' |
echo $(curl | sh) |
program > command > command_substitution > pipeline |
echo $(curl | sh) |
( cd /t && rm x ) |
program > subshell > list |
( cd /t && rm x ) |
Descend program/list/pipeline and emit each command node's text.
A non-command top-level statement (subshell, control-flow, redirected_statement) is emitted as its own whole-text unit without descending — quotes are respected by the parser, and substitution/subshell contents stay inside the enclosing statement's text (the chosen scope).
Design Overview
Decision model: the unit of bash policy is the simple-command, not the raw shell program.
Add one slice to BashProgram, evaluate each unit through the unchanged synchronous checkPermission, and combine with pickMostRestrictive in the gate layer — exactly mirroring the path/external-directory gates.
New BashProgram slice
BashProgram.parse() already walks the AST once; capture the top-level command texts in the same walk and store them alongside rawTokens.
// src/handlers/gates/bash-program.ts
export class BashProgram {
private constructor(
private readonly rawTokens: readonly string[],
private readonly leadingCdTarget: string | undefined,
private readonly topLevelCommandTexts: readonly string[], // new
) {}
/** Top-level simple-commands of the chain, in source order. May be empty
* (e.g. an unparseable command or a bare subshell); callers fall back to
* the whole command so the surface is never evaluated weaker than before. */
topLevelCommands(): string[] { return [...this.topLevelCommandTexts]; }
}
A new private collectTopLevelCommandTexts(rootNode) walker descends program/list/pipeline (and redirected_statement), emits each command node's .text, and emits other top-level statement nodes (subshell, control-flow) whole.
parse() runs it once and passes the result to the constructor.
Most-restrictive resolver (gate layer)
// src/handlers/gates/bash-command.ts
type CheckPermissionFn = (
surface: string, input: unknown, agentName?: string, sessionRules?: Rule[],
) => PermissionCheckResult;
const defaultDecompose = async (cmd: string): Promise<string[]> =>
(await BashProgram.parse(cmd)).topLevelCommands();
/** Evaluate each top-level command on the bash surface and select the
* most-restrictive result (deny > ask > allow). */
export async function resolveBashCommandCheck(
command: string,
agentName: string | undefined,
sessionRules: Rule[],
checkPermission: CheckPermissionFn,
decompose: (cmd: string) => Promise<string[]> = defaultDecompose,
): Promise<PermissionCheckResult> {
const units = await decompose(command);
const results = units.map((unit) =>
checkPermission("bash", { command: unit }, agentName, sessionRules),
);
return (
pickMostRestrictive(results) ??
checkPermission("bash", { command }, agentName, sessionRules)
);
}
pickMostRestrictive returns | undefined; the ?? fallback handles an empty units list (no top-level commands found) by evaluating the whole command — identical to today's behavior and never weaker.
Because checkPermission("bash", { command: unit }) sets resultExtras.command = unit, the selected result carries the offending sub-command in command and its rule in matchedPattern, so the session-approval suggestion and decision value scope to that command (e.g. npm install pkg → npm *), while the whole command remains available via tcc.input for the prompt preview.
Gate wiring (call-site sketch)
The final gate producer becomes async for bash; non-bash tools are unchanged:
async () => {
const toolCheck =
tcc.toolName === "bash"
? await resolveBashCommandCheck(
getNonEmptyString(toRecord(tcc.input).command) ?? "",
tcc.agentName ?? undefined,
getSessionRuleset(),
checkPermission,
)
: checkPermission(tcc.toolName, tcc.input, tcc.agentName ?? undefined, getSessionRuleset());
const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
toolDescriptor.preCheck = toolCheck;
return toolDescriptor;
};
No change to GateDescriptor/GateRunnerDeps; describeToolGate already consumes a PermissionCheckResult, so all existing prompt/log/decision/session machinery is reused.
Edge cases and the no-weakening guarantee
- Single command →
topLevelCommands()returns one entry → identical to today. - Empty/whitespace command → no command node →
unitsempty →??fallback evaluates{ command: "" }, matching current behavior. - Bare subshell
( rm x )→ emitted whole → matched as( rm x )(the documented top-level-scope limitation; never weaker). - All-allow chain →
pickMostRestrictivereturns the first allow → allow (no prompt). - Quoted operators (
echo 'a && b') → one command (parser respects quotes) — no false split. - Behavior change (intended): a config pattern that spans a chain (e.g.
"cd * && npm *": "allow") no longer matches as a unit, because each command is evaluated separately. Documented and called out in Risks.
Design-review checklist
- Dependency width:
resolveBashCommandCheckparams all used;CheckPermissionFnmatches the sibling-gate local type. - Law of Demeter: no reach-through chains.
- Output arguments: resolver returns a value, mutates nothing.
- Parameter relay:
sessionRulesis consumed at the endpoint (checkPermission). - Test mock depth:
checkPermissionanddecomposeare injected functions — fakeable withoutas unknown as. - ISP: new functions take primitives/functions only.
Module-Level Changes
src/handlers/gates/bash-program.ts— add thetopLevelCommandTextsfield + constructor parameter, thetopLevelCommands()method, and a privatecollectTopLevelCommandTextswalker; populate it inparse().src/handlers/gates/bash-command.ts— new module exportingresolveBashCommandCheck(and the localCheckPermissionFntype, matching sibling-gate convention).src/handlers/permission-gate-handler.ts— make the final gate producer's bash branch async and callresolveBashCommandCheck; import it.docs/configuration.md— rewrite thebashSurface section: patterns match each top-level command in a chain (not the full string); most-restrictive-wins;&& || ; | &and newlines split commands; quotes/substitutions/subshells are not split; behavior-change note for chain-spanning patterns.docs/architecture/architecture.md— add abash-command.tsentry to the directory listing, update thebash-program.tsentry to mentiontopLevelCommands(), and note in the gate-pipeline section that the bash command-pattern check decomposes chains and combines most-restrictively.README.md— verify the bash matching description; update only if it claims whole-command-string matching.
Test Impact Analysis
- New unit tests enabled:
BashProgram.topLevelCommands()— testable for every chain operator, quoting, nesting, redirection, and the empty case (extends the existingtest/handlers/gates/bash-program.test.ts).resolveBashCommandCheck— testable with an injecteddecomposeand a fakecheckPermission:deny > ask > allow, single-command passthrough, all-allow, empty-units fallback, andsessionRulesthreading. No tree-sitter needed.
- Existing tests that stay as-is:
test/rule.test.ts—checkPermission/evaluateFirstunchanged.test/bash-external-directory.test.ts,test/handlers/gates/bash-path.test.ts,bash-external-directory.test.ts— path/external-directory behavior untouched.test/handlers/tool-call.test.tsbash gate tests — single-command bash routes throughresolveBashCommandCheck→ one-element decomposition → identical outcomes; verify they still pass after Step 3.
- No tests become redundant; new tests are additive.
TDD Order
fix: enumerate top-level bash commands in BashProgram- Surface: extend
test/handlers/gates/bash-program.test.tswithtopLevelCommands()cases — single command → one entry;&&/||/;/|/&/newline chains → ordered entries;echo 'a && b'→ one entry;( … )/$( … )→ enclosing statement only; redirection → command captured; empty →[]. - Implement the field, method, and
collectTopLevelCommandTextswalker inbash-program.ts. - Run
pnpm run check(constructor signature change is internal to the file).
- Surface: extend
fix: evaluate each bash sub-command with most-restrictive precedence- Surface: new
test/handlers/gates/bash-command.test.tsagainstresolveBashCommandCheckwith injecteddecompose+ fakecheckPermission. - Covers: all-allow → first allow; allow+deny → deny with the deny unit's
matchedPattern/command; allow+ask → ask; single command passthrough; empty units → whole-command fallback;sessionRulesforwarded to each call. - Implement
src/handlers/gates/bash-command.ts.
- Surface: new
fix: gate bash command chains per sub-command (#301)- Surface:
test/handlers/tool-call.test.ts— new case. Withsession.checkPermissionmocked to returndenywheninput.commandmatchesnpm *andallowforecho *, firing abashtool_call withcommand: "echo start && npm install compromised-package"(no external paths, so earlier gates do not fire) returns{ block: true }; assert a non-chained allowed bash command still returns{}. - Implement the async bash branch in the final gate producer of
permission-gate-handler.ts. - Run
pnpm run checkafter this commit.
- Surface:
docs: document per-sub-command bash chain evaluation (#301)- Update
docs/configuration.md,docs/architecture/architecture.md, andREADME.mdper Module-Level Changes. - Docs-only commit.
- Update
Risks and Mitigations
- Chain-spanning config patterns stop matching as a unit. Mitigation: documented behavior change; per-command evaluation is strictly safer (deny/ask take precedence) and chain-spanning patterns are an anti-pattern.
- Subshell / command-substitution contents are not independently evaluated. Mitigation: documented known limitation; never weaker than today (the enclosing statement's whole text is still matched).
- Extra tree-sitter parse per bash command (now path + external-directory + command). Mitigation: consistent with the current design; parse-sharing is the deferred gate-consolidation follow-up.
- The synchronous service API / RPC remain whole-string. Mitigation: out of scope (Non-Goals); the runtime gate — the security boundary — is fully fixed.
Open Questions
- Should the session-approval suggestion for a denied/ask chain scope to the offending sub-command (current plan: yes, via
check.command = unit) or to the whole command the user submitted? Decide during the prompt UX review in Step 3.