import type { AccessPath } from "#src/access-intent/access-path"; import { ARG_NODE_TYPES, SKIP_SUBTREE_TYPES, } from "#src/access-intent/bash/node-text"; import type { TSNode } from "#src/access-intent/bash/parser"; import { classifyPromotedRuleCandidate, classifyTokenAsPathCandidate, classifyTokenAsRuleCandidate, } from "#src/access-intent/bash/token-classification"; import { collectCommandTokens, collectPathCandidateTokens, collectRedirectTokens, extractCommandName, } from "#src/access-intent/bash/token-collection"; import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization"; import type { PathNormalizer } from "#src/path-normalizer"; import { isSafeSystemPath } from "#src/safe-system-paths"; import type { PathRuleTokenMatcher } from "#src/types"; /** Default promotion predicate: promotes nothing (#509). */ const NO_PROMOTION: PathRuleTokenMatcher = () => false; // ── Internal types ─────────────────────────────────────────────────────────── /** * The working directory in force where a path candidate appears. * * A `known` base carries an `offset` to be joined with `cwd` at resolution * time: a relative-or-absolute path string built by folding the literal targets * of current-shell `cd` commands (`""` = `cwd`); an absolute offset (from * `cd /abs`) ignores `cwd` at resolution time. * An `unknown` base marks a non-literal `cd` target (`cd "$DIR"`, `cd $(…)`, * `cd -`, bare `cd`, `cd ~…`) that made the effective directory unresolvable. */ type EffectiveBase = | { readonly kind: "known"; readonly offset: string } | { readonly kind: "unknown" }; /** * A path-candidate token paired with the effective working directory projected * onto the point in the command stream where it appears. */ interface PathCandidate { readonly token: string; readonly base: EffectiveBase; } // ── Public output types ────────────────────────────────────────────────────── export interface BashPathRuleCandidate { /** Raw path-like token shown in prompts, logs, and session approvals. */ readonly token: string; /** The path's lexical and canonical forms for permission policy matching. */ readonly path: AccessPath; } /** * The filesystem paths a bash program references, resolved against the working * directory and platform — the two typed slices {@link BashProgram} exposes. */ export interface ResolvedBashPaths { /** Deduplicated paths resolving outside the working directory (#418). */ readonly externalPaths: readonly AccessPath[]; /** Every path-rule token paired with its cd-aware policy values (#393). */ readonly ruleCandidates: readonly BashPathRuleCandidate[]; } // ── Walk-time constants ────────────────────────────────────────────────────── /** The working directory in force at the start of a program (`cwd`). */ const CWD_BASE: EffectiveBase = { kind: "known", offset: "" }; /** The effective directory after a non-literal or unresolvable `cd`. */ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" }; /** * Resolves the filesystem paths a parsed bash program references. * * Holds a {@link PathNormalizer} (platform + cwd baked in) as its primary * collaborator and answers all platform/cwd-dependent questions through it — * `cd`-base folding (`isAbsolute`/`joinBase`), per-candidate resolution * (`forPath`/`forLiteral`/`resolveBase`), and the outside-cwd boundary * decision — so no walk step re-reads the platform or threads the cwd. * * Also holds an `isPromotablePathToken` predicate (default: promotes nothing) * deciding whether a bare token that fails the broad rule-candidate shape gate * should still be promoted because it matches an active, specific `path` rule * (#509). The resolver never sees the rules themselves — the predicate is * built and owned by `PermissionManager.getPromotablePathTokenMatcher`. * * Tell-don't-ask: callers hand it a parsed tree and receive the resolved * {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk, * the `cd`-folding state, and the intermediate path candidates stay private. * One instance per parse ({@link BashProgram.parse} constructs it with the * session normalizer and promotion predicate). */ export class BashPathResolver { constructor( private readonly normalizer: PathNormalizer, private readonly isPromotablePathToken: PathRuleTokenMatcher = NO_PROMOTION, private readonly workdir?: string, ) {} /** * Resolve a parsed bash program's path references into its external-path and * rule-candidate slices, walking the AST exactly once. * * When a `workdir` is set (an aliased shell tool's working directory, #574), * it seeds the initial effective base — as if the program were prefixed with * `cd ` — so relative tokens resolve against it, and the `workdir` * itself is added to the external paths when it resolves outside the cwd. * Containment is always measured against the session cwd baked into the * normalizer, so a `workdir` outside the cwd does not widen the sandbox. */ resolve(rootNode: TSNode): ResolvedBashPaths { const initialBase = this.workdir === undefined ? CWD_BASE : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir); const candidates = this.collectPathCandidates(rootNode, initialBase); return { externalPaths: this.withWorkdirExternal( this.projectExternalPaths(candidates), ), ruleCandidates: this.projectRuleCandidates(candidates), }; } /** * Prepend the `workdir`'s own {@link AccessPath} to the external paths when it * resolves outside the cwd. A real `cd /etc` flags `/etc` via its argument * token; the seeded base carries no such token, so it is added explicitly and * deduplicated against the command's own external tokens (#574). */ private withWorkdirExternal( tokenExternals: readonly AccessPath[], ): AccessPath[] { if (this.workdir === undefined) return [...tokenExternals]; const wdPath = this.normalizer.forBashToken(this.workdir); const canonical = wdPath.boundaryValue(); const isExternal = canonical ? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical) : true; if (!isExternal) return [...tokenExternals]; const key = canonical || wdPath.value(); const alreadyPresent = tokenExternals.some( (p) => (p.boundaryValue() || p.value()) === key, ); return alreadyPresent ? [...tokenExternals] : [wdPath, ...tokenExternals]; } // ── AST walk — collect PathCandidates ────────────────────────────────── /** * Walk the AST once, collecting every path-candidate token tagged with the * effective working directory projected onto its position. * * The effective directory is stateful: it starts at `cwd` and each * current-shell `cd ` (joined by `&&`, `||`, `;`, or a newline) * folds into it for subsequent commands. * A `cd` inside a pipeline or a backgrounded command runs in a subshell and * does not update the running directory; subshell and brace-group interiors * inherit the enclosing base without folding their own `cd`s (a conservative * first tier). */ private collectPathCandidates( rootNode: TSNode, initialBase: EffectiveBase, ): PathCandidate[] { const out: PathCandidate[] = []; this.walkForCandidates(rootNode, initialBase, out); return out; } /** * Collect a single node's candidates tagged with `base`, returning the * effective base in force *after* the node (the input base unless the node is * a current-shell `cd ` that folds the running directory). */ private walkForCandidates( node: TSNode, base: EffectiveBase, out: PathCandidate[], ): EffectiveBase { switch (node.type) { case "program": case "list": case "redirected_statement": return this.walkCurrentShellSequence(node, base, out); case "command": tagTokens(collectCommandTokens(node), base, out); return this.foldCd(node, base); case "pipeline": // tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the // first stage of a pipeline (`cd a && pnpm x 2>&1 | tail` parses as // `(cd a && pnpm x 2>&1) | tail`), burying a current-shell `cd` inside // a node the `default` case treats as non-folding. Recover bash operator // precedence (`|` binds tighter than `&&`/`||`/`;`): fold the first // stage's leading current-shell commands while keeping its terminal // command and every downstream stage as non-folding subshells (#454). return this.walkPipeline(node, base, out); case "subshell": // A subshell runs in a child shell: its interior `cd`s fold within the // subshell but reset on exit, so the folded base is discarded. this.walkCurrentShellSequence(node, base, out); return base; case "compound_statement": // A `{ … }` brace group runs in the current shell, so its `cd`s persist // to following commands — thread and return the folded base. return this.walkCurrentShellSequence(node, base, out); default: // Pipelines, control-flow bodies, redirect targets, and command/process // substitution interiors: collect every candidate in the subtree tagged // with the enclosing base and do not fold their internal `cd`s. (Folding // inside substitutions is deferred — conservative, never under-flags.) tagTokens(collectPathCandidateTokens(node), base, out); return base; } } /** * Fold a current-shell sequence (`program` / `list` / `redirected_statement`): * thread the effective base left-to-right through the children so a `cd` * updates the base for following siblings. * A statement immediately followed by the background operator (`&`) runs in a * subshell, so its folded base is discarded. */ private walkCurrentShellSequence( seqNode: TSNode, base: EffectiveBase, out: PathCandidate[], ): EffectiveBase { let current = base; for (let i = 0; i < seqNode.childCount; i++) { const child = seqNode.child(i); if (!child?.isNamed) continue; if (SKIP_SUBTREE_TYPES.has(child.type)) continue; const after = this.walkForCandidates(child, current, out); current = isBackgrounded(seqNode, i) ? current : after; } return current; } /** * Walk a `pipeline` node, returning the effective base in force after it. * * Each stage of a true pipeline (`A | B | C`) runs in a subshell, so a `cd` * inside any stage must not leak — the base normally passes through unchanged. * The exception is the first stage: tree-sitter-bash wraps a redirect-bearing * current-shell `&&`/`;` list (`cd a && pnpm x 2>&1 | tail`) as that stage, * and bash precedence makes the list's leading commands current-shell, so they * fold and the folded base persists past the pipeline to following siblings. * * The terminal command of the first stage is the real pipe stage (a subshell) * and must not fold; every stage after a `|` is a downstream subshell stage * and collects tokens against the folded base without folding (#454). */ private walkPipeline( node: TSNode, base: EffectiveBase, out: PathCandidate[], ): EffectiveBase { let current = base; let first = true; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (!child?.isNamed) continue; if (SKIP_SUBTREE_TYPES.has(child.type)) continue; if (first) { current = this.foldPipelineFirstStage(child, current, out); first = false; continue; } // Downstream stage (after a `|`): subshell — collect against the folded // base, do not fold. tagTokens(collectPathCandidateTokens(child), current, out); } return current; } /** * Collect the first pipe stage's candidates, folding its leading current-shell * `cd` commands when tree-sitter wrapped a `list` or `redirected_statement` * around them. * The terminal command of that container is the real pipe stage (a subshell) * and is collected without folding. * A bare `command` first stage (a true pipeline first stage such as * `cd nested | cat ../b`) is a subshell: it collects against the input base * and does not fold. */ private foldPipelineFirstStage( node: TSNode, base: EffectiveBase, out: PathCandidate[], ): EffectiveBase { if (node.type === "list") return this.foldListExceptTerminal(node, base, out); if (node.type === "redirected_statement") { let current = base; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (!child?.isNamed) continue; if (child.type === "file_redirect") { // Redirect destinations are part of the piped stage; collect them // against the folded base without folding. tagTokens(collectRedirectTokens(child), current, out); continue; } // The inner statement is the `list`/`command` being redirected; fold its // leading current-shell commands via the terminal-excluding walk. current = this.foldPipelineFirstStage(child, current, out); } return current; } // Bare `command` or any other shape: a true subshell first stage. tagTokens(collectPathCandidateTokens(node), base, out); return base; } /** * Fold every named, non-skip child of a `list` except the last, threading the * effective base left-to-right through the leading current-shell commands; the * terminal child is the real pipe stage and is collected without folding. */ private foldListExceptTerminal( node: TSNode, base: EffectiveBase, out: PathCandidate[], ): EffectiveBase { const namedChildren: TSNode[] = []; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (child?.isNamed && !SKIP_SUBTREE_TYPES.has(child.type)) { namedChildren.push(child); } } let current = base; for (let i = 0; i < namedChildren.length; i++) { const child = namedChildren[i]; if (i < namedChildren.length - 1) { current = this.walkForCandidates(child, current, out); } else { // Terminal child = the real pipe stage; collect without folding. tagTokens(collectPathCandidateTokens(child), current, out); } } return current; } /** * Compute the effective base after a command runs. * Returns `base` unchanged unless the command is `cd`: * * - `cd /abs` (absolute literal) → a fresh known base, recovering from an * earlier unknown base. On win32 a drive-mount target (`cd /c/x`) folds to * its translated Windows base, while a non-mount POSIX absolute * (`cd /tmp`) is not deterministically resolvable and yields unknown (#533). * - `cd rel` (relative literal) → fold into a known base, or stay unknown if * the base was already unknown. * - `cd "$DIR"` / `cd $(…)` / `cd -` / bare `cd` / `cd ~…` (non-literal) → * unknown. * * The target's platform/MSYS interpretation is delegated to the * {@link PathNormalizer}; this method owns only the base-folding state. */ private foldCd(commandNode: TSNode, base: EffectiveBase): EffectiveBase { if (extractCommandName(commandNode) !== "cd") return base; const target = cdLiteralTarget(commandNode); if (target === null) return UNKNOWN_BASE; return this.deriveBaseFromCdTarget(base, target); } /** * Fold a literal `cd`/working-directory target string into the effective * base, delegating the platform/MSYS interpretation to the * {@link PathNormalizer}. Owns only the base-folding state: * * - `absolute` → a fresh known base (recovers from an earlier unknown base). * - `unknown` → the base becomes conservatively unknown. * - `relative` → join into a known base, or stay unknown if already unknown. * * Shared by {@link foldCd} (inline `cd` commands) and the initial-base seed * (an aliased shell tool's `workdir`, an implicit leading `cd `). */ private deriveBaseFromCdTarget( base: EffectiveBase, target: string, ): EffectiveBase { const interpreted = this.normalizer.interpretBashCdTarget(target); switch (interpreted.kind) { case "absolute": return { kind: "known", offset: interpreted.value }; case "unknown": return UNKNOWN_BASE; case "relative": if (base.kind === "unknown") return UNKNOWN_BASE; return { kind: "known", offset: this.normalizer.joinBase(base.offset, target), }; } } // ── Projection ───────────────────────────────────────────────────────── /** * Project the collected candidates into deduplicated external paths. * * Filters candidates through the strict path classifier * (`classifyTokenAsPathCandidate`), resolves each against its effective working * directory base, and returns only paths that resolve outside the baked cwd in * their lexical (as-typed, normalized but not symlink-resolved) form. * * The outside-cwd decision and the dedup identity use the canonical * (symlink-resolved) form so `external_directory` config patterns match the * path as the user typed it (#418). */ private projectExternalPaths( candidates: readonly PathCandidate[], ): AccessPath[] { const seen = new Set(); const externalPaths: AccessPath[] = []; for (const { token, base } of candidates) { const candidate = classifyTokenAsPathCandidate(token); if (!candidate) continue; // Unknown effective directory: a relative candidate could resolve // anywhere, so flag it conservatively (resolved against the baked cwd // only for a display path). Absolute / `~` candidates are base-independent // below. if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) { const accessPath = this.normalizer.forPath(candidate); const canonical = accessPath.boundaryValue(); if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) { seen.add(canonical); externalPaths.push(accessPath); } continue; } const resolveBase = base.kind === "known" ? this.normalizer.resolveBase(base.offset) : undefined; const accessPath = this.normalizer.forBashToken(candidate, { resolveBase, }); const lexical = accessPath.value(); if (!lexical) continue; // The boundary decision and dedup identity use the canonical // (symlink-resolved) form the AccessPath already derived, but the returned // value is the lexical form so config patterns match the path as the user // typed it (#418). A win32 device path preserves `/dev/null` as its // boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the // safe-path exclusion (#533). const canonical = accessPath.boundaryValue(); // A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`) // has no canonical form; it is foreign to the win32 cwd, so it is always // external. Its lexical value is the dedup identity so two distinct // literal-only paths do not collapse (#533). const isExternal = canonical ? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical) : true; const dedupKey = canonical || lexical; if (isExternal && !seen.has(dedupKey)) { seen.add(dedupKey); externalPaths.push(accessPath); } } return externalPaths; } /** * Project the collected candidates into rule candidates with their cd-aware * policy lookup values. * * Filters candidates through the broad path classifier * (`classifyTokenAsRuleCandidate`), falling back to the rule-driven promoted * classifier (`classifyPromotedRuleCandidate`, #509) for a bare token the * broad classifier rejects for shape — promoted only when the injected * `isPromotablePathToken` predicate matches an active, specific `path` rule. * On win32 the broad classifier is told to treat a backslash as a path * separator, so a backslash-relative token (`dir\file`) is recognized as a * rule candidate the same as its forward-slash equivalent (#520); on POSIX * `\` is a legal filename character, so the token stays bare there. * Pairs each qualifying token with its set of policy values (absolute + * project-relative + raw). * A token after a non-literal `cd` keeps only its literal value so no * spurious absolute rule can match (#393). */ private projectRuleCandidates( candidates: readonly PathCandidate[], ): BashPathRuleCandidate[] { const seen = new Set(); const result: BashPathRuleCandidate[] = []; for (const { token, base } of candidates) { const candidate = classifyTokenAsRuleCandidate(token, this.normalizer.flavor) ?? classifyPromotedRuleCandidate(token, this.isPromotablePathToken); if (!candidate) continue; const path = this.buildRuleCandidatePath(candidate, base); const matchValues = path.matchValues(); if (matchValues.length === 0) continue; const key = matchValues.join("\0"); if (seen.has(key)) continue; seen.add(key); result.push({ token: candidate, path }); } return result; } private buildRuleCandidatePath( candidate: string, base: EffectiveBase, ): AccessPath { // An unknown base + relative candidate stays literal-only: a resolved // absolute or canonical alias would resolve against the wrong directory and // could spuriously match a rule (#393). if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) { return this.normalizer.forLiteral(normalizePathPolicyLiteral(candidate)); } const resolveBase = base.kind === "known" ? this.normalizer.resolveBase(base.offset) : undefined; return this.normalizer.forBashToken(candidate, { resolveBase }); } /** * True when a path candidate is relative (resolved against the effective * directory) rather than absolute or home-relative (`~…`), which are * base-independent. * * Delegates the absoluteness decision to the platform-aware `PathNormalizer` * rather than a POSIX-only `startsWith("/")` check, so Windows drive-letter * paths (`C:/…`, `C:\…`) are correctly treated as absolute on win32 and as * relative on POSIX (where they denote an in-CWD path). */ private isRelativeCandidate(candidate: string): boolean { return !this.normalizer.isAbsolute(candidate) && !candidate.startsWith("~"); } } // ── Pure AST/string helpers ────────────────────────────────────────────────── /** * True when the statement at `index` is immediately followed by the background * operator (`&`) — distinct from the `&&` / `||` / `;` current-shell * separators. */ function isBackgrounded(seqNode: TSNode, index: number): boolean { const next = seqNode.child(index + 1); if (!next || next.isNamed) return false; return next.type === "&"; } function tagTokens( tokens: readonly string[], base: EffectiveBase, out: PathCandidate[], ): void { for (const token of tokens) out.push({ token, base }); } /** * Resolve the literal target of a `cd` command, or `null` when the first * argument is not a static literal (contains an expansion or command * substitution) or cannot be resolved against the working directory (`cd -`, * `cd ~…`, bare `cd`). */ function cdLiteralTarget(commandNode: TSNode): string | null { for (let i = 0; i < commandNode.childCount; i++) { const child = commandNode.child(i); if (!child) continue; if (child.type === "command_name" || child.type === "variable_assignment") continue; if (!child.isNamed) continue; // Skip the `--` end-of-flags marker; the next argument is the target. if (child.type === "word" && child.text === "--") continue; if (!ARG_NODE_TYPES.has(child.type)) return null; return literalTextOf(child); } return null; } /** * The literal string value of an argument node, or `null` when it contains a * variable expansion / command substitution or is a non-resolvable `cd` * destination (`-`, `~…`). */ function literalTextOf(node: TSNode): string | null { switch (node.type) { case "word": { const text = node.text; if (text === "-" || text.startsWith("~")) return null; return text; } case "raw_string": { const text = node.text; return text.length >= 2 && text.startsWith("'") && text.endsWith("'") ? text.slice(1, -1) : text; } case "concatenation": { let result = ""; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (!child) continue; const part = literalTextOf(child); if (part === null) return null; result += part; } return result; } case "string": { let result = ""; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (!child) continue; if (child.type === '"') continue; if (child.type !== "string_content") return null; result += child.text; } return result; } default: return null; } }