UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

229 lines (228 loc) • 16.5 kB
/** * The shell read STATION — the gate input that judges where a shell command reads, before approval, on * every shell face (`createShellReadCheck`), and the pre-spawn admission the shell tools call with the * same establishment (`ShellReadAdmission`). ONE pipeline, one call: * * planShellReads(text) → establish(plan, env) → judgeRead ×N → foldShellReadFindings → decideShellRead(seat) * * The station is a POLICY-class member of the gate fold: a `deny_hit` refuses outright (composing under * `deny > ask > allow`, so it outranks a hook's or a policy's ask), a mandated finding tightens a surviving * allow to a MANDATED ask (or marks an existing ask mandated), an ordinary finding tightens to an ORDINARY * ask (or marks the pass `probeBoundaryDeclined`), and `none` leaves the decision untouched. The three * tools' execute legs judge nothing: what they run is what this station admitted, and the admission only * re-establishes the SAME candidates (a symlink re-pointed while the approver was thinking) — it never * parses text or judges a word the station did not. * * That pipeline is written ONCE (`runShellReadStation`) and has two entries: the gate input above, and * {@link judgeShellRead}, the offline face. The steps between stay private — the station's working, not * its answer, and a caller composing them would be a second judgment of the same question. */ import type { ExecutionEnv } from "../../internal/harness-types.js"; import { violationDetails } from "./safety.js"; import { type ShellReadPlan } from "./shell-read-plan.js"; import { type HomeViewSource } from "./read-deny-home.js"; import { type EstablishedRead, type ShellReadBoundary, type ShellReadDisposition, type ShellReadFinding, type ShellReadSeat } from "./shell-read-judge.js"; import type { ReadRootGrantCandidate } from "../../core/tool-spec.js"; import type { ToolCallLike } from "../../core/tool-registry.js"; /** The ONE mint of the containment card — the targets a finding established OUTSIDE the roots (an out-of-root read, a * target that resolves elsewhere by the spawn). */ declare function containmentDetails(paths: readonly string[]): { readonly type: "readonly_out_of_root"; readonly code: "readonly_out_of_root"; readonly paths: readonly string[]; }; /** The ONE mint of the card of a finding that established nothing: where the command reads could not be established * (an unreadable or re-entered program text, a program named from its input, a station that could not answer). */ declare function unestablishedDetails(): { readonly type: "read_unestablished"; readonly code: "read_unestablished"; }; /** The structured details a refusal or a mandated card carries: the deny card is the read faces' own (`violationDetails`, * safety.ts), the containment and the unestablished cards are minted above. */ export type ShellReadRefusalDetails = NonNullable<ReturnType<typeof violationDetails>> | ReturnType<typeof containmentDetails> | ReturnType<typeof unestablishedDetails>; /** What the station answers for ONE call. */ export interface ShellReadVerdict { readonly disposition: ShellReadDisposition; readonly finding: ShellReadFinding; /** `refuse`: the refusal text (`Error (<tool>): …`, the five-segment deny sentence or the containment * sentence). `mandated` / `ordinary`: the ACCOUNT of the finding, for the approval card's disclosure seat * (never the ask's own message — that is the auto-mode classifier's input). Absent for `vouch`. */ readonly sentence?: string; /** `refuse`: the refusal's structured details. `mandated` on an `outside_roots` / `unestablishable` finding (and * `ordinary` on the file tools' out-of-root read): the finding's card, so an ask a person refuses or nobody can answer * still carries WHAT was found on its `tool_end` — the targets outside the roots (`readonly_out_of_root`, or the file * tools' `path_not_in_root`), or that nothing could be established (`read_unestablished`). */ readonly details?: ShellReadRefusalDetails; /** `mandated` on an `outside_roots` finding: the directory whose grant would clear this ask. */ readonly readRootCandidate?: ReadRootGrantCandidate; /** A FACT beside the disposition, for the mode that answers a file command itself: every write target this command * names was established inside the boundary's WRITE roots at every seat the program writes it (the entry, where it * lands, or both — `WriteSeat`, shell-read-write-targets.ts), off the write-protection table and off every deployment * write guard (`ShellReadBoundary.writeProtectionJudge` / `writeTargetGuard`), no removal (`rm` / `rmdir`) targets a * write root or a directory containing one, every program the command runs is in its text (no `sed -f` file, no * re-entered shell, no unreadable text), and the line's order keeps each segment's establishment true * ({@link lineOrderPlaced}). Absent ⇒ not established — the mode does not answer; the station's own table is unchanged. */ readonly writesPlaced?: true; /** A FACT beside the disposition: a write target this command names lands on the write-protection table — judged by * the table's own judge, as a write tool's target is — so the command's ask carries that table's class, whichever * mode reads it. Absent ⇒ no target the station established is covered (or no judge is mounted). */ readonly writeProtected?: true; /** A FACT beside `writesPlaced`'s absence: the command is one of the mode's file commands whose writes the station would * have placed but for an OPTION WORD on a `cp` / `mv` segment ({@link UNPLACED_OPTIONS}: any word starting with `-` — the * reference implementation's rule, "with flags requires manual approval to ensure path safety"). Names the first such * program, so the approval card and the headless refusal can say WHY the mode did not answer. Absent ⇒ not that reason * (or the writes were placed). */ readonly unplacedOptions?: { readonly program: "cp" | "mv"; }; /** A FACT beside the disposition: some read of the command could not be established (an `unestablishable` or `declined` * finding anywhere in it, whichever finding won). A mode that releases the call releases it past the read-deny table. */ readonly unestablished?: true; /** The station's admission of this call's judged target, activated only by the caller that APPLIES this verdict (the * gate's fold, the resumed leg's belt): a verdict nobody received — a wait that elapsed, a judgment that threw — * admits nothing, whenever it completes. */ readonly admit?: () => void; } /** The station seat one tool call is judged on (the `rejudge` seat is asked for by the caller). */ export type ShellReadStationSeat = Exclude<ShellReadSeat, "rejudge">; /** * The ENGINE-FILLED gate input. `judge` returns `null` SYNCHRONOUSLY when the call is not this station's * business — not a shell-running tool, no command string — and otherwise the verdict, a promise. The split * is load-bearing for the gate: a call the station does not cover must not cost the fold a suspension * point. `unanswered` is the verdict the fold substitutes when the wait elapsed or threw — fail-closed as * `unestablishable` on the call's own seat. * * Both take the CALL (`{ toolName, face }`), not a name: a station is a tighten keyed on the engine's own tool names, * so it holds a call made under ANY of the call's names (`ruleNamesOf` — a tool that answers to `Bash` under another * wire name is judged as `Bash`). The first of the names the station's membership holds keys its seat, its boundary * and the admission the engine's tool reads before it spawns; the sentences name the call's wire name. */ export interface ShellReadCheck { judge(call: ReadStationCall, args: unknown, cwd: string | undefined, signal: AbortSignal | undefined, opts: { readonly toolCallId?: string; readonly seat?: "rejudge"; }): Promise<ShellReadVerdict> | null; unanswered(call: ReadStationCall, cause: string): ShellReadVerdict; } /** The call a read station judges: its wire name and the face the gate stamped (whose `names` the station reads). */ export type ReadStationCall = Pick<ToolCallLike, "toolName" | "face">; /** The read station a call belongs to — the first of its names (the spelling first) a station's membership holds; every * station reads the call's slot and seat under that one name. */ export declare function readStationMemberOf<V>(call: ReadStationCall, members: { get(name: string): V | undefined; }): { readonly name: string; readonly value: V; } | undefined; export declare function readStationMemberOf(call: ReadStationCall, members: { has(name: string): boolean; }): { readonly name: string; readonly value: true; } | undefined; /** The pre-spawn admission the shell tools call: re-establishes what the station established for this * call and refuses when any canonical target moved. `undefined` ⇒ spawn. A call the station never judged * (no gate ran) has nothing to re-establish and spawns. */ export type ShellReadAdmission = (toolName: string, toolCallId: string | undefined, command: string, signal?: AbortSignal) => Promise<{ content: string; isError: true; details?: unknown; } | undefined>; export interface ShellReadStation { readonly check: ShellReadCheck; readonly admit: ShellReadAdmission; } export interface ShellReadStationOptions { readonly env: ExecutionEnv; /** Which tool names spawn a shell command through this leg — the station's membership. */ readonly tools: ReadonlySet<string>; /** The trust-policy seat a tool is judged on. */ readonly seatOf: (toolName: string) => ShellReadStationSeat; /** The boundary for a call, read per call (the tracked working directory moves). `undefined` ⇒ no * boundary declared, nothing to be outside of — the station vouches. */ readonly boundaryOf: (toolName: string, cwd: string | undefined) => ShellReadBoundary | undefined; /** The listed read verbs (decides whether a segment is read by verb slots or word by word). */ readonly readers?: ReadonlySet<string>; /** `true` ⇔ every root `boundaryOf` returns is already a canonical identity (the Runner's): the station never * re-resolves one, so a root swapped for a link after it was declared or granted cannot bring in the link's target. */ readonly rootsCanonical?: boolean; /** The home views of the placement the boundary's deny seats were compiled at (read-deny-home.ts) — the Runner's. Absent, * the declared home alone. */ readonly homeViews?: HomeViewSource; } /** What a consumer hands {@link judgeShellRead}: the command as written, the environment whose filesystem * answers where its words land (symlinks resolved, patterns expanded — a `NodeExecutionEnv` over a real * directory is what the gate itself runs on), the boundary it is judged against, and the trust-policy seat * it is judged on. The working directory a relative operand starts from is the boundary's own `cwd`. */ export interface ShellReadJudgeInput { readonly command: string; readonly env: ExecutionEnv; readonly boundary: ShellReadBoundary; readonly seat: ShellReadSeat; /** The listed read verbs — which words of a segment are read operands. Defaults to the classify face's. */ readonly readers?: ReadonlySet<string>; /** The face named in a refusal sentence. Defaults to `Bash`. */ readonly tool?: string; /** The home list the boundary's deny rows were compiled under (`HandsToolkitOptions.readDenyHomes`'s seat, same list, same * meaning: absolute home paths, anything else rejects naming `readDenyHomes`). Present ⇒ the judgment's home views are * that table's — the environment's declared home is always among them, every other entry is read once under a short * bound of its own — and the account database is not asked and no home is added; `[]` leaves the declared home alone. * Absent ⇒ the judgment is its own placement: the declared home and the account's home, asked once for this call. */ readonly readDenyHomes?: readonly string[]; /** Handed to the ENVIRONMENT's own primitives (`canonicalPath`, `listDir`) while the reads are * established; whether an abort stops one is the adapter's answer, and a walk short enough to finish * first finishes. It is NOT a deadline and it does not reach `boundary.alsoInside`, whose signature * carries no signal — see {@link judgeShellRead} on which layer owns the bound. */ readonly signal?: AbortSignal; } /** What {@link judgeShellRead} answers about ONE command. */ export interface ShellReadJudgment { /** What the station read out of the command's text before any filesystem was consulted. */ readonly plan: ShellReadPlan; /** Every read candidate the station established, in the plan's own order, nested plans included — each * carrying how far it got (`EstablishedRead.established`) and the target it was judged on. */ readonly establishment: readonly EstablishedRead[]; readonly verdict: ShellReadVerdict; /** The directory-scoped grant offer THIS STATION is willing to make — the verdict's candidate through the * same validator the gate applies, so a candidate that validator drops is absent here as it is there. It * is the station's offer, not the composed card's: over an ask another layer already raised, the fold * adds the mandate and nothing else, so the offer does not land there. */ readonly offer?: ReadRootGrantCandidate; } /** * The station's judgment of ONE command, OUTSIDE a run — the consumer face of everything above. Same plan, * same establishment, same judge, same table, same verdict the gate fold reads: this is * {@link createShellReadStation}'s own derivation with the admission bookkeeping left out, so a caller * holding a command, an environment and a boundary gets the decision the gate would reach for them. * * An ENVIRONMENT question, not a text question: a symlink that leaves the roots, a pattern that expands * onto a deny row, a traversal root that cannot be enumerated are answered by `env`, not guessed from the * spelling. {@link judgeShellReadLexically} is the no-environment reading and not a substitute — it is * LAXER where only a filesystem sees the escape (an in-root symlink pointing out vouches) and answers a * pattern on its spelling alone, so the two diverge in both directions. * * A read target is also judged at its HOME VIEW (a path spelled through a firmlink or a bind mount of a home, respelled * at the home). The views read the home list the boundary's rows were compiled under when the input names it * ({@link ShellReadJudgeInput.readDenyHomes}); without it this judgment is its own placement — the environment's declared * home and the account home the account database answers once, for this call (`readDenyHomesOf`'s homes). Every home * beside the declared one waits at most a short bound of its own. * * It carries NO deadline. Bounding an environment-facing wait and substituting a fail-closed verdict is the * gate FOLD's policy on the gate's one deadline seat; the station has never held one on either entry, and a * second default here would decide the same number twice and turn a slow-but-correct filesystem's true * answer into a fail-closed one unasked. A consumer wanting that posture races its own deadline and reads * the row the fold reads: `SHELL_READ_DECISION.unestablishable[seat]`. */ export declare function judgeShellRead(input: ShellReadJudgeInput): Promise<ShellReadJudgment>; /** * The station's verdict with NO environment — the lexical reading (what an isolated sandbox gets, and what a * pin or a lexical pre-read can compute): the same plan, the same judge, the same table, over the folded * spellings alone. `tool` names the face in the refusal sentence. */ export declare function judgeShellReadLexically(command: string, boundary: ShellReadBoundary, seat: ShellReadSeat, opts?: { readonly readers?: ReadonlySet<string>; readonly tool?: string; }): ShellReadVerdict; /** Mount the station for one execution environment. */ export declare function createShellReadStation(opts: ShellReadStationOptions): ShellReadStation; export {};