import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { Language, Parser, type Node as SyntaxNode, type Tree } from "web-tree-sitter"; import { type WrapperRecord, unwrapCommand } from "./bash-unwrap.js"; import { expandHome } from "./secrets.js"; /** * Bash command analysis on a real syntax tree. * * A string/regular-expression analyser has to guess where a command starts and stops; a syntax tree * knows. Everything the policy engine needs is derived from one `parse()` per command: * * - every *executable unit* anywhere in the tree (`a && b`, `a | b`, `$(a)`, `(a; b)`, `if … then a`, * `for …; do a`, `find … -exec a`, `sh -c 'a'`, function bodies), * - the argv of each unit after quote removal, with shell expansions marked as unverifiable, * - its redirections, its wrappers (`sudo`, `env`, `timeout`, …) and its effective `cwd` after a * leading `cd`, * - constructs that run text the analyser can never see (`curl | bash`, heredocs into a shell). * * Tree-sitter is error tolerant: a syntax error produces an `ERROR` node instead of throwing, so an * unparseable command is reported as `tooComplex` and still yields whatever units could be read. */ // ============================================================================ // Parser lifecycle // ============================================================================ const MAX_NESTING = 4; let initPromise: Promise | undefined; let sharedParser: Parser | undefined; let initError: string | undefined; function resolveRuntimeWasm(): string | undefined { try { const require = createRequire(import.meta.url); return require.resolve("web-tree-sitter/tree-sitter.wasm"); } catch { return undefined; } } function grammarPath(): string { return fileURLToPath(new URL("./vendor/tree-sitter-bash.wasm", import.meta.url)); } /** * Loads the WASM parser and the Bash grammar once per process. * * Loading costs ~20 ms; a single command then parses in ~0.01 ms, so the parser is created lazily * and reused. A failure is remembered instead of retried on every request. */ export async function ensureBashParser(): Promise { if (sharedParser) { return; } if (initError) { throw new Error(initError); } if (!initPromise) { initPromise = (async () => { const runtimeWasm = resolveRuntimeWasm(); await Parser.init(runtimeWasm ? { locateFile: () => runtimeWasm } : undefined); const language = await Language.load(grammarPath()); const parser = new Parser(); parser.setLanguage(language); sharedParser = parser; })().catch((err: unknown) => { initError = `Bash parser unavailable: ${err instanceof Error ? err.message : String(err)}`; throw new Error(initError); }); } await initPromise; } export function isBashParserReady(): boolean { return sharedParser !== undefined; } export function bashParserError(): string | undefined { return initError; } /** Drops the shared parser; the next `ensureBashParser` builds a new one (tests, `/reload`). */ export function disposeBashParser(): void { sharedParser?.delete(); sharedParser = undefined; initPromise = undefined; initError = undefined; // Analyses from the dropped parser must not survive into the next instance. cache.clear(); } // ============================================================================ // Public shapes // ============================================================================ export interface Redirection { /** `write` for `>`/`>>`/`&>`, `read` for `<`. Input redirections carry no `path` when they are pipes. */ readonly mode: "read" | "write"; readonly path: string; } /** Where a unit came from, so a policy can tell `$(…)` apart from the outer command if it wants to. */ export type UnitOrigin = "command" | "subshell" | "substitution" | "inline"; export interface ExecutionUnit { /** Source text of the command as written (quotes and all). */ readonly raw: string; /** Words after quote removal; shell expansions are kept verbatim and listed in `dynamic`. */ readonly argv: readonly string[]; /** Command name with leading wrappers stripped (`sudo timeout 5 rm` → `rm`). */ readonly name: string; /** Argv of the unwrapped command incl. its name (`["rm", "-rf", "x"]`). */ readonly targetArgv: readonly string[]; readonly wrappers: readonly WrapperRecord[]; readonly redirections: readonly Redirection[]; /** Effective working directory: a leading `cd &&` moves it for the rest of the sequence. */ readonly cwd: string; /** 0-based position inside its pipeline, `-1` when the unit is not part of one. */ readonly pipelineIndex: number; /** `true` when stdin is the previous pipeline element (`curl … | bash`). */ readonly stdinFromPipe: boolean; /** Words whose runtime value the analyser cannot know (`$VAR`, `$'\x2f'`, `<(...)`). */ readonly dynamic: readonly string[]; /** Code handed to another interpreter in one argument (`sh -c`, `eval`, `find -exec`). */ readonly inlineCode: readonly string[]; /** `true` when the unit only changes directory. */ readonly isCd: boolean; readonly origin: UnitOrigin; /** Source offsets of the command node, used to rebuild the remainder of a `cd … && …` chain. */ readonly startIndex: number; readonly endIndex: number; } export interface BashAnalysis { readonly units: readonly ExecutionUnit[]; /** Shell text that will run but is not reachable statically (`curl … | bash`, heredoc into sh). */ readonly unverifiable?: string; /** The tree had syntax errors; whatever could be read is reported, the caller should confirm. */ readonly tooComplex?: string; readonly parseError: boolean; } // ============================================================================ // Word rendering // ============================================================================ interface RenderedWord { readonly text: string; readonly dynamic: boolean; } /** `namedChildren` is typed `(Node | null)[]`; the nulls are never produced in practice. */ function named(node: SyntaxNode): SyntaxNode[] { const children: SyntaxNode[] = []; for (const child of node.namedChildren) { if (child) { children.push(child); } } return children; } const DYN_NODE_TYPES = new Set([ "ansi_c_string", "arithmetic_expansion", "command_substitution", "process_substitution", "simple_expansion", "expansion", "translated_string", ]); function stripOuter(text: string, quotes: string): string { return text.length >= 2 && text.startsWith(quotes) && text.endsWith(quotes) ? text.slice(1, -1) : text; } /** * Renders a word node to the string the shell would pass to the command, so far as it can be known. * * Quotes are removed, `$VAR` / `$(…)` / `$'…'` are kept verbatim and reported as dynamic: their value * only exists at run time and no static rule can decide whether they name `.env` or `README.md`. */ export function renderWord(node: SyntaxNode): RenderedWord { const type = node.type; if (type === "raw_string") { return { text: stripOuter(node.text, "'"), dynamic: false }; } if (type === "string") { let text = ""; let dynamic = false; for (const child of named(node)) { const rendered = renderWord(child); text += rendered.text; dynamic ||= rendered.dynamic; } return { text: named(node).length > 0 ? text : stripOuter(node.text, '"'), dynamic }; } if (type === "translated_string") { const inner = node.text.replace(/^\$"/, "").replace(/"$/, ""); return { text: inner, dynamic: true }; } if (DYN_NODE_TYPES.has(type)) { return { text: node.text, dynamic: true }; } if (type === "concatenation") { let text = ""; let dynamic = false; for (const child of named(node)) { const rendered = renderWord(child); text += rendered.text; dynamic ||= rendered.dynamic; } return { text, dynamic }; } if (named(node).length === 0) { return { text: node.text, dynamic: false }; } let text = ""; let dynamic = false; for (const child of named(node)) { const rendered = renderWord(child); text += rendered.text; dynamic ||= rendered.dynamic; } return { text, dynamic }; } // ============================================================================ // Redirections // ============================================================================ const REDIRECT_NODE_TYPES = new Set([ "file_redirect", "heredoc_redirect", "herestring_redirect", "simple_heredoc_redirect", ]); function redirectOperator(node: SyntaxNode): string { for (const child of node.children) { if (child && !child.isNamed) { return child.text; } } return ""; } interface RedirectHarvest { readonly redirections: Redirection[]; /** Heredoc or here-string: the command's stdin is text the analyser cannot see. */ readonly stdinCode: boolean; } function harvestRedirect(node: SyntaxNode): RedirectHarvest { if (node.type === "heredoc_redirect" || node.type === "simple_heredoc_redirect") { // Tree-sitter nests `< file` as `heredoc_redirect (file_redirect …)`: the write target // lives *inside* the heredoc node, so it has to be harvested recursively. const nested: Redirection[] = []; for (const child of named(node)) { if (REDIRECT_NODE_TYPES.has(child.type)) { nested.push(...harvestRedirect(child).redirections); } } return { redirections: dedupeRedirections(nested), stdinCode: true }; } if (node.type === "herestring_redirect") { return { redirections: [], stdinCode: true }; } const redirections: Redirection[] = []; const operator = redirectOperator(node); const destination = node.childForFieldName("destination"); // `2>&1`, `>&-`: a descriptor copy, not a path. if (destination && destination.type !== "number" && !operator.includes("&")) { const rendered = renderWord(destination); if (rendered.text.length > 0) { redirections.push({ mode: operator.startsWith("<") ? "read" : "write", path: rendered.text, }); } } return { redirections, stdinCode: false }; } // ============================================================================ // Flattening the tree into units // ============================================================================ const SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "fish", "mksh", "oksh"]); const INLINE_INTERPRETERS = new Set(["python", "python3", "node", "perl", "ruby", "php", "osascript", "bun", "deno"]); const INTERPRETER_CODE_FLAGS: Record = { python: ["-c"], python3: ["-c"], node: ["-e", "--eval", "-p", "--print"], bun: ["-e", "--eval"], deno: ["eval"], perl: ["-e"], ruby: ["-e"], php: ["-r"], osascript: ["-e"], }; interface WalkState { cwd: string; origin: UnitOrigin; pipelineIndex: number; stdinFromPipe: boolean; depth: number; } interface Collector { readonly units: ExecutionUnit[]; readonly seenInline: { target: string; code: string }[]; unverifiable?: string; tooComplex?: string; parseError: boolean; } function isCommandLike(node: SyntaxNode): boolean { return ( node.type === "command" || node.type === "pipeline" || node.type === "list" || node.type === "redirected_statement" || node.type === "subshell" || node.type === "compound_statement" ); } function commandName(node: SyntaxNode): string { const nameNode = node.childForFieldName("name"); if (nameNode) { return renderWord(nameNode).text; } return ""; } function isShellInterpreter(name: string): boolean { return SHELL_INTERPRETERS.has(path.basename(name)); } /** `sh`, `bash`, `python3`, … : the command that runs the code it is given. */ function isInterpreterUnit(unit: ExecutionUnit): boolean { return isShellInterpreter(unit.name) || INLINE_INTERPRETERS.has(path.basename(unit.name)); } function isSourceCommand(unit: ExecutionUnit): boolean { const base = path.basename(unit.name); return base === "source" || base === "."; } /** `source <(curl …)` / `bash <(curl …)`: the script arrives through process substitution. */ function referencesProcessSubstitution(unit: ExecutionUnit): boolean { return [...unit.argv, ...unit.dynamic].some((word) => /^<\s*\(|^>\s*\(/.test(word)); } /** * Resolves the directory a `cd`/`pushd`/`popd` unit moves to. * * Returns `undefined` when the target cannot be known statically (`cd -`, `cd $VAR`, bare * `pushd`/`popd`); the caller must then treat later relative paths as unverifiable instead of * keeping the old cwd and silently checking them against the wrong directory. */ export function resolveCdTarget(unit: ExecutionUnit, cwd: string): string | undefined { const base = path.basename(unit.name); if (base === "popd") { return undefined; } const args = unit.targetArgv.slice(1); let target: string | undefined; for (let index = 0; index < args.length; index++) { const arg = args[index] ?? ""; if (arg === "--") { target = args[index + 1]; break; } if (arg.startsWith("-") && arg !== "-") { // `-L`, `-P`, `-e`, `-@`: cd options, not the target. continue; } target = arg; break; } if (target === undefined) { // Bare `cd` goes home; bare `pushd` swaps the stack, which is not knowable. return base === "pushd" ? undefined : os.homedir(); } if (target === "-") { return undefined; } const expanded = expandHome(target); if (/[$`]/.test(expanded)) { return undefined; } return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(cwd, expanded); } /** Arguments that carry the command of another interpreter, which must be analysed as a program. */ function inlineCodeArguments(name: string, argv: readonly string[]): { code: string[]; nested: boolean } { const base = path.basename(name); const args = argv.slice(1); const code: string[] = []; let nested = false; if (isShellInterpreter(base) || base === "eval" || base === "exec") { const flagIndex = args.findIndex((arg) => arg === "-c" || /^-[A-Za-z]*c[A-Za-z]*$/.test(arg)); if (base === "eval" || base === "exec") { if (args.length > 0) { code.push(args.join(" ")); nested = base === "eval"; } } else if (flagIndex !== -1 && args[flagIndex + 1] !== undefined) { code.push(args[flagIndex + 1] ?? ""); nested = true; } return { code: code.filter((entry) => entry.trim().length > 0), nested }; } if (base === "find") { for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg !== "-exec" && arg !== "-execdir" && arg !== "-ok" && arg !== "-okdir") { continue; } const parts: string[] = []; for (let cursor = index + 1; cursor < args.length; cursor++) { const token = args[cursor] ?? ""; if (token === ";" || token === "+" || token === "\\;") { break; } parts.push(token); } if (parts.length > 0) { code.push(parts.join(" ")); nested = true; } } return { code, nested }; } if (INLINE_INTERPRETERS.has(base)) { const flags = INTERPRETER_CODE_FLAGS[base] ?? []; for (let index = 0; index < args.length; index++) { const arg = args[index] ?? ""; if (!flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`))) { continue; } const snippet = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[index + 1]; if (snippet && snippet.trim().length > 0) { code.push(snippet); } } } return { code, nested }; } /** Free-standing `rm -rf /` hidden in text nobody parses (`curl … | bash`, heredoc body). */ class Flattener { constructor(private readonly collector: Collector) {} run(tree: Tree, cwd: string): void { this.walkSequence(named(tree.rootNode), { cwd, origin: "command", pipelineIndex: -1, stdinFromPipe: false, depth: 0, }); } private walkSequence(nodes: readonly SyntaxNode[], state: WalkState): WalkState { let current = state; for (const node of nodes) { current = this.walk(node, current); } return current; } private walk(node: SyntaxNode, state: WalkState): WalkState { switch (node.type) { case "comment": case "variable_assignment": case "variable_name": case "number": case "test_command": case "heredoc_start": case "heredoc_end": case "heredoc_body": return state; case "command": return this.walkCommand(node, state); case "pipeline": return this.walkPipeline(node, state); case "list": return this.walkSequence(named(node), state); case "redirected_statement": { const redirects: Redirection[] = []; let stdinCode = false; for (const child of named(node)) { if (!REDIRECT_NODE_TYPES.has(child.type)) { continue; } const harvested = harvestRedirect(child); redirects.push(...harvested.redirections); stdinCode ||= harvested.stdinCode; } const body = node.childForFieldName("body"); const before = this.collector.units.length; const next = body ? this.walk(body, state) : state; // Attach the statement's redirects to the unit that was produced by its body. for (let index = before; index < this.collector.units.length; index++) { const unit = this.collector.units[index]; if (!unit) { continue; } if (redirects.length > 0) { this.collector.units[index] = { ...unit, redirections: dedupeRedirections([...unit.redirections, ...redirects]), }; } if (stdinCode && isInterpreterUnit(unit)) { this.collector.unverifiable = `a here-document is fed into '${unit.name}'`; } } if (redirects.length > 0 && this.collector.units.length === before) { this.collector.units.push(this.redirectOnlyUnit(node, redirects, state)); } return next; } case "subshell": case "command_substitution": case "process_substitution": { if (state.depth >= MAX_NESTING) { this.collector.tooComplex = "command nesting is deeper than the analyser follows"; return state; } return this.walkSequence(named(node), { ...state, origin: node.type === "subshell" ? "subshell" : "substitution", pipelineIndex: -1, stdinFromPipe: false, depth: state.depth + 1, }); } case "function_definition": { const body = node.childForFieldName("body"); if (body && state.depth < MAX_NESTING) { this.walk(body, { ...state, depth: state.depth + 1 }); } return state; } default: { // Compound statements (`if`, `for`, `while`, `case`, `{ … }`): walk the children in // order so a `cd` inside still moves the cwd for what follows in the same branch. if (named(node).some((child) => isCommandLike(child) || named(child).length > 0)) { if (state.depth >= MAX_NESTING && named(node).some(isCommandLike)) { this.collector.tooComplex = "command nesting is deeper than the analyser follows"; return state; } return this.walkSequence(named(node), state); } return state; } } } private walkPipeline(node: SyntaxNode, state: WalkState): WalkState { let index = 0; for (const child of named(node)) { this.walk(child, { ...state, origin: state.origin, pipelineIndex: index, stdinFromPipe: index > 0 }); index++; } return state; } private walkCommand(node: SyntaxNode, state: WalkState): WalkState { const argv: string[] = []; const dynamic: string[] = []; const redirections: Redirection[] = []; let stdinCode = false; const nameNode = node.childForFieldName("name"); for (const child of named(node)) { if (child.type === "redirect" || REDIRECT_NODE_TYPES.has(child.type)) { const harvested = harvestRedirect(child); redirections.push(...harvested.redirections); stdinCode ||= harvested.stdinCode; continue; } if (child.type === "variable_assignment" || child.type === "comment") { continue; } if (child === nameNode || child.type === "command_name") { const rendered = renderWord(child); argv.push(rendered.text); if (rendered.dynamic) { dynamic.push(rendered.text); } // `$(…)` inside the command name still executes. this.walkSubstitutions(child, state); continue; } const rendered = renderWord(child); argv.push(rendered.text); if (rendered.dynamic) { dynamic.push(rendered.text); } this.walkSubstitutions(child, state); } if (argv.length === 0) { return state; } const unwrapped = unwrapCommand(argv); const unit = this.buildUnit(node, argv, dynamic, redirections, unwrapped, state); this.collector.units.push(unit); if (stdinCode && isInterpreterUnit(unit)) { this.collector.unverifiable = `a here-document is fed into '${unit.name}'`; } if ((isInterpreterUnit(unit) || isSourceCommand(unit)) && referencesProcessSubstitution(unit)) { this.collector.unverifiable = "a script is sourced from process substitution"; } for (const inline of unit.inlineCode) { this.collector.seenInline.push({ target: unit.name, code: inline }); } const inline = inlineCodeArguments(unit.name, unit.targetArgv); if (inline.code.length > 0) { if (inline.nested && state.depth >= MAX_NESTING) { this.collector.tooComplex = "command nesting is deeper than the analyser follows"; } const wantsNested = inline.nested && state.depth < MAX_NESTING; for (const code of inline.code) { if (wantsNested && sharedParser) { const tree = parseWith(sharedParser, code); if (tree) { try { this.walkSequence(named(tree.rootNode), { ...state, origin: "inline", pipelineIndex: -1, stdinFromPipe: false, depth: state.depth + 1, }); } finally { tree.delete(); } } } } } // `curl … | bash`: the payload only exists at run time. if (unit.stdinFromPipe && isInterpreterUnit(unit)) { this.collector.unverifiable = `a command is piped into '${unit.name}'`; } return this.maybeAdvanceCwd(unit, state); } private maybeAdvanceCwd(unit: ExecutionUnit, state: WalkState): WalkState { if (!unit.isCd) { return state; } // A `cd` inside a subshell/substitution never moves the parent shell. if (unit.origin === "subshell" || unit.origin === "substitution") { return state; } const resolved = resolveCdTarget(unit, state.cwd); if (resolved === undefined) { // Keeping the old cwd would check later relative paths against the wrong directory. this.collector.unverifiable = `the working directory after '${unit.raw}' cannot be determined statically`; return state; } return { ...state, cwd: resolved }; } private walkSubstitutions(node: SyntaxNode, state: WalkState): void { if (node.type === "command_substitution" || node.type === "process_substitution") { this.walk(node, { ...state, depth: Math.min(state.depth + 1, MAX_NESTING) }); return; } for (const child of named(node)) { this.walkSubstitutions(child, state); } } private redirectOnlyUnit(node: SyntaxNode, redirections: Redirection[], state: WalkState): ExecutionUnit { return { raw: node.text.trim(), argv: [], name: "", targetArgv: [], wrappers: [], redirections, cwd: state.cwd, pipelineIndex: state.pipelineIndex, stdinFromPipe: state.stdinFromPipe, dynamic: [], inlineCode: [], isCd: false, origin: state.origin, startIndex: node.startIndex, endIndex: node.endIndex, }; } private buildUnit( node: SyntaxNode, argv: readonly string[], dynamic: readonly string[], redirections: readonly Redirection[], unwrapped: ReturnType, state: WalkState, ): ExecutionUnit { const name = unwrapped.targetCommand; if (unwrapped.tooComplex) { this.collector.tooComplex = unwrapped.reason ?? `'${name}' cannot be unwrapped statically`; } const inlineCode = inlineCodeArguments(name, unwrapped.targetArgv).code; return { raw: node.text.trim(), argv, name, targetArgv: unwrapped.targetArgv, wrappers: unwrapped.wrappers, redirections, cwd: state.cwd, pipelineIndex: state.pipelineIndex, stdinFromPipe: state.stdinFromPipe, dynamic, inlineCode, isCd: ["cd", "pushd", "popd"].includes(path.basename(name)), origin: state.origin, startIndex: node.startIndex, endIndex: node.endIndex, }; } } function dedupeRedirections(redirections: readonly Redirection[]): Redirection[] { const seen = new Set(); const result: Redirection[] = []; for (const redirection of redirections) { const key = `${redirection.mode}\u0000${redirection.path}`; if (seen.has(key)) { continue; } seen.add(key); result.push(redirection); } return result; } function parseWith(parser: Parser, command: string): Tree | undefined { try { return parser.parse(command) ?? undefined; } catch { return undefined; } } // ============================================================================ // Entry point // ============================================================================ const CACHE_LIMIT = 512; const cache = new Map(); export function clearBashAnalysisCache(): void { cache.clear(); } /** * Parses a command and returns its executable units. * * Returns `undefined` when the WASM parser has not been loaded yet — callers must `await * ensureBashParser()` (or use `analyzeBashAsync`) first. Kept synchronous so the hot path does not * pay for a promise per command. */ export function analyzeBash(command: string, cwd: string): BashAnalysis | undefined { const parser = sharedParser; if (!parser) { return undefined; } const key = `${cwd}\u0000${command}`; const cached = cache.get(key); if (cached) { // Refresh the entry so eviction is LRU instead of FIFO. cache.delete(key); cache.set(key, cached); return cached; } const tree = parseWith(parser, command); if (!tree) { return { units: [], parseError: true, tooComplex: "the command could not be parsed" }; } const collector: Collector = { units: [], seenInline: [], parseError: tree.rootNode.hasError, }; try { new Flattener(collector).run(tree, cwd); } finally { tree.delete(); } const analysis: BashAnalysis = { units: collector.units, unverifiable: collector.unverifiable, tooComplex: collector.tooComplex ?? (collector.parseError ? "the command has syntax errors" : undefined), parseError: collector.parseError, }; if (cache.size >= CACHE_LIMIT) { const oldest = cache.keys().next(); if (!oldest.done) { cache.delete(oldest.value); } } cache.set(key, analysis); return analysis; } /** Convenience wrapper that loads the parser on demand. */ export async function analyzeBashAsync(command: string, cwd: string): Promise { await ensureBashParser(); const analysis = analyzeBash(command, cwd); if (!analysis) { return { units: [], parseError: true, tooComplex: "the bash parser is unavailable" }; } return analysis; } // ============================================================================ // Path arguments // ============================================================================ const WRITE_COMMANDS = new Set([ "chmod", "chown", "chgrp", "cp", "dd", "install", "ln", "mkdir", "mkfifo", "mknod", "mv", "rm", "rmdir", "shred", "tee", "touch", "truncate", "unlink", ]); /** `sed -i`, `tar -x`, `unzip`, `git checkout --` and friends mutate the files they name. */ function writesByDefault(name: string, argv: readonly string[]): boolean { const base = path.basename(name); if (WRITE_COMMANDS.has(base)) { return true; } if (base === "sed" && argv.some((arg) => arg === "-i" || arg.startsWith("-i"))) { return true; } if (base === "tar" && argv.some((arg) => /^-[A-Za-z]*[xc]$/.test(arg) || arg === "--extract" || arg === "--create")) { return true; } if (base === "unzip" || base === "patch" || base === "apply_patch") { return true; } return false; } export interface PathArgument { readonly token: string; readonly absolutePath: string; readonly direction: "read" | "write"; readonly fromRedirection: boolean; /** Effective cwd of the unit that names the path. */ readonly unitCwd: string; } /** * Is this argv token something that can name a path? * * Deliberately strict: treating every word as a path makes `git status` hit a `*` deny rule. A token * qualifies when it is directory-relative, contains a separator, is a dotfile, or actually exists * relative to the unit's working directory. */ export function isPathLikeToken(token: string, cwd: string): boolean { if (token.length === 0 || token.startsWith("-")) { return false; } if (token === "." || token === ".." || token === "~" || token.startsWith("~/")) { return true; } if (token.includes("/") || token.includes("\\")) { return true; } if (/^\.[^./]/.test(token)) { return true; } const expanded = expandHome(token); if (expanded !== token) { return true; } try { return fs.existsSync(path.resolve(cwd, expanded)); } catch { return false; } } const CANDIDATE_KEYS = new Set([ "config", "file", "filepath", "key", "keyfile", "cacert", "cert", "output", "outfile", "infile", "input", "dest", "destination", "source", "src", "of", "if", "prefix", "directory", ]); /** Values of `--flag=value` / `key=value` tokens that plausibly name a path. */ function assignedValueCandidates(token: string): string[] { const equals = token.indexOf("="); if (equals === -1 || equals === token.length - 1) { return []; } const key = token.slice(0, equals).replace(/^-+/, "").toLowerCase(); const value = token.slice(equals + 1); if (key.length === 0) { return []; } if (CANDIDATE_KEYS.has(key.split(".").pop() ?? key)) { return [value]; } // `-Dfoo=/x` and other short-option assignments still carry a path. return value.includes("/") ? [value] : []; } /** Tokens that name a path: redirection targets and argv words that resolve to something path-like. */ export function pathArguments(unit: ExecutionUnit): PathArgument[] { const result: PathArgument[] = []; const write = writesByDefault(unit.name, unit.targetArgv); const seen = new Set(); const push = (token: string, direction: "read" | "write", fromRedirection: boolean): void => { const candidate = token.replace(/^@/, ""); if (!fromRedirection && !isPathLikeToken(candidate, unit.cwd)) { return; } const key = `${direction}\u0000${candidate}`; if (seen.has(key)) { return; } seen.add(key); // `~`, `$HOME` and `${HOME}` expand deterministically, so they are checked after expansion. const expanded = expandHome(candidate); result.push({ token: candidate, absolutePath: path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(unit.cwd, expanded), direction, fromRedirection, unitCwd: unit.cwd, }); }; for (const redirection of unit.redirections) { push(redirection.path, redirection.mode, true); } // The unwrapped argv is what actually runs (`sudo rm -rf /` → `rm -rf /`). const argv = unit.targetArgv.length > 0 ? unit.targetArgv : unit.argv; for (let index = 1; index < argv.length; index++) { const token = argv[index] ?? ""; if (token.startsWith("-")) { for (const value of assignedValueCandidates(token)) { push(value, write ? "write" : "read", false); } continue; } push(token, write ? "write" : "read", false); } return result; }