mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user