24 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 307 | Project a running effective working directory across cd's onto bash path candidates |
Project a running effective working directory onto bash path candidates
Problem Statement
The bash external-directory guard resolves every relative path candidate in a command against a single base.
BashProgram.parse() records one leadingCdTarget — the argument of the very first command, and only when that first command is cd — and externalPaths(cwd) resolves all candidates against cwd joined with that one target.
This single-base model is wrong in both directions.
It over-prompts on benign multi-cd paths, and — worse — it can miss a real escape.
For cd nested/deep && cd .. && cat ../../etc/passwd the real effective directory is cwd/nested, so ../../etc/passwd escapes to cwd/../etc/passwd and should be flagged; today we resolve against cwd/nested/deep (the first cd only), which collapses back inside cwd and sails through.
The fix is to project the effective working directory at each point in the command stream onto that point's path candidates.
The effective directory is stateful: it starts at cwd, each current-shell cd <literal> mutates it for subsequent commands, and subshell / brace-group / pipeline / background contexts scope changes that must not leak.
A relative candidate must resolve against the directory in force where it appears, not against one base for the whole string.
Goals
- Retire the single
leadingCdTarget: string | undefinedin favor of a per-candidate effective base. - Tier 1 — fold a sequence of top-level current-shell
cd <literal>commands (joined by&&,||,;, or a newline) into a running effective directory, and resolve each candidate against the directory in force where it appears. - Tier 2 — model subshell
( … )and command/process-substitution scoping with a directory-frame stack (acdinside resets on exit), persist acdinside a{ … }brace group (brace groups run in the current shell), and ensurecds inside pipelines (a | b) and backgrounded commands (a &) never update the running directory. - Conservative bail — when a
cdtarget is not a static literal (cd "$DIR",cd $(…),cd -), the effective directory becomes unknown; subsequent relative candidates are treated as potentially external and flagged (least-privilege). - Preserve
pathTokens()exactly — it is cwd-independent and must not change. - This change makes external-directory decisions more precise; it is a behavior change (more escapes caught, occasional new prompts), surfaced via
feat:commits.
Non-Goals
- Variable- or substitution-valued
cdtargets,pushd/popd,cd -/$OLDPWD,CDPATH— these only mark the effective directory unknown (the conservative bail), they are not resolved. - Modeling
&&/||success or failure — assume eachcdmay take effect. - Symlink resolution, physical vs. logical paths (
cd -P). eval, sourced scripts, functions, aliases.- Folding
cdstate inside control-flow bodies (if/while/for/case) and function definitions — their candidates are collected against the base in force at entry, but their internalcds do not fold (deferred, conservative). - Changing the command-pattern slice (
commands()/resolveBashCommandCheck) or theBashCommandmodel — this issue touches only the path-candidate slice. - Changing
pathTokens()output or the two extractor facades' signatures.
Background
Relevant modules (all changes are private to bash-program.ts plus its tests):
src/handlers/gates/bash-program.ts—BashProgram.parse(command)walks the AST once, today producingrawTokens: string[],leadingCdTarget: string | undefined, andcommandUnits: BashCommand[].externalPaths(cwd)resolves everyrawTokenscandidate againstcomputeEffectiveResolveBase(leadingCdTarget, cwd);pathTokens()rule-classifies the samerawTokens(cwd-independent).extractLeadingCdTarget(viafindFirstCommand, which descends onlyprogram/listand takes the first command) andcomputeEffectiveResolveBaseare the two private helpers this issue retires.src/handlers/gates/bash-token-classification.ts—classifyTokenAsPathCandidate(strict: accepts a token only if it starts with/, starts with~/, or contains..) andclassifyTokenAsRuleCandidate(broader). This is the load-bearing fact: the strict external-directory classifier never accepts a bare relative filename (x,src/foo.ts), so the effective base only ever changes the resolution of..-containing relative candidates.src/handlers/gates/bash-path-extractor.ts—extractExternalPathsFromBashCommand(command, cwd)/extractTokensForPathRules(command)facades overBashProgram; signatures unchanged.src/handlers/gates/bash-external-directory.ts/bash-path.ts— the two gates; they callbashProgram.externalPaths(cwd)/.pathTokens(). Neither gate signature changes.src/path-utils.ts—normalizePathForComparison(candidate, base)=resolve(base, candidate)+ normalize;isPathWithinDirectory,isSafeSystemPathunchanged.
Constraints from AGENTS.md / the package skill that apply:
- Default to least privilege; silent over-matching is a permission bypass — the conservative-bail decision (flag unknown-base relatives) follows this.
- Do not read
process.cwd()inside the walk —cwdis already a parameter ofexternalPaths(cwd), and the parse-time walk computes only a relative offset, never resolving againstcwd. - New / changed
BashProgrammethods keep the// fallow-ignore-next-line unused-class-membersuppression (private-ctor false positive). - Run
pnpm run checkimmediately after each interface-changing step; run the full suite after any step that touches the shared walk.
AST facts (verified during planning with disposable web-tree-sitter probes):
| Input | Tree (relevant shape) |
|---|---|
cd a && cd b && cat x |
program > list > [list > [command, &&, command], &&, command] (left-associative) |
mkdir d && cd d && cat x |
same list nesting; cd is the second command |
cd a & cat x |
program > [command, ·&, command] — & is an anonymous token after the backgrounded command |
cat a | cd b |
program > pipeline > [command, |, command] |
( cd sub && cat y ) && cat z |
program > list > [subshell > list, &&, command] |
cd a && { cd b; cat c; } |
program > list > [command, &&, compound_statement > [command, command]] |
echo $(cd q && cat r) |
command > command_name > [echo, command_substitution > list] |
cat $(cd s; pwd) y |
command > [command_name, command_substitution, word "y"] — y is an outer-scope sibling |
& (background) is distinguished from && / || / ; by the anonymous operator token type that follows a command — the cd-fold honors && / || / ; / newline and skips &.
Design Overview
What actually changes
Because the strict classifier only admits absolute, ~/, and ..-relative tokens, and absolute / ~/ tokens are independent of the working directory, the effective base affects exactly one class of candidate: ..-containing relative paths.
The entire feature is "resolve ..-relative external-directory candidates against the effective directory in force where they appear, and flag them conservatively when that directory is unknown." pathTokens() never resolves against a base, so it is provably unaffected.
Per-candidate effective base
rawTokens: readonly string[] and leadingCdTarget: string | undefined are replaced by a single list of candidates carrying their projected base:
// src/handlers/gates/bash-program.ts
/**
* The working directory in force where a path candidate appears, expressed as
* an offset to be joined with `cwd` at resolution time (the walk never sees
* `cwd`). `known` carries a relative-or-absolute offset string built by folding
* `cd` literals ("" = cwd); `unknown` marks a non-literal `cd` that made the
* effective directory unresolvable.
*/
type EffectiveBase =
| { readonly kind: "known"; readonly offset: string }
| { readonly kind: "unknown" };
interface PathCandidate {
readonly token: string;
readonly base: EffectiveBase;
}
The constructor stores rawCandidates: readonly PathCandidate[] in place of rawTokens + leadingCdTarget; commandUnits is unchanged.
Slices derive from the one list
pathTokens(): string[] {
// Identical to today: rule-classify + dedup over the candidate tokens,
// ignoring base. Source order and token set are unchanged.
}
externalPaths(cwd: string): string[] {
const normalizedCwd = normalizePathForComparison(cwd, cwd);
const seen = new Set<string>();
const out: string[] = [];
for (const { token, base } of this.rawCandidates) {
const candidate = classifyTokenAsPathCandidate(token);
if (!candidate) continue;
// Unknown base + relative candidate → conservative: always external.
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
pushExternalForDisplay(candidate, cwd, seen, out); // resolve vs cwd for display, never suppress
continue;
}
const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
const normalized = normalizePathForComparison(candidate, resolveBase);
if (!normalized) continue;
if (
normalizedCwd !== "" &&
!isSafeSystemPath(normalized) &&
!isPathWithinDirectory(normalized, normalizedCwd) &&
!seen.has(normalized)
) {
seen.add(normalized);
out.push(normalized);
}
}
return out;
}
isRelativeCandidate(c) = !c.startsWith("/") && !c.startsWith("~") (absolute and ~/ are base-independent and resolve normally even under an unknown base).
resolve(cwd, base.offset) handles both a relative offset ("nested" → cwd/nested) and an absolute offset ("/abs" → /abs, ignoring cwd).
The old computeEffectiveResolveBase escape-to-cwd fallback is dropped: a cd that escapes cwd is now tracked faithfully, so candidates after it resolve against the (external) effective directory and are flagged — which is the desired least-privilege behavior and the whole point of the missed-escape fix.
The base-threading walk
collectPathCandidateTokens(node): string[] is replaced by collectPathCandidates(rootNode): PathCandidate[], a single recursive walk that threads an EffectiveBase and emits each candidate tagged with the base in force.
The leaf token collectors (collectCommandTokens, collectGenericCommandTokens, collectPatternCommandTokens, collectRedirectTokens) keep their existing token-selection logic so the candidate set and order are unchanged — they are wrapped to (a) tag direct-argument tokens with the current base and (b) hand nested substitution / subshell subtrees back to the scoped walk rather than flat-collecting them.
Threading rules by node type:
program/list/compound_statement(current-shell sequences) — fold left: the offset flows through children in source order; a child's returned offset becomes the next child's input. A brace group{ … }therefore persists its internalcds to following siblings (it runs in the current shell).command— emit the command's direct path candidates tagged with the current base; recurse into nested command/process substitutions with a scoped copy of the base (interiorcds do not leak out, but interior tokens inherit and may fold within the substitution). If the command iscd <literal>at a current-shell position, return the folded offset (join(offset, literal), or the literal if absolute); if it iscdwith a non-literal target, return{ kind: "unknown" }; otherwise return the input base unchanged. A command's owncd-argument candidate is emitted with the pre-update base (the directory thecdruns from).redirected_statement— emit the redirect-target candidates with the current base; fold the inner command and return its offset.pipeline— every member runs in a subshell: walk each member with a scoped copy of the base (collecting its candidates) and discard the returned offset; return the input base unchanged.- backgrounded command (a statement followed by the anonymous
&token) — walk it scoped and discard its offset; return the input base unchanged. subshell( … )— push a frame: walk the interior starting from the current base, discard the returned offset (pop); return the input base unchanged.- command/process substitution
$(…)/`…`/<(…)/>(…)— scoped like a subshell; interior tokens inherit the enclosing base and may fold within, but the offset does not leak to the enclosing command. - control-flow bodies and function definitions — collect interior candidates against the entry base, do not fold their internal
cds (conservative, deferred).
isNamed / SKIP_SUBTREE_TYPES guards are preserved exactly so heredoc bodies, comments, and operator tokens are skipped as today.
Worked examples
cd nested/deep && cd .. && cat ../../etc/passwd(cwd =/p): seg1 offsetnested/deep(nested/deepis not a../abs candidate, not flagged); seg2 candidate..resolves vs/p/nested/deep→/p/nested(inside, not flagged), offset folds tonested; seg3 candidate../../etc/passwdresolves vs/p/nested→/p/../etc/passwd→ outside/p→ flagged. The escape is now caught.mkdir d && cd d && cat ../x:cd dis the second command but current-shell, so it folds;../xresolves vscwd/d→cwd/xinside → not flagged.( cd sub && cat ../x ) && cat ../y: inside the subshell../xresolves vscwd/sub→cwd/xinside (not flagged); the subshellcdis popped, so../yresolves vscwd→cwd/../y→ flagged.cd a & cat ../x:cd ais backgrounded → does not fold;../xresolves vscwd→ flagged.cd "$DIR" && cat ../x: non-literalcd→ base unknown;../xis relative → flagged conservatively; a siblingcat /etc/hosts(absolute) still resolves normally and is flagged on its own merits.
Design-review notes
- Dependency width:
rawCandidatesreplaces two fields (rawTokens+leadingCdTarget) with one;externalPaths/pathTokenseach consume it fully. No gate signature changes, so no dependency-bag widening crosses a module boundary. - Law of Demeter: gates still call
bashProgram.externalPaths(cwd)/.pathTokens()— methods on the injected collaborator. - The
BashCommandmodel is deliberately not extended with path candidates, despite the forward note in the #308 plan. The cwd-frame grouping needed here descends into brace groups and substitution interiors and foldscdstate, whereascommands()emits brace groups whole and emits nested commands as separate rule units — different descent semantics. Conflating them would force a discriminator and leak one slice's descent policy into the other; keeping the path-candidate walk as its own derivation of the shared single parse honors the #308 anti-drift goal (one parse, two faithful slices) without the wrong abstraction.
Module-Level Changes
src/handlers/gates/bash-program.ts:- Add the
EffectiveBasetype andPathCandidateinterface (module-private). - Constructor: replace
rawTokens+leadingCdTargetparameters withrawCandidates: readonly PathCandidate[]. parse(): call the newcollectPathCandidates(rootNode); remove theextractLeadingCdTarget(...)call.pathTokens(): iteraterawCandidates, rule-classifytoken, dedup (behavior identical).externalPaths(cwd): resolve per-candidate base as above; addisRelativeCandidate+ the unknown-base conservative branch; remove thecomputeEffectiveResolveBasecall.- Replace
collectPathCandidateTokenswith the base-threadingcollectPathCandidateswalk and its scope-aware helpers; wrap the existing leaf collectors to tag tokens and delegate nested subtrees. - Remove
findFirstCommand,extractLeadingCdTarget, andcomputeEffectiveResolveBase(sole callers removed in this file). - Keep the
// fallow-ignore-next-line unused-class-membersuppressions onpathTokens/externalPaths.
- Add the
test/handlers/gates/bash-program.test.ts— extend theexternalPathsdescribe with the new cases (multi-cdfold,cd-not-first, missed-escape, subshell scoping + non-leak, brace-group persistence, pipeline / background non-leak, unknown-base conservative + absolute-still-normal).test/bash-external-directory.test.ts— in theleading cd prefixdescribe, re-frame the two tests whose comments encode the retired single-cd/fallback model and strengthen their assertions to the new resolved paths:"cd is not first command: cd is ignored"→ the secondcdnow folds; rename to reflect sequential-fold semantics and assert the resolved escape path."cd to external dir: paths after cd are still checked against cwd"→ faithful tracking now resolves against/tmp; update the comment and assert the new resolved path (/etc/hosts) plus the/tmpcandidate. The single-leading-cdregression tests stay green unchanged (a single leadingcdis the one-element case of the fold).
docs/architecture/architecture.md— update thebash-program.tslisting line:externalPaths(cwd)projects a running effective working directory across a sequence of current-shellcds with subshell / brace-group / pipeline / background scoping and a conservative unknown-base bail (retiring the singleleadingCdTarget); note thatpathTokens()is unchanged.
No README.md, docs/configuration.md, schema, or example-config changes — no config surface changes.
docs/architecture/v3-architecture.md is historical narrative, left unchanged.
Test Impact Analysis
- New unit coverage enabled — the per-candidate base makes each Tier directly testable at the
BashProgram.externalPathsboundary: sequential fold,cd-not-first fold, the missed-escape regression, each Tier-2 scope (subshell frame, brace-group persistence, pipeline / background non-leak), and the unknown-base conservative branch (relative flagged, absolute normal). These were previously impossible because the singleleadingCdTargetcollapsed all of them to one base. - Tests that become redundant — none are deleted.
Two
leading cd prefixtests are re-framed (their looselength > 0assertions stay green by coincidence, but their comments/titles asserted the retired model); strengthening them to exact resolved paths turns a coincidental pass into documentation of the new behavior. - Tests that must stay as-is — the single-leading-
cdregression and within-cwd cases inbash-external-directory.test.ts, the entireextractTokensForPathRules/pathTokensblock (cwd-independent, must not move), and thecommands()/ gate / tool-call suites (this issue does not touch the command-pattern slice). The 1027-line characterization suite is the primary guard thatpathTokensand the unchanged-base candidate set did not drift.
TDD Order
feat: fold sequential current-shell cd into the bash effective directory— introduceEffectiveBase/PathCandidate, replacerawTokens+leadingCdTargetwithrawCandidates, build the base-threadingcollectPathCandidateswalk handling current-shellcdfolding over&&/||/;/ newline while excluding backgrounded (&) and pipeline-membercds, and resolve per-base inexternalPaths; subshell / brace-group / substitution interiors inherit the outer base without folding their internalcds (correct Tier-1 conservative behavior). RetirefindFirstCommand/extractLeadingCdTarget/computeEffectiveResolveBase. Add the multi-cd,cd-not-first, missed-escape, and pipeline / background non-leak tests; re-frame the twobash-external-directory.test.tstests. This is one atomic step: therawTokens→rawCandidatesrepresentation change breaks bothpathTokensandexternalPathsinternally at once. Runpnpm run check, then the full suite.feat: scope cd inside subshells and persist it across brace groups— extend the walk so subshell( … )and command/process-substitution interiors fold their internalcds within a popped frame, and brace groups{ … }persist theircds to following current-shell siblings. Add the subshell-internal fold, subshell non-leak, brace-group persistence, and substitution-internal scoping tests. Runpnpm run check, then the full suite.feat: flag relative paths conservatively after a non-literal cd— mark a non-literalcdtarget (cd "$DIR",cd $(…),cd -) as{ kind: "unknown" }, propagate it through subsequent current-shell commands, and inexternalPathsflag relative candidates under an unknown base unconditionally while resolving absolute /~/candidates normally. Add the unknown-base conservative tests (relative flagged, absolute still normal,cd -unknown). Runpnpm run check, then the full suite.docs: document effective-cwd projection in the bash gate architecture— update thebash-program.tsarchitecture listing line.
After step 3, a bash command's external-directory candidates each resolve against the effective directory in force where they appear, with a conservative bail on unknowable directories.
Risks and Mitigations
- The base-threading walk drifts the candidate set, changing
pathTokensor an unchanged-baseexternalPathsresult — Mitigation: the leaf collectors keep their exact token-selection logic; the 1027-line characterization suite plus thepathTokensunit cases assert the set and order are unchanged. Run the full suite after steps 1–3. - Left-associative
listnesting is mis-ordered, foldingcds out of source order — Mitigation: the walk recurseslistchildren in order and threads the offset through nestedlistnodes; the AST nesting was probed during planning and the multi-cdtest asserts source-order fold. - Background
&is mis-detected as a current-shell separator, leaking a backgroundedcd— Mitigation: the fold inspects the anonymous operator token following each command and folds only on&&/||/;/ newline; a dedicatedcd a & cat ../xnon-leak test guards it. - The dropped escape-to-
cwdfallback changes a previously-green assertion — Mitigation: the only affected tests are the two re-framedleading cd prefixcases; both are updated in step 1 with strengthened assertions, and the change is the intended faithful-tracking fix. - Conservative unknown-base over-prompts on common benign relatives — Mitigation: the strict classifier already excludes bare relative filenames, so only
..-containing relatives under an unknowncdare flagged — a narrow, genuinely-ambiguous set; the decision was confirmed with the owner (least-privilege). - fallow flags
pathTokens/externalPathsas unused class members (private-ctor false positive) — Mitigation: carry the existing// fallow-ignore-next-line unused-class-membersuppressions; runpnpm fallow dead-codefrom the repo root before committing.
Open Questions
- Whether the substitution-internal
cdfold (Tier 2) is worth its small added walk complexity given how rareecho $(cd q && cat ../r)is in practice. Planned in because it falls out of the same recursive frame mechanism that subshells need; if it proves to add disproportionate branching during implementation, it can be reduced to "substitution interiors inherit the outer base without internal folding" (still conservative) without affecting Tiers 1 or the unknown-base bail.