@sema-agent/core
Version:
Stateless, task-oriented AI agent core
243 lines (242 loc) • 18.4 kB
TypeScript
/**
* design/98 §2.5 / §E.2 (S8b, security core) — the DEFAULT-DENY governance for a sub-agent an LLM-authored
* workflow script spawns. The child spec is BUILT (complete-by-construction) from the deployment's trusted
* {@link WorkflowGovernanceBaseline} + a strict WHITELIST of safe work-fields the script may set — NEVER by
* stripping a denylist off the untrusted spec (a missed control-plane field would leak).
*
* The script's spec is UNTRUSTED. Only {@link WHITELIST_KEYS} are ever read from it; every other field
* (toolPolicy / onAsk / hooks / principal / tools / mcp / skills / lspManager / checkpointStore /
* getApiKeyAndHeaders / promptProvider / sessionId / signal / …) is structurally never copied. "Never
* copied" is a statement about the SCRIPT's spec — the child's control plane still arrives from the
* TRUSTED side, and some of it inherits from the HOST run on trusted lanes of its own: `principal` / the
* durable off-switch via `buildWorkflowPrimitives`' engine injections, `model` / `thinking` / the stated
* subagent persona via the launch site's child seats (`withWorkflowChildSeats`), and the host's
* effective approver via the run-workflow mount's base-slot fold (`RunWorkflowToolDeps.parentOnAsk` →
* `baseline.base.onAsk`, backlog #342 — filled only when the deployment did not pin the seat). Those are
* host-ctx bindings the engine writes onto the baseline/child, never a read of anything the script wrote:
* a script-authored `onAsk` (or any other control-plane key) keeps being stripped + announced. The model is
* chosen by NAME ONLY (resolved against an allowlist to a deploy-configured `Model` — the script never sees a
* `Model` object, which carries `baseUrl`/`headers` = an exfil surface). Resource limits
* are CLAMPED to the baseline + workflow ceilings (`tightenTaskSpec` only covers the safety knobs, not
* cost/token caps).
*/
import type { Model } from "../internal/llm.js";
import type { EngineNotice, ImageInput, TaskSpec, ThinkingLevel, WorkflowGovernanceBaseline } from "../core/types.js";
import { type ClampValues, type ScriptSeat } from "../core/child-clamp-seats.js";
/** Thrown when an LLM-authored script picks a `modelName` not in the workflow model allowlist (or no
* allowlist is configured). FAIL-CLOSED: a script can only ever name a model the deployment pre-approved. */
export declare class WorkflowModelNotAllowedError extends Error {
readonly modelName: string;
readonly code = "workflow.model_not_allowed";
constructor(modelName: string, reason: string);
}
/** The clamp table's script-declarable rows as a script writes them on its spec (mutable members, the shape every other
* spec field has). */
type ScriptClampSeats = {
-readonly [K in ScriptSeat]?: ClampValues[K];
};
/**
* The SAFE fields an LLM-authored workflow script may set per sub-agent — the WORK, never the control plane.
* `model` is a NAME (string), NOT a `ModelRef`/`Model` object (a `Model` carries
* `baseUrl`/`headers`/`extraBody` = endpoint + auth-header injection). No `modelRole`/`roles` (a `roles.select`
* conditional selector is its own vector). The engine resolves `modelName` against the allowlist.
*
* The TIGHTEN-ONLY declarations a script may make are the clamp table's script-declarable rows (`ScriptSeat`), typed off
* the table (`ClampValues`: a restricting row as its restricting value only, an add-only row as its list) — never a second
* list here.
*/
export interface WorkflowAgentSpec extends ScriptClampSeats {
objective: string;
/**
* The canonical spelling, and the only one this type expresses. At RUNTIME the spec ALSO accepts
* `model` as an alias for this field (see {@link SPEC_KEY_ALIASES}) — the same axis is spelled
* `model` by the Agent tool and by agents frontmatter, and a script author who reaches for the
* spelling those two taught should not silently get the deployment's default model instead. The
* alias carries a NAME exactly like this field does; a `Model` OBJECT is refused under either
* spelling.
*/
modelName?: string;
thinking?: ThinkingLevel;
systemPrompt?: string;
images?: ImageInput[];
/** Every sub-field must be a POSITIVE number — a non-positive value is a DISABLE sentinel downstream
* (or an exhausted budget), never a tighter ceiling, and is refused (see {@link NON_POSITIVE_LIMIT_MSG}). */
limits?: {
maxTurns?: number;
maxWalltimeMs?: number;
maxTokens?: number;
maxCostUsd?: number;
};
}
/**
* The SINGLE source of truth for the whitelist (a test pins that it contains no control-plane key). `objective`
* + `modelName` are handled explicitly in {@link buildGovernedChildSpec}; the rest map 1:1 onto `TaskSpec`.
*
* WHAT HAPPENS TO EVERY OTHER FIELD — {@link pickWhitelist} READS these keys and only these keys; it never
* enumerates the script's fields to judge them, which is precisely the complete-by-construction property this
* module is built on (nothing can leak by being forgotten in a denylist). The consequence is that an
* unrecognized field is STRIPPED, not refused: the spawn proceeds on the baseline as if the field had never
* been written. That silence is what {@link STRIPPED_KEYS_NOTICE_CODE} exists to close — one aggregated
* notice per governed build names the fields that did not cross the seam, so a mistyped budget axis (a
* top-level `maxCostUsd` that belongs inside `limits` since design/164, which would run the child unbounded
* on exactly the axis the author tried to bound) or a rejected containment request is visible to the operator
* instead of evaporating. Turning the strip into a LOUD REFUSAL that fails the spawn is a deliberate
* NON-GOAL of this window: it would break every existing script carrying a harmless extra field, and is held
* for a BREAKING window.
*
* The TIGHTEN-ONLY half is DERIVED: every clamp-table row a script may declare (`SCRIPT_SEATS` — the rows whose `script`
* law is not `false`). Those declarations carry no capability — everything they can express makes the child strictly
* more restricted — and each is re-checked by `tightenTaskSpec`'s own tighten-only merge below; stripping them made a
* governed script's protective intent evaporate silently, the one direction a default-deny seam has no reason to block.
* Each crosses under its row's law, by VALUE in {@link pickWhitelist}, not by key: a restricting row only as its
* restricting value, an add-only row through the row's script and entry laws. A row marked inherited-only (its
* restricting value switches off an operator-side record) is outside this set and is stripped like any other key.
*/
export declare const WHITELIST_KEYS: readonly ("thinking" | "systemPrompt" | "objective" | "images" | "limits" | ScriptSeat | "modelName")[];
/**
* #505 ② — ACCEPTED ALIAS SPELLINGS on the spec: `alias → the {@link WHITELIST_KEYS} member it means`.
*
* One axis, three faces, two words: the Agent tool takes `model`, agents frontmatter takes `model`, and
* this seam takes `modelName` (the name is not free to change here — `model` on a `TaskSpec` is the
* resolved `Model` OBJECT, and the phases meta of the workflow tool already spends the word too). Renaming
* across three surfaces buys uniformity at the price of a breaking churn in two of them; MUTUAL
* RECOGNITION buys the same authoring outcome for the price of this map. So the alias is normalized here,
* at the single door untrusted spec objects come through, and NOTHING downstream of {@link pickWhitelist}
* ever sees the alias spelling — the rest of this module, `buildGovernedChildSpec` and `resolveModelName`
* included, keeps reading exactly one field.
*
* An alias is NOT a whitelist key: `WHITELIST_KEYS` stays the closed set of fields that map onto `TaskSpec`,
* so the "no control-plane key on the whitelist" property is untouched and a `Model` object written as
* `model` is still structurally unable to reach the child (it is type-refused, not copied — see the
* normalization at the foot of `pickWhitelist`).
*
* A test pins that this map has exactly ONE member, because the normalization arm below is written for that
* one member by name: reading it generically would mean re-reading `s[canonical]` a second time, and the
* single-read discipline every other field in this file follows is worth more than a loop over one entry.
*/
export declare const SPEC_KEY_ALIASES: ReadonlyMap<string, string>;
/**
* One field of an untrusted agent spec that did NOT reach the child, and why.
* - `not_whitelisted` — the field is outside {@link WHITELIST_KEYS}: a control-plane field, a work field
* spelled at the wrong nesting level, or plain noise. It was never read.
* - `not_a_tightening_value` — the KEY is whitelisted but only its containment-TIGHTENING value may cross
* the seam, and the script wrote a different one. Today that is `readFace`, where `"roots"` tightens and
* anything else (`"open"`, or garbage) would either widen the child's read face or ask for a value the
* resolver does not define.
*/
export interface StrippedSpecKeyNote {
key: string;
reason: "not_whitelisted" | "not_a_tightening_value";
}
/** The {@link EngineNotice} family for the strip announcement (see {@link WHITELIST_KEYS}). ONE notice per
* governed build, listing every field that did not cross the seam — a governed script can carry an
* arbitrary number of unrecognized fields, and a per-field notice would turn one authoring mistake into a
* flood. Not de-duplicated across builds: each spawn is a distinct fact about a distinct spec — and
* `detail.occurrenceId` (#495) is what makes that sentence checkable by a consumer, which otherwise saw
* two builds of one spec as one repeated notice with every key equal. */
export declare const STRIPPED_KEYS_NOTICE_CODE = "workflow.governance_key_stripped";
/**
* #505 ① — the OPTIONS-side twin of {@link STRIPPED_KEYS_NOTICE_CODE}. `agent(spec, opts)`'s SECOND
* argument crosses its own membrane (`safeAgentOptions` in workflow-primitives.ts), which copies a small
* accepted set and dropped everything else in silence — a mistyped `lable`, an option that belongs on
* another seam, or a known option carrying a value its type check rejects (`label: 42`) all vanished with
* the call still succeeding.
*
* A SEPARATE code from the spec-side strip on purpose: the two membranes accept different key sets and the
* message names the set, so an operator reading `workflow.governance_key_stripped` about an OPTIONS key
* would look for it in the spec whitelist and correctly not find it. The severe half of this family — a
* SPEC field written on the options object — is not announced at all but REFUSED at the membrane (a
* silently ignored `model` runs the child on the deployment default, which is a different agent than the
* author asked for); this notice covers only the residue that is genuinely just ignored.
*/
export declare const IGNORED_AGENT_OPTIONS_NOTICE_CODE = "workflow.agent_option_ignored";
/** The announcement budget, shared with the spec-side survey: one bound, one renderer, one place. Exported
* so the surveying caller can stop COLLECTING at the same number this emitter would render. */
export declare const MAX_ANNOUNCED_KEYS = 20;
/**
* Announce the ignored option keys — one aggregated notice per `agent()` call, never one per key (an
* options object's key count is the script's to choose). `sample` is expected pre-bounded by the caller to
* {@link MAX_ANNOUNCED_KEYS}; `total` is exact. Key names are script-authored text landing on an operator
* surface, so they cross {@link renderStrippedKey} — the same scrubber the spec-side line uses, never a
* second one — and the caller quotes nothing itself.
*/
export declare function emitIgnoredAgentOptionsNotice(total: number, sample: readonly string[], accepted: readonly string[], onNotice?: (n: EngineNotice) => void): void;
/** Per-child workflow ceilings the engine forces onto every spawned agent (design/98 §D.6), independent of
* what the script asks for. The child's effective limits = min(script, baseline, these). */
export interface WorkflowChildCaps {
/** design/164 — per-child wall-clock ceiling in MILLISECONDS (was `perAgentTimeoutSec`, seconds).
* No default: a deployment that wants a ceiling states it. */
perAgentMaxWalltimeMs?: number;
childMaxCostUsd?: number;
childMaxTokens?: number;
childMaxTurns?: number;
}
/**
* RB-378 disclosure — one resource field whose EFFECTIVE (applied) value differs from what the script's spec
* literally carried. `requested` is `undefined` when the script omitted the field entirely (a baseline/cap
* ceiling was FORCED onto an unset field, not just a script value pushed down). Emitted so a caller can tell
* "the script asked for/omitted X" apart from "the child actually ran under Y" — see
* {@link buildGovernedChildSpec}'s `onResourceClamp` param.
*/
export interface ResourceClampNote {
field: "maxCostUsd" | "maxTokens" | "maxWalltimeMs" | "maxTurns";
/** What the script's spec literally set for this field, or `undefined` if it never set it. */
requested: number | undefined;
/** The effective value the child actually runs under (min of script/baseline/caps, or a forced ceiling). */
applied: number;
}
/**
* The worktree-overlay merge — `{ ...base, ...worktreeBase }` with ONE amendment: an overlay key
* whose value is OWN NULLISH (`undefined` OR `null` — both plain-JS deployment shapes) never
* overrides `base`; it reads as the absent key its value spells ("an absent overlay field inherits
* from base", the documented semantics). The GENERIC successor of the per-key protections that grew
* one field at a time (three face arrays, then `onAsk` — the mount-side deletes, which remain for
* their base-slot readings) while every OTHER same-shaped key stayed destructive: a surviving own
* nullish overlay key erased the base value — `toolPolicy` / `shellGate` / `limits` / `hooks` /
* `restoreGatedTools` / `readFace` / … — for exactly the children an untrusted script can route to
* the overlay (`isolation: "worktree"`), own-`undefined` keys included (a spread copies them; the
* old `=== null` face drop missed that half). One rule at the one merge point: a new baseline key
* is safe with no list to extend, and there is no exception list — the one key that used to give
* `null` a meaning of its own (`checkpointStore`, the durable off switch) now spells it as the word
* `"disabled"`, an ordinary non-nullish value the rule already keeps. Explicit non-nullish overlay
* values keep winning wholesale — the documented deployment override.
*
* ACCEPTED COST, stated (adversarial-review round 2): the rule is direction-blind — a CAPABILITY
* key's out-of-type overlay `null` used to WITHHOLD the base value by the same spread accident
* (e.g. `worktreeBase: { getApiKeyAndHeaders: null }` overwrote the base resolver AND defeated the
* parent-credential `=== undefined` fill in workflow.ts — silently blocking the documented
* inheritance, the same seam-defeat disease the onAsk r3 ruling deleted null for), and such a key
* now inherits per the documented contract. Deployments withhold capabilities from isolated
* children with IN-TYPE spellings (`tools: []`, a deny `toolPolicy`, `handsReadOnly: true`);
* `getApiKeyAndHeaders` currently has no in-type disable value — that expressiveness gap is a
* baseline-contract question, deliberately not solved by resurrecting undocumented null semantics.
*/
export declare function overlayWorktreeBaseline(base: WorkflowGovernanceBaseline["base"], worktreeBase: WorkflowGovernanceBaseline["base"]): WorkflowGovernanceBaseline["base"];
/** Resolve an LLM-supplied model NAME to a deploy-configured `Model`, FAIL-CLOSED against the allowlist.
* Throws (never falls open to the whole catalog) when no allowlist is configured or the name is not on it. */
export declare function resolveModelName(name: string, allowlist: string[] | undefined, models: Record<string, Model> | undefined): Model;
/**
* Build a TRUSTED child `TaskSpec` for an LLM-authored sub-agent (design/98 §2.5). Complete-by-construction:
* 1. read ONLY the whitelist work-fields off the untrusted script spec (type-checked);
* 2. resolve `modelName` → a deploy `Model` against the allowlist (fail-closed);
* 3. clamp resource limits to the baseline + workflow ceilings;
* 4. `tightenTaskSpec(baseline.base, safe)` → the child spec (baseline governance FIRST, safe work-fields
* overlay; safety fields can only equal-or-tighten — belt-and-suspenders over the whitelist).
* Every governance/control-plane field of the result comes from `baseline.base` (the deployment's trusted
* config), never from the script.
*
* RB-378: `onResourceClamp`, when given, receives the non-empty list of {@link ResourceClampNote}s produced by
* step 3 — i.e. it fires exactly when the child's effective maxCostUsd/maxTokens/maxWalltimeMs/maxTurns diverges
* from what the script's spec literally asked for (never called when nothing was clamped/forced). The clamp
* VALUES and the fact that it is always-tighten-never-raise are UNCHANGED by this param — it is a pure
* disclosure hook the caller can wire to a log/event channel (see `buildWorkflowPrimitives`, which logs it
* onto the run's log stream so the LLM-authored script's caller can see requested→applied per field instead
* of the child silently running under different limits than the script wrote).
*
* `onNotice` is the deployment's structured notice sink (`RunnerDeps.onNotice`): step 1's strip is announced
* through it as one aggregated {@link STRIPPED_KEYS_NOTICE_CODE} notice (see {@link WHITELIST_KEYS}). An
* absent sink keeps the historic loudness (`console.warn`); the delivery itself is swallow-guarded, so no
* sink can turn an announcement into a failed spawn.
*/
export declare function buildGovernedChildSpec(scriptSpec: unknown, baseline: WorkflowGovernanceBaseline, models: Record<string, Model> | undefined, caps?: WorkflowChildCaps, onResourceClamp?: (notes: ResourceClampNote[]) => void, onNotice?: (n: EngineNotice) => void): TaskSpec;
export {};