UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

622 lines (621 loc) • 48.3 kB
import { type LexedCommand, type LexedSegment, type LexedWord, type ShellLex, type ShellSegmentConnector, type WordPiece } from "../../shell/lex.js"; import { type ProgramRow } from "./program-read-model.js"; /** * Bare command names `bash_readonly` permits out of the box, ALSO the default reversible set for the * The {@link import("./fs-bash.js").bashReversibilityProbe} classifier. **Coarse first filter, NOT a security * boundary**: {@link coarseReadonlyCheck} matches only the command NAME, so a listed command with a * writing flag (e.g. `find -delete`, `sort -o`, `tee`) would still write. Such commands are therefore * kept OFF this default — the list is curated to commands with NO write/mutation mode under ANY args, so * the `effect:"read"` declaration (relied on by wake/resume reconcile + the verifier read-only boundary) * AND the classifier's "reversible" promise both stay truthful. The deployment's tool-policy gate is the * authoritative control (council blocker #1); anything that can mutate state or run arbitrary * code belongs on the full `bash` (effect:write, gated), not here. A deployment may widen this list, * accepting that responsibility. * * Deliberately ABSENT: `date` (`-s`/`--set` → CLOCK_SETTIME), `hostname` (`<name>` → kernel * hostname), and `file` (`-C -m` → compiles/writes a magic file) were REMOVED — each is read-only by NAME * but state-MUTATING with args, which an argv[0]-only filter cannot tell apart. Leaving them in defeated * both the `effect:read` truthfulness here and the classifier's irreversibility promise (a `date -s` would * auto-allow an irreversible clock jump on the full shell face). * * Rows 3-4 are the CC-anchored expansion — the members of CC's safe-command * set (2.1.296 `v3e` (cli296.js:249645); `e6s` in the 223 corpus, identical in 220; the 220→245 campaign diff records * no change on this face) that satisfy THIS list's curation bar (no write/mutation mode under any args) * without new stdin-floor/option-value modeling: pure status/computation printers (`cal`…`sleep` — none * reads stdin when bare, none takes a path it READS except realpath/readlink, which stay boundary-judged) * plus the floor-2 content comparers `diff`/`cmp`/`comm` (their rows state the floor of 2 operands; diff's * recursive form was already a traversal row of the program read model). Still deliberately excluded, each for a * stated reason: `env` (an executor: `env FOO=1 cmd` runs cmd — CC excludes it too), `sort` (`-o` writes), * `uniq` (second positional is an OUTPUT file; CC allows a flags-only regex form this filter cannot * express), `printf` (builtin `-v var` assigns), `find` (CC allows only a regex excluding * `-delete`/`-exec`/…), and the bare-stdin text filters (`od`/`strings`/`nl`/`tsort`/`pr`/…) whose * value-taking options defeat the stdin-floor operand count without a per-verb option-value model * — those are a follow-on slice, not a silent drop. */ export type { ShellSegmentConnector } from "../../shell/lex.js"; export declare const BASH_READONLY_DEFAULT_ALLOW: readonly string[]; /** * The verbs the CLASSIFY face admits ON TOP of the shared list ({@link bashClassifyDefaultAllow}) — * admissible only where the compound classifier's per-verb judgment runs, and NOT on the shared list's * other consumer faces: * · `find` — read-only only when no CC `aCy` dangerous predicate token is present (guard arm); * · `sed` — read-only only in CC's `_Ld` grammar (guard arm); * · `cd` — never touches the filesystem, but it MUTATES the persistent shell's committed cwd, so it * must not reach the shared list's `isConcurrencySafe` consumer (a parallel-batch `cd` reorders * every sibling's working directory) or the `effect:"read"` declaration face; the classify face's * EXISTING cd boundary arm (glob/no-arg/`-` conservative, target judged against the roots) is what * makes it admissible here. * The bash_readonly DECLARATION face checks names without argument vetting (its own header says so), so * none of the three may join {@link BASH_READONLY_DEFAULT_ALLOW}: `find . -delete` / `sed -i` under an * `effect:"read"` declaration would falsify it. Consumed as * {@link import("./fs-bash.js").bashReversibilityProbe}'s default when the caller passes NO allow list — * a caller-narrowed list is honored verbatim (naming these verbs there opts into the same guard/boundary * arms; omitting them keeps them refused), so the superset can never widen a narrowed deployment. The * poll-loop face inherits the guard arms for free: its body vetting delegates to the same compound * classifier. */ export declare const BASH_CLASSIFY_EXTRA_ALLOW: readonly string[]; /** A verb only the classify face admits. */ export type BashClassifyExtraVerb = (typeof BASH_CLASSIFY_EXTRA_ALLOW)[number]; /** * @contract shell.classify_allow.one_union_point — the classify face's default allow set is the union of * two SPELLED-OUT tables ({@link BASH_READONLY_DEFAULT_ALLOW} ∪ {@link BASH_CLASSIFY_EXTRA_ALLOW}) and * this function is its only assembly point; no call site composes its own. Both halves stay arrays of * string literals so a static reader can enumerate them: a vocabulary derived in place * (`[...TABLE, "extra"]`) is invisible to the release's wire-change manifest, so a verb added to or * dropped from this security-relevant set would move nothing a downstream reader could see. */ export declare function bashClassifyDefaultAllow(): readonly string[]; /** * G2 — options for {@link parseLeadingCommandName}. Omitting them keeps the parser byte-identical to * what every existing caller (the `bash_readonly` declaration face, the coarse command-name policy, the * permission-rule floor, the skill tool specifier) has always received. */ export interface LeadingCommandNameOptions { /** * Read a shell operator that sits inside a QUOTED region as ordinary argument TEXT rather than as an * operator, so `grep -E "a|b" f.log` parses as the single simple command it is. * * OPT-IN, read off the one lexer's result: an operator character bash reads as text (inside quotes, or * behind an escape) is text here. Two callers set it — the compound classify face * ({@link classifyCompoundReadonlyDetailed}) and the persisted permission-RULE lane (`RULE_LANE_FLOOR`), * whose segments come out of the same residual predicate ({@link splitShellCompoundSegments}). The * `bash_readonly` DECLARATION face does NOT set it: its contract is "a single simple command with no * operator characters at all", and it runs on raw tool input with no whole-string gate in front of it. * * Fail-closed on a command the lexer cannot delimit: every operator character then counts, exactly as * without this option. */ quotedOperatorsAreText?: boolean; /** * Read a PATH-PREFIXED `argv[0]` (`./gradlew`, `/usr/bin/git`, `bin/tool`) as an ordinary command * name instead of refusing it. * * OPT-IN, and used by ONE family of callers: the persisted permission-RULE lane * ({@link import("../../core/permission-rule-model.js").parseRuleText} and its matcher), where * both sides of the comparison are literal text a person read on an approval card. The refusal this * option lifts exists for the argv[0]-NAME filters (the read-only allowlist, the coarse * command-name policy, the skill specifier): those compare a bare token against a name set, and a * path prefix is how a caller reaches a program the set never vetted. A rule lane compares the whole * command line instead, so `./gradlew` there is not a way past a name set — it IS the name that was * approved, and refusing it made the single most ordinary build command in a repository unable to * carry a standing approval at all. * * What it does NOT lift: the argv[0] metacharacter refusal (quotes/braces/globs/`~` still make the * parsed token differ from the program bash runs), the leading env-assignment refusal, and every * shell operator. And it does not make a path-prefixed name equal its basename anywhere — the rule * lane matches on the text as written, so `./gradlew` and `gradlew` stay two different commands. The * one place a basename IS taken is the rule lane's own interpreter refusal, which must read * `/usr/bin/node` as `node` (see * {@link import("../../core/permission-rule-model.js").BARE_INTERPRETER_NAMES}). */ pathPrefixedNameIsText?: boolean; } /** * The SINGLE fail-closed simple-command parser shared by `bash_readonly` ({@link coarseReadonlyCheck}), the * `bash` reversibility classifier ({@link import("./fs-bash.js").bashReversibilityProbe}), and the coarse command-name policy * ({@link import("../../core/tool-policy.js").createCoarseCommandNamePolicy}). It extracts the leading * `argv[0]` command NAME of a SINGLE simple command, rejecting anything that could chain past or escape an * argv[0]-name filter: shell operators (pipes / redirects / `;` / `&&` / `$(…)` / subshells / backticks / * newlines / backslash), a path-prefixed command (`/usr/bin/foo`), or a leading env-assignment (`FOO=bar cmd`). * The path-prefix refusal is the one arm a caller may lift, opt-in and for the rule lane only — see * {@link LeadingCommandNameOptions.pathPrefixedNameIsText} for why it is sound exactly there. * * Returns `{ name }` for a parseable single bare command, or `{ reject }` with a human reason otherwise. It * does NOT inspect ARGUMENTS for write flags or consult any allowlist — that is the caller's job (the * allowlist for `bash_readonly`, the allow/deny lists for the coarse policy). Keeping ONE parser is the whole * point: a second argv[0] parser would drift from this one and silently open a circumvention. */ export declare function parseLeadingCommandName(command: string, options?: LeadingCommandNameOptions): { name: string; } | { reject: string; }; /** Coarse pre-execution check for `bash_readonly`: the command must be a single bare allowlisted command * with no shell operators. It validates the NAME only (+ blocks operators/path-prefix/env-assignment); it * does NOT inspect arguments for write flags — keeping the allowlist free of write-capable commands and * the policy gate are what make `effect:"read"` truthful. Returns a reason when rejected, else undefined. * This `effect:"read"` DECLARATION face deliberately stays strict-single-command; only the * classify face ({@link classifyCompoundReadonly} via {@link import("./fs-bash.js").bashReversibilityProbe}) segments compounds. */ export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>, options?: LeadingCommandNameOptions): string | undefined; /** * Does this text carry a bare redirection operator — the ONE metacharacter family * {@link splitShellCompoundSegments} can be asked to tolerate at its whole-string gate * (`redirection: "tolerate"`)? * * Exported because the tolerating caller owes a PER-SEGMENT judgement in its place, and that judgement * must read the same character class this module tolerated — two hand-kept lists would drift, and a * drift here is a segment nobody judged. Says nothing about what a caller should DO about a hit: the * rule lane's offer face contributes no member for such a segment; the read-only classify face passes * only the two data-free spellings ({@link isDataFreeRedirection}). */ export declare function carriesShellRedirection(text: string): boolean; /** What a caller asks the residual predicate to pass: see {@link strictResidualRefusal}. */ export interface StrictResidualOptions { /** `<`/`>` are not refused and a redirection is the segment's own business (the rule lane's offer reading). */ readonly tolerateRedirection?: boolean; /** Only the two data-free spellings pass ({@link isDataFreeRedirection}); every other redirection refuses. */ readonly dataFreeRedirections?: boolean; /** The declared home: a word that spells it ({@link homeExpansionOf}) is not an expansion to refuse — unless the * command may rebind `HOME` ({@link textBindsHome}), which makes the declared home a guess. */ readonly homeDir?: string; } /** * THE classify faces' whole-string gate, as a PREDICATE over the one lexer's result: the residual space those * faces live in is what this function does not refuse. Four passes, in the order the gate's sentences were * always minted, so a refused command is refused for the same stated reason: * 1. the quote-blind class ({@link SHELL_SEGMENT_QUOTE_BLIND_REJECT}) over every written spelling — a word, a * redirection target under `tolerateRedirection` — plus every group, here-document, line-break connector * and (unless tolerated or data-free) redirection token; * 2. the expansion character where bash expands it and a `~user` / `~+` / `~-` tilde word * ({@link expandsBeyondText}), the home-directory word excepted under `homeDeclared`; * 3. a backgrounding `&` and a `|&`; * 4. a word whose value the text does not settle ({@link UNRESOLVED_SENTENCE}) — a compound array, an * off-table ANSI-C escape, a subscript or parameter expansion this lexer does not read. * A command the lexer cannot delimit is refused for the class if its text carries one (the reading the gate * gave an unbalanced string), else for the parse failure. Returns the refusal REASON, or `undefined` to proceed. */ export declare function strictResidualRefusal(lexed: ShellLex, source: string, opts?: StrictResidualOptions): string | undefined; /** What {@link splitShellCompoundSegments} produces: the connector-delimited segments, plus which of * them a PIPE fed (the read-only classifier's stdin arm needs that distinction; a rule lane does not). */ export interface ShellCompoundSegments { segments: string[]; /** Parallel to {@link segments}: was this segment preceded by `|` (rather than `;`/`&&`/`||`/nothing)? * A VIEW of {@link connectors}, derived at the one place both are minted — two rows that could drift are * two rows that will. */ pipeFed: boolean[]; /** Parallel to {@link segments}: the connector bash reached this segment through. Read by the read * boundary, where it is the difference between a directory change the shell itself proved happened and * one this walk cannot verify. A consumer that does not read it gets the unverified reading, which is * the fail-closed one. */ connectors: ShellSegmentConnector[]; /** Parallel to {@link segments}: the segments as the lexer minted them — the words (with their facts) and * the redirections of each. A consumer that tokenizes reads these; the text is for the sentences. */ lexed: readonly LexedSegment[]; } /** * Split a compound command into the segments bash would run, or refuse the whole string. * * THE one segmentation in this repository, and the one place the classify faces' residual predicate is * applied: the persisted permission-rule lane and the read-only classify face both read their segments * here, so a drift in what a deny rule is even looking at cannot open between them. * * Two steps: the trailing-terminator strip, then ONE lex ({@link lexShell}) judged by * {@link strictResidualRefusal}. The strip is OPTIONAL because it is the one step whose answer depends on * what the caller is asking. A face that asks "which programs does this run" wants it stripped (`ls;` runs * `ls`, nothing else). A face that asks "is this ONE command" must NOT strip it: `"keep"` makes `ls;` two * segments, the second empty, so the caller's own empty-command rule puts the whole string outside its lane * — which is what keeps a statement like "this rule form never matches a command containing a connector" * literally true instead of true-except-for-one-spelling. `redirection` picks which redirections the * predicate passes: none (the default, the incumbent whole-string refusal), every one (`"tolerate"` — the * refusal moves to the caller's per-segment vetting via {@link carriesShellRedirection}), or the two * data-free spellings (`"data-free"`, the read-only classify face). `homeDir` lets the word spelling the * declared home through the expansion refusal. * * Empty segments are NOT refused here (`;;`, a leading `;`, a trailing `&&`) — they come back as empty * strings, and every caller's per-segment vetting rejects them through its own empty-command rule. The * split does not judge segments; it says where they are. */ export declare function splitShellCompoundSegments(source: string, options?: { trailingTerminator?: "strip" | "keep"; redirection?: "reject" | "tolerate" | "data-free"; homeDir?: string; }): ShellCompoundSegments | { reject: string; }; /** * Compound read-only classification for the `bash` full-shell face (the shape tier). * Splits a command on the connector set `;` `&&` `||` `|` and passes every segment through * {@link coarseReadonlyCheck} (the single {@link parseLeadingCommandName} parser + bare-name allowlist). * All segments green ⇒ read-only. Returns a reason when rejected, else undefined. * * G2 — the split CARRIES QUOTE STATE (the one lexer): a connector character inside a quoted region is * argument text, not a boundary, so `grep -E "error:|BUILD FAILED" x.log | tail -10` is two segments * (`grep …` and `tail -10`) rather than four, and the phantom head `BUILD` no longer decides the verdict. * The whole compound face is what this fixes: a report of an every-command approval prompt on a read-only * monitoring pipeline traced to exactly this — a quoted alternation in a `grep -E` pattern, which is the * ordinary spelling of that idiom. * * Soundness — why the quote-aware split is NOT a relaxation. Backslash/`$`/backtick/subshells/redirection/ * line breaks are refused first (the two data-free forms of {@link isDataFreeRedirection} pass, and they * move no data into any file), so in the residual space `'`/`"` pairing is * the ENTIRETY of quoting and the mask's regions are exactly bash's quoted regions. The unquoted connectors it splits on are therefore exactly bash's command boundaries: * every program bash runs still starts at the head of some segment this classifier vetted, and its argv[0] * is the head token (a token carrying a quote/brace/glob/tilde character is rejected by the parser's * argv[0] metachar rule, and a clean token equals the executed program name). What changes versus the * older quote-blind scan is only that PHANTOM segments — heads manufactured out of quoted text, which no * program was ever going to run — are gone. Those could only ever ADD a rejection, so removing them * removes false demotions and admits no additional program. * * UNBALANCED QUOTING is a parse failure, refused whole (fail-closed). A string with an unterminated quote * is one bash cannot parse into commands at all, so there is no "true" segmentation to be accurate about. * * A LONE `&` is hard-rejected (not a connector): it backgrounds a process that survives the shell — the node * execution env spools stdio and deliberately leaves descendants alive on completion — escaping TaskStop and * background-lifecycle governance entirely. Backgrounding is not a read-only combinator, even for a listed * reader (`tail -f x &` outlives the call). `|&` is refused by name. * One TRAILING `;` is stripped (a no-op terminator); any other empty segment (`;;`, leading `;`, trailing * `&&`/`|`) rejects via the parser's empty-command rule. * * NOT here by design: no git/subcommand allowlist — "zero-flag" vetting operates * on pre-expansion text while the shell expands quotes/braces/globs into argv, and the candidate subcommands * are not provably read-only under repo config anyway. Deployments needing those forms must widen via a * dedicated widening decision, not this classifier. */ /** * The READ-BOUNDARY face of the read-only classification (opt-in). * * The allowlist vets `argv[0]` only, so until this face existed every listed reader could name ANY * path: `ls /etc` and `cat /etc/passwd` classified read-only, and a deployment whose manual-mode shell * gate auto-allows the read-only verdict served the whole filesystem with no approval prompt. CC 2.1.220 * demotes exactly these shapes (its per-command path extractor + working-directory containment check, * pretty220 @457568) and renders an approval option naming the directory (@905642 (cli250.js:66007)). * * Supplying this object turns the check ON; omitting it leaves the classifier byte-identical to its * name-only behaviour, so a consumer pinned to an older calling convention is unaffected. */ export interface BashReadonlyRootBoundary { /** Canonical containment roots — the primary root plus any additional allowed directories. An EMPTY * array means "no directory is allowed", so every path-shaped operand demotes (a bare `cat a.txt` * still does not: a name with no separator can only reach the working directory, which is the * caller's to govern). A caller that wants the check OFF omits the whole object instead. */ roots: readonly string[]; /** Base for resolving relative operands. Defaults to `roots[0]`. Callers tracking an observable `cd` * should pass the LIVE working directory, or a `..` operand will be judged against the wrong base. */ cwd?: string; /** Expansion for a leading `~` — and the value the read faces substitute for the `$HOME` / `${HOME}` word * spellings ({@link homeExpansionOf}). Without it a `~`-rooted operand is unresolvable * and demotes (with no out-of-root signal — the classifier does not know where it points, which is a * different verdict from knowing it points outside). */ homeDir?: string; /** The sensitive-path read deny judge over LEXICALLY RESOLVED operands: a hit demotes the command (ask, * never auto-allow), independently of the roots — in-root operands are judged too. Returns the matched * pattern, or null. TWO named residuals, both inherited from this classifier's declared purity * (synchronous, zero I/O — the same scope stated for the containment half): ① operand TARGET matching * only, no ancestor intersection: the judge sees the operand's own resolved spelling, never its subtree. * The RECURSIVE-reach half of ① is narrowed — a listed recursive verb's path operand under this wired * seat rides {@link CompoundReadonlyVerdict.recursiveReadPaths} (⊂ undecidedPaths), so `grep -r x ~/` no * longer auto-allows; what REMAINS is the traversal rows' stated open set (the KNOWN OPEN SET note at * the program read model's traversal rows: a traversal verb absent there that a deployment allowlists classifies by * its ordinary form and can auto-run a traversal unjudged). ② LEXICAL only HERE — an in-root symlink * whose target is guarded reads as its innocent spelling at this seat, as it does for the containment * half, because the classify auto-allow lane has no I/O seat by contract; the CANONICAL view is judged by * an execute-time recheck that asks THIS judge (the same function object, forwarded) where each candidate * really lands, on a `checkedPaths` literal and a pattern's expansions alike. * EVERY read face wires this one seat, the `bash_readonly` shell included, at BOTH of its judging * moments — roots answer WHERE a command may read and the allowlist WHICH verb runs, neither answers * WHICH FILES are off the table. What this seat is and is not is stated once, at * `shell.read_boundary.friction_floor`. */ denyMatch?: (resolvedPath: string) => string | null; /** The row a directory NAMES or contains, or `{ unplaced }` — a home-anchored row no home places, which any root may hold * ({@link import("./read-deny.js").ReadDenyMatcher.rowUnderRoot}), or null — read for a recursive read's root, the * one question the table answers about a tree without enumerating it. PRESENT-IFF {@link denyMatch} is. */ denyUnder?: (resolvedPath: string) => import("./read-deny.js").ReadDenyRootAnswer; /** What the wired {@link denyMatch} judge would apply — the ACTIVE deny patterns. Read for ONE question: * is there anything for a traversal to defeat? EMPTY ⇒ the judge refuses nothing, so a walk under it * bypasses nothing and {@link CompoundReadonlyVerdict.recursiveReadPaths} is not minted. ABSENT ⇒ the * caller did not say, which is not "none": the walk is reported, the reading every caller had before * this seat existed. It is deliberately NOT the reference implementation's question at the same place — * its deny rules are ROOTED, so "is the guarded place inside this traversal?" is lexically answerable * there; these patterns match a segment run ANYWHERE in a path ({@link * import("./read-deny.js").ReadDenyEntry}), so only emptiness is. */ denyEntries?: readonly string[]; /** The resolved read-face containment state. Under "open" the CONTAINMENT half of * this boundary is structurally satisfied (an out-of-roots operand is not a demotion; it reports * as a checked candidate instead), while the deny half above keeps judging in both faces. * Absent ⇒ "roots" (byte-compat). The `bash_readonly` face never passes this seat — its * containment is load-bearing and never opens. */ face?: "open" | "roots"; } /** * The structured verdict of {@link classifyCompoundReadonlyDetailed}. `reason === undefined` * is the read-only verdict; everything else is a demotion. */ export interface CompoundReadonlyVerdict { /** Demotion reason (the same string {@link classifyCompoundReadonly} returns), or undefined: the * command's SHAPE is not one the shape tier auto-allows. WHERE the command reads is not this * verdict's business — that is the gate's shell read station (`shell-read-check.ts`). */ reason?: string; } /** * The single minting point for the out-of-root-read approval option text, so a gate rendering * the prompt and any other consumer cannot drift apart. Shape follows CC: the option * shows the LAST segment of the grantable directory followed by a separator, not the full path. * * `directory` is the directory to be granted — a caller holding a filesystem (this classifier does not) * resolves a file operand to its parent first; CC does the same with a stat-then-dirname step. * The separator is always `/`: this classifier's path handling is POSIX-lexical throughout, where CC * uses the host's native separator. */ export declare function formatOutOfRootReadApprovalOption(directory: string): string; /** True for a token that can denote a filesystem location OUTSIDE the working directory. A bare word * (`foo`, `a.txt`, a grep pattern, an awk program) cannot: with no separator and no `..` it always * resolves under the cwd, which the caller's cwd governance already covers. * * The one spelling that would break that argument is a win-form absolute written with * backslashes only (`\\host\share\secret`, `C:\other\secret`): no `/`, so not path-shaped, yet it names * something the cwd does not contain. Two separate facts, once written as one: * · on the COMPOUND faces it is indeed unreachable — {@link SHELL_SEGMENT_QUOTE_BLIND_REJECT} refuses the * whole command as soon as it contains a backslash, wherever it stands, so no such token reaches this scan; * · on {@link classifySimpleCommandReadBoundary} — one command, no compound grammar, and therefore no * such gate — it DOES reach it. Measured on a win32-rooted boundary, before the lane split below: the operand * resolved to nothing, was dropped, and the verdict was empty, i.e. a read outside every root * AUTO-ALLOWED on the one face whose doctrine is hard refusal in exactly that direction. * The second fact is why the lane split in * {@link collectSegmentBoundaryFindings} tests {@link isAbsoluteForFamily} (an address's own family) BESIDE this predicate, * which is what the option-payload arm always did; the two now read one rule rather than two. */ export declare function isPathShapedToken(tok: string): boolean; /** Resolve one operand against the boundary's base, WITHOUT touching the filesystem. Returns undefined * when the token cannot be resolved statically — the fail-closed direction, reported as its own * demotion rather than silently treated as in-root. * * The resolution is TEXTUAL, and its limitation runs in BOTH directions (only the first shows up as * a refusal, so only that half tends to get reported): * · false REFUSAL — a non-canonical spelling of an in-root path (a symlinked `/tmp` → `/private/tmp` * prefix) does not textually match the canonical roots and is refused; * · false ALLOW — a symlink INSIDE a root that points outside it (`<root>/vendor` → `/etc`) resolves * to a textual prefix match, so `cat vendor/passwd` is judged in-root and the real command follows * the link. This face is therefore lexical containment, NOT filesystem containment: it is weaker * than the structured Read/Grep tools, which canonicalize through `realpath` before comparing * (see safety.ts's `canonicalizeAndEnforceRoot`). Closing it means resolving each candidate against * the live filesystem, which this function deliberately cannot do (it is synchronous, pure, and * shared with the approval-gate face); the seam for it is the ENFORCING caller, which holds the * ExecutionEnv. * * The verdict now carries {@link CompoundReadonlyVerdict.checkedPaths} (the * candidates that landed inside), and `createBashReadonlyTool` canonicalizes them on a LOCAL env * before running — closing BOTH directions above for that face (an escaping symlink is refused; a * non-canonical spelling that resolves back inside is no longer refused). Two residuals stay here by * construction, and belong to the caller rather than to this function: the compound/approval-gate face * has no filesystem and stays lexical, and a REMOTE env keeps the lexical behaviour (its * `canonicalPath` is an RPC per candidate). */ export declare function resolveOperandLexically(base: string | undefined, operand: string, homeDir: string | undefined): string | undefined; /** One operand's two absolute spellings. `folded` has its `..` collapsed in the TEXT and is the * containment answer — every root comparison, deny judgment and verdict field here is built from it. * `spelled` keeps those segments, the walk the KERNEL performs: each prefix resolves (symlinks included) * BEFORE a parent step applies, so `<root>/link/../x` names `dirname(realpath(link))/x`. Present ONLY * when the operand traverses `..`; a filesystem-resolving caller judges it BESIDE the folded form. */ export interface OperandSpellings { /** The `..`-collapsed absolute path — what containment is decided on. */ folded: string; /** The uncollapsed absolute path, when the operand traverses `..`. */ spelled?: string; } /** Resolve one operand into {@link OperandSpellings}, or `undefined` on the same unresolvable operands * {@link resolveOperandLexically} reports (it is this function's folded half). */ export declare function resolveOperandSpellings(base: string | undefined, operand: string, homeDir: string | undefined): OperandSpellings | undefined; /** * Does the shell REWRITE this word before the program sees it — a brace list, a parameter, command, process or * arithmetic expansion, or a `$` / backtick / brace character where bash reads it (unquoted; `$` and a backtick * also inside double quotes)? Read off the lexer's pieces. Brace expansion runs BEFORE tilde expansion and * before the word ever reaches the program, so `cat {<abs>,}` hands the program an absolute path no spelling * shows, and `ls {~,}` a home directory the `~` arm of {@link resolveOperandLexically} never got to refuse — * both were real out-of-root reads. Rather than emulate expansion (the classifier resolves paths lexically and * has no filesystem), such a word is declared unresolvable — the direction the whole file errs in. * * Deliberately NOT here: the GLOB characters ({@link globInWord}) — a glob only ever names entries of a * directory that is already on the command line — and a tilde, which the resolver reads. A bracket expression whose * body carries an expansion (`[$x]`, `[$(…)]`: bash expands the members before it matches) IS here. */ export declare function expansionInWord(w: { readonly pieces: readonly WordPiece[]; }): boolean; /** * Does this word carry PATHNAME expansion (`*`, `?`, `[`), unquoted? Split from {@link expansionInWord} because * the two have different consequences, not different detection: a brace/variable/command expansion can produce * ANY word, so it is unresolvable; a glob only ever names entries of a directory that is already on the command * line, so a caller holding a filesystem can decide it. Both quoting forms suppress globbing in bash. */ export declare function globInWord(w: { readonly pieces: readonly WordPiece[]; }): boolean; /** * Read ONE sed script and answer whether it can name a file or run a command. * * sed's command letters are a CLOSED set, which is what makes this a small recognizer over a known * grammar rather than a second hand-written parser over an open one: address → optional negation → * command letter → the argument that letter takes. Anything the walk cannot finish reading — an * unbalanced `s` delimiter, an unclosed `{`, a letter outside the set — answers `unparsed`, which * its caller treats exactly as it treats `names`: sed itself rejects those scripts, so the cost of the * conservative answer is one approval on a script that was not going to run. */ /** What one sed script NAMES: the files its `r`/`R` commands read, the files its `w`/`W` commands (and `s///w`) * write, the command lines its `e` commands run, and whether an `s///e` flag executes the pattern space. */ export interface SedScriptNames { readonly reads: readonly string[]; readonly writes: readonly string[]; readonly commands: readonly string[]; readonly executesPatternSpace: boolean; /** Why the script's names are NOT all here, when they are not: the script is supplied from a FILE whose text is not * in the command (one reading), or no reading could parse it to its end. Absent when every name is listed. */ readonly unread?: string; } /** Every file and command line the program text of a read-language row names, under EVERY implementation's grammar * (the union, deduplicated): what a candidate extraction hands the read judge (`reads`), the write face (`writes`) and * a nested plan (`commands`). `undefined` for a row whose program text the station does not read. A reading that saw * no text names nothing here — that fact rides the verdict face. */ export declare function programTextSlotFiles(row: ProgramRow, args: readonly string[]): SedScriptNames | undefined; /** The program the shell will RUN here and where its OWN argv begins — the two seats * {@link HeadedSegment.argvProgram} / {@link HeadedSegment.argvFrom} carry, from one reader so they cannot * disagree. The LEADING assignments (both spellings) are the program-position reader's own step, so this * seat no longer strips them a second time: a second strip read the quoted `'A+=x'` as an assignment the * one reader calls a program name, which is two readers with two answers. */ export declare function argvProgramOf(words: readonly LexedWord[]): { program: string; from: number; } | undefined; export declare function attachedOptionPayloads(tok: string): string[]; /** * The home directory's VARIABLE spelling as a word FACT: `$HOME` / `${HOME}` as the word's FIRST piece, * unquoted or double-quoted, followed by `/`, by a double-quoted piece, or by nothing (unquoted), or — inside * the quotes — by `/` or the closing quote; every later piece literal. Those are exactly the words whose expansion * is the DECLARED home and nothing else, so `spelled` is what bash hands the program: `<home><rest>`. Everything * else is left to be the expansion it is: a single-quoted spelling does not expand, a mid-word or suffixed one * (`x$HOME`, `$HOME.bak`, `$HOMES`) is a substitution the classify faces refuse and the read station cannot * place, and an unquoted spelling of a home bash would split or glob ({@link HOME_EXPANDS_TO_ONE_WORD}) is not * one word. The caller reads it only against a declared home (with none declared the child's `$HOME` may differ * from what `~` falls back to) and only when the command cannot rebind `HOME` ({@link textBindsHome}). */ export declare function homeExpansionOf(w: LexedWord, home: string): { readonly spelled: string; } | undefined; /** An `?(` `*(` `+(` `@(` `!(` extended-glob opener spelled UNQUOTED, over an already-lexed text: a word whose last piece * stands unquoted and whose written text ends in one of `? * + @ !`, with the source `(` right after it (across `\<newline>` * continuations only). A here-document delimiter counts too: the lexer ends * it at the `(` (`<<@(x)` reads as the delimiter `@` and a group), where bash with `extglob` on reads the one word * `@(x)` — a different delimiter, so the body the lexer read as data is not the body bash reads. The token keeps no * per-character quoting, so a delimiter quoted anywhere (`<<'E'@(X)`) counts as well: a quoted opener is bash's * syntax error, where reading it as one costs a card and runs nothing. */ export declare function extglobOpenerIn(lexed: LexedCommand, text: string): boolean; /** * {@link classifyCompoundReadonly} with the structured verdict. The name/operator/blocking arms * run FIRST and unchanged, so an existing demotion keeps its exact reason and never acquires the * out-of-root signal; the boundary scan below only ever converts a would-be READ-ONLY verdict into a * demotion, which is why omitting `boundary` reproduces the name-only behaviour exactly. */ export declare function classifyCompoundReadonlyDetailed(command: string, allow: ReadonlySet<string>, /** The environment's declared home, so the `$HOME` / `${HOME}` word spellings fold the way the read station * folds them before the shape is read. Nothing else is read off it: WHERE a command reads is the station's. */ boundary?: { readonly homeDir?: string; }): CompoundReadonlyVerdict; /** * Compound read-only classification, reason-only face. Returns the demotion reason, or * undefined when the command classifies read-only. ⚠️ `undefined` is NOT "safe to auto-execute": * the detailed verdict may still carry `undecidedPaths` (operands whose unexpanded spelling — a * glob — is what got checked), and this face discards that field. An auto-allow decision must read * {@link classifyCompoundReadonlyDetailed} and treat a non-empty `undecidedPaths` as ask — the * engine's own probe does exactly that (fs-bash.ts). The optional `boundary`: with it, * an allowlisted reader whose path arguments leave the allowed directories is demoted too (use the * detailed face when the caller wants to know WHY, e.g. to offer the narrow "allow reading from * <dir>" approval); without it the verdict is exactly what it always was. */ export declare function classifyCompoundReadonly(command: string, allow: ReadonlySet<string>, boundary?: { readonly homeDir?: string; }): string | undefined; /** * Iteration cap for {@link classifyBoundedReadonlyPollLoop}. The bound must be a LITERAL in the * command text (that is what makes "bounded" provable without executing anything); this cap keeps a * literal-but-huge bound (`$(seq 1 999999)`) from turning "bounded" into "unbounded in practice". * 120 beats × the sleep cap below ≈ 20h worst case — the walltime axis governs actual runtime; this * arm's job is only that termination is structurally guaranteed. */ export declare const POLL_LOOP_MAX_BEATS = 120; /** * Per-`sleep` cap (seconds) for {@link classifyBoundedReadonlyPollLoop}. A literal `sleep 99999999` * is technically bounded but practically a hang — the same hazard class the availability arms * demote (a green verdict that blocks until the tool timeout is green in name only). */ export declare const POLL_LOOP_MAX_SLEEP_SECONDS = 600; /** * The BOUNDED READ-ONLY POLL LOOP arm of the classify face. The compound classifier above rejects any * control structure (`for` is not an allowlisted name), which makes the shape tier behave like * the `"always"` floor for a monitoring consumer whose whole idiom is `for i in $(seq 1 8); do tail -n 5 x.log; * sleep 2; done`. This arm accepts EXACTLY that grammar and nothing else. Returns the rejection * reason, or undefined when the command matches the grammar (⇒ safe to treat as reversible). * * It is ADDITIVE and consulted only after {@link classifyCompoundReadonly} rejected (see * {@link import("./fs-bash.js").bashReversibilityProbe}): it can never change a verdict the plain face * already gives, and every unparseable shape falls through to a rejection — the default path is * untouched. * * SAFETY ARGUMENT — why this allowance does not increase irreversibility/egress risk. Each pillar * maps to a test group in test/poll-loop-classifier.test.ts (G1-G6): * G1 BOUNDED TERMINATION. The loop head must be one of three LITERAL forms with a computable * iteration count ≤ {@link POLL_LOOP_MAX_BEATS} ({@link pollLoopBeatsOf}); `while`/`until` never * match the skeleton. Every body command terminates: non-`sleep` segments inherit the availability/ * 108/153 availability arms via {@link classifyCompoundReadonlyDetailed} (blocking stdin, `tail * -f`, unbounded devices all reject), and `sleep` must be a literal ≤ * {@link POLL_LOOP_MAX_SLEEP_SECONDS}. Bounded beats × terminating body ⇒ the loop halts. * G2 NO WRITE OR EGRESS CHANNEL. Every non-`sleep` body segment must pass the SAME * {@link coarseReadonlyCheck} parser + allowlist as the plain face (one parser, no drift); * `sleep` touches neither filesystem nor network. No redirection (`>` `<`), no `$`/backtick * substitution, no subshell exists anywhere: the body bans them wholesale * ({@link POLL_BODY_HARD_REJECT}), and the head's single `$(seq <int> <int>)` exemption is an * exact-spelling match whose two arguments are literal digits. * G3 CONNECTOR CLOSURE. `;` is the only connector in the body; `|`/`&`/`&&`/`||` are banned there * outright, and a nested control structure surfaces as a `for`/`do`/`done` head token that fails * the allowlist. Nothing can chain past the vetted segment heads. * G4 READ BOUNDARY UNCHANGED. The reassembled body goes through the boundary scan of * {@link classifyCompoundReadonlyDetailed} with the caller's boundary — out-of-root operands * reject exactly as on the plain face (not relaxed by one rule). STRICTER here: a * verdict carrying {@link CompoundReadonlyVerdict.undecidedPaths} (an unexpanded glob) rejects, * because the probe face is reason-only and a repeated read of a runtime-decided target must not * auto-allow. * G5 STRUCTURE SOUNDNESS (the parser sees what bash executes). Any non-ASCII / control whitespace * ({@link NON_ASCII_WHITESPACE}) rejects the whole command up front, the reference implementation's * form. The skeleton is then read off the ONE lexer's segments and words ({@link pollLoopShapeOf}) — the * same reading bash gives the text: an unterminated quote is a parse failure (reject), a `done` hidden in * quotes is a word that is not `done`, and the var/iterable words must be written bare. The body's * segments are the lexer's, so a connector inside quotes is text, never a boundary. * G6 ENVIRONMENT INTEGRITY. The loop variable must be a SINGLE LOWERCASE LETTER: the `for` * assignment mutates a shell variable, and a name like `PATH`/`IFS`/`LD_PRELOAD`/`BASH_ENV` * would change how the body's commands RESOLVE (`for PATH in dir; do cat x; done` would run * `dir/cat` — arbitrary code under an auto-allow). No known resolution-affecting variable is a * single lowercase letter, and the body cannot read the variable anyway (`$` is banned there), * so the counter is write-only and inert. * * ACCEPTED-COST NOTES (the residuals, recorded rather than silently tolerated — same posture as the * `head` note above): * · `$(seq …)` RESOLVES A COMMAND. This is the ONE command-substitution form the ruling exempts, and * it does run whatever `seq` resolves to on PATH (or a shell function/alias of that name). Two * facts bound the exposure. FIRST, the trust assumption is ISOMORPHIC to the allowlist itself: * `ls`/`cat`/`grep` are equally PATH-resolved, so a party that can shadow `seq` can already shadow * `cat` — this arm adds no trust assumption the plain face does not already make, and the file's * standing posture is that the tool-policy gate is the authoritative control and command-name * resolution to the real coreutils binaries is assumed (see {@link BASH_READONLY_DEFAULT_ALLOW}). * SECOND, the beat count is taken from the LITERAL digits, not from seq's output, so a shadowed * `seq` emitting more items only loosens the ITERATION bound (never the read-only / no-write / * no-egress envelope — every beat still runs allowlisted readers + literal `sleep`), and the * walltime axis cascades a hard kill regardless. A deployment wanting NO command-substitution at * all can prefer `{<lo>..<hi>}` or a literal list, which bash expands internally with no command * run. (The substitution is a flagged, high-severity shape kept per the ruling that names * `$(seq <int> <int>)` as the one exemption — recorded for a * tightening decision, since the brace form is a drop-in equivalent.) * · STDIN-BLOCKING BODY (an availability residual, not a safety one). What REMAINS residual is the general * statement: this arm inherits whatever the shared availability arms can see, they are best-effort * (an operand that only turns out to be unreadable at run time is not in the text), and a hang is * bounded by the tool timeout — outside the irreversibility / egress envelope this arm governs. The * G1 termination guarantee is therefore stated for the NON-stdin-blocking body. * * NON-RESIDUAL (a divergence this arm CLOSES rather than accepts): the JS-`\s`-vs-bash-`$IFS` gap. * Because the shared parser splits on JS `\s` (which includes NBSP/U+2000-block/U+3000/form-feed/ * vertical-tab) but bash's default `$IFS` does not, `ls x` reads as head `ls` here yet is ONE * command word to bash. Rather than reason about the (harmless — nonexistent program, exit 127) * consequence case by case, the whole command is rejected up front if it carries any such character * ({@link NON_ASCII_WHITESPACE}), so the grammar's tokenization and bash's stay identical. */ export declare function classifyBoundedReadonlyPollLoop(command: string, allow: ReadonlySet<string>, boundary?: { readonly homeDir?: string; }): string | undefined; /** * The poll-loop face's STRUCTURED verdict — the same {@link CompoundReadonlyVerdict} the compound face * returns, so a consumer reads the boundary's structural findings (deny row, containment, recursive or * undecided operands) off the loop body exactly as off a plain compound, beside whatever sentence the * loop grammar or the body's shape produced. {@link classifyBoundedReadonlyPollLoop} is its `reason` * projection. A refusal of the LOOP GRAMMAR itself (not a bounded loop, an unlisted body program, an * unvetted sleep) carries no structure: no reader segment was walked. */ export declare function classifyBoundedReadonlyPollLoopDetailed(command: string, allow: ReadonlySet<string>, boundary?: { readonly homeDir?: string; }): CompoundReadonlyVerdict;