UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

247 lines (246 loc) • 18.4 kB
/** * 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 ReadDenyEntry } from "../tools/fs/read-deny.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 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. */ export interface WorkflowAgentSpec { 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; }; /** * design/199 件A, TIGHTEN-ONLY: `"roots"` — the read-face containment judgment — is the ONLY value a * script may set. `"open"` is the WIDENING direction (it removes the containment step), so it is * stripped and announced rather than applied; the type states the asymmetry, and * {@link pickWhitelist} enforces it on the untrusted value. */ readFace?: "roots"; /** * TIGHTEN-ONLY, the write seat's twin of {@link readFace}: `"roots"` — the write-face containment * judgment — is the ONLY value a script may set. `"open"` is the WIDENING direction, so it is * stripped and announced rather than applied. */ writeFace?: "roots"; /** * design/199 件B, TIGHTEN-ONLY: additional read-deny entries. Add-only at every layer a governed * SCRIPT can reach: the baseline's entries and the deployment-ACTIVE built-in set are always in * force and a script cannot remove or replace them (`compileReadDeny` treats additions as * add-only; `tightenTaskSpec` unions). Which built-in tiers are active is a DEPLOYMENT seat * (#245: `readDenyBuiltinTiers`/`readDenyBuiltinExclude` on RunnerDeps) that no script-side key * reaches — so anything a script writes here can only ever narrow what the child may read. */ readDenyPatterns?: readonly ReadDenyEntry[]; } /** * 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. */ export declare const WHITELIST_KEYS: readonly ["objective", "modelName", "thinking", "systemPrompt", "images", "limits", "readFace", "writeFace", "readDenyPatterns"]; /** * #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;