UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

276 lines (275 loc) • 22.4 kB
/** * The permission MODE's two derivations, beside the arbitration that reads it (`ask-origin.ts`): * * - {@link modePolicyOf} — what a mode INSTALLS on the leg: the plan pair and the mode's own write gate (the * reference implementation's per-mode defaults: `plan` keeps every tool mounted and ASKS at the call for a write * and for a tool it did not define that is not read-only, `acceptEdits` writes inside the root without asking, * `default` / `auto` / `dontAsk` ask on every covered write — auto's classifier and dontAsk's refusal then act on * that ask — and `bypassPermissions` installs no gate). One writer of the mode (the caller, the steer verb, the * model's `EnterPlanMode`), one interpreter (this engine): a library embedder's `plan` is not a word a serving * layer has to translate first. * - {@link PermissionModeController} — the one RESOLUTION of a leg's effective mode, read live by every mode * consumer (the gate's arbitration, the mode gate policy, the manifest, the children's ceiling): the * requested word, clamped under the parent's live ceiling, folded back to `default` where the deployment * denies it or the arming face is absent. A steer rewrites the requested word; nothing caches the answer. */ import type { AgentTool, ExecutionEnv } from "../internal/harness-types.js"; import type { ContainmentFace } from "../tools/fs/containment-face.js"; import type { PermissionResult, ToolCallRequest, ToolPolicy } from "./tool-policy.js"; import { type ToolRoster } from "./tool-roster.js"; import { type PermissionMode } from "./ask-origin.js"; import type { RuntimeCaps } from "./runtime-caps.js"; import type { RunnerDeps } from "./runner-deps.js"; import type { TaskSpec } from "./task-spec.js"; /** WHY an effective mode is not the requested one: the deployment's deny bit (`RuntimeCaps.bypassPermissions` / * `RuntimeCaps.autoMode`), a requested `auto` with no classifier face wired, or a child's declaration folded under * its parent's ceiling. */ export declare const PERMISSION_MODE_FOLD_REASONS: readonly ["operator_deny", "face_absent", "inherited_ceiling"]; export type PermissionModeFoldReason = (typeof PERMISSION_MODE_FOLD_REASONS)[number]; /** The mode's seats on a leg. `writeGate`: `none` — no mode write gate; `ask` — every covered write asks; * `accept_root` — writes inside the leg's write roots (the task root and every additional working directory) are allowed, * writes outside them ask; `plan` — every write {@link planRefusesWrite} names is the mode's OWN question (the plan * sentence, `decisionReason: "mode"`: a person may say yes, no rule and no automatic lane answers it). `fileCommands`: the * shell commands whose ordinary question the mode answers itself ({@link modeAcceptsFileCommand}) — the reference * implementation's `acceptEdits` set ("auto-approve file edits and common file commands"); empty for every other mode. * `writeFace`: the write face the mode states where no seat declared one (the mode row of {@link import("../tools/fs/containment-face.js").resolveContainmentFace}) — * `bypassPermissions` writes outside the write roots (`open`); every other mode states nothing and the default fence stands. * `unstated`: the mode's answer for a call to a tool that states no permission of its own (permission seat `passthrough`) * when no layer of the fold stated the allow — `ask` (the arbitration then answers it as any ask: dontAsk refuses it, auto's * classifier may answer it), or `none` (the call runs; `bypassPermissions`, which installs no gate and asks nothing it would * release). */ export interface ModePolicy { readonly enablePlanMode: boolean; readonly writeGate: "none" | "ask" | "accept_root" | "plan"; readonly fileCommands: readonly string[]; readonly writeFace: ContainmentFace | undefined; readonly unstated: "ask" | "none"; } /** The `acceptEdits` file commands, verbatim from the reference implementation (2.1.280): a closed set. */ export declare const ACCEPT_EDITS_FILE_COMMANDS: readonly ["mkdir", "touch", "rm", "rmdir", "mv", "cp", "sed"]; /** The mode → seats table, total over {@link PermissionMode}. */ export declare function modePolicyOf(mode: PermissionMode): ModePolicy; /** * Is this call a shell command the mode's {@link ModePolicy.fileCommands} cover — every segment a SIMPLE command * whose command word is, as written, one of them? The reference implementation's reading (it compares the first * word of each command against its list): no redirection, no pipe or background, no substitution, no leading * assignment, no wrapper in front, a literal unquoted command word — `/bin/rm` and `"rm"` are not `rm`. The answer * is only ever a license for the mode to answer the call's ORDINARY question: the gate's arbitration reads it beside * the class set and the read station's `writesPlaced` fact (every write target established inside the write roots, no * removal of a root, every program in the text), so a deny rule, an ask rule, the delete guard, a read-boundary finding * or a write the station could not place still stands. A `run_in_background` call is not accepted (as a `&` is not). */ export declare function modeAcceptsFileCommand(mode: PermissionMode, toolName: string, args: unknown): boolean; /** What the mode gate policy resolves paths against. `acceptDirs` = the leg's WRITE ROOTS (the task root plus every additional * working directory), the set `acceptEdits` allows writes inside — absent ⇒ the root alone; `exemptDirs` = the task's scratchpad, * never asked about; `roster` = the leg's live tool roster (who defined the called tool and its effect) and `readOnlyDeclared` = * whether the mounted tool of that name declares itself read-only, both read per call by {@link planRefusesWrite}. */ export interface ModeGateSeats { env: ExecutionEnv; rootPath: string; acceptDirs?: readonly string[]; exemptDirs?: readonly string[]; roster?: { readonly current: ToolRoster | undefined; }; readOnlyDeclared?: (toolName: string) => boolean; } /** Does the LAST mounted instance of this name (the one a call dispatches to) declare itself read-only — an MCP server's * `readOnlyHint` ({@link AgentTool.mcpReadOnlyHint})? The mode gate's `readOnlyDeclared` seat over the leg's live tools. */ export declare function mountedToolDeclaresReadOnly(tools: readonly AgentTool[], toolName: string): boolean; /** The ONE rule of the unstated question, read at the fold's last station and at the approved-edit round: an allow nobody * stated (no `decisionReason`) on a tool that states no permission of its own, under a mode whose `unstated` answer is * `ask`. Who may answer it is the allow layer's business (a hook's own `allow` is one of its members), not this rule's. */ export declare function asksUnstated(d: PermissionResult, seat: "passthrough" | undefined, mode: PermissionMode | undefined): boolean; /** The unstated question's sentence (the reference implementation's passthrough sentence, without its product name). */ export declare const unstatedAskMessage: (toolName: string) => string; /** One call's permission seat: `passthrough` (an allow nobody stated meets the mode's unstated answer) and the question * put when it does — absent ⇒ {@link unstatedAskMessage}. */ export interface PermissionSeatReading { readonly seat: "passthrough"; readonly question?: string; } /** The gate's permission-seat inputs (a member group of `ToolGateInput`), read through {@link permissionSeatAt}. */ export interface PermissionSeatInputs { /** The called tool's permission seat ({@link import("./tool-spec.js").ToolSpec.permissionSeat}): `"passthrough"` ⇒ an allow no layer stated * meets the mode's answer for an unstated call ({@link ModePolicy.unstated}). Absent ⇒ the tool answers for itself. */ permissionSeat?: "passthrough"; /** The seat read off THIS call's arguments, for a tool whose seat depends on them (the Runner wires it for the `Skill` * tool: a skill that declares a list asks `Execute skill: <name>`). Present ⇒ it is the reading. */ permissionSeatOf?: (args: unknown) => PermissionSeatReading | undefined; } /** * The ONE reading of a call's permission seat, taken on the arguments the gate is judging (the policy's rewrite and the * hook's included): the per-call reader where the tool's seat depends on its arguments, else the mounted tool's static * seat. Read at the fold's last station and at the approved-edit round, on that round's edited bytes. */ export declare function permissionSeatAt(input: PermissionSeatInputs, args: unknown): PermissionSeatReading | undefined; /** The PERMISSION SEAT of the LAST mounted instance of this name (the one a call dispatches to) — `passthrough` when the * instance states no permission of its own ({@link AgentTool.permissionSeat}), else `undefined` (the engine's stations and * the deployment's declarations answer for it). Read per call off the leg's live mount array, so a refresh is seen. */ export declare function mountedToolPermissionSeat(tools: readonly AgentTool[], toolName: string): "passthrough" | undefined; /** Does the LAST mounted instance of this name (the one a call dispatches to) carry its server's declaration that a call must be put to a * person ({@link AgentTool.mcpRequiresUserInteraction})? Read per call off the leg's live mount array, so a refresh is seen. The gate's * seat for the `requires_user_interaction` class. */ export declare function mountedToolRequiresUserInteraction(tools: readonly AgentTool[], toolName: string): boolean; /** * Is this call a WRITE the `plan` mode asks about? Two faces, one question — does the call write by its declaration: * · a covered path write (the catalog's protective floor: a file-write tool, or a same-name tool wearing its slot), and * · a call to a tool the engine does not define (a caller's, an MCP server's, an A2A peer's) that is not read-only — the * reference implementation's plan arm for a tool it did not write (`!isReadOnly` ⇒ the mode). Read-only is the tool's * effect on the leg's roster being `read`, or — only where that effect is the fail-closed default (nobody declared one) * — the mounted tool's own read-only declaration (`readOnlyDeclared`: an MCP server's `readOnlyHint`, which the * reference reads for this question). A declared effect (the tool's own, or the deployment's `toolAxes`) always * outranks the server's claim; the effect is the repeat-safety axis a server's self-claim never lowers. * The engine's own tools answer through their own seats: a child a delegation spawns inherits `plan` as its ceiling, and * the shell station and the tool marks raise their own questions, which reach a person under `plan` as under `default`. * A name the roster does not carry dispatches nowhere and is not judged here. */ export declare function planRefusesWrite(req: ToolCallRequest, roster: ToolRoster | undefined, readOnlyDeclared?: (toolName: string) => boolean): boolean; /** * The mode's WRITE GATE as one live policy layer: every call reads the mode ref and dispatches to the gate the * table names for it, so a steer changes the next call's gate without re-composing the chain. The three gates are * built once (their root / exemption screens run at construction). The plan gate is the ask gate with the plan * sentence in place of the ordinary write question: the same structural exemptions (the scratchpad, the advertised * writable roots — the reference exempts its scratchpad and agent-memory files under plan too) admit a write, everything * else the mode names asks. */ export declare function createModeGatePolicy(modeRef: { readonly current: PermissionMode; }, seats: ModeGateSeats): ToolPolicy; /** * The mode a resumed leg REQUESTS: the re-passed spec's word, else the word the checkpoint row recorded — except where the * resume approves or edits a `plan_review` over a row recorded in `plan`: the plan exits to the word the leg had before it * (`prePlanMode`, the reference implementation's ExitPlanMode: `prePlanMode ?? "default"`; absent ⇒ the leg's own default), * unless the resume names another mode — the approver's choice. A re-passed `plan` does not keep an approved plan open. */ export declare function modeRequestAfterResume(seat: PermissionMode | undefined, recorded: { readonly permissionMode?: PermissionMode; readonly prePlanMode?: PermissionMode; }, outcome: { readonly gate: string; readonly decision?: string; } | undefined): PermissionMode | undefined; /** * The permission MODE's door — three refusals in the pre-first-await stretch, each by name and never a fold back to * absence (a mode a caller believes it declared must not silently run as another): a RETIRED mode key on the spec * (`config.spec_retired_key` — `autoModeRequested` / `approverPosture` are one key now); a `permissionMode` outside * the closed set (`config.permission_mode_invalid`); a `shellGate` value other than the floor word `"always"` * (`config.shell_gate_value_retired` — `"off"` / `"classify"` were a doctrine the mode arbitration replaced). The * value it screened is the value it RETURNS — the mode seat reads this snapshot, never the caller's spec again. The ONE * function both entrances call: prepare (a run's spec, in its pre-first-await stretch) and the resume ladder's admission rung * (the re-supplied config, before the claim) — a resume prepare would refuse is refused before it consumes the row. */ export declare function refusePermissionModeDoors(spec: Pick<TaskSpec, "permissionMode" | "shellGate">): PermissionMode | undefined; /** The resume's ONE refusal channel for a door prepare would refuse at: runs `screen` (prepare's own function, which throws prepare's sentence and code) and * leaves a refusal of one of {@link CHECKPOINT_CONFIG_REFUSAL_CODES} as `checkpoint.config_refused` — the sentence, prepare's code as `detail.code`, the input * that carries it as `detail.field`, and prepare's own arm as `detail.reason` when its error names one. Anything else leaves as it was thrown. What the * door returns is returned (a {@link ConfigDoorEntry}: the resume's way of entering a door the config-door phase lists). */ export declare function asResumeConfigRefusal<T>(field: "taskConfig" | "deps" | "inheritedGate", screen: () => T): T; /** The three-member answer every mode question gets — the manifest member, the steer verb's return value and the * fold notice's detail share it. `requested` is absent when the caller declared nothing (a root inherits `default`, * a child inherits its parent). */ export interface PermissionModeReceipt { readonly requested?: PermissionMode; readonly effective: PermissionMode; readonly foldedBy?: PermissionModeFoldReason; } /** The arming facts the controller folds a declared mode against, bound once the deployment's caps resolved. */ export interface PermissionModeArming { readonly caps: Pick<RuntimeCaps, "bypassPermissions" | "autoMode"> | undefined; readonly autoFacePresent: boolean; /** Arm the classifier for a leg whose effective mode became `auto` after prepare (a steer, a parent's live ceiling); * idempotent. A failed arming leaves no classifier: a steer is rolled back and rejects with the failure; an arming a * read started (no caller awaits it) reports it through {@link onArmFault}. */ readonly armAuto?: () => Promise<void> | void; /** Where an arming a read of {@link PermissionModeController.current} started reports its failure. Observe-only: a throw * or a rejection from it is contained. */ readonly onArmFault?: (error: unknown) => void; } /** * The auto-mode classifier FACE a leg arms from — ONE reading for the arming fact ({@link PermissionModeArming.autoFacePresent}), * the arming itself and the face this leg hands its children: the leg's own deployment face (`RunnerDeps.autoMode`), else * the face its ancestor armed from, riding the trusted chain (`InheritedGate.autoModeFace`). A delegated child whose own * runner wires no face is classified from its ancestor's deployment face — each of a subagent's actions goes through the * same decision order as the parent's — on the child's own transcript and model; the child's own face wins where it has one. */ export declare function autoModeFaceOf(deps: Pick<RunnerDeps, "autoMode">, inherited: InheritedAutoModeFace | undefined): RunnerDeps["autoMode"]; /** The chain's carriage of that face (`InheritedGate` extends it). */ export interface InheritedAutoModeFace { /** The auto-mode face the spawning leg armed from — opaque half, never serialized; present only when that leg's deps (or its own * chain) carried one. A child whose runner wires no face of its own arms its classifier from it ({@link autoModeFaceOf}). */ autoModeFace?: RunnerDeps["autoMode"]; } /** A live ceiling: the parent leg's own controller (`current` tracks the parent's steers), or a recorded word. A ceiling * that can start something on a read (a controller, the engine's leg ceiling) also answers `observed` — the same word, * read without starting anything; one without it is read through `current` on an observation too. */ export type PermissionModeCeiling = { readonly current: PermissionMode; readonly observed?: PermissionMode; } | PermissionMode; /** * The one resolution of a leg's effective mode. `current` is derived on every read from three inputs — * the requested word (the spec's, or the newest steer's whose arming has not failed), the ceiling (a parent's live controller, or the * word a checkpoint recorded) and the arming facts — so a parent's steer reaches a running child on the * child's next read with no forwarding, and nothing holds a stale answer. */ export declare class PermissionModeController { private readonly requestedBySpec; private readonly ceiling; /** The steers that still count, oldest first: each is pending (its arming running) or took effect. A steer whose * arming fails leaves the list; one that takes effect drops the older ones (a newer steer that took effect outranks * them whatever they do next). */ private readonly steers; private arming; private readonly onFold; private prePlan; /** `onFold` hears every fold with the word the fold FOLDED — what a fold notice names: the leg's own request for the * ceiling fold (the word the parent's mode outranks); for the deployment's folds the declared word (the request after * the ceiling, or — a leg that requested nothing — the word it inherited). */ constructor(requestedBySpec: PermissionMode | undefined, ceiling: PermissionModeCeiling | undefined, opts?: { onFold?: (receipt: PermissionModeReceipt, folded: PermissionMode) => void; prePlanMode?: PermissionMode; }); /** The declared word the leg had when a steer (or the model's `EnterPlanMode`) last moved it into `plan`, while it is * there; a leg that started in plan has none, and a steer out of plan clears it. A checkpoint row records it. */ get prePlanMode(): PermissionMode | undefined; /** The word the caller asked for right now: the newest steer that still counts, else the spec's. */ get requested(): PermissionMode | undefined; private ceilingNow; /** The declared mode after the ceiling clamp — what a checkpoint records and what the leg's children inherit. */ get declared(): PermissionMode; private declaration; private armedForAuto; /** Contains the fault observer: its synchronous throw and its rejection both stop here. */ private readonly faultSeat; /** The effective mode: declared, then folded against the arming facts. A read that finds the leg in `auto` with its * classifier not yet armed (a parent's steer reached this child through the live ceiling) starts the arming — idempotent, * not awaited: the call that observed the transition may still meet the person, the next one meets the classifier. A * failure goes to {@link PermissionModeArming.onArmFault}; the leg then has no classifier, so its asks go on to the * approval seat as under `default`. */ get current(): PermissionMode; /** The effective mode read to REPORT it (a hook context), not to decide with it: the word {@link current} answers, * reached through the ceilings' own `observed`, so neither this leg nor an ancestor starts its classifier's arming. * A decision read is what starts it; an observation that did would arm a leg earlier than its own gate, and so move * who answers the leg's next ask. */ get observed(): PermissionMode; /** The three-member answer as it stands right now. */ receipt(): PermissionModeReceipt; private receiptOf; /** The receipt and, when it records a fold, the word that fold folded — one declaration read for both. */ private answerOf; /** Hand a fold to the observer with the word it folded (an answer without a fold says nothing). */ private announce; /** The construction-time announcement: a fold the leg STARTS with (a ceiling fold, before any arming is bound). */ announceStart(): PermissionModeReceipt; /** The ceiling's source moved to another leg (the parent session's next leg); `before` is this leg's receipt under the * previous one. A ceiling fold the move produced — or changed — is announced, as the construction-time fold is. */ ceilingRebound(before: PermissionModeReceipt): void; /** Bind the deployment's arming facts (once, after the caps resolved); announces a fold-back if one results. */ bindArming(arming: PermissionModeArming): PermissionModeReceipt; /** A steer: the requested word changes; the arming fold is re-judged; a leg that became `auto` arms its classifier. * When that arming fails the steer rejects with the failure, stops counting (the requested word is again the newest * steer's that still counts, else the spec's — a failed arming never undoes another steer, and never leaves the leg * in `auto` on its own word), and the arming may start again on the leg's next move into `auto`. */ steer(mode: PermissionMode): Promise<PermissionModeReceipt>; }