UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

331 lines (330 loc) • 26.8 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 engine's per-call sideband) 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 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 { HookSeatOutcome, 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). */ export interface SettledAsk { settlement: Settlement; origin: AskOrigin; } /** 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 attestation read (the main adjudication, the approval-edit re-check, the gate's * own ask resolutions), so the three sites cannot disagree about what an attested decision means: * - an engine attestation on a decision object (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 — read off the * identity-keyed seat, never off a caller-composable member; * - a deny carrying a REFUSAL attestation 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); * - 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). * For a resolver's object the attestation outranks the exposed member; the member is the fallback for a * resolver seat that composes without attesting (a hand-built resolver; the ask lane's own intercept * deny). TRUST BOUNDARY, stated: `ToolGateInput.resolveAsk` is wired by prepare from the engine's own * resolver — a wrapper that spreads a resolved object (dropping the identity-keyed attestation) and * REPLACES the member is inside the engine's trust line, not a deployment's; making the member * attestation-only is the follow-up that migrates the ask lane's composition and the test resolvers * onto an engine-owned mint. */ 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: the resolver's settlement beside the ask's own origin word. */ fromResolved(resolved: ResolvedAsk, asked: PermissionResult, toolName: string): void; /** Read an adjudication's attestation. For a deny, returns the refusing layer word (`ask_resolution` * for an attested refusal, `policy` otherwise — a veto when a yes rides the settlement); undefined * for an allow or an ask. */ fromDecision(decision: PermissionResult): DeniedBy | undefined; /** Read a deny's attested classifier CAUSE for this call (an inherited station's `unavailable` / * `parse_error` round travelling through an ancestor wrapper into this fold); undefined for a ruling * block, a non-classifier deny, an allow or an ask. */ causeOf(decision: PermissionResult): ClassifierDenyCause | undefined; /** Read a decision's attested CLASSIFICATION for this call — which model an ancestor's frozen classifier * asked and which answered, on the allow arm as much as the deny arm. Undefined when no inherited * classifier judged the call, or when the ancestor's decider could vouch for no name. */ 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 PreToolUse screening face as the gate's stations see it — the hook machinery of `runToolGate` (the seat * runner, the per-call seat bound, the fresh per-screening context, the result screen, the two fail-closed * reason texts) behind three methods, so the approved-edit re-screen in the lanes runs the SAME screening the * phase-1 pass ran without the lanes module reaching into the hook machinery. Present on the pass exactly when * a `preToolUse` face is wired. */ export interface PreToolUseScreening { /** Run the deployment's PreToolUse face over `args` under the seat bound. The answer is the SEAT's, unscreened — * the caller passes it through {@link PreToolUseScreening.screened} in the seat's own continuation. The seat * promise is returned as is (no wrapping `async`): what the gate does after the face answers — the policy in * phase 1, the re-check in the edit chain — runs on the tick the seat settles, as it did when this call was the * seat runner's own; a wrapper that screened the answer inside a second `async` cost one more tick there. An * expired seat is reported, not thrown; a crashing face rejects. */ screen(args: unknown): Promise<HookSeatOutcome<PreToolUseResult | undefined>>; /** The result screen over an answered seat's value: an out-of-contract decision becomes the ordinary hook deny. */ screened(answer: PreToolUseResult | undefined): PreToolUseResult | undefined; /** The model-facing text for a face that THREW while screening `subject`. */ crashReason(subject: string, err: unknown): string; /** The model-facing text for a face that never ANSWERED while screening `subject` (the timeout arm also * reports on the deployment lane; a task-abort expiry does not). */ unansweredReason(subject: string, cause: "timeout" | "aborted"): Promise<string>; /** The model-facing text for the face's OWN deny of this call (unwrapped; every exit wraps it with the leg's mark). */ deniedReason(answer: PreToolUseResult): string; /** The model-facing text for an answer the decision receipt refused (an ENGINE fail-closed, not the face's deny). */ refusedReason(answer: PreToolUseResult): string; } /** * 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: Omit<PermissionDeniedPayload, "signal">) => Promise<void>; /** borrowed-readonly — the PreToolUse screening face ({@link PreToolUseScreening}), or undefined when no * `preToolUse` face is wired (the edit chain then re-screens nothing, as before). */ 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: the model's input, rewritten by the phase-1 hook, by the policy * rewrite on the ask path, by the content-ask lane's presented snapshot 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 model's input 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 and at the * content-ask lane's presented snapshot. 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 hook answered its own `allow` (the exact word): the allow layer's hook member, which * speaks only while no policy rewrote the arguments after the hook saw them. Writer: the dispatch (phase 1). Reader: the * lanes' allow layer (its one reader). */ hookAllowed?: 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 same judge could NOT RESOLVE the target: no automatic lane may answer the * ask. Writer and reader as above; the pair is mutually exclusive (one verdict per judgment). */ writeProtectionUnresolved?: 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 hook's OWN deny in phase 1. A PreToolUse hook deny does NOT fire `permissionDenied` * (the exclusion recorded on `Hooks.permissionDenied`) — 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>;