feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,692 @@
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 {
classifyBareTokenCandidate,
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";
// ── 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.
*
* A bare token that fails both shape gates is admitted when the normalizer's
* existence probe says it names a real filesystem entry (ADR 0009, #645). The
* resolver consults no ruleset: candidacy is a filesystem question, and the
* policy decision belongs to the gates downstream.
*
* 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).
*/
export class BashPathResolver {
constructor(
private readonly normalizer: PathNormalizer,
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 <workdir>` — 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 <literal>` (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 <literal>` 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 <workdir>`).
*/
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<string>();
const externalPaths: AccessPath[] = [];
for (const { token, base } of candidates) {
const candidate = classifyTokenAsPathCandidate(token);
if (!candidate) {
// A bare token the strict shape gate rejects can still escape the tree
// through a symlink, so probe it and apply the ordinary boundary
// decision to whatever it resolves to (#645).
const probed = this.probeBareToken(token, base);
if (probed) this.collectIfExternal(probed.path, seen, externalPaths);
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;
this.collectIfExternal(
this.normalizer.forBashToken(candidate, { resolveBase }),
seen,
externalPaths,
);
}
return externalPaths;
}
/**
* Record `accessPath` when it resolves outside the working directory and has
* not already been collected.
*
* The boundary decision and dedup identity use the canonical
* (symlink-resolved) form the {@link AccessPath} already derived, while the
* stored value keeps 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).
*
* 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).
*/
private collectIfExternal(
accessPath: AccessPath,
seen: Set<string>,
out: AccessPath[],
): void {
const lexical = accessPath.value();
if (!lexical) return;
const canonical = accessPath.boundaryValue();
const isExternal = canonical
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
: true;
const dedupKey = canonical || lexical;
if (isExternal && !seen.has(dedupKey)) {
seen.add(dedupKey);
out.push(accessPath);
}
}
/**
* 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 {@link probeBareToken}
* for a bare token the broad classifier rejects for shape — admitted only
* when it names an existing filesystem entry (#645).
* 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<string>();
const result: BashPathRuleCandidate[] = [];
for (const { token, base } of candidates) {
const shaped = classifyTokenAsRuleCandidate(
token,
this.normalizer.flavor,
);
const candidate =
shaped === null
? this.probeBareToken(token, base)
: { token: shaped, path: this.buildRuleCandidatePath(shaped, base) };
if (!candidate) continue;
const matchValues = candidate.path.matchValues();
if (matchValues.length === 0) continue;
const key = matchValues.join("\0");
if (seen.has(key)) continue;
seen.add(key);
result.push(candidate);
}
return result;
}
/**
* Promote a bare token the shape gates rejected, when it names an existing
* filesystem entry — the existence probe (ADR 0009, #645).
*
* Most bash argument tokens are not paths (`status`, `build`, `main`), so a
* bare token is admitted only when the filesystem confirms it names something
* real. Candidacy therefore comes from the filesystem and never from the
* ruleset, which keeps the classifiers pure and lets a symlink be matched by
* rules naming its *target* — the case raw-token matching could not see.
*
* Returns `null` when the token's shape rules out a path, when the effective
* base is unknown (no concrete directory to resolve against, so the token
* stays unpromoted per #393 conservatism), or when nothing exists at the
* resolved location.
*
* Shared by both projections so a promoted token is identical whether it is
* being matched against `path` rules or tested against the cwd boundary.
*/
private probeBareToken(
token: string,
base: EffectiveBase,
): BashPathRuleCandidate | null {
const bare = classifyBareTokenCandidate(token);
if (bare === null) return null;
if (base.kind !== "known") return null;
const path = this.normalizer.forBashToken(bare, {
resolveBase: this.normalizer.resolveBase(base.offset),
});
const lexical = path.value();
if (!lexical || !this.normalizer.entryExists(lexical)) return null;
return { token: bare, path };
}
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;
}
}
@@ -0,0 +1,237 @@
import {
EXECUTION_HOST_TYPES,
forEachNestedExecution,
} from "#src/access-intent/bash/nested-execution";
import type { TSNode } from "#src/access-intent/bash/parser";
import {
type CommandWord,
classifyWrapperWords,
executedUnitOf,
type WrapperKind,
} from "#src/access-intent/bash/wrapper-analysis";
import type { BashCommandContext } from "#src/types";
export type { WrapperKind } from "#src/access-intent/bash/wrapper-analysis";
// ── Command type ─────────────────────────────────────────────────────────────
/**
* 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;
/**
* Execution context for a nested command (substitution or subshell); absent
* for a current-shell (top-level) command.
*/
readonly context?: BashCommandContext;
/**
* Set when this unit is a floored indirection wrapper; its decision is floored
* to at least `ask` so the wrapped command cannot ride a permissive `allow`.
* Absent for an ordinary command.
*/
readonly wrapperKind?: WrapperKind;
/**
* The command this wrapper unit actually runs (#713). Display-only — it is
* never gated on its own, so the wrapper floor still applies. Absent for an
* ordinary command, and for a wrapper whose inner command cannot be
* established.
*/
readonly executedUnit?: string;
}
// ── Command enumeration ──────────────────────────────────────────────────────
/**
* Container node types descended into when enumerating command units.
*/
const COMMAND_ENUM_DESCEND = new Set([
"program",
"list",
"pipeline",
"redirected_statement",
]);
/**
* Named node types abandoned during command enumeration: they are neither
* commands nor able to host one, so nothing in their subtree ever runs.
*
* A redirect and a heredoc body are deliberately NOT listed here. Neither is a
* command, but each can host a substitution that really executes, so both are
* {@link EXECUTION_HOST_TYPES} members instead — conflating the two questions
* ("is this a command?" and "can this host one?") is the bypass #741 fixed.
*
* Anonymous tokens (chain operators `&&`/`;`/`|`, substitution and subshell
* delimiters `$(`/`)`/`` ` ``/`(`) are filtered by the `isNamed` guard, not
* listed here.
*/
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
/**
* Enumerate the command units of a bash program, in source order.
*
* Descends container nodes (`program`, `list`, `pipeline`,
* `redirected_statement`) and emits each `command` node whole.
* Additionally descends into the three nested execution contexts — command
* substitution (`$(…)`, backticks), process substitution (`<(…)`/`>(…)`), and
* subshells (`( … )`) — emitting each inner command as its own unit *in
* addition to* the enclosing command, since those inner commands really execute
* (#306).
* Control-flow bodies and `{ … }` brace groups are emitted whole without
* descending (deferred).
*
* The enclosing command/subshell is always still emitted whole, so adding the
* nested units can only ever produce a more-restrictive decision, never weaker.
*
* Each emitted command unit has any leading `variable_assignment` prefix
* stripped (so an env-var prefix cannot defeat a command-pattern rule), and a
* wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
* tagged with a {@link WrapperKind} so its decision is later floored to `ask`.
*/
export function collectCommands(node: TSNode): BashCommand[] {
const out: BashCommand[] = [];
collectCommandsInto(node, undefined, out);
return out;
}
function collectCommandsInto(
node: TSNode,
context: BashCommandContext | undefined,
out: BashCommand[],
): void {
// Anonymous tokens (operators `&&`/`;`/`|`, delimiters `$(`/`)`/`` ` ``/`(`)
// carry no command.
if (!node.isNamed) return;
if (COMMAND_ENUM_SKIP.has(node.type)) return;
if (node.type === "command") {
out.push(makeCommandUnit(node, context));
// A command's text already contains any substitution; descend its subtree
// to ALSO emit the inner commands of command/process substitutions.
collectHostedCommands(node, out);
return;
}
if (EXECUTION_HOST_TYPES.has(node.type)) {
// Not a command itself, but its subtree can host one that really runs
// (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
collectHostedCommands(node, out);
return;
}
if (node.type === "subshell") {
out.push(makeUnit(node.text, context)); // never-weaker whole emit
descendCommandChildren(node, "subshell", out);
return;
}
if (COMMAND_ENUM_DESCEND.has(node.type)) {
descendCommandChildren(node, context, out);
return;
}
// Any other named statement (compound_statement `{ … }`, if/while/for/case,
// function_definition): emit whole, do not descend — deferred (#306).
out.push(makeUnit(node.text, context));
}
function makeUnit(
text: string,
context: BashCommandContext | undefined,
wrapperKind?: WrapperKind,
executedUnit?: string,
): BashCommand {
const unit: BashCommand = context ? { text, context } : { text };
const flagged = wrapperKind ? { ...unit, wrapperKind } : unit;
return executedUnit === undefined ? flagged : { ...flagged, executedUnit };
}
/**
* Build the unit for a `command` node, reading its words once to answer both
* wrapper questions: whether the unit is floored, and what it actually runs.
*/
function makeCommandUnit(
node: TSNode,
context: BashCommandContext | undefined,
): BashCommand {
const text = commandUnitText(node);
const words = readCommandWords(node);
return makeUnit(
text,
context,
classifyWrapperWords(words),
executedUnitOf(text, words) ?? undefined,
);
}
/**
* A `command` node's words — its `command_name` followed by its arguments — each
* carrying its offset into the unit text `commandUnitText` produces.
*
* A leading `variable_assignment` prefix is skipped (matching
* `commandUnitText`), so offsets are relative to the `command_name`. An empty
* list means a pure assignment with no `command_name`.
*/
function readCommandWords(node: TSNode): CommandWord[] {
const words: CommandWord[] = [];
let unitStart: number | undefined;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child?.isNamed) continue;
if (child.type === "variable_assignment") continue;
unitStart ??= child.startIndex;
words.push({ text: child.text, offset: child.startIndex - unitStart });
}
return words;
}
/**
* The command-pattern text of a `command` node, with any leading
* `variable_assignment` prefix stripped.
*
* An env-var prefix (`AWS_PROFILE=prod aws …`, `PGPASSWORD=…`) is part of the
* `command` node's text but must not defeat a rule that gates the underlying
* command, so matching targets the text from the first non-assignment child
* (the `command_name`) onward, sliced verbatim to preserve spacing. A pure
* assignment (`FOO=bar`, no `command_name`) runs no command and is returned
* unchanged.
*/
function commandUnitText(node: TSNode): string {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.isNamed && child.type !== "variable_assignment") {
return node.text.slice(child.startIndex - node.startIndex);
}
}
return node.text;
}
function descendCommandChildren(
node: TSNode,
context: BashCommandContext | undefined,
out: BashCommand[],
): void {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) collectCommandsInto(child, context, out);
}
}
/**
* Enumerate the commands of every nested execution context in a subtree, each
* tagged with the context it was found in.
*
* The traversal itself lives in `nested-execution.ts` so the bash path surface
* shares one definition of what counts as a nested execution (#741); this
* function supplies the command-surface interpretation of each one found.
*/
function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
forEachNestedExecution(node, (contextNode, context) => {
descendCommandChildren(contextNode, context, out);
});
}
@@ -0,0 +1,64 @@
/**
* Pure shape classifier for a bash-command token on a win32 host, where Pi core
* executes commands through Git Bash and POSIX-shaped absolute tokens carry
* MSYS mount semantics rather than `node:path.win32` semantics.
*
* Consumed only by {@link PathNormalizer.forBashToken}; kept as a standalone
* module so the shape knowledge is unit-testable in isolation (no filesystem,
* no platform read).
*/
import { isSafeSystemPath } from "#src/safe-system-paths";
/**
* The MSYS interpretation of a win32 bash token:
*
* - `device` — a safe MSYS runtime device (`/dev/null`, `/dev/std{in,out,err}`);
* never a filesystem path.
* - `drive-mount` — an MSYS drive mount (`/c/…`, `/d/…`); `windowsPath` is its
* deterministic Windows equivalent (`C:\…`).
* - `posix-absolute` — any other absolute POSIX path (`/tmp/foo`, `/usr/bin`);
* its Windows target is install-dependent and not deterministically knowable,
* so it is treated literally.
* - `plain` — everything else (relative tokens, `~/…`, native Windows drive
* paths); handled by ordinary win32 resolution.
*/
export type BashTokenShape =
| { kind: "device" }
| { kind: "drive-mount"; windowsPath: string }
| { kind: "posix-absolute" }
| { kind: "plain" };
/**
* A single-letter first path segment identifies an MSYS drive mount: `/c`,
* `/c/`, or `/c/rest`. A multi-letter first segment (`/dev`, `/tmp`) is not a
* mount. The device set is checked before this pattern, so `/dev/*` never
* reaches it.
*/
const MSYS_DRIVE_MOUNT_PATTERN = /^\/([a-zA-Z])(\/.*)?$/;
export function classifyWin32BashToken(token: string): BashTokenShape {
if (isSafeSystemPath(token)) return { kind: "device" };
const driveMatch = MSYS_DRIVE_MOUNT_PATTERN.exec(token);
if (driveMatch) {
return {
kind: "drive-mount",
windowsPath: toWindowsDrivePath(driveMatch[1], driveMatch[2]),
};
}
if (token.startsWith("/")) return { kind: "posix-absolute" };
return { kind: "plain" };
}
/**
* Build the Windows equivalent of an MSYS drive mount: uppercase drive letter,
* `:\`, and the remainder with `/` separators rewritten to `\`. A bare or
* trailing-slash mount (`/c`, `/c/`) maps to the drive root (`C:\`).
*/
function toWindowsDrivePath(letter: string, rest: string | undefined): string {
const drive = `${letter.toUpperCase()}:`;
const tail = (rest ?? "").replace(/^\//, "").replaceAll("/", "\\");
return tail ? `${drive}\\${tail}` : `${drive}\\`;
}
@@ -0,0 +1,76 @@
import type { TSNode } from "#src/access-intent/bash/parser";
import type { BashCommandContext } from "#src/types";
/**
* AST node types whose interior commands really execute when the shell runs the
* program: command substitution (`$(…)`, backticks) and process substitution
* (`<(…)`/`>(…)`).
*
* Subshells (`( … )`) are deliberately absent — a subshell is also a command
* unit in its own right, so the command enumerator emits it whole and descends
* it separately rather than treating it as a pure nesting wrapper.
*
* This map is the single vocabulary shared by the bash command surface and the
* bash path surface, so the two cannot disagree about what counts as a nested
* execution (#741).
*/
export const NESTED_EXECUTION_CONTEXTS: ReadonlyMap<
string,
BashCommandContext
> = new Map([
["command_substitution", "command_substitution"],
["process_substitution", "process_substitution"],
] satisfies [string, BashCommandContext][]);
/**
* AST node types that are neither commands nor argument values themselves, but
* whose subtree can host a nested execution context that really runs.
*
* A redirect destination is the motivating case: tree-sitter-bash parses
* `echo hi > $(rm x)` with the `file_redirect` as a *sibling* of the `command`,
* so a consumer that abandons the redirect never sees the substitution inside
* it — the bypass #741 fixed.
*
* An interpolating heredoc body is the second case: `cat <<EOF` with `$(rm e)`
* in the body really runs `rm e`. Quoting needs no special handling here —
* tree-sitter-bash emits a `command_substitution` node under `heredoc_body`
* only for a bare `<<EOF`, never for `<<'EOF'` or `<<"EOF"`, so the parser
* already encodes the interpolation rule.
*
* Membership means "do not read this subtree's own text, but do descend it for
* executions"; each consumer keeps its own handling of the destination tokens.
*/
export const EXECUTION_HOST_TYPES: ReadonlySet<string> = new Set([
"file_redirect",
"heredoc_redirect",
"herestring_redirect",
"heredoc_body",
]);
/**
* Visit every nested execution context in `node`'s subtree, in source order.
*
* The walk does not descend *past* a context it finds: `visit` receives the
* context node itself and decides how to treat its interior (the command
* enumerator enumerates commands there; the path collector collects operand
* tokens), which keeps recursion policy with the consumer that understands it.
*
* A substitution can nest under `command_name` (when the whole command is
* `$(…)`), under an argument, inside a redirect destination, or inside an
* interpolating heredoc body, so the entire subtree is searched.
*/
export function forEachNestedExecution(
node: TSNode,
visit: (contextNode: TSNode, context: BashCommandContext) => void,
): void {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
const context = NESTED_EXECUTION_CONTEXTS.get(child.type);
if (context) {
visit(child, context);
} else {
forEachNestedExecution(child, visit);
}
}
}
@@ -0,0 +1,84 @@
import type { TSNode } from "#src/access-intent/bash/parser";
import { resolvePlainVariableExpansion } from "#src/access-intent/bash/shell-variable-expansion";
/**
* Node types whose text content is never a command argument, so no path
* candidate is ever read from it.
*
* This governs the subtree's *text*, not whether it is visited at all: an
* interpolating `heredoc_body` is also an execution host, so it is still
* descended for the commands it runs while its prose stays out of the path
* surface (#741). See `EXECUTION_HOST_TYPES` in `nested-execution.ts`.
*/
export const SKIP_SUBTREE_TYPES = new Set([
"heredoc_body",
"heredoc_end",
"comment",
]);
/**
* Node types that represent argument values in the AST
* (word, concatenation, single-quoted string, double-quoted string).
*/
export const ARG_NODE_TYPES = new Set([
"word",
"concatenation",
"string",
"raw_string",
]);
/**
* Resolve the "shell value" of an argument node — the string the shell
* would pass to the command after quote removal.
*
* - `word` → `.text` (already unquoted)
* - `raw_string` → strip surrounding single quotes
* - `string` → strip surrounding double quotes, concatenate children text
* - `concatenation` → concatenate resolved children
* - expansions → the resolved value of a plain `$HOME`/`$PWD` reference,
* else `.text` (see `shell-variable-expansion.ts`)
* - other → `.text` as fallback
*/
export function resolveNodeText(node: TSNode): string {
switch (node.type) {
case "word":
return node.text;
case "raw_string": {
// Strip surrounding single quotes: 'content' → content
const t = node.text;
if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
return t.slice(1, -1);
}
return t;
}
case "string": {
// Double-quoted string: concatenate the resolved text of inner children,
// skipping the quote-delimiter nodes (literal `"`).
let result = "";
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
// Skip the literal `"` delimiters
if (child.type === '"') continue;
result += resolveNodeText(child);
}
return result;
}
case "string_content":
return node.text;
case "simple_expansion":
case "expansion":
return resolvePlainVariableExpansion(node) ?? node.text;
case "concatenation": {
let result = "";
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
result += resolveNodeText(child);
}
return result;
}
default:
return node.text;
}
}
@@ -0,0 +1,83 @@
import { createRequire } from "node:module";
import { memoizeAsyncWithRetry } from "#src/async-cache";
/**
* Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
* Defined locally so callers do not need to import web-tree-sitter types.
*/
export interface TSNode {
readonly type: string;
readonly text: string;
/** Absolute byte offset of this node's start in the parsed source. */
readonly startIndex: number;
readonly childCount: number;
/** False for anonymous tokens (operators, delimiters); true for named nodes. */
readonly isNamed: boolean;
child(index: number): TSNode | null;
}
/**
* Minimal subset of web-tree-sitter's Parser used by this module.
*/
interface TSParser {
parse(input: string): { rootNode: TSNode; delete(): void } | null;
delete(): void;
}
async function initParser(): Promise<TSParser> {
// Use named imports — web-tree-sitter exports Parser as a named class.
const { Parser, Language } = await import("web-tree-sitter");
const req = createRequire(import.meta.url);
const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
await Parser.init({ locateFile: () => treeSitterWasm });
const parser = new Parser();
const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
const bash = await Language.load(bashWasm);
parser.setLanguage(bash);
return parser;
}
// Memoize on success but drop a rejected result so a transient init failure
// (e.g. a slow WASM load) is retried on the next tool call instead of poisoning
// the parser for the process lifetime.
export const getParser = memoizeAsyncWithRetry(initParser);
// Resolved parser cached for synchronous access after warm-up. The tree-sitter
// parser is stateless (parse is a pure function of its input), so caching it at
// module scope is safe even though module state now persists across same-cwd
// session switches.
let warmedParser: TSParser | null = null;
/**
* Warm the tree-sitter parser so {@link getWarmBashParser} can hand it out
* synchronously. Triggered at `before_agent_start` (which precedes any tool
* call) so the synchronous advisory bash path can decompose at gate parity
* (#309).
*
* Best-effort and idempotent: it swallows a WASM init failure (the sync
* accessor stays cold and callers fall back to whole-string matching), and it
* returns immediately once warm, so calling it every turn is free.
*/
export async function warmBashParser(): Promise<void> {
if (warmedParser) return;
try {
warmedParser = await getParser();
} catch {
// Leave cold → advisory bash queries fall back to whole-string matching.
// getParser's own retry memoization re-attempts init on the next call.
}
}
/**
* The warmed parser for synchronous use, or `null` when it has not been warmed
* yet (the pre-warm window). Callers that get `null` must degrade gracefully.
*/
export function getWarmBashParser(): TSParser | null {
return warmedParser;
}
/** Test-only: clear the warmed-parser cache so cold/warm cases are isolatable. */
export function resetWarmBashParser(): void {
warmedParser = null;
}
@@ -0,0 +1,134 @@
import type { AccessPath } from "#src/access-intent/access-path";
import {
BashPathResolver,
type BashPathRuleCandidate,
} from "#src/access-intent/bash/bash-path-resolver";
import {
type BashCommand,
collectCommands,
} from "#src/access-intent/bash/command-enumeration";
import { getParser } from "#src/access-intent/bash/parser";
import type { PathNormalizer } from "#src/path-normalizer";
export type { BashCommand, BashPathRuleCandidate };
/**
* A bash command parsed once into a born-ready representation.
*
* Parsing is the expensive step (tree-sitter WASM); `BashProgram` performs it
* a single time and eagerly resolves all three typed slices so the bash
* permission gates do not each re-parse or re-walk the command, and so the
* slices are guaranteed to agree.
*
* Construct via the async `parse()` factory; the constructor is private.
*/
export class BashProgram {
private constructor(
private readonly sourceCommand: string,
private readonly commandUnits: readonly BashCommand[],
private readonly resolvedExternalPaths: readonly AccessPath[],
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
) {}
/**
* Parse a bash command into a born-ready `BashProgram`.
*
* Uses tree-sitter-bash to build the full AST, enumerates command units and
* walks path-candidate tokens once, then eagerly resolves all three slices
* through the injected {@link PathNormalizer} (platform + cwd baked in).
* Heredoc bodies, comments, and other non-argument content are skipped. An
* unparseable command yields an empty program.
*
* A bare token (e.g. `id_rsa`, `outside-link`) enters both slices when it
* names an existing filesystem entry — the existence probe the resolver owns
* (ADR 0009, #645). No policy is consulted, so every caller gets identical
* slices for a given command and working directory.
*
* `options.workdir`, when supplied (an aliased shell tool's working directory,
* #574), seeds the initial effective base — as if the command were prefixed
* with `cd <workdir>` — so relative tokens resolve against it, and the workdir
* itself is flagged as external when it resolves outside the cwd.
*/
static async parse(
command: string,
normalizer: PathNormalizer,
options?: { workdir?: string },
): Promise<BashProgram> {
const parser = await getParser();
const tree = parser.parse(command);
if (!tree) return new BashProgram(command, [], [], []);
try {
const { externalPaths, ruleCandidates } = new BashPathResolver(
normalizer,
options?.workdir,
).resolve(tree.rootNode);
return new BashProgram(
command,
collectCommands(tree.rootNode),
externalPaths,
ruleCandidates,
);
} finally {
tree.delete();
}
}
/**
* The source command string this program was parsed from.
*
* The bash gates read this for prompts, logs, and decision display instead of
* receiving the command as a separate parameter — the program is the parsed
* command, so it owns its source text (#574). Native `bash` and an aliased
* shell tool alike reach the gates through this single collaborator.
*/
commandText(): string {
return this.sourceCommand;
}
/**
* The top-level command-pattern units of the chain, in source order.
*
* Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
* quotes, command substitution, and subshells are respected by the parser and
* are NOT split — a subshell or other compound statement is emitted whole.
* Each unit has any leading `variable_assignment` prefix stripped, and a
* wrapper unit (`bash -c`/`eval`, or an indirection wrapper such as `sudo`) is
* tagged with a `wrapperKind` so its decision is floored to `ask`.
* May be empty (e.g. an empty command or a comment-only line); callers fall
* back to the whole command so the surface is never evaluated weaker than
* before.
*/
commands(): BashCommand[] {
return [...this.commandUnits];
}
/**
* Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
* objects holding both the lexical (as-typed) and canonical (symlink-resolved)
* forms behind distinct accessors.
*
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
* `parse()` (platform + cwd baked in).
* Use `.matchValues()` for `external_directory` pattern matching and
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
*/
externalPaths(): AccessPath[] {
return [...this.resolvedExternalPaths];
}
/**
* Path-rule candidates paired with their policy lookup values.
*
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
* `parse()` (platform + cwd baked in).
* Each token is resolved against the effective working directory in force at
* the token's position (folding literal current-shell `cd` commands), while
* raw and project-relative aliases are retained for backward-compatible
* relative rules. A token after a non-literal `cd` keeps only its literal
* value so no spurious absolute rule can match (#393).
*/
pathRuleCandidates(): BashPathRuleCandidate[] {
return [...this.resolvedRuleCandidates];
}
}
@@ -0,0 +1,77 @@
/**
* Resolution of the shell variable references the bash path projection can
* settle statically.
*
* Runs at token collection, upstream of classification: by the time a token
* reaches `classifyTokenAsPathCandidate` it already carries the expanded path,
* so `$HOME/x` is accepted by the ordinary absolute-shape branch and needs no
* per-variable knowledge in the classifiers (#694). Keeping the vocabulary here
* — rather than teaching each classifier a `$HOME` prefix — is what stops the
* two from drifting apart, which is the defect this module closes.
*
* The resolvable set is deliberately tiny and closed. `HOME` is the spelling
* `expandHomePath` already resolves for config patterns and path literals, so
* resolving it here removes an inconsistency rather than widening the
* determinism boundary; `PWD` reads no environment at all. Every other name
* keeps its literal text, so ADR 0003's exclusion of ambient host state stands.
* See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
*/
import { homedir } from "node:os";
import type { TSNode } from "#src/access-intent/bash/parser";
/**
* The value of a plain `$NAME` / `${NAME}` reference, or `null` when the node
* is not a plain reference or names a variable outside the resolvable set.
*
* Plainness is decided structurally, not by matching the node's text: a plain
* reference carries exactly one `variable_name` child and nothing else but
* delimiters. An operator form (`${HOME:-/tmp}`, `${#HOME}`, `${HOME%/*}`)
* carries additional children and is therefore rejected without this module
* needing to enumerate bash's expansion operators.
*/
export function resolvePlainVariableExpansion(node: TSNode): string | null {
const name = plainVariableName(node);
return name === null ? null : (RESOLVABLE_VARIABLES.get(name)?.() ?? null);
}
/**
* How each resolvable variable is spelled as a path.
*
* `PWD` resolves to the base-relative marker rather than a directory: the
* shell's working directory at a given point *is* the projection's effective
* base, which the resolver already applies via `resolveBase`. Handing back `.`
* therefore lands `$PWD/x` on the same footing as `./x` — correct after any
* `cd` folding, conservative under an unknown base (#393), and free of both a
* threaded base parameter and a platform branch.
*/
const RESOLVABLE_VARIABLES: ReadonlyMap<string, () => string> = new Map([
["HOME", homedir],
["PWD", () => "."],
]);
/** Node types that delimit an expansion without altering what it evaluates to. */
const EXPANSION_DELIMITERS: ReadonlySet<string> = new Set(["$", "${", "}"]);
/**
* The variable a node plainly references, or `null` when it references none —
* because it has no `variable_name` child, has more than one, or carries a
* child that is neither the name nor a delimiter (an expansion operator and its
* operand, or an assignment's `=` and value).
*/
function plainVariableName(node: TSNode): string | null {
let name: string | null = null;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === "variable_name") {
if (name !== null) return null;
name = child.text;
continue;
}
if (!EXPANSION_DELIMITERS.has(child.type)) return null;
}
return name;
}
@@ -0,0 +1,31 @@
import {
type BashCommand,
collectCommands,
} from "#src/access-intent/bash/command-enumeration";
import { getWarmBashParser } from "#src/access-intent/bash/parser";
/**
* Synchronously enumerate the command-pattern units of a bash command using the
* warmed tree-sitter parser.
*
* Returns `null` when the parser has not been warmed yet (the pre-warm window),
* so the caller can fall back to whole-string matching rather than block. Once
* warm it mirrors the enumeration the gate performs (`BashProgram.commands()`):
* chains split, nested substitutions/subshells descend, opaque wrappers flagged
* (#306). Only the command-pattern surface is produced — no path slices, so no
* `PathNormalizer` is needed.
*
* An unparseable command yields an empty array (the caller's decompose path
* fails it closed via `resolveBashCommandCheck`, #452).
*/
export function parseBashCommandsSync(command: string): BashCommand[] | null {
const parser = getWarmBashParser();
if (!parser) return null;
const tree = parser.parse(command);
if (!tree) return [];
try {
return collectCommands(tree.rootNode);
} finally {
tree.delete();
}
}
@@ -0,0 +1,178 @@
/**
* Pure, synchronous token-classification helpers for bash path extraction.
*
* Exports three classifiers consumed by `bash-path-resolver.ts`:
* - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
* - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
* - `classifyBareTokenCandidate` — prelude-only gate for a bare token (e.g.
* `id_rsa`, `outside-link`) that `classifyTokenAsRuleCandidate` rejects for
* shape. It answers only "is this shape capable of naming a path?"; whether
* it *does* name one is settled by the resolver's existence probe (#645).
*
* Token classification is three-valued: definitely-path (shape), definitely-not
* (prelude), and unknown (a bare word). These functions own the first two; the
* third is resolved against the filesystem rather than against policy, so no
* classifier here consults the ruleset — see
* `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
*
* All three classifiers share the private `rejectNonPathToken` predicate that
* captures the six rejection cases common to them (the production clone this
* module was extracted to eliminate).
*
* Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
* Windows drive-letter absolute paths (`C:/…`, `C:\…`) unconditionally on all
* platforms. On POSIX the token resolves as a real in-CWD relative path and is
* gated by the `path` surface; on Windows the `PathNormalizer` routes it through
* the absolute-path branch. Shape recognition is platform-independent string
* matching; the platform-sensitive absoluteness decision belongs to `PathNormalizer`.
*
* `classifyTokenAsRuleCandidate` takes the resolved {@link PathFlavor}: a
* backslash-relative token (`dir\file`, no leading `.`, no `/`, no `..`, not a
* drive-letter absolute) is accepted as path-shaped only under the win32 flavor,
* whose `hasPathSeparator` counts `\` as a separator (#520). This is the one
* genuinely platform-sensitive shape rule the classifier owns — on POSIX `\` is
* a legal filename character — and the flavor owns the bit, so the classifier
* never reads `process.platform` itself.
*/
import type { PathFlavor } from "#src/path/path-flavor";
// ── Public classifiers ─────────────────────────────────────────────────────
/**
* Strict path-candidate classifier for the external-directory guard.
*
* Accepts tokens that unambiguously look like filesystem paths:
* - Absolute paths (starting with `/`)
* - Home-relative paths (starting with `~/`)
* - Parent-traversal paths (containing `..`)
* - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
*
* Returns the raw token string if it qualifies, or `null` to skip.
*/
export function classifyTokenAsPathCandidate(token: string): string | null {
if (rejectNonPathToken(token)) return null;
if (token.startsWith("/")) return token;
if (token.startsWith("~/")) return token;
if (token.includes("..")) return token;
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token;
return null;
}
/**
* Broader token classifier for cross-cutting `path` permission rules.
*
* Accepts the same shapes as `classifyTokenAsPathCandidate`, plus:
* - Dot-files and `./`-relative paths (starting with `.`)
* - Any token carrying a path separator under `flavor` (`src/foo.ts`, and on
* win32 the backslash-relative `dir\file`, #520) — `flavor.hasPathSeparator`
* owns the platform bit (POSIX: `/` only; win32: `/` or `\`), so this
* classifier never reads `process.platform`.
* - Windows drive-letter absolute paths (`C:/…` or `C:\…`)
*
* The `~/foo` case is covered by `hasPathSeparator` — no separate `~/` branch needed.
* The forward-slash drive form (`C:/…`) is also caught by `hasPathSeparator`, but the
* explicit `WINDOWS_DRIVE_PATH_PATTERN` branch makes both separator forms first-class
* and order-independent, and covers the backslash-only form (`D:\…`) which the POSIX
* flavor's `hasPathSeparator` cannot reach.
*
* Does NOT require the strict "must start with `/` or `~/` or contain `..`"
* gate that the external-directory classifier uses.
*
* Returns the raw token string if it qualifies, or `null` to skip.
*/
export function classifyTokenAsRuleCandidate(
token: string,
flavor: PathFlavor,
): string | null {
if (rejectNonPathToken(token)) return null;
if (token.startsWith(".")) return token;
if (flavor.hasPathSeparator(token)) return token; // ~/ paths, relative paths with /, and win32 dir\file
if (token.includes("..")) return token; // bare ".." (no slash)
if (WINDOWS_DRIVE_PATH_PATTERN.test(token)) return token; // backslash-only drive form
return null;
}
/**
* Prelude-only classifier for a bare token (#645).
*
* A bare token (`id_rsa`, `outside-link`) has none of the shapes
* `classifyTokenAsRuleCandidate` accepts, because most bash argument tokens are
* not file paths (subcommands, branch names, search patterns). This classifier
* answers the narrower question the existence probe needs: could this token's
* *shape* name a path at all?
*
* It runs only the shared `rejectNonPathToken` prelude, so a flag,
* env-assignment, URL, `@scope` token, or regex-shaped token is never a
* candidate. Everything else is returned for the caller to probe.
*
* Deliberately consults no policy: candidacy is settled by the filesystem and
* the decision by the ruleset, which keeps this module a pure shape function
* (ADR 0009). It replaced the rule-driven promotion of #509, which matched a
* token's *spelling* against `path` rules and so could never see that a
* symlink's target is what a rule names.
*
* Returns the raw token string if it qualifies, or `null` to skip.
*/
export function classifyBareTokenCandidate(token: string): string | null {
return rejectNonPathToken(token) ? null : token;
}
// ── Private rejection predicate ────────────────────────────────────────────
/**
* Windows drive-letter absolute path: a single ASCII letter, a colon, then a
* separator (`/` or `\`). Matches `C:/…` and `C:\…` but not drive-relative
* `C:foo` (no separator) or multi-letter schemes (`https:`, `mailto:`).
* Single-letter schemes with `//` (e.g. `c://x`) are already rejected by
* `URL_PATTERN` before this pattern is tested.
*/
const WINDOWS_DRIVE_PATH_PATTERN = /^[a-zA-Z]:[/\\]/;
/**
* URL pattern to skip tokens that look like URLs rather than paths.
*/
const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
/**
* Regex metacharacter sequences that are never found in real filesystem paths.
* If a token contains any of these, it is almost certainly a regex pattern
* (e.g. a grep argument) rather than a path.
*/
const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
/**
* Shared rejection prelude: returns `true` when a token can never be a
* filesystem path, regardless of which classifier is asking.
*
* Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
* URLs, `@scope/package` patterns, and regex metacharacter sequences.
*
* A bare `/` (or `//`, `///`) is NOT rejected: it denotes the filesystem root,
* a deliberate external-directory access (`find /`, `ls /`), so it must reach
* the path surfaces like any other absolute token (#583).
*/
function rejectNonPathToken(token: string): boolean {
if (!token) return true;
if (token.startsWith("-")) return true;
// Env assignment: = appears before any / (FOO=/bar is an assignment,
// /foo=bar is not because the slash comes first).
const eqIndex = token.indexOf("=");
const slashIndex = token.indexOf("/");
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex))
return true;
if (URL_PATTERN.test(token)) return true;
// @scope/package patterns (npm scoped packages) — but @/ is allowed through
// since it looks like an absolute-rooted path, not an npm scope.
if (token.startsWith("@") && !token.startsWith("@/")) return true;
if (REGEX_METACHAR_PATTERN.test(token)) return true;
return false;
}
@@ -0,0 +1,433 @@
import { basename } from "node:path";
import {
EXECUTION_HOST_TYPES,
forEachNestedExecution,
NESTED_EXECUTION_CONTEXTS,
} from "#src/access-intent/bash/nested-execution";
import {
ARG_NODE_TYPES,
resolveNodeText,
SKIP_SUBTREE_TYPES,
} from "#src/access-intent/bash/node-text";
import type { TSNode } from "#src/access-intent/bash/parser";
// ── Public surface ─────────────────────────────────────────────────────────
/**
* Recursively visit the AST and collect resolved text of nodes that
* represent command arguments or redirect destinations.
*
* Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
* still descends an execution host for the commands it hosts — an interpolating
* heredoc body runs its substitution even though its prose is never an operand
* (#741). That is why the {@link EXECUTION_HOST_TYPES} branch sits above the
* {@link SKIP_SUBTREE_TYPES} check: `heredoc_body` is in both sets, and the
* host reading is the one that must win.
*
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
* argument skipping to avoid collecting inline patterns/scripts
* as path candidates. For all other commands, collects all
* arguments generically.
*/
export function collectPathCandidateTokens(node: TSNode): string[] {
if (node.type === "command") return collectCommandTokens(node);
if (node.type === "file_redirect") return collectRedirectTokens(node);
if (EXECUTION_HOST_TYPES.has(node.type)) {
return collectHostedExecutionTokens(node);
}
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
const tokens: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) tokens.push(...collectPathCandidateTokens(child));
}
return tokens;
}
/**
* Select the collection strategy for a `command` node: pattern-first
* commands use `collectPatternCommandTokens`; all others use
* `collectGenericCommandTokens`.
*/
export function collectCommandTokens(node: TSNode): string[] {
const commandName = extractCommandName(node);
const config = commandName
? PATTERN_FIRST_COMMANDS.get(commandName)
: undefined;
const tokens = config
? collectPatternCommandTokens(node, config)
: collectGenericCommandTokens(node);
return [...tokens, ...collectEmbeddedOptionValues(node)];
}
/**
* Collect redirect-destination tokens from a `file_redirect` node.
*
* The destination itself is an argument value (`> out.txt`), but it can also
* host a command that really runs (`> $(cat /etc/shadow)`, `< <(cmd)`), whose
* own operands are path candidates too — so each child is both read for its
* text and searched for nested executions (#741).
*
* Both passes are needed: a substitution can be the destination outright, or be
* concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
* argument node.
*/
export function collectRedirectTokens(node: TSNode): string[] {
const tokens: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (ARG_NODE_TYPES.has(child.type)) {
tokens.push(resolveNodeText(child));
}
tokens.push(...collectHostedExecutionTokens(child));
}
return tokens;
}
/**
* Collect the path-candidate tokens of every command nested inside `node`'s
* execution contexts, reading none of the host subtree's own text.
*
* This is what lets a heredoc body contribute its substitution's operands while
* its prose stays out of the path surface entirely.
*
* `node` may be a context outright (`> $(cmd)`) or merely contain one
* (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
* subtree, so the first case is checked here.
*/
function collectHostedExecutionTokens(node: TSNode): string[] {
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
return collectPathCandidateTokens(node);
}
const tokens: string[] = [];
forEachNestedExecution(node, (contextNode) => {
tokens.push(...collectPathCandidateTokens(contextNode));
});
return tokens;
}
/**
* Extract the command name from a `command` node.
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
* if the command name cannot be determined (e.g. variable expansion).
*/
export function extractCommandName(node: TSNode): string | undefined {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === "command_name") {
const text = resolveNodeText(child);
return text ? basename(text) : undefined;
}
}
return undefined;
}
// ── Private helpers and config ─────────────────────────────────────────────
/**
* A long or short option carrying its value inline: one or two leading dashes,
* a name containing no `=` or whitespace, then `=` and a non-empty value.
* Only the first `=` separates, so `--opt=/tmp/a=b` yields `/tmp/a=b`.
*/
const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
/**
* The values embedded in this command's `--opt=value` argument tokens.
*
* Read straight from the argument nodes rather than from the collected token
* list, because a pattern-first command's collector classifies a flag and never
* emits it — so `grep --file=/tmp/patterns` would otherwise lose the path.
*
* This is token *preprocessing*, not classification: the extracted value is
* handed to the ordinary shape classifiers and existence probe, so
* `--file=/tmp/patterns` reaches the path surfaces while `--format=json`
* yields a bare `json` that names nothing and is dropped. Keeping the split
* here is what lets the projection see option-embedded paths without per-command
* option tables (ADR 0009, #645).
*/
function collectEmbeddedOptionValues(node: TSNode): string[] {
const values: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === "command_name" || child.type === "variable_assignment")
continue;
if (!ARG_NODE_TYPES.has(child.type)) continue;
const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
if (value !== undefined) values.push(value);
}
return values;
}
interface PatternCommandConfig {
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
readonly argConsumingFlags: ReadonlySet<string>;
/** Flags that consume the next argument as a file path */
readonly fileConsumingFlags: ReadonlySet<string>;
/**
* Number of leading positional arguments that are patterns/scripts, not paths.
* Default: 1 (covers sed, awk, grep, rg).
* sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
*/
readonly patternPositionals?: number;
}
/**
* Commands whose first N positional arguments are inline patterns/scripts,
* not filesystem paths. The map stores per-command flag configuration so
* the walker can correctly identify which arguments are consumed by flags
* vs. which are positional.
*/
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
new Map([
[
"sed",
{
argConsumingFlags: new Set(["-e", "-i"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"awk",
{
argConsumingFlags: new Set(["-e", "-F", "-v"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"gawk",
{
argConsumingFlags: new Set(["-e", "-F", "-v"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"nawk",
{
argConsumingFlags: new Set(["-e", "-F", "-v"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"grep",
{
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"egrep",
{
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"fgrep",
{
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"rg",
{
argConsumingFlags: new Set([
"-e",
"-A",
"-B",
"-C",
"-m",
"-g",
"-t",
"-T",
"-j",
"-M",
"-r",
"-E",
]),
fileConsumingFlags: new Set(["-f"]),
},
],
[
"sd",
{
argConsumingFlags: new Set(["-n", "-f"]),
fileConsumingFlags: new Set([]),
patternPositionals: 2,
},
],
]);
/**
* Describes what the walker should do when it encounters a flag word inside
* a pattern-first command. Using a discriminated union lets the `switch` in
* `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
* assertion (which would trigger the Biome/ESLint assertion conflict).
*/
type PatternCommandFlagDirective =
| { kind: "end-of-flags" }
| { kind: "regular-flag" }
| {
kind: "consume-arg";
nextArgAction: "skip" | "extract";
setsExplicitScript: boolean;
};
/**
* Classify a flag word from a pattern-first command into a directive that
* tells the walker how to handle the flag and its following argument.
*/
function classifyPatternCommandFlag(
text: string,
config: PatternCommandConfig,
): PatternCommandFlagDirective {
if (text === "--") return { kind: "end-of-flags" };
if (config.argConsumingFlags.has(text)) {
return {
kind: "consume-arg",
nextArgAction: "skip",
setsExplicitScript: text === "-e" || text === "-f",
};
}
if (config.fileConsumingFlags.has(text)) {
return {
kind: "consume-arg",
nextArgAction: "extract",
setsExplicitScript: true,
};
}
return { kind: "regular-flag" };
}
/**
* Collect path-candidate tokens from a command known to have
* pattern/script arguments in leading positional slots.
*
* Uses position-based skipping: the first N positional arguments
* (where N = patternPositionals, default 1) are assumed to be
* inline patterns/scripts and are skipped. Remaining positional
* arguments are collected as path candidates.
*
* Flags listed in `argConsumingFlags` consume the next argument
* (skipped). Flags in `fileConsumingFlags` consume the next
* argument as a file path (collected). The flags `-e` and `-f`
* additionally signal that an explicit script was provided via
* flag, so no inline positional script is expected.
*/
function collectPatternCommandTokens(
node: TSNode,
config: PatternCommandConfig,
): string[] {
const patternPositionals = config.patternPositionals ?? 1;
let hasExplicitScript = false;
let positionalsSeen = 0;
let nextArgAction: "skip" | "extract" | null = null;
let pastEndOfFlags = false;
const tokens: string[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
// Skip command_name and variable_assignment nodes.
if (child.type === "command_name" || child.type === "variable_assignment")
continue;
// Only process argument-like nodes; recurse into others
// (e.g. command_substitution) for nested commands.
if (!ARG_NODE_TYPES.has(child.type)) {
tokens.push(...collectPathCandidateTokens(child));
continue;
}
const text = resolveNodeText(child);
// Handle consumed argument from previous flag.
if (nextArgAction === "skip") {
nextArgAction = null;
continue;
}
if (nextArgAction === "extract") {
tokens.push(text);
nextArgAction = null;
continue;
}
// Flag detection (only before "--" end-of-flags marker).
if (
!pastEndOfFlags &&
child.type === "word" &&
text.startsWith("-") &&
text.length > 1
) {
const directive = classifyPatternCommandFlag(text, config);
switch (directive.kind) {
case "end-of-flags":
pastEndOfFlags = true;
break;
case "consume-arg":
nextArgAction = directive.nextArgAction;
if (directive.setsExplicitScript) hasExplicitScript = true;
break;
case "regular-flag":
break;
}
continue;
}
// Positional argument.
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
positionalsSeen++;
continue; // Skip: this is an inline pattern/script.
}
// File argument — collect as path candidate.
tokens.push(text);
}
return tokens;
}
/**
* Collect all argument tokens from a generic (non-pattern-first) command node,
* skipping the command name and variable assignments.
*/
function collectGenericCommandTokens(node: TSNode): string[] {
const tokens: string[] = [];
let seenCommandName = false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (child.type === "command_name") {
seenCommandName = true;
continue;
}
// Skip variable_assignment nodes (FOO=/bar)
if (child.type === "variable_assignment") continue;
// If there was no explicit command_name node, the first word-like
// child is the command name itself — skip it.
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
seenCommandName = true;
continue;
}
// Argument nodes: resolve their text and collect.
if (ARG_NODE_TYPES.has(child.type)) {
tokens.push(resolveNodeText(child));
continue;
}
// Recurse into other children (e.g. command_substitution nested in args)
tokens.push(...collectPathCandidateTokens(child));
}
return tokens;
}
@@ -0,0 +1,335 @@
/**
* Wrapper interpretation for a bash command unit: what kind of wrapper it is,
* and — where it can be established — what it actually runs.
*
* Pure and word-based; the AST walk that produces the words lives in
* `command-enumeration.ts`. Both questions live here together deliberately: the
* shape that floors a unit to `ask` and the shape that names its inner command
* must agree, and two classifiers over the same vocabulary would drift.
*/
/** One word of a command unit: its text, and its offset into the unit's text. */
export interface CommandWord {
readonly text: string;
readonly offset: number;
}
/**
* Why a command unit's decision is floored to at least `ask`.
* `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
* program is not re-parsed (#481).
* `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
* whose inner command is a visible argument but is not gated on its own (#490).
* The kind selects the audit sentinel; both floor identically.
*/
export type WrapperKind = "opaque-payload" | "indirection";
/**
* Classify a command unit's words as a floored wrapper, or `undefined` for an
* ordinary command. `words[0]` is the command name; a leading
* `variable_assignment` prefix is already stripped by the caller. The command
* name is matched on its basename, so `/bin/bash -c …` counts.
*
* `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
* `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
* argument the enumerator does not re-parse (#481).
*
* `"indirection"`: an always-invoking prefix/exec wrapper
* ({@link INDIRECTION_WRAPPER_NAMES}), or a search tool
* ({@link EXEC_CONDITIONAL_WRAPPERS}, `find`/`fd`) carrying a per-result exec
* flag — the inner command is a visible argument that a `<cmd> *` rule would
* otherwise never match (#490). A bare `find`/`fd` search runs no subcommand and
* is not flagged.
*/
export function classifyWrapperWords(
words: readonly CommandWord[],
): WrapperKind | undefined {
const commandName = wrapperName(words);
if (commandName === undefined) return undefined;
const args = words.slice(1).map((word) => word.text);
if (commandName === "eval") return "opaque-payload";
if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
return "opaque-payload";
}
if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
if (execFlagIndex(commandName, args) !== -1) return "indirection";
return undefined;
}
// ── Wrapper vocabulary ───────────────────────────────────────────────────────
/**
* The command a wrapper unit actually runs, or `null` when it cannot be
* established or adds nothing over the unit itself.
*
* Display-only (ADR 0011 §3.5, #713): the result is never gated and never
* becomes a `BashCommand`, so the wrapper floor is untouched. Because it is
* shown on a decision surface, the rule is to fail to `null` rather than to a
* guess — an unrecognized option shape yields nothing rather than a remainder
* that might name the wrong command.
*
* Nested wrappers unwrap to the innermost command (`sudo timeout 5 xargs grep
* foo` → `grep foo`), bounded by {@link MAX_UNWRAP_DEPTH}.
*/
export function executedUnitOf(
unitText: string,
words: readonly CommandWord[],
): string | null {
let text = unitText;
let current = words;
for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
const kind = classifyWrapperWords(current);
if (kind === undefined) break;
if (kind === "opaque-payload") {
// The payload is an inner *program*, not a slice of this command line, so
// it is unquoted and terminal — unwrapping it further would need a parse.
return nothingNew(opaquePayload(current), unitText);
}
const start = innerCommandIndex(current);
if (start === -1 || start >= current.length) break;
const end = execTerminatorIndex(current, start);
text = sliceWords(text, current, start, end).trimEnd();
current = rebase(current, start, end);
}
return nothingNew(text, unitText);
}
/** How many wrapper layers to unwrap before giving up. */
const MAX_UNWRAP_DEPTH = 4;
/**
* The extracted text, or `null` when it establishes nothing new — it is absent
* or empty, it still begins with an option (so the inner command was never
* reached), or it simply repeats the unit.
*/
function nothingNew(text: string | null, unitText: string): string | null {
if (text === null || text === "" || text === unitText) return null;
return text.startsWith("-") ? null : text;
}
/** The inline-shell payload argument, unquoted; `null` when absent. */
function opaquePayload(words: readonly CommandWord[]): string | null {
const args = words.slice(1);
// `eval` takes its program as the first argument (no `-c`, so the index is
// -1); a shell takes it after the `-c` cluster.
const flagIndex = shortFlagCIndex(args.map((word) => word.text));
const payload = args[flagIndex + 1] as CommandWord | undefined;
return payload === undefined ? null : unquote(payload.text);
}
/** Strip one matching pair of surrounding quotes. */
function unquote(text: string): string {
const first = text.at(0);
const quoted =
(first === "'" || first === '"') &&
text.length >= 2 &&
text.endsWith(first);
return quoted ? text.slice(1, -1) : text;
}
/**
* Index of the word beginning the inner command, or `-1` when the wrapper's own
* options run out first.
*
* Skips the wrapper name, environment assignments, options (consuming a
* following value for the options in {@link VALUE_TAKING_FLAGS}), and a leading
* operand for the wrappers that take one. An exec-conditional wrapper instead
* starts immediately after its exec flag.
*/
function innerCommandIndex(words: readonly CommandWord[]): number {
const name = wrapperName(words);
if (name === undefined) return -1;
const argTexts = words.slice(1).map((word) => word.text);
const execFlag = execFlagIndex(name, argTexts);
if (execFlag !== -1) return execFlag + 2;
const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
let operandPending = LEADING_OPERAND_WRAPPERS.has(name);
let index = 1;
while (index < words.length) {
const word = words[index].text;
if (word === "--") return index + 1;
if (isEnvironmentAssignment(word)) {
index++;
continue;
}
if (word.startsWith("-")) {
index += valueTaking.has(word) ? 2 : 1;
continue;
}
if (operandPending) {
operandPending = false;
index++;
continue;
}
return index;
}
return -1;
}
/**
* Index of an exec wrapper's `;`/`+` terminator, or `words.length` — the
* terminator belongs to `find`, not to the command it runs.
*/
function execTerminatorIndex(
words: readonly CommandWord[],
start: number,
): number {
const terminator = words.findIndex(
(word, index) =>
index >= start && EXEC_TERMINATORS.has(word.text.replace(/^\\/, "")),
);
return terminator === -1 ? words.length : terminator;
}
/** The unit text spanned by `words[start..end)`. */
function sliceWords(
unitText: string,
words: readonly CommandWord[],
start: number,
end: number,
): string {
const from = words[start].offset;
return end < words.length
? unitText.slice(from, words[end].offset)
: unitText.slice(from);
}
/** `words[start..end)` with offsets rebased onto the sliced text. */
function rebase(
words: readonly CommandWord[],
start: number,
end: number,
): CommandWord[] {
const origin = words[start].offset;
return words
.slice(start, end)
.map((word) => ({ text: word.text, offset: word.offset - origin }));
}
/** True for a `NAME=value` environment prefix. */
function isEnvironmentAssignment(word: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
}
/**
* Shell command names whose `-c` flag introduces an opaque inline program.
*/
const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
/**
* Indirection wrappers that always invoke a following command, so the wrapper
* (not the inner command) is what a bash rule matches. Floored by command-name
* basename alone. Extend this set to cover another always-invoking wrapper.
*/
const INDIRECTION_WRAPPER_NAMES = new Set([
"sudo",
"env",
"xargs",
"time",
"nohup",
"timeout",
"nice",
// Exec-capable rewrites and prefix wrappers surveyed in #575: parallelizers
// (parallel/rust-parallel/rush), a sudo rewrite (doas), and prefix wrappers
// (setsid/stdbuf/watch/flock) that all always invoke a following command.
"parallel",
"rust-parallel",
"rush",
"doas",
"setsid",
"stdbuf",
"watch",
"flock",
]);
/**
* Search tools that invoke a command per result only when an exec flag is
* present; a bare search runs no subcommand. Floored only when an argument
* exactly matches one of the tool's exec flags. Extend by adding a tool with
* its exec-flag set.
*/
const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
]);
/**
* Curated per-wrapper options that consume the following word, so skipping a
* wrapper's own arguments does not mistake an option's value for the inner
* command. Attached forms (`-I{}`, `--user=root`) need no entry — they are one
* word. Only the display-side extraction reads this, and a missing or wrong
* entry yields `null` (see {@link executedUnitOf}), never a weaker gate.
*/
const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
["sudo", new Set(["-u", "-g", "-p", "-C", "-h", "-U", "-r", "-t"])],
["doas", new Set(["-u", "-C"])],
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
[
"xargs",
new Set(["-n", "-P", "-I", "-i", "-d", "-E", "-L", "-l", "-s", "-a"]),
],
["timeout", new Set(["-s", "-k", "--signal", "--kill-after"])],
["nice", new Set(["-n", "--adjustment"])],
["time", new Set(["-o", "-f", "--output", "--format"])],
["stdbuf", new Set(["-i", "-o", "-e", "--input", "--output", "--error"])],
["watch", new Set(["-n", "--interval"])],
["flock", new Set(["-w", "-E", "--timeout", "--conflict-exit-code"])],
]);
const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
/**
* Wrappers whose first bare word is an operand (a duration, a lock file) rather
* than the start of the inner command.
*/
const LEADING_OPERAND_WRAPPERS = new Set(["timeout", "flock"]);
/** Words ending a `find -exec` clause; they belong to `find`, not its command. */
const EXEC_TERMINATORS = new Set([";", "+"]);
// ── Shared helpers ───────────────────────────────────────────────────────────
/** The wrapper's command-name basename, or `undefined` for an empty unit. */
function wrapperName(words: readonly CommandWord[]): string | undefined {
return words.length === 0 ? undefined : basename(words[0].text);
}
/**
* True when an argument list has a short-flag cluster containing `c` before any
* `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
* flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
*/
function hasShortFlagC(args: readonly string[]): boolean {
return shortFlagCIndex(args) !== -1;
}
/** Index within `args` of the `-c` short-flag cluster, or `-1`. */
function shortFlagCIndex(args: readonly string[]): number {
for (const [index, arg] of args.entries()) {
if (arg === "--") return -1;
if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
return index;
}
}
return -1;
}
/** Index within `args` of a matched per-result exec flag, or `-1`. */
function execFlagIndex(commandName: string, args: readonly string[]): number {
const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
if (!execFlags) return -1;
return args.findIndex((arg) => execFlags.has(arg));
}
/** The final path segment of a command name (`/bin/bash` → `bash`). */
function basename(name: string): string {
const slash = name.lastIndexOf("/");
return slash === -1 ? name : name.slice(slash + 1);
}