UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

158 lines (157 loc) • 11.8 kB
/** * `planShellReads` — ONE parse of a shell command into the read candidates every shell face judges from. * * The read boundary today re-derives "which words may be read" at several sites (the compound walk, the * containment-only gate, the refused-command whitelist, the execute-time recheck), each with its own idea * of launchers, option slots, redirections and re-entry. This module is the single extraction those sites * are to consume: a pure, zero-I/O function over the one lexer's word FACTS (`src/shell/lex.ts`) and the * program-position reader (`bash-program-position.ts`), returning every path word a command MAY read, each tagged with its * PROVENANCE (why it is a candidate) and the working-directory candidates it is relative to. Establishment * (`shell-read-establish.ts`) and judgment are downstream; nothing here decides allow/ask/deny. * * Contract, in one sentence per rule: * · a LISTED reader hands its positional operands and the declared option-value slots (the program read model's * rows, `program-read-model.ts`, walked by its one argv reader — this module only maps what it read to candidates); * · an UNLISTED program hands every word of its argv (a program declared to open no path operand hands none); * · an ASSIGNMENT's value is a candidate only in a text that runs a program able to open a file; * · a LAUNCHER chain is peeled to its program and the chain's own words are never candidates; a launcher * option the row cannot read is recorded as a decline and every word after it is handed over; * · a SHELL re-entry (`sh -c S`, `eval S`, a launcher handing text to a shell, `env -S S`, a builtin's code-body * operand the shell runs later — `trap S SIG`, `alias N=S`, `mapfile -C S`) carries its text as a NESTED plan; * an argv re-entry (`xargs`) is a decline unless the target opens nothing it is handed; * · an INTERPRETER's inline program is a shape fact (`reentry:"interpreter"`), never parsed, never a finding; * · `<` targets are read candidates, `>`-family targets are write targets (tagged, not read), here-documents * and here-strings are data (an UNQUOTED here-document body's substitutions are nested plans) — except to a shell * whose argv names no script: what arrives on its standard input is its script, a nested plan where the text spells it; * · `$(…)`, backticks and `<(…)` bodies are nested plans; a word carrying one cannot be resolved from the text; * · the working-directory thread (`shell-read-workdir.ts`) moves the base the LATER segments resolve against, as a * candidate SET (`&&` proves a move, nothing else does), and gives `PWD` / `OLDPWD` their values; a loop body with * a base move is replayed per LITERAL beat and otherwise loses the base for what follows. * Every ambiguity resolves toward MORE candidates, never fewer. */ import { type LenientConnector, type LexedRedirect, type LexedWord } from "../../shell/lex.js"; import { type ShellScanDecline } from "./bash-program-position.js"; import { type StdinText } from "./shell-reentry-text.js"; import { type PlanStart } from "./shell-read-start.js"; /** WHY a word is a read candidate — a closed set, registered in `docs/CLOSED-SETS.md`. */ export declare const READ_CANDIDATE_PROVENANCES: readonly ["operand", "option_value", "stdin_redirect", "write_redirect_target", "env_assignment", "unlisted_word", "traversal_root", "cd_target", "program_path", "script_slot_file", "mount_source", "object_path"]; export type ReadCandidateProvenance = (typeof READ_CANDIDATE_PROVENANCES)[number]; /** What the caller declares about the deployment: the listed readers and the boundary the bases start from. */ export interface ShellReadDeclarations { /** The listed read verbs. They decide whether a segment is read by verb slots or word by word, never whether it hands candidates. */ readonly readers: ReadonlySet<string>; /** The declared home: a word that spells it (`$HOME` / `${HOME}` — {@link homeExpansionOf}) is named by it, * unless the command may rebind `HOME` (then `~` is unresolvable too); `~` itself is left to establishment. */ readonly homeDir?: string; /** The working directory the first segment's relative words resolve against. */ readonly cwd: string; readonly roots: readonly string[]; readonly face?: "open" | "roots"; /** The `CDPATH` the shell starts with (the environment's declared one, `ExecutionEnv.cdPath`; a bare relative `cd` is * searched through it): absent ⇒ none declared, read as unset; `unknown` ⇒ the text cannot know it. */ readonly cdPath?: readonly string[] | "unknown"; } export interface ReadCandidate { /** The folded word (quotes removed, a literal leading tilde spelled `./~`) — a pattern's name where nothing matches. */ readonly spelled: string; /** The spelling a pattern's names are matched by where the matcher widens a class (`["!"a]`, `[[:alpha:]]`: any one character). */ readonly pattern?: string; /** The raw spelling, quotes retained — where "is this metacharacter quoted" is still answerable. */ readonly raw: string; readonly provenance: ReadCandidateProvenance; /** `"glob"` = an unquoted pathname-expansion character; `"unresolvable"` = the shell rewrites the word * (brace/variable/command substitution) so its text is not the path. */ readonly expands: false | "glob" | "unresolvable"; /** The working directories this word may be relative to (a candidate SET where a `cd` could not be proven); * EMPTY for an absolute or home-rooted spelling; `"unmodelled"` for a relative word in a segment whose base the * text does not settle (an unfollowable move before it, a loop this parser cannot count) — it cannot be placed. */ readonly bases: readonly string[] | "unmodelled"; /** Only beside `bases: "unmodelled"`: the working directories the text was certainly standing in when it LOST * track (the reachable candidate set at the unprovable move; the declared cwd when nothing had moved yet). * A deny row is a floor, so the judge resolves the spelling against these for deny rows ONLY — never to * establish a read. `cd sub && cd $'\x2e' && cat <deny row under sub>` (bash: `cd .`) is refused through it. */ readonly floorBases?: readonly string[]; /** A home word the plan does not place (the text may rebind `HOME`, it sits in a value, or it is a literal tilde the * program may expand itself): spelled with the DECLARED home. The judge reads it for deny rows ONLY. */ readonly floorSpelled?: string; /** Deny rows only (never containment): a `floorSpelled` shadow, or the read copy of a word the program only writes. */ readonly denyOnly?: true; /** A WRITE target (a `>`-family redirection, a `sed` `w` file, a file-writing command's operand): established like a * read, judged on the WRITE face — the write deny rows, then the write roots where written — never as a read. */ readonly face?: "write"; /** A reading the station takes where no row says the program opens the word — a word of a program with no row, a * role only another implementation reads — so the filesystem decides it: `containment` = the spelling always meets * the deny rows and is a containment candidate only where it exists; `whole` = a whole word (`NAME=value`, an option * word) that is a candidate at all only where it exists as a file. Where the filesystem confirms it is not there (and * nothing in the text writes or creates it), it is no read; a lexical establishment keeps it whole. */ readonly speculative?: "containment" | "whole"; /** A standard device a program or redirection OPENS, its descriptor (if any) unrebound by the text ({@link markStandardDevices}). */ readonly device?: true; /** Only beside `face: "write"`: the program writes the word's CONTENT through the path, not its ENTRY ({@link passesUnasked}). */ readonly through?: true; } /** How the shell reached one segment — what the base-candidate walk needs from the connectors. */ export interface SegmentReach { /** Reached from the previous emitted segment through `&&` only: everything before it exited zero. */ readonly andJoined: boolean; /** The crossing that broke the `&&` run carried a `||`. */ readonly orBroken: boolean; /** A directory change here, once proven to have run, moves the base the later segments see. */ readonly moveProvable: boolean; } export interface ShellReadRedirect { readonly op: LexedRedirect["op"]; readonly direction: "in" | "out" | "dup"; readonly fd?: number; readonly target?: ReadCandidate; } export interface ShellReadSegment { readonly text: string; readonly connector: LenientConnector; /** Every argv word (structure removed); the shape consumers' input. The read judgment reads `reads` only. */ readonly argv: readonly LexedWord[]; /** The launcher chain in front of the program (`env`, `nice`, …) — never candidates. */ readonly launcherChain: readonly string[]; readonly verb?: { readonly name: string; readonly basename: string; readonly listed: boolean; }; /** `shell` = the program runs its argument as shell text; `argv` = its operands arrive on a stream; * `interpreter` = it runs an inline program in a language this parser does not read (a shape fact only). */ readonly reentry?: "shell" | "argv" | "interpreter"; /** Why what this segment reads is not established, as a sentence: program text the station could not read (a script supplied * from a file, text not parseable to its end, a program on standard input) or the list file the operands come from. */ readonly unreadProgramText?: string; readonly declines: readonly ShellScanDecline[]; readonly reads: readonly ReadCandidate[]; readonly redirects: readonly ShellReadRedirect[]; /** A shell re-entry's standard input: each text that may arrive there. The shell runs it where its argv names no script * (`reentryText`); a text the command spells is then a nested plan. */ readonly stdin?: readonly StdinText[]; readonly baseMove?: { readonly target: ReadCandidate | "home" | "previous" | "unmodelled"; }; /** The working directories this segment may be standing in (what every relative candidate's `bases` is), or * `"unmodelled"` when the text does not settle it. */ readonly bases: readonly string[] | "unmodelled"; /** Substitution bodies, unquoted here-document substitutions, and re-entered shell text, each its own plan. */ readonly nested: readonly ShellReadPlan[]; readonly reach: SegmentReach; /** The names a change that can reach this segment touched (`BindingTable.rebound`): any other name still holds what the * shell provides here. `all`: any name may have changed. */ readonly rebound: ReadonlySet<string> | "all"; } export interface ShellReadPlan { readonly segments: readonly ShellReadSegment[]; /** The lexer refused the whole text (an unterminated construct, nesting past the bound). UNCONDITIONAL * fail-closed for the consumer: a quote-blind re-read yields more words, never zero, so this is not "no candidates". */ readonly unreadable?: string; /** Shape facts the same lex produced, for the shape consumers; the read judgment does not read them. */ readonly shape: { readonly grouped: boolean; readonly backgrounded: boolean; readonly strayRedirection: boolean; }; } /** Plan every read candidate of one command (the contract is this module's header). */ export declare function planShellReads(command: string, decl: ShellReadDeclarations, depth?: number, start?: PlanStart): ShellReadPlan;