@sema-agent/core
Version:
Stateless, task-oriented AI agent core
622 lines (621 loc) • 48.3 kB
TypeScript
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;