17 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 308 | Introduce a structured BashCommand model and parse the bash command once per tool_call |
Structured BashCommand model and parse-once injection
Problem Statement
The three bash permission gates each parse the same command independently and each apply a slightly different AST descent policy, and the unit of bash command-pattern policy is a flat string[] re-derived per feature.
That divergence is the bug surface: #301 arose because the command-pattern walk descended differently than the path walk, and #306 is re-tuning that same descent.
This issue introduces a structured BashCommand model for the command-pattern slice and makes all three bash gates share a single parse, so the queued bash work (#306, #307) becomes a consumer of one representation instead of a fourth bespoke walk.
This is the behavior-preserving enabling refactor — the same move #304 made one level shallower. No permission decision changes.
Goals
- Introduce a
BashCommandvalue object andBashProgram.commands(): BashCommand[], replacing the flattopLevelCommands(): string[], consumed by the command-pattern decomposition. - Parse the bash command once per
tool_calland inject the parsedBashPrograminto all three bash gates, retiring the three independentparse()calls. - Preserve every permission decision exactly — the existing suites (the 1000-line extractor suite, both gate suites, the tool-call integration suite, the manager suite) stay green unchanged.
Non-Goals
- Changing any permission decision — strictly behavior-preserving.
- Evaluating commands nested inside command substitution, process substitution, or subshells (#306) — that descent lands on top of this model.
- Migrating the path / external-directory slices to per-command resolution, or effective-working-directory projection (#307).
- Unifying the synchronous advisory
checkPermission/ RPC path with the gate's decomposed fidelity (#309). - Structured (name + argv) rule matching — bash rules remain text/glob matched against command text.
- Retiring the
extractTokensForPathRules/extractExternalPathsFromBashCommandfacades — they are kept as the seam exercised bytest/bash-external-directory.test.ts(the #304 lift-and-shift decision). - Adding a
context,name,argv,pathCandidates, oreffectiveCwdfield toBashCommand— those are added by their consuming issues (#306, #307); a field nothing reads is a fallow-flagged maintenance trap.
Background
Relevant modules:
src/handlers/gates/bash-program.ts—BashProgram.parse(command)walks the AST once intorawTokens+leadingCdTarget+topLevelCommandTexts, and exposespathTokens(),externalPaths(cwd), andtopLevelCommands(). The private constructor + staticparse()factory defeats fallow's syntactic analysis, so each public method carries a// fallow-ignore-next-line unused-class-membersuppression (#304 retro).src/handlers/gates/bash-command.ts—resolveBashCommandCheck(command, agentName, sessionRules, checkPermission, decompose?)decomposes a chain (via the injectabledecompose, defaulting toBashProgram.parse(cmd).topLevelCommands()), evaluates each unit on thebashsurface, and combines withpickMostRestrictive, falling back to the whole command when decomposition is empty.src/handlers/gates/bash-path-extractor.ts— theextractTokensForPathRules/extractExternalPathsFromBashCommandfacades, eachawait BashProgram.parse(command)then call a slice.src/handlers/gates/bash-path.ts/bash-external-directory.ts— the two path-bearing bash gates; each takes(tcc, checkPermission, getSessionRuleset), reads the command fromtcc.input, and calls a facade (which parses).src/handlers/permission-gate-handler.ts—handleToolCallbuilds an orderedgateProducersarray; three producers are bash-specific (describeBashExternalDirectoryGate,describeBashPathGate, and the inline command-pattern producer callingresolveBashCommandCheck).test/bash-external-directory.test.ts— 1027 lines, ~90 call sites against the two extractor facades directly; the load-bearing characterization suite forexternalPaths/pathTokens.
Constraints from AGENTS.md / the package skill that apply:
- Default to least privilege; silent over-matching is a permission bypass — a behavior-preserving refactor must not change a single decision.
- Do not add a declared field that nothing reads at runtime — fallow flags it and it is a maintenance trap.
- New
BashProgrammethods need the// fallow-ignore-next-line unused-class-membersuppression (singular kind, no trailing prose). - Run
pnpm run checkimmediately after each interface-changing step — behavior-preserving signature changes are caught first by the type checker, not the suite.
AST facts (verified empirically during planning):
| Input | Tree | topLevelCommands() today |
|---|---|---|
cd a && cd b |
program > list > [command, &&, command] |
cd a, cd b |
cat f | grep b |
program > pipeline > [command, |, command] |
cat f, grep b |
npm i > out.txt |
program > redirected_statement > [command, file_redirect] |
npm i |
{ cd a; cat b; } |
program > compound_statement > [command, command] |
{ cd a; cat b; } (whole) |
( cd a && rm x ) |
program > subshell > list |
( cd a && rm x ) (whole) |
The compound_statement and subshell rows are why commands() enumerates the same top-level units topLevelCommands() does today — emitting compound statements whole — rather than every command node in the tree.
Descending those is #306's job, not this issue's.
Design Overview
Decision model: the command-pattern unit is promoted from a bare string to a one-field BashCommand, and the three bash gates stop owning the parse — the handler parses once and injects the BashProgram.
Both moves are behavior-preserving; the value is the extension seam and the single parse, not new behavior.
BashCommand and commands()
// src/handlers/gates/bash-program.ts
/**
* One command-pattern unit of a parsed bash program.
*
* Minimal by design — `text` is the simple-command (or whole compound
* statement) string matched against the bash rules. The type is the stable
* extension point: #306 adds an execution `context`, #307 adds per-command
* path candidates and an effective working directory.
*/
export interface BashCommand {
readonly text: string;
}
export class BashProgram {
// …
// fallow-ignore-next-line unused-class-member
commands(): BashCommand[] {
return this.topLevelCommandTexts.map((text) => ({ text }));
}
}
topLevelCommands() and its fallow-ignore line are removed; commands() takes their place over the same stored topLevelCommandTexts.
The enumeration walker (collectTopLevelCommandTexts) is unchanged.
Command-pattern resolver consumes units, not a parse
resolveBashCommandCheck is changed from "parse a command string internally" to "combine a caller-supplied unit list," so the handler owns decomposition from the single shared parse and the resolver becomes a pure combiner:
// src/handlers/gates/bash-command.ts
export async function resolveBashCommandCheck(
command: string, // retained for the empty-units fallback
units: string[], // = program.commands().map((c) => c.text)
agentName: string | undefined,
sessionRules: Rule[],
checkPermission: CheckPermissionFn,
): Promise<PermissionCheckResult> {
const results = units.map((unit) =>
checkPermission("bash", { command: unit }, agentName, sessionRules),
);
return (
pickMostRestrictive(results) ??
checkPermission("bash", { command }, agentName, sessionRules)
);
}
The injectable decompose parameter and the private decomposeTopLevelCommands helper are removed — decomposition now happens once in the handler.
The ?? checkPermission(command) fallback is preserved, so the empty-units case stays never-weaker.
The function no longer needs to be async for parsing, but stays async to keep the handler's await call site and the gate-producer signature unchanged.
Parse-once injection in the handler
// src/handlers/permission-gate-handler.ts (sketch)
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command ? await BashProgram.parse(command) : null;
// bash-specific producers receive the shared program:
() => describeBashExternalDirectoryGate(tcc, bashProgram, checkPermission, getSessionRuleset),
() => describeBashPathGate(tcc, bashProgram, checkPermission, getSessionRuleset),
async () =>
tcc.toolName === "bash" && bashProgram
? describeToolGateFor(
await resolveBashCommandCheck(
command ?? "",
bashProgram.commands().map((c) => c.text),
tcc.agentName ?? undefined,
getSessionRuleset(),
checkPermission,
),
)
: describeToolGateFor(checkPermission(tcc.toolName, tcc.input, …)),
The two path gates gain a bashProgram: BashProgram | null parameter and call bashProgram.externalPaths(cwd) / bashProgram.pathTokens() directly instead of the facade.
They keep their existing tcc.toolName !== "bash" / !command early-returns; with bashProgram === null the gate returns null as before.
Design-review notes
- Dependency width: each bash gate genuinely uses the injected
BashProgram(one slice each), so the new parameter is a real dependency, not a bag. - Law of Demeter: gates call
bashProgram.externalPaths(cwd)/.pathTokens()— a method on the injected collaborator, not a reach-through. - Parameter relay:
bashProgramflows handler → gate (the endpoint), not threaded through intermediaries. BashCommandis a one-field type; that is intentional (the extension seam) and fallow-clean becausetextis read by the resolver.- The extractor facades are kept (test-only seam after this issue); fully retiring them by migrating the 1027-line suite onto
BashProgrammethods is a deferred cleanup, not part of this behavior-preserving refactor.
Module-Level Changes
src/handlers/gates/bash-program.ts— addexport interface BashCommand; replace thetopLevelCommands()method (and itsfallow-ignoreline) withcommands(): BashCommand[]; the storedtopLevelCommandTextsfield andcollectTopLevelCommandTextswalker are unchanged.src/handlers/gates/bash-command.ts— changeresolveBashCommandCheckto accept(command, units, agentName, sessionRules, checkPermission); remove thedecomposeparameter and thedecomposeTopLevelCommandshelper.src/handlers/permission-gate-handler.ts— parsebashProgramonce whentcc.toolName === "bash"and a command is present; pass it to the two path gates; computeunitsfrombashProgram.commands()and pass them toresolveBashCommandCheck.src/handlers/gates/bash-external-directory.ts— add abashProgram: BashProgram | nullparameter; replaceextractExternalPathsFromBashCommand(command, cwd)withbashProgram.externalPaths(cwd); returnnullwhenbashProgramisnull.src/handlers/gates/bash-path.ts— add abashProgram: BashProgram | nullparameter; replaceextractTokensForPathRules(command)withbashProgram.pathTokens(); returnnullwhenbashProgramisnull.src/handlers/gates/bash-path-extractor.ts— unchanged (kept fortest/bash-external-directory.test.ts).test/handlers/gates/bash-program.test.ts— rename thetopLevelCommandsdescribe and update its assertions to thecommands(): BashCommand[]shape ([{ text: "…" }, …]).test/handlers/gates/bash-command.test.ts— passunitsdirectly instead of adecomposestub; add thecommandfallback argument.test/handlers/gates/bash-external-directory.test.tsandtest/handlers/gates/bash-path.test.ts— construct aBashProgram(realparse) and pass it to the gate under test.test/handlers/tool-call.test.ts— verify the bash chain / single-command integration tests still pass through the handler's single parse (assertions unchanged).docs/architecture/architecture.md— update thebash-program.tsandbash-command.tslisting lines:commands(): BashCommand[](nottopLevelCommands()), and note the gate handler parses once and injects theBashProgram.
No pkg:* doc under docs/configuration.md or README.md changes — behavior is unchanged.
docs/architecture/v3-architecture.md is historical narrative and is left unchanged (#304 retro).
Test Impact Analysis
- New unit coverage enabled:
commands()returns typedBashCommand[]entries — the renamedbash-program.test.tscases assert the object shape ({ text }), documenting the seam. No genuinely new behavior is exercised; the enumeration is unchanged. - Tests that become redundant: none — no assertion is removed.
The
decompose-stub indirection inbash-command.test.tsis replaced by passingunitsdirectly, which is a simplification, not a coverage loss. - Tests that must stay as-is:
test/bash-external-directory.test.ts(the 1027-line characterization suite) provesexternalPaths/pathTokensoutputs are unchanged — the strongest behavior-preservation signal for the parse-once move; both gate suites and the tool-call integration suite confirm the injected program produces identical gate decisions.
TDD Order
test: model bash command-pattern units as BashCommand— addBashCommandandcommands()tobash-program.ts, removetopLevelCommands(); updateresolveBashCommandCheck's default decompose tocommands().map((c) => c.text); rename/updatebash-program.test.tsto the object shape. Single atomic commit: removingtopLevelCommands()breaks its sole consumer and its tests at the type level, so the method swap, the consumer update, and the test update land together. Usefeat:is wrong (no behavior change) — userefactor:. Runpnpm run check.refactor: inject the shared BashProgram into the bash path gates— parsebashProgramonce in the handler; add thebashProgramparameter todescribeBashExternalDirectoryGateanddescribeBashPathGate, callingexternalPaths/pathTokenson it; updatetest/handlers/gates/bash-external-directory.test.tsandbash-path.test.tsto pass a parsed program. The signature change and its call sites (handler + gate tests) must land together. Runpnpm run check.refactor: evaluate bash command units from the shared parse— changeresolveBashCommandCheckto(command, units, …), removedecomposeanddecomposeTopLevelCommands; the handler passesbashProgram.commands().map((c) => c.text); updatebash-command.test.tsto pass units; confirmtool-call.test.tsstays green. Runpnpm run check; run the full suite (resolveBashCommandCheckis a shared helper).docs: update architecture listing for the BashCommand model and parse-once— update the twoarchitecture.mdlines.
After step 3, a bash tool_call parses the command exactly once.
Risks and Mitigations
- Parse-once changes the decision for some command — Mitigation: the gates call the identical slice methods on the same parse; the 1027-line extractor suite, both gate suites, and the tool-call integration suite assert unchanged outputs. Run the full suite after step 3.
- The extractor facades become production-dead and fallow flags them — Mitigation:
test/bash-external-directory.test.tsimports both, and fallow treats test files as consumers (#301 retro), so they stay live; retaining them is the explicit #304 lift-and-shift decision. commands()is flagged by fallow as an unused class member (private-ctor false positive) — Mitigation: carry the// fallow-ignore-next-line unused-class-membersuppression (singular kind, no trailing prose) exactly astopLevelCommands()did.- Gate-signature change ripples to mocks beyond the gate suites — Mitigation: grep for every constructor of the gate-call arguments; the gates are called only from the handler and their own suites.
Run
pnpm run checkafter steps 1–3. - This refactor ships stacked under #306's release — Mitigation: like #304 under #301, note at ship time that release-please omits
refactor:commits from the changelog, so [#308] must be closed explicitly when #306 ships (the/ship-issuestacked-enabler check covers this).
Open Questions
- Whether
resolveBashCommandCheckshould takeunits: string[]orcommands: BashCommand[]directly. The plan passesstring[]to keep the resolver decoupled from the model shape and its tests trivial; revisit if #306 needs the per-unitcontextinside the resolver (it evaluates bytext, so likely not).