/** * Deterministic, no-LLM Bash effect classifier. * * Classifies a Bash command string into a three-way verdict: * * "read-only" — proven: every leaf command head is in the read-only * catalog, no output redirection, no dynamic/residue node. * "side-effecting" — at least one leaf is provably effecting (unknown head * outside the catalog, effecting flag, output redirection). * "undecidable" — a residue construct (eval, $VAR head, $(…) head, sh -c, * xargs, pipe-to-shell, ProcSubst, background &, Subshell) * makes the static verdict unsound; fail-closed. * * FAIL-CLOSED: "read-only" is returned ONLY when it can be proven. Anything * not proven read-only → "side-effecting" or "undecidable" (never "read-only"). * The zero-false-read-only property is the invariant this module maintains. * * Parse errors → "undecidable" (can't analyze ⇒ not read-only). * * This is a STANDALONE pure module. It does NOT import any adapter or dialect. * Wiring into effectSurface / scan is a separate follow-up step. * * See `research/bash-effect-classification.md` for the full design rationale. */ /** The three-way verdict. */ export type BashEffect = "read-only" | "side-effecting" | "undecidable"; /** * Classify a Bash command string by its effect. Sound by construction: * returns "read-only" ONLY when every leaf command is a catalogued read-only * head with no output redirection and no effecting flag; "undecidable" for the * dynamic residue (eval, $(...)-as-command, $VAR head, sh -c, xargs, pipe-into-shell); * "side-effecting" otherwise (the fail-closed default). NEVER returns "read-only" * for a command it cannot fully prove read-only. */ export declare function classifyBashCommand(command: string): BashEffect; /** * True iff classifyBashCommand(command) === "read-only" — the safe predicate a * caller uses to decide "this Bash is provably an observation." */ export declare function isReadOnlyBash(command: string): boolean; /** * Extract the static argv of every simple command (CallExpr) in `command`, each * as an array of literal words (dynamic / quoted-interpolated words are dropped). * AST-backed, so a leaf nested in a pipeline, `&&`/`;`/`|`, a subshell, or a * compound command is still found — the structural query a robust matcher needs * (a regex over the raw string misses `cd x && git push`). Parse failure → []. * * This is the matching primitive a typed hook's `command.runs("git push")` is * built on: it sees the real `git push` leaf however it's wrapped, which the * native `Bash(git:*)` glob (issue #30519) and a hand-written `grep` both miss. */ export declare function leafCommands(command: string): string[][]; /** * One redirection attached to a simple command (`cmd > f`, `cmd 2>> log`). * * The parser used to DROP these: `echo x > out.md` normalized to a leaf whose * every field (`head`/`argv`/`args`/`flags`/`assigns`) mentioned only `echo` and * `x`, so the file the command actually WROTE appeared nowhere. That made the * single most common write shape invisible to any matcher built on the leaf. */ export interface LeafRedirect { /** The operator as written: `>`, `>>`, `>|`, `&>`, `&>>`, `<`, `<<`, `>&`, … */ readonly op: string; /** * The redirection target, quote-unwrapped and `$HOME`-canonicalized like every * other word. `null` when the target is dynamic (`> "$out"`, `> $(f)`) — present * but unresolvable, never silently dropped. For an fd-dup (`2>&1`) this is the * fd, not a path; for a heredoc it's the delimiter. */ readonly target: string | null; /** The explicit source fd (`2> f` → 2), or `null` when omitted. */ readonly fd: number | null; /** * True iff this redirection CREATES OR MODIFIES the file named by `target` — * `>`, `>>`, `>|`, `&>`, `&>>`. False for input (`<`, `<<`, `<<<`) and for fd * duplication (`2>&1`), whose "target" is an fd, not a path. */ readonly writes: boolean; } /** A single simple command, normalized to its operation form. */ export interface NormalizedLeaf { /** Head normalized to its basename, backslash-stripped: `/bin/rm`→`rm`, `\rm`→`rm`. */ readonly head: string; /** `[head, ...args]` — every word quote-unwrapped and $HOME/~-canonicalized. */ readonly argv: readonly string[]; /** The normalized args (argv without the head). */ readonly args: readonly string[]; /** * Canonical flag tokens present on this leaf. Short clusters are split * (`-rf`→`r`,`f`) and each flag is recorded in BOTH short and long form via the * alias table, so a caller can test `--force`/`-f` or `--no-verify`/`-n` * uniformly. Long values are split on `=` (`--index-url=x`→`index-url`). */ readonly flags: ReadonlySet; /** True iff ANY of `names` is present in {@link flags} (short or long). */ hasFlag(...names: readonly string[]): boolean; /** * Command-level env-assignments that apply to THIS leaf, keyed by variable * name → static value (empty string for a naked `NAME=`). Populated from both * the mvdan leading `CallExpr.Assigns` (`PIP_INDEX_URL=… pip install …`) AND * the `NAME=value` words consumed by an `env` wrapper (`env NPM_CONFIG_REGISTRY=… npm i`). * * These carry supply-chain / behavior-altering configuration (`PIP_INDEX_URL`, * `NPM_CONFIG_REGISTRY`, …) that an argv-only extractor never sees because the * assignment is not an argv word. A dynamic RHS (command substitution, non-HOME * parameter) is recorded with value `null` — present but unresolved. */ readonly assigns: ReadonlyMap; /** True iff a command-level assignment for ANY of `names` is present (resolved or not). */ hasAssign(...names: readonly string[]): boolean; /** * The redirections attached to this leaf, in source order — see * {@link LeafRedirect}. Empty for a command with no redirection. */ readonly redirects: readonly LeafRedirect[]; /** * The directory a chdir WRAPPER moved this leaf into before exec'ing it — * `env -C dir`, `env --chdir=dir`, `sudo -D dir` — or `null` when there was * none. Nested wrappers accumulate (`sudo -D a env -C b cmd` → `a/b`). * * The parser already READ this token in order to skip past it, then threw the * value away — so every relative operand of the wrapped command resolved * against the wrong directory for every consumer. Same shape as the * redirection targets the leaf used to drop: the parser knew, the leaf did * not carry it. `git -C` is deliberately NOT here — `git` is not a wrapper * (it does not exec the rest of its argv as a command). * * ⚠️ ONE LEAF'S OWN WRAPPER, NOT A CWD MODEL. A directory changed by a * PRECEDING statement (`cd x && …`, `pushd`, a subshell) is not reported: * connectors do not survive leaf extraction, and `cd x; cmd` writes into the * OLD directory when the `cd` fails — that is a model with failure semantics, * not a field. A dynamic value (`env -C "$DIR" …`) is not resolvable and the * whole leaf is already unnormalizable in that case. */ readonly chdir: string | null; } /** * Known short↔long flag aliases. Deliberately small and operation-relevant: * a caller always gates on the head (e.g. only treats `index-url` as supply-chain * when the head is `pip`), so recording both forms unconditionally is safe. */ /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */ export declare const SHORT_TO_LONG: Readonly>; /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */ export declare const LONG_TO_SHORT: Readonly>; /** @internal — read by `bash-equivalents.ts` to generate shell-equivalent variants. */ export declare const WRAPPER_HEADS: Set; /** * Resolve a wrapped command through one or more wrapper layers. Given a leaf's * full normalized argv (`[head, ...args]`), returns the effective argv of the * REAL command plus any `NAME=value` words an `env` wrapper consumed (so the * caller can fold them into the leaf's assignment map). If the head is not a * wrapper, or the wrapper has no following command, the argv is returned * unchanged with an empty assignment set. * * @internal — the SINGLE answer to "which word is the real command head". Read * by `bash-equivalents.ts` (to generate wrapper-prefixed spellings) and by * `core/command-files.ts` (to find the interpreter behind a wrapper, so * `env python3 guard` names its script). That second reader had its own idea of * what a wrapper was — namely none — so it reported no script at all for the * wrapped form, and a missing script that is never named is a hook that runs, * exits 2, and is scored as a block. One list, or the next wrapper is missed in * whichever copy nobody remembered. */ export declare function stripWrappers(argv: readonly string[]): { argv: readonly string[]; envAssigns: Map; chdir: string | null; }; /** * Extract every simple command as a {@link NormalizedLeaf} — the operation-level * twin of {@link leafCommands}. Same AST-backed structural coverage (a leaf nested * in a pipeline / `&&` / subshell is still found), but each word is quote-unwrapped * and $HOME-canonicalized, the head is reduced to its backslash-stripped basename, * and flags are expanded to short+long canonical forms. A matcher built on this * compares against the OPERATION rather than the surface tokens, so it is robust * to quoting, interpreter path, backslash escaping, flag aliasing, and $HOME/~. * * Each leaf also carries its REDIRECTIONS ({@link LeafRedirect}), which live on * the enclosing `Stmt` rather than the `CallExpr` — walking CallExprs alone * dropped them, hiding the file a command writes. Every CallExpr in the mvdan AST * IS a `Stmt.Cmd`, so iterating statements finds exactly the same leaves. * * Purely additive: reuses the same mvdan-sh parse, changes nothing above. Parse * failure → []. A leaf with a dynamic head is skipped (can't be normalized). */ export declare function leafCommandsNormalized(command: string): NormalizedLeaf[]; /** * Every simple command's argv, POSITIONALLY, each word reconstructed at source * level — the primitive a caller needs to tell an EXECUTED PROGRAM from a DATA * OPERAND. * * 🔴 WHY POSITION, AND WHY A THIRD EXTRACTOR. `leafCommands` DROPS the words it * cannot reduce to a literal, so `"$GUARD" --flag` yields `["--flag"]` — a * dynamic head silently promotes an argument into head position, which is worse * than useless to a positional reader. `leafCommandsNormalized` skips such a leaf * outright AND basenames the head, so `./hooks/x.sh` loses the path a file * resolver needs. This one keeps every word in its slot (an unreconstructable * word becomes `""`) and keeps the head's spelling. * * Wrappers are still resolved through (`env FOO=1 bash x.sh` → `bash x.sh`) * using the same wrapper table as the normalized extractor, not a second copy. * * 🔴 ONLY THE LEAVES THAT UNCONDITIONALLY RUN, and this used to be a blanket * `Walk` over every `CallExpr` in the tree. A syntactic leaf is not an executed * command: `false && bash hooks/pre.sh` and `true || bash hooks/pre.sh` never run * the hook, and `if false; then bash hooks/x.sh; fi` and a function BODY do not * run at all where they are written — yet all four were reported as executed * programs. For the one caller (coverage attribution) that is a FALSE GRANT: a * hook credited with a run that never happened. Same class as attributing a data * operand, one level up in the grammar. * * So the tree is DESCENDED, not walked, and only through positions whose children * always execute: a statement list, both sides of a PIPELINE, a subshell, a * block. `&&`/`||` contribute their LEFT side only — the right is conditional. * Every other construct (`if`, `while`, `until`, `for`, `case`, a function * declaration, `time`, …) is not entered at all: whether its body ran is a * runtime fact this parse cannot have, and abstaining costs one warning while * guessing costs a false claim that something was tested. * * ⚠️ THE MEASURED COST, stated rather than assumed: `cd /repo && bash hooks/x.sh` * now yields NOTHING, because the hook sits on the right of `&&`. That idiom has * a first-class replacement — `runHook(cmd, event, { cwd })` — which is how this * repo's own examples already write it. * * Parse failure → `[]`. */ export declare function leafArgvSource(command: string): string[][]; //# sourceMappingURL=bash-effects.d.ts.map