UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

357 lines (356 loc) • 28.3 kB
/** * The tool gate's RECORD FLOOR — everything about how one gate pass is recorded and how it ends: * * - the ONE mint of a {@link GateOutcome} ({@link mintGateOutcome}) — the record of what a tool-gate pass * decided, what ask it settled and who asked. The gate accumulates two facts on its way to an exit (WHO * refused, written once at the refusing site; WHAT was settled, written at the resolution site) and hands * them here; the mint assembles the record, runs the invariant screen, and refuses a record the engine * composed wrong. Every projection of the outcome (the deny observer's payload, the call's `tool_end` * frame, the call's occurrence record) is the object minted here, never a re-derivation; * - the settlement LEDGER of a pass ({@link createSettlementLedger}) — the one rule every attestation read * goes through; * - the PASS RECORD ({@link GatePass}) — the per-call cells the gate's station groups read and write, and * the hook-machinery seats they borrow, built once per call by `runToolGate` and handed to every station; * - the EXITS — the two phase-1 short-circuits ({@link engineFailClosedExit}, {@link hookDenyExit}) and the * single deny / allow exit of the adjudicate chain ({@link exitGate}), the only places a pass turns into a * `ToolGateResult`; plus the two observer-facing helpers every exit uses (the payload isolation clone and * the deployment-lane crash report). * * A floor module (layer 0 of the prepare/run path): the two station modules (`gate-fold.ts`, `gate-lanes.ts`) * and the gate's dispatch in `hooks.ts` all reach DOWN for it, and it reaches down only for the vocabulary, * the reminder envelope and the text helpers. Its `import type` edges up to `hooks.ts` name the gate's public * contract (`ToolGateInput`, `ToolGateResult`, the observer payload), which stays declared there. */ import { type ClassifierDenyCause, type DeniedBy, type GateOutcome, type ModeRefusal, type ModeRelease, type Settlement } from "../gate-outcome.js"; import type { AutoModeClassifierRound } from "../auto-mode.js"; import { type Arbitration, type AskClassification, type AskFace, type AskOrigin, type AskOriginFacts } from "../ask-origin.js"; import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js"; import type { SafeNotifier } from "../safe-notify.js"; import type { PermissionDeniedPayload, PreToolUseResult, ToolGateInput, ToolGateResult } from "../hooks.js"; /** The ask a gate pass settled, as the gate captured it at its resolution site: the settlement the * resolver composed and the origin word the ask carried. Both or neither (invariant I1). `licensed` is the engine record's * digest of the bytes the settlement licensed: the allow exit keeps a settlement only for the bytes that run. */ export interface SettledAsk { settlement: Settlement; origin: AskOrigin; licensed?: string; } /** The facts a gate pass hands the mint. `deniedBy` is present exactly when the pass refused; `cause` only * beside it, when the refusing round was a classifier round that did not rule (the station's attestation, * read once by the fold or the classifier step); `settled` exactly when the pass consumed an ask's * settlement (a person's yes that was later vetoed included). */ export interface GateExitFacts { deniedBy?: DeniedBy; cause?: ClassifierDenyCause; settled?: SettledAsk; /** The classifier CLASSIFICATION this pass ran, when it ran one on a decider that can name its model. * Orthogonal to `deniedBy`: one that ALLOWED is attributed too (a fallback model's allow is exactly the case * an operator needs to see), which is why it rides the disposition's shared member, not the deny arm. */ classifier?: AutoModeClassifierRound; /** The permission mode RELEASED the surviving ask (the allow exit's account of it); never beside `deniedBy`. */ released?: ModeRelease; /** The permission mode REFUSED the surviving ask — only beside `deniedBy: "mode"`. */ modeRefusal?: ModeRefusal; } /** * Mint the outcome. A defect here is an ENGINE defect (a refusing site that did not say who refused, a * settlement without its origin, a person's yes beside a non-veto layer's deny) and throws: the gate's * own composition is not a caller input, so there is no fail-closed "deny" to fall back to that would be * more honest than the fault. */ export declare function mintGateOutcome(facts: GateExitFacts): GateOutcome; /** A direct refusal by one layer, with no ask settled — the five short-circuit exits (a hook that threw or * never answered, the hook's own deny, the compliance lock, the plan-mode block). */ export declare function directDeny(deniedBy: Exclude<DeniedBy, "ask_resolution">): GateOutcome; /** * The gate's settlement LEDGER for one pass — the one place "which ask did this pass consume" is written, * with ONE rule for every read, all through the decision's engine record ({@link attestationOf}): * - a record on an adjudication (the approval factory's wait ended inside a policy `check()`; an inherited * lane resolved an ancestor's ask) is a settled ask of THIS call — an approval only for the bytes it licensed (an * allow that runs other bytes runs unattributed); a deny carrying a REFUSAL record is the * ask's own resolution (`ask_resolution`), a deny carrying a person's YES is a later layer's VETO (the yes * stays on the settlement, the layer is the disposition — invariant I3), and an allow carrying a refusal * record is that refusal (invariant I4: the record says what ended the wait, whatever the decision claims); * - a resolution's record counts only when it answers THE ask the gate put to the resolver — its origin word, * its class set, the bytes the answer licenses (the record's `inputDigest`); a resolution * without such a record settled nothing the engine can attribute: a deny is recorded as the resolver's * out-of-contract refusal (`approver_contract`), an allow on a real-approval ask is refused, a plain allow * is a posture allow; a resolution still carrying the retired `settlement` member is refused loudly; * - the LATEST resolution wins (a re-check refusal replaces the first approval; a re-approval renews it); * - a SURVIVING ask supersedes any settlement read off an earlier decision (a tighten minted a new * question; whoever resolves it settles it afresh). */ export interface SettlementLedger { /** The ask this pass consumed, or undefined when no ask was settled (a direct deny, a plain allow). */ readonly settled: SettledAsk | undefined; /** An engine-composed settlement the gate itself minted (the unshown content ask's refusal). */ set(settled: SettledAsk): void; /** Record an ask's resolution; returns the resolution the gate goes on with (the resolver's, or the engine's refusal). */ fromResolved(resolved: ResolvedAsk, asked: PermissionResult, req: ToolCallRequest): ResolvedAsk; /** Read an adjudication's record; returns the decision the gate goes on with and, for a deny, the refusing layer word * (`ask_resolution` for a refusal record, `policy` otherwise — a veto when a yes rides the settlement). */ fromDecision(decision: PermissionResult): { decision: PermissionResult; deniedBy?: DeniedBy; }; /** The allow exit: the bytes that RUN are decided — a settlement licensed for other bytes leaves the record (the call runs * unattributed). */ keepForBytes(runs: unknown): void; /** A deny's recorded classifier CAUSE for this call (an inherited station's `unavailable` / `parse_error` outcome). */ causeOf(decision: PermissionResult): ClassifierDenyCause | undefined; /** A decision's recorded CLASSIFICATION for this call — which model an ancestor's frozen classifier asked and which * answered, on either arm. */ classifierRoundOf(decision: PermissionResult): AutoModeClassifierRound | undefined; /** A surviving ask supersedes whatever an earlier decision settled. */ supersede(): void; } export declare function createSettlementLedger(call: { toolCallId: string; toolName: string; }): SettlementLedger; /** * Observe-only payload isolation for {@link import("../hooks.js").Hooks.permissionDenied}: * clone the tool args before they ride the observer payload, so a hook mutating `payload.input` can never * pollute the LIVE args object (later events / audit records share it). Same posture as the postToolUseFailure * details clone (the gate-stations phase): `structuredClone` first; a non-structured-cloneable graph * (functions/handles) falls back to a SHALLOW plain object/array copy (top-level mutation isolated); * a non-object primitive passes through as-is (immutable anyway). */ export declare function cloneObserverInput(input: unknown): unknown; /** * The input a gate exit decided on, as the call's record keeps it ({@link ToolGateResult.finalInput}): a deep, * FROZEN copy — never the live object the tool receives (a tool or a later layer writing into its own arguments * must not rewrite what the gate decided), and never writable by a reader; the deny observer gets a writable * {@link cloneObserverInput} of it. A graph `structuredClone` refuses (functions, handles) is kept as a shallow * copy frozen at its own top level only — its members are the live arguments' own, and freezing those would * freeze the call's input under the tool. */ export declare function frozenInputSnapshot(input: unknown): unknown; /** What one `classic.PreToolUse` screening came to: an answer the receipt took (`undefined` = no opinion), or the * engine's fail-closed refusal of the call with its sentence (a handler that failed or broke the site's contract, * or a cancel). */ /** A screen's outcome; an answer names the registration whose answer the chain returned (`answeredBy`, absent when no * handler spoke). */ export type PreToolUseScreen = { readonly kind: "answered"; readonly answer: PreToolUseResult | undefined; readonly answeredBy?: string; } | { readonly kind: "refused"; readonly reason: string; }; /** * The PreToolUse screening face as the gate's stations see it — `classic.PreToolUse` raised on the leg's bus, the * per-screening e, the answer receipt and the fail-closed sentences behind one method, so the approved-edit re-screen * in the lanes runs the SAME screening the phase-1 pass ran. Present on the pass exactly when a handler hangs on the * event (an observe handler runs inside the screening too; it never decides). */ export interface PreToolUseScreening { /** Raise `classic.PreToolUse` over `args`; `subject` names what is screened in a fail-closed sentence. What the gate * does after the screen answers runs in the screen's own continuation. */ screen(args: unknown, subject: string): Promise<PreToolUseScreen>; /** The model-facing text for the screen's OWN deny of this call (unwrapped; every exit wraps it with the leg's mark). */ deniedReason(answer: PreToolUseResult): string; } /** The FACE a pass's questions are put on ({@link askFaceOf} of the call's wire name) — the one reading of it the * write-protection tighten (the fold) and every class derivation (the lanes) make. */ export declare function passAskFace(pass: Pick<GatePass, "req">): AskFace; /** * ONE gate pass — the record the station groups (`runGateFold`, `runGateLanes`) and the exits read and write, * built once per call by `runToolGate` before the screening pass. What used to be the closure-captured `let` * cells of a 1 500-line function are its MUTABLE cells (each written by the station named on it, read by the * ones after); what the stations borrow from the hook machinery are its SEATS (fixed for the pass). Ownership * classes follow the phase-Input convention: `borrowed-readonly` = fixed for the pass, the stations only read * it; `borrowed-mutable` = a cell a station writes. */ export interface GatePass { /** borrowed-readonly — the gate's input: every deployment seat the stations read by name (`egress`, * `orgRules`, `persistedRules`, `autoMode`, …), and the identity / reminder mark the exits stamp. */ readonly input: ToolGateInput; /** borrowed-readonly — the gate's event, read ONCE at entry (the dispatch's entry destructure): the exit compares the * final args against THIS event's input, never against a re-read of the input record. */ readonly event: ToolGateInput["event"]; /** borrowed-readonly — the deployment's policy face, captured ONCE at entry before the screening pass. The fold * adjudicates through it and the edit chain re-adjudicates through the SAME binding: a stateful accessor on the * input cannot hand the fold one policy and the approved-edit re-check another (or none). */ readonly adjudicate: ToolGateInput["adjudicate"]; /** borrowed-readonly — the ask resolver, captured ONCE at entry (same rule: the first-round resolution and the edit * round's re-ask resolve through one binding). */ readonly resolveAsk: ToolGateInput["resolveAsk"]; /** borrowed-readonly — the durable park closure, captured ONCE at entry (the three park sites hand it the same * thirteen seats; presence is judged off this one read). */ readonly suspendAsk: ToolGateInput["suspendAsk"]; /** borrowed-readonly — `input.event.toolName`, read once. */ readonly toolName: string; /** borrowed-readonly — `input.event.toolCallId`, read once. */ readonly toolCallId: string; /** borrowed-readonly — ONE read of the per-call signal for the whole gate call (the one-read * discipline the settlement screen already states): every conditional seat invocation (the three wait * seats plus the three suspendAsk park sites) judges presence and passes the value from this same cell, so * a stateful accessor cannot answer the arity check with a signal and the call with something else. */ readonly callSignal: AbortSignal | undefined; /** borrowed-readonly — the one host-callback isolation scope of this gate call (the deny observer, * the hook-error trace sink, the org and rule lanes' disclosure sinks). */ readonly notifier: SafeNotifier; /** borrowed-readonly — the settlement LEDGER of this pass (the binding is fixed; the ledger's own methods * are how the resolution sites write it — one rule per read, see {@link createSettlementLedger}). */ readonly ledger: SettlementLedger; /** borrowed-readonly — the ONE emission seat of the deny observer for this pass, bounded and contained like * every other seat (the hook machinery owns the seat runner; the exits call this). */ readonly notifyPermissionDenied: (payload: PermissionDeniedPayload) => Promise<void>; /** borrowed-readonly — the PreToolUse screening face ({@link PreToolUseScreening}), or undefined when no handler hangs * on `classic.PreToolUse` (the edit chain then re-screens nothing). */ readonly screening: PreToolUseScreening | undefined; /** borrowed-readonly — the station group that runs AFTER the fold (the lanes: org layer, rule lane, classifier, * parks, resolution, edit chain, and the exit they end in), handed the pass by the fold's LAST station on the * fold's own completing tick. A seat rather than an import because the two station modules sit on one layer * and may not name each other; a seat rather than a second `await` in the dispatch because that await is a * suspension point of its own: the org face and the rule store read HOST state the host may refresh * concurrently, and the tick on which they are consulted is the contract — when the station bodies were one * function, the org face was invoked in the same continuation that ran the last tighten, and a refresh queued * behind the policy's answer could not land in between. Returns the gate result (a park's suspension or the * exit's record). Writer: the dispatch (`runToolGate`, which wires `runGateLanes`). Reader: the fold's tail. */ readonly afterFold: (pass: GatePass) => Promise<ToolGateResult>; /** borrowed-mutable — the FINAL args, always the pass's own immutable value (`ownArgs`): the model's input as taken at * the entry, rewritten by the phase-1 hook, by the policy rewrite on the ask path and by the exits' rewrite fold. * Writers: the dispatch (phase 1), the lanes, the exits. */ currentInput: unknown; /** borrowed-mutable — the adjudication request. Minted over the pass's args so the record is whole from * the first station, and RE-MINTED by the adjudication station over the FINAL post-screen args (the * pre-existing `const req` site); `args` is re-pointed at the policy rewrite on the ask path. Writers: the * fold (mint), the lanes (`args`). */ req: ToolCallRequest; /** borrowed-mutable — the PreToolUse `additionalContext` collected for this call (pushed into; the binding * is fixed). Writers: the dispatch (phase 1), the lanes (sandbox admission note, the edit re-screen). */ readonly preToolContext: string[]; /** borrowed-mutable — the phase-1 hook's ASK, remembered for the fold. Only ever an ask (assigned under the * `r.action === "ask"` arm); typed as the narrowed member so the fold's reconstruction reads the ask-only * fields without a runtime re-test that could, if it ever failed, silently skip the promotion (i.e. loosen). * Writer: the dispatch (phase 1). Readers: the fold, the sandbox-admission conjunct. */ hookAsk: Extract<PermissionResult, { action: "ask"; }> | undefined; /** borrowed-mutable — the phase-1 screen answered its own `allow` (the exact word), naming the registration that answered * it: the allow layer's hook member (its `entries`), which speaks only while no policy rewrote the arguments after the * screen saw them. Writer: the dispatch (phase 1). Reader: the lanes' allow layer (its one reader). */ hookAllowedBy?: readonly string[]; /** borrowed-mutable — `tool.check` was raised in this pass's first adjudication, which raises it once: set by the * raise in front of the ask branch, read by the raise behind the read-only arm (it then raises nothing). Writer and * reader: the lanes. */ toolCheckRaised?: true; /** borrowed-mutable — set by a park attempt that FAILED (see `ParkAttemptFailed`). Read at the single deny * exit — whatever the fallback chain then decides to say, it says it knowing the park was tried first. * Writers: the three park sites. */ parkFailed: string | undefined; /** borrowed-mutable — WHO refused, written ONCE by the refusing site (a direct policy / org / classifier * deny, a hook re-screen refusal, the write-protection re-judge, the edit chain's cap, an ask resolution * that ended as a refusal). The exit mints the outcome from it beside the ledger's `settled`; a refusal * that reaches the exit without it is an engine defect and the mint throws. */ deniedBy: DeniedBy | undefined; /** borrowed-mutable — a refusing station's STRUCTURED details (the shell read station's refusal card), written * beside `deniedBy` by that station alone; the exit puts it on the gate result for the loop's error result. * OPTIONAL (the `probeUnanswered` form): the dispatch that builds the pass has nothing to declare here. */ denyDetails?: unknown; /** borrowed-mutable — the shell read station's containment card behind a standing MANDATED ask (an out-of-root * or unestablishable read): when that ask ends as a refusal (a person's no, nobody to ask), the exit puts the * card on the gate result, so the refusal's `tool_end` still says WHAT was found (`readonly_out_of_root`) beside * WHO refused (`ask_resolution`). Writer: the fold's read station. */ shellReadDetails?: unknown; /** The read station PLACED every write the command makes inside the write roots (`ShellReadVerdict.writesPlaced`) — the * one precondition the mode's file-command acceptance reads (the reference's path checks precede its acceptEdits arm). */ shellWritesPlaced?: true; /** The read station did NOT place the command's writes because a `cp` / `mv` segment carries an option word * (`ShellReadVerdict.unplacedOptions`) — the one non-placement reason the mode's card and the headless refusal name. */ shellUnplacedOptions?: { readonly program: "cp" | "mv"; }; /** borrowed-mutable — the FORM of the classifier round behind a deny (`unavailable` / `parse_error`), written * beside `deniedBy` by the gate's own classifier step (its attestation, read once) or by the fold / the * re-check when an inherited station's attested deny arrives through the chain; undefined for a ruling * block and every non-classifier deny. The exit mints `GateDisposition.cause` from it. */ denyCause: ClassifierDenyCause | undefined; /** borrowed-mutable — the classifier CLASSIFICATION this pass ran ({@link AutoModeClassifierRound}). * Written ONCE by the gate's classifier step from the verdict the decider handed back, on EVERY arm (allow * included — a fallback model's allow is the fact the record exists for); absent when no classifier was * consulted or the decider could name no model. Both exits mint `GateDisposition.classifier` from it. * OPTIONAL rather than `| undefined` (the `probeUnanswered` form): the dispatch that builds the pass is * not a station of this lane and has nothing to declare here. */ classifierRound?: AutoModeClassifierRound; /** borrowed-mutable — the standing verdict. The no-policy default until the adjudication station writes the * policy's answer; then rewritten by every station that changes the verdict (fold, tightens, org, rule * lane, stamp, classifier, sandbox, content lane, resolution, edit chain). */ decision: PermissionResult; /** borrowed-mutable — which of the gate's OWN post-fold stations (the tightens and the unstated station) minted the standing ask, if one did — an * input of the ask-origin derivation (`AskOriginFacts.tightened`). Written at most once per pass: every * tighten fires only over a surviving `allow`, and the first one to fire leaves an `ask` the others do not * touch. Recorded by the gate rather than read off `decisionReason` because that member is a policy's to * compose (a policy stamping `"safety"` on its own ask must read as `policy`). Writer: the fold. */ tightenedBy: AskOriginFacts["tightened"]; /** borrowed-mutable — the reversibility probe was consulted on this pass and DID NOT ANSWER (it threw or * timed out). The out-of-root mandate has no other source than the probe's answer, so an unanswered probe * leaves the call's boundary UNKNOWN: the allow layer (a stored allow rule, the read-only reader) must * not clear the surviving ask — a person confirms it. Writer: the fold. Reader: the lanes' mandate. */ probeUnanswered?: true; /** borrowed-mutable — the probe ANSWERED and reported that the read boundary DECLINED to judge where this * command reads ({@link import("../tool-spec.js").ReversibilityVerdict.boundaryDeclined}). The member above's * twin on the answered side, and narrower: the ask stays ordinary for every ANSWERER, and only the allow * layer's read-only-command arm stands down. Writer: the fold. Reader: that arm, and nothing else. */ probeBoundaryDeclined?: true; /** borrowed-mutable — the shell read station could not establish what this command reads (finding `unestablishable` * or `declined`): a mode's release of the call is a release past the leg's read-deny table, announced as such. * Writer: the fold's read station. Reader: the release point. */ shellReadUnestablished?: true; /** borrowed-mutable — the shell read station minted the standing ask's directory-grant offer * (`probeReadRootCandidate`). Read by the classify seat's shape probe, which withdraws the offer when its * own objection stands beside the station's — the grant answers the station's finding, not the shape's. * Writer: the fold's read station. */ shellReadOffer?: true; /** borrowed-mutable — the write-protection judge covered this call and its table MATCHED: the allow * layer must not clear the surviving ask, a live decider still may. Writers: the fold's fourth tighten, * on BOTH its arms (a write tool's target), and the fold's read station (a shell command's written targets, * `ShellReadVerdict.writeProtected`). Reader: the lanes' mandate. */ writeProtected?: true; /** borrowed-mutable — the READING of a written target could not say where it leads on disk (a target a shell command's * program writes, placed, whose destination the read station could not resolve): no automatic lane may answer the ask. * A fact of the reading, not of a judge. Writer: the fold's read station (`ShellReadVerdict.targetUnresolved`). Reader: * the lanes' mandate. */ targetUnresolved?: true; /** borrowed-mutable — the rewrite remembered BEFORE a hook-ask promotion / tighten, re-applied on the * approved path (a redaction is not cancelled because a person confirmed). Writer: the fold. Readers: the * fold's probe and write-protection judges, the lanes, the allow exit. */ policyRewrite: unknown; /** borrowed-mutable — the standing ask's CLASS SET and folded row (`classifyAsk`), written at the lanes' origin * stamp and at every re-mint (the denial-limit fallback, the approved-edit round); read by the classifier * station, the sandbox leg and the arbitration point. Writer: the lanes. */ askClassification?: AskClassification; /** borrowed-mutable — the mode arbitration's verdict when it RELEASED or REFUSED the standing ask (the exit * projects it onto the disposition). Writer: the lanes' arbitration point. */ arbitration?: Exclude<Arbitration, { kind: "ask"; }>; } /** * The engine's fail-closed block of phase 1 — the PreToolUse face THREW or never ANSWERED, so the engine * decides, on its own rule, that the call does not run. BOUNDARY (deliberate asymmetry with * {@link hookDenyExit} — do NOT unify the two): a hook `deny` is the HOOK's decision and the observer * exclusion covers it; a crash / an unanswered seat produces no decision at all, so the deny OBSERVER fires * here — an attribution channel whose job is "why was this call denied" must see an engine deny, or the * deployment's only evidence of a broken hook is a tool that mysteriously stops working. */ export declare function engineFailClosedExit(pass: GatePass, reason: string): Promise<ToolGateResult>; /** The refusal sentence for args the gate could not take into its own copy (`ownArgs` refused them), naming what. */ export declare const unownedArgsMessage: (subject: string, reason: string) => string; /** * The model's args could not be taken into the pass's own copy (a function, a shared buffer, a typed array): nobody judged * them, so nothing may run them. The single deny exit (the observer fires), in the policy layer's word — the value is the * deployment's own (a host driving the gate; the runner's calls are always clonable). */ export declare function unownedArgsExit(pass: GatePass, reason: string): Promise<ToolGateResult>; /** The hook's OWN deny in phase 1. A PreToolUse hook deny does NOT fire `permissionDenied` * (the exclusion recorded on `PermissionDeniedPayload`) — this return stays observer-free. */ export declare function hookDenyExit(pass: GatePass, deniedReason: string): ToolGateResult; /** * The exit of the adjudicate chain — the pass has a standing `deny` or `allow` (every ask was resolved or * parked before this), and this is where it becomes the gate result and the ONE record of the pass. */ export declare function exitGate(pass: GatePass): Promise<ToolGateResult>;