@sema-agent/core
Version:
Stateless, task-oriented AI agent core
229 lines (228 loc) • 16.5 kB
TypeScript
/**
* 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 {};