@sema-agent/core
Version:
Stateless, task-oriented AI agent core
1,404 lines • 106 kB
TypeScript
import type { ActorAssertion, DocumentContent, ImageContent, TextContent } from "../internal/llm.js";
import type { ExecutionEnv, FileError, Result, SessionTreeEntry } from "../internal/harness-types.js";
import type { DecisionReason, PermissionResult, ResolvedAsk, ToolCallFace, ToolCallRequest, ToolPolicy } from "./tool-policy.js";
import { type CallTargetStamp } from "./effective-path-target.js";
import type { GateOutcome } from "./gate-outcome.js";
import { type PreToolUseScreening } from "./runner/gate-exit.js";
export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./rule-lane-reads.js";
import { type AskClass } from "./ask-class.js";
import { type PermissionMode } from "./ask-origin.js";
import { type AutoModeDenialTracker } from "./auto-mode.js";
export { formatHookFeedback } from "./reminder-mint.js";
import type { WiringLegKind } from "./wiring-manifest.js";
import type { PermissionSeatInputs } from "./mode-policy.js";
import type { SkillGateSeats } from "./skill-grant.js";
import type { AllowEntryGateSeats } from "./allow-entry.js";
/**
* The classic stations' faces and the two-phase tool gate. A deployment extends the engine through the mods bus
* (`RunnerDeps.mods` / `TaskSpec.mods`, `src/core/mods/`): each sema station raises one `classic.*` event, and the
* types here are those events' `e` shapes and answer forms. All feedback the model should see (a block reason,
* injected context) is wrapped as a `<system-reminder>` via {@link formatHookFeedback} so the model can read and
* self-correct.
*
* **FAILURE POSTURE, and a correction to a widely-quoted anchor (recorded 2026-08-23).** "CC fails open on hook
* failures, exit code 2 excepted" is TRUE OF ONE LANE: CC's `command` hooks (shell scripts) are non-blocking on a
* spawn failure, a non-zero exit or a timeout. CC's in-process CALLBACK lane — the one structurally equivalent to a
* handler here — is FAIL-CLOSED on PreToolUse (a callback that throws or times out does not execute the call) and
* its UserPromptSubmit timeout BLOCKS the prompt. So the two decision events here (`classic.PreToolUse`,
* `classic.UserPromptSubmit`) refuse on a handler's failure (the event table's failure column), and the observation
* events skip the failed handler, because an observer has nothing to fail closed about.
*/
/** The members every classic event's `e` carries for the leg it fires on. */
export interface ClassicEventBase {
/** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
readonly identity?: HookInvocationIdentity;
/** The delegated leg's id (its task id — the key its `task_progress` frames and its parent's fleet row use).
* Absent on a top-level leg. */
readonly agentId?: string;
/** The leg's EFFECTIVE permission mode when the event is raised — the mode seat's `observed` read (it arms no
* classifier). Absent where there is no leg seat: a host-driven {@link runToolGate}, the delegation-fold twin, the
* `"resume_message"` screen (before the resumed leg exists), and the compaction / StopFailure events (the
* reference's hook input omits it there). Not a version signal. */
readonly permissionMode?: PermissionMode;
}
/**
* `classic.PreToolUse`'s `e` — raised before a tool executes (the gate's phase 1, the approved-edit re-screen, the
* resumed leg's re-screen of an approved call). Pinned: `tool`, `tool_use_id`, `agentId`, `input` (what a handler
* judges is what runs — a rewrite is the answer's `updatedInput`). Answer: {@link PreToolUseResult}; a handler with no
* opinion returns `next(e)`. A handler's `allow` does NOT circumvent the policy: the policy still runs on the (possibly
* rewritten) args and has the final say ({@link runToolGate}).
*/
export interface PreToolUseEvent extends ClassicEventBase {
/** The call's wire name (the tool the call dispatches to). */
readonly tool: string;
readonly tool_use_id: string;
/** A deep-frozen copy of the args the gate judges. */
readonly input: unknown;
/** The TASK's tracked working directory at this call — the base the hand tools resolve a RELATIVE path against,
* read live off the same ref the tool policy reads. NOT {@link HookEnvCapabilities.cwd} (the environment's own,
* which never moves on a shell `cd` or a worktree entry). ABSENT when the run has no tracked cwd to report (no real
* write hands, a host-driven gate without one, the delegation-fold twin) — absence means "this engine cannot tell
* you", never "the root". */
readonly cwd?: string;
/** The read-only path-resolution face over the env the hands run against ({@link HookEnvCapabilities}). ABSENT when
* the deployment wired no execution environment at all. */
readonly env?: HookEnvCapabilities;
}
/** `classic.PostToolUse`'s `e` — after a tool executed successfully. Answer: {@link PostToolUseResult}. */
export interface PostToolUseEvent extends ClassicEventBase {
readonly tool: string;
readonly tool_use_id: string;
readonly input: unknown;
readonly output: HookToolOutput;
readonly cwd?: string;
readonly env?: HookEnvCapabilities;
}
/**
* `classic.PostToolUseFailure`'s `e` — after a tool EXECUTION failed (mutually exclusive with `classic.PostToolUse`).
* Raised ONLY for genuine execution failures: a gate/policy block, a plan-mode deny, a pre-execution abort or a durable
* suspend raises neither post event. Answer: {@link PostToolUseFailureResult} (context only; the failure itself cannot
* be rewritten).
*/
export interface PostToolUseFailureEvent extends ClassicEventBase {
readonly tool: string;
readonly tool_use_id: string;
readonly input: unknown;
readonly failure: HookToolFailure;
readonly cwd?: string;
readonly env?: HookEnvCapabilities;
}
/**
* `classic.PostToolBatch`'s `e` — raised ONCE after every tool call of one assistant turn resolved, before the next
* model request (the per-call post events fire first). A zero-tool turn, an aborted or steered-away boundary raise
* nothing. On a boundary where the engine itself injected (`injectedThisTurn`) the batch is DELIVERED with that
* attribution and the answer's `additionalContext` is IGNORED (no stacking onto an engine injection). Answer:
* {@link PostToolBatchResult}.
*/
export interface PostToolBatchEvent extends ClassicEventBase {
readonly batch: readonly PostToolBatchCall[];
readonly injectedThisTurn?: "final_verification" | "finalize";
}
/**
* `classic.UserPromptSubmit`'s `e` — EVERY user-lane entrance: the objective, a live steer, a wake resume's message
* (pre-claim, at the verb) and a parked steer's redelivery, discriminated by {@link source}. Pinned: `source`,
* `inputId`, `actor`. A handler that blocks unconditionally therefore refuses steers too — branch on `source` (and
* `actor`). Answer: {@link UserPromptSubmitResult}. Block per entrance: objective ⇒ the task ends `blocked`; live steer
* ⇒ typed `steering.blocked_by_hook`; resume message ⇒ typed pre-claim refusal (the checkpoint stays pending); parked
* redelivery ⇒ the frame is withheld (a `steering.parked_input_blocked` notice) and the resume proceeds.
*
* ⚠️ RE-ENTRANT within one leg: the steer verb goes live before the objective screen runs, so screens can overlap in
* flight; only steer-vs-steer is serialized (FIFO — each queued steer waits behind its predecessor's whole screen).
*/
export interface UserPromptSubmitEvent extends ClassicEventBase {
/** The caller's text, raw. */
readonly prompt: string;
/**
* WHICH entrance: `"objective"` (the task's opening prompt), `"steer"` (a live mid-run steer), `"resume_message"`
* (a wake resume's message, screened pre-claim), `"parked_redelivery"` (a parked steer re-screened on the CURRENT
* process when any resume kind that drains parked steers redelivers it). ⚠️ DOUBLE ARRIVAL IS CONTRACTUAL: one input
* can arrive twice (screened live as `"steer"`, parked, re-screened as `"parked_redelivery"`) — idempotency is the
* handler's, keyed on {@link inputId}. `"followUp"` is a RESERVED spelling: no entrance carries it yet.
*/
readonly source: "objective" | "steer" | "resume_message" | "followUp" | "parked_redelivery";
/**
* The screened input's correlation id — present on every entrance but the objective (no id exists there; nothing is
* fabricated): the steer's effective id, the parked entry's stored id, or the wake message's id (the caller's, else
* minted PER CALL — a retried wake with no caller id re-screens under a fresh id; a host that retries wakes mints its
* own). The dedup key for the double-arrival contract.
*/
readonly inputId?: string;
/** The API-boundary actor snapshot attributed to this input (the one the `human_input` event reports), when the caller
* supplied one. Attribution only, never authority. */
readonly actor?: ActorAssertion;
/**
* The files declared on this objective (`TaskSpec.userAttachments`), in order — present only on `source:"objective"`
* when the objective declares any. A path entry shows its path; a host entry its name and byte count, never its
* bytes. Blocking the objective reads and delivers none of them. A handler does not govern the engine's reading of an
* allowed attachment (`classic.PreToolUse` is not raised for it either): a deployment that must keep a path from the
* model writes a deny rule, which the attachment read honors like the Read tool does.
*/
readonly userAttachments?: readonly import("./user-attachment.js").UserAttachmentDeclaration[];
}
/**
* `classic.Stop`'s `e` — raised when the agent WOULD OTHERWISE END its run (no more tool calls, steering and follow-up
* queues dry). Answer: {@link StopHookResult}; `{ block }` pushes back (the reason is injected and the run continues one
* more turn). Guardrails (CC-exact): consecutive push-backs are capped (default 8 — the loop then overrides and ends with
* a warning); `stopHookActive` is true while the run is already continuing because of one. `maxTurns` outranks it.
*/
export interface StopEvent extends ClassicEventBase {
/** True when this run is already continuing because a previous answer pushed back — check it and let the run end
* once your condition can't be improved, or it loops to the cap. */
readonly stopHookActive: boolean;
/** Consecutive push-backs so far in this run (resets when the run is let proceed). */
readonly consecutiveBlocks: number;
/** A LAZY view of the conversation being judged (a handler that only reads the counters pays nothing). Absent when
* the runner cannot supply one. */
readonly getBranch?: () => Promise<readonly SessionTreeEntry[]>;
/** The run's background tasks still in flight (pending / running / parked under this run's owner triple; CC
* `SubagentStop.background_tasks`). LAZY like {@link getBranch}; `[]` = none, ABSENT = no view. */
readonly backgroundTasks?: () => ReadonlyArray<{
id: string;
description?: string;
status: string;
}>;
}
/**
* `classic.StopFailure`'s `e` — raised ONCE, after the result is assembled, when the task terminates `failed` BECAUSE
* the model/API layer errored. NOT for engine-owned failure classes (budget, timeout/abort, max-turns, degenerate /
* walltime cutoffs, storage conflict, blocked, a durable suspend). Observe-only.
*/
export interface StopFailureEvent extends ClassicEventBase {
/** Human-readable error message (the assembled `errorMessage`; any `[code]` prefix already stripped). */
readonly error: string;
/** Machine-readable kind — the assembled `errorCode` (auth / network / rate_limit / server_error / …); absent when
* the provider stamped no code. */
readonly errorKind?: string;
/** Turns completed when the failure ended the run. */
readonly turns: number;
}
/** `classic.PreCompact`'s `e` — before each compaction, after the trigger gate and a valid cut point. Answer:
* {@link PreCompactResult} (`block` honored on "auto" / "manual" only). */
export interface PreCompactEvent extends ClassicEventBase {
readonly trigger: PreCompactContext["trigger"];
readonly customInstructions?: string;
}
/** `classic.PostCompact`'s `e` — observe-only, after the compaction landed. */
export interface PostCompactEvent extends ClassicEventBase {
readonly trigger: PostCompactContext["trigger"];
readonly summary: string;
readonly tokensBefore?: number;
readonly tokensAfter?: number;
readonly freedTokens?: number;
}
/** `classic.PermissionDenied`'s `e` (CC-exact fields + the gate outcome) — raised when the gate's adjudicate chain
* DENY-SHORT-CIRCUITS a call (a policy deny, a tighten whose ask resolved to deny, the plan-mode write-deny, the
* compliance deny) and on the engine's fail-closed refusal of a failed PreToolUse screening (`source:"hook"`); NOT on a
* PreToolUse handler's own deny (that is the handler's decision, and this event attributes the ENGINE's) nor on a
* durable suspend. Observe-only. `identity` is present on every ENGINE emission; absent only when a host drives
* {@link runToolGate} without `ToolGateInput.identity`. (CC raises its PermissionDenied from the auto-mode classifier's
* refusals alone; sema's trigger surface is deliberately WIDER — a divergence, not a parity claim.) */
export interface PermissionDeniedPayload extends ClassicEventBase {
readonly toolName: string;
/** The FINAL (post-rewrite) args the chain adjudicated — what would have executed; not necessarily the model's
* original args. A detached copy (the live args are shared with later frames and audit records). */
readonly input: unknown;
readonly toolCallId: string;
/** Human/model-readable deny reason (CC `reason`) — the RAW decision text, NOT `<system-reminder>`-wrapped. */
readonly reason: string;
/**
* The gate outcome this deny is — the SAME object the call's `tool_end` frame carries as `gate` and the engine's
* per-call sideband records (one mint, three faces). `disposition.deniedBy` says which LAYER refused; when the
* refusal came through an ask resolution, `settlement` says how the wait ended and `origin` who asked.
*/
readonly gate: GateOutcome;
}
/** The failed tool execution a PostToolUseFailure callback inspects (design/134). */
export interface HookToolFailure {
/** Model-facing error text of the failed call (the error tool result's text content, joined). */
error: string;
/** Wide any-abort semantics (⊇ CC `is_interrupt`, which is user-interrupt specifically): true when
* the task abort signal fired — timeout, budget, cancel or suspend — not only a human interrupt. */
isInterrupt: boolean;
/** Tool execution time in ms when the engine has it (CC 0.3.202 `duration_ms` parity); else absent. */
durationMs?: number;
/** Element-level clone of the error result's content blocks (mutating them cannot rewrite the
* transcript — the failure is observe-only). */
content: Array<TextContent | ImageContent | DocumentContent>;
/** BEST-EFFORT deep clone (structuredClone) of the tool's raw details. When the details graph is
* not structured-cloneable (functions/handles), this is the LIVE reference — treat it as
* READ-ONLY: a mutation would corrupt the result the loop is about to commit. */
details: unknown;
}
/** A PostToolUseFailure result: append model-readable context to the error result (CC-exact, no rewrite). */
export interface PostToolUseFailureResult {
additionalContext?: string;
}
/** One resolved call in a postToolBatch payload (CC PostToolBatchToolCall shape, thin projection —
* `response` is a bounded text digest of the result, never the full details object). */
export interface PostToolBatchCall {
/** The name the model spelled on the tool_use block (an alias stays an alias) — the reference's PostToolBatch payload
* names each block the same way, while the per-call post hooks receive the resolved tool's wire name. Correlate
* the two faces by `toolCallId`. */
toolName: string;
input: unknown;
toolCallId: string;
response?: string;
isError: boolean;
}
/** A PostToolBatch result: inject context once for the whole batch at the turn boundary. */
export interface PostToolBatchResult {
additionalContext?: string;
}
/** What the compaction module hands its `preCompact` option (design/134); the Runner raises `classic.PreCompact` from it. */
export interface PreCompactContext {
/** "auto" = threshold-triggered; "manual" = /compact; "forced" = promptTooLong recovery or
* trim-pressure propagation (block is ignored on forced — the compaction is not optional). */
trigger: "auto" | "manual" | "forced";
/** The summarization instructions in effect (spec/deployment-level), when set. */
customInstructions?: string;
}
/** A PreCompact result: skip this compaction (auto/manual only) and/or extend the summary instructions. */
export interface PreCompactResult {
/** When set (and trigger is not "forced"), this compaction is skipped; the reason is traced. */
block?: string;
/** Appended to the summarization instructions for this compaction (never replaces them).
* LLM-summary path only: a compaction served from a `summaryProvider` reuse (Seam C) never runs
* the summarization call, so these instructions are not consumed there (recorded design gap —
* extending the provider contract is out of scope for design/134). */
additionalInstructions?: string;
}
/** What the compaction module hands its `postCompact` option (design/134, observe-only); the Runner raises `classic.PostCompact` from it. */
export interface PostCompactContext {
trigger: "auto" | "manual" | "forced";
/** The conversation summary the compaction produced (CC `compact_summary` parity). */
summary: string;
/** DIAGNOSTIC, NOT subtractable: prepareCompaction's coordinate, LACKING the overhead term
* {@link tokensAfter} carries. Use {@link freedTokens}. */
tokensBefore?: number;
/** DIAGNOSTIC, NOT subtractable: always structural + prompt overhead, a different coordinate from
* {@link tokensBefore}, so the difference can go negative. Absent when the post measurement failed. */
tokensAfter?: number;
/** The engine's measured delta (clamped ≥ 0) — the ONE subtractable number here, and the same value
* this pass's `compacted` frame carries. Absent ⇒ the post measurement failed. */
freedTokens?: number;
}
/** A Stop hook result: block the run from ending, with a model-readable reason. */
export interface StopHookResult {
/** When set, the run does NOT end: this reason is injected (as a `<system-reminder>`) and the
* model gets another turn to address it. */
block?: string;
/**
* Extra model-readable context injected at the stop point — DECOUPLED from `block`. When present it
* is injected (as a `<system-reminder>`, neutral framing: "Stop hook additional context: …") whether
* or not the hook blocked, and an `additionalContext` WITHOUT `block` still continues the run one
* more turn.
*
* **It DOES count toward the consecutive-block cap, and it never resets it.** This sentence used to
* say the opposite ("the cap is driven by `block` alone", cited to CC 2.1.201), and it was wrong on
* both halves. Re-read against CC: the `additionalContext` branch pushes into the SAME array as the
* blocking-error branch, the loop counts that array's length against the cap, and CC never resets
* mid-run. The engine was fixed to match; the sentence here was not, so the module that DEFINES this
* seat and the module that IMPLEMENTS it stated opposite behaviors for years. What the cap bounds is
* "the run did not end when it otherwise would have", and a context-only push-back is exactly that —
* a hook whose most common failure mode is "no parseable verdict, here is some text" would otherwise
* run to `maxTurns` looking busy rather than stuck.
*/
additionalContext?: string;
}
/**
* A READ-ONLY path-resolution capability face over the task's {@link ExecutionEnv}, handed to tool hooks
* on {@link PreToolUseEvent.env} (and the two post events' `env`).
*
* **Why it exists.** A hook that judges a path — "is this write inside the workspace", "does this
* innocent-looking name resolve onto a guarded file" — must resolve it in the filesystem the HANDS
* actually write to. For a sandboxed/remote deployment that filesystem is NOT the host process's `fs`:
* the env is minted by `RunnerDeps.executionEnvFactory` DURING prepare, i.e. after the spec (and any
* policy/hook the caller built with it) already exists, so a hook closed over a spec-time env resolves
* against the wrong disk and answers `exists`/symlink questions about a different machine. Reading the
* env off the CALL CONTEXT removes the ordering problem structurally: the context is built after the env
* is, and a resumed/revived run's rebuilt env is picked up with no re-instantiation protocol.
*
* **Capabilities, not the object.** A classic handler is an extension boundary the deployment fills with code whose
* job is to DECIDE; the whole `ExecutionEnv` is a face that can ACT (`exec`, `writeFile`, `remove`,
* `cleanup`). So this is four INDEPENDENTLY BOUND read primitives and nothing else: no `env`
* back-reference, no prototype of the env object, and the face itself is frozen (a hook cannot re-point a
* member for a later hook in the same run). Widening it later is additive and cheap; taking a wide object
* back is not.
*
* The line this draws, stated so nobody reads more into it: it separates what a hook is HANDED from what
* the environment can do. It is NOT an isolation boundary against code that can already reach the env's own
* class — anything that can redefine `SomeExecutionEnv.prototype.exec` intercepts every call the ENGINE
* makes into that env, receiver included, and needs nothing from this face to do it. Deployments that must
* withstand that run their hooks somewhere else, not behind a narrower argument.
*
* **Names are the env's own** ({@link ExecutionEnv} / `FileSystem`), deliberately: a hook author reads one
* contract, not a translation of it. The seam sketch's `canonicalize`/`root` are `canonicalPath`/`cwd`
* here — `canonicalPath` IS the realpath-semantics primitive, and `ExecutionEnv` declares no "root" at all
* (the engine's task root is a RUNNER notion — `deps.rootPath`/worktree/`requestedCwd` — not something the
* environment reports about itself), so the honest anchor is the env's own working directory. `cwd` is a
* FUNCTION, not the snapshot string, so a hook reading it late sees where the env is NOW. Read {@link
* HookEnvCapabilities.cwd} before using it as a base for RELATIVE paths — the task's tracked cwd is a
* different value, and this face carries the env's, on purpose.
*
* **Every member is optional, and absence is the signal.** A member is present iff the underlying env
* exposes that primitive (`readLink` is optional in `FileSystem`; a transport env may implement less than
* it declares). A missing member is never a stub that throws or an emulation that guesses — the hook is
* told, in the only way that cannot be misread, that this environment cannot answer, and takes its own
* conservative branch. The face carries a NULL PROTOTYPE so that reading is a question about this object
* alone (see {@link createHookEnvCapabilities}). Errors that the env CAN report stay in the `Result`
* (these methods never throw, inheriting the `FileSystem` contract verbatim).
*/
export interface HookEnvCapabilities {
/** {@link ExecutionEnv.canonicalPath} — the canonical path of an EXISTING path, symlinks resolved
* (realpath semantics), in the env's own namespace. */
canonicalPath?(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** {@link ExecutionEnv.exists} — `false` for a missing path; other failures (permission, transport)
* come back as a `FileError`, which is NOT an answer of "absent". */
exists?(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
/** {@link ExecutionEnv.readLink} — a symlink's raw target, ONE hop, not followed. Absent whenever the
* env omits the primitive: a DANGLING symlink's real target is then unknowable to the hook. */
readLink?(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/**
* {@link ExecutionEnv.cwd} — the ENVIRONMENT's own working directory, read live: the base the env's own
* primitives resolve a relative path against, and the only anchor it reports about itself.
*
* NOT the task's tracked cwd, and the difference is load-bearing for anyone judging a RELATIVE path.
* The engine keeps a separate per-task cwd — it starts at the resolved task root (which is
* `deps.rootPath` when that is set, not necessarily the env's cwd) and moves with the shell's `cd` and
* with worktree entry — and the TOOLS' relative paths resolve against THAT, while the environment is
* never told it moved. So a hook that must decide a relative target the way the tool will interpret it
* cannot take the base from here: judge absolute targets, or have the deployment hand the tracked cwd
* to the hook through its own channel. What this member is good for is the env-side anchor — which
* filesystem/mount the answers below are about.
*/
cwd?(): string;
}
/**
* Build the {@link HookEnvCapabilities} face for `env`. One wrapper per primitive (never a bound method of
* the env, never the env itself), presence probed at build time so the face's shape reports what this env
* can actually do, and frozen so it cannot be re-pointed by whoever receives it.
*
* Two details the "absent means cannot" contract rests on, both cheap and both load-bearing:
* · **null prototype, both ways.** Absence must be a fact about THIS object — on the way OUT (a face
* behind `Object.prototype` would answer `ctx.env.readLink` from there, turning "this env cannot read
* links" into "here is some function"; freezing the face does not freeze what it inherits) and on the
* way IN ({@link findEnvSlot} stops before `Object.prototype`, so an ambient property of that name is
* never adopted as a capability and never gets called with the env as its receiver).
* · **capture, do not re-read.** Each primitive is read ONCE, checked, and that captured callable is what
* the wrapper invokes — through module-load-captured intrinsics, never a build-time or call-time global
* lookup. Re-reading the property, or the machinery that does the reading, would let something other
* than what was validated end up running, or the receiver end up somewhere it was never handed.
* {@link createCwdReader} carries the one live read under the same rules.
*/
export declare function createHookEnvCapabilities(env: ExecutionEnv): HookEnvCapabilities;
/**
* The UNIFIED IDENTITY ENVELOPE every classic event can carry (`e.identity`): which run, which leg, and
* where in the delegation tree the event is raised. The CC analog is `createBaseHookInput`'s per-event base fields;
* the members here are sema's OWN identity facts (session/leg/delegation axes), not a transcription of CC's names.
*
* WHY: the deployment seat (`RunnerDeps.mods`) is PROCESS-level — one handler serves the root run, every delegated
* child and every resume leg at once, and without this envelope none of those events could say which leg it belongs
* to (`e.agentId` names a delegated leg on every event too).
*
* PRESENCE LAW:
* · PRESENT on every ENGINE-raised classic event: the gate's `classic.PreToolUse` screenings (phase 1, the
* approval-edit re-screen, the resumed leg's re-screen), the two post events, `classic.PostToolBatch`, every
* `classic.UserPromptSubmit` entrance (the resume-message screen carries the pre-leg envelope minted off the
* checkpoint and the trusted spawn facts), `classic.Stop`, `classic.StopFailure`, the compaction pair, and every
* engine `classic.PermissionDenied`. The engine mints ONE frozen envelope per prepared leg
* ({@link mintHookInvocationIdentity} — single mint home) and every station reuses it.
* · HONESTLY ABSENT (no fabrication) where the engine structurally cannot know:
* 1. a HOST driving the exported {@link runToolGate} directly without supplying `ToolGateInput.identity`;
* 2. an ancestor's chain riding a DESCENDANT's constraint fold ({@link createPreToolUseConstraintPolicy}) — the
* fold's request carries no leg identity, and stamping the INSTALL-site leg would name the wrong run;
* 3. a deployment driving the compaction module directly with its own `preCompact`/`postCompact` options.
*
* CONSUMER NOTE — the minted object is FROZEN and carries a NULL PROTOTYPE (same rule and same
* trade as {@link HookEnvCapabilities}: absence must be a fact about this object, not about what
* `Object.prototype` happens to hold). Read members directly (`identity.agentName`), test presence
* with `in` or `!== undefined`; do NOT call inherited methods on it — `identity.hasOwnProperty(...)`
* or implicit string coercion would throw, exactly as on the env capability face.
*/
export interface HookInvocationIdentity {
/** The run's session id (uuid domain) — the same key `TaskResult.sessionId` reports. */
readonly sessionId: string;
/** The run's unified task id (`spec.taskId ?? sessionId` — the value TaskEvent's `sourceTaskId`
* and `task_progress` frames key on). Equal to {@link sessionId} when no task id was declared. */
readonly taskId: string;
/** Which prepared leg this invocation belongs to — the wiring manifest's own `leg.kind`
* derivation, verbatim ({@link WiringLegKind}: `"root" | "child" | "resume"`). NOTE the fork
* lane's legs read `"child"` here like any other delegation; {@link insideFork} is the
* fork-lane discriminator. */
readonly legKind: WiringLegKind;
/** RB-204 delegation fact, verbatim: `true` iff this leg runs as a delegated child (every core
* spawn lane — sync/steer/background/fork/workflow — sets it; a top-level run reads `false`). */
readonly isDelegatedChild: boolean;
/** Present (`true`) iff this leg IS a forked child (`Agent(subagent_type:"fork")` — the
* design/110 trusted internals fact). Absent everywhere else. */
readonly insideFork?: true;
/** The delegated child's display name, when one was threaded at spawn (taskName / agent-type).
* UNTRUSTED display text (model-chosen) — already single-line-bounded at mint (same
* `inlineUntrusted` treatment as the `task_progress` name lane); never an identity key. */
readonly agentName?: string;
/** The EXPLICIT `Agent({name})` spawn identity, when the spawn carried one — sema's teammate key
* (validated at spawn: `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`, never `main`). The same string the
* child's roster row, its uplink `from` label and its `task_progress.spawnName` carry, so a hook
* seat binds "which teammate" by string equality. ABSENT = an unnamed delegation (not a
* teammate); {@link agentName} above stays the DISPLAY label and is never an identity key. */
readonly spawnName?: string;
/** The RESOLVED subagent type the leg was spawned as (`"general-purpose"` / `"fork"` / a roster
* definition's name — the fleet row's `agentType` word, never the description). Absent on a
* top-level run and on a leg whose spawner threaded no type (a workflow-lane agent). */
readonly agentType?: string;
/** The spawning Agent tool call's own id, when this leg runs under one — the delegation-chain
* position anchor ({@link import("./types.js").TaskEvent}'s `parentToolCallId` twin). Absent on
* a top-level run and on a directly-started workflow child (no launching tool call — no id is
* fabricated). */
readonly parentToolCallId?: string;
}
/**
* #281 件A — the ONE construction home of {@link HookInvocationIdentity} (domain-lexicon: one mint
* site; the runner calls it once per prepared leg and every hook station reuses the same object).
* Frozen AND null-prototype: the envelope is handed to arbitrarily many deployment callbacks, and a
* hook mutating `ctx.identity` must not rewrite what a later hook (or the delegation-lifecycle
* observer) reads. The null prototype is the {@link createHookEnvCapabilities} rule applied to the
* other absence-signaling face this module mints — "absent member" must be a fact about THIS object,
* and a frozen object with an ordinary prototype still answers `insideFork`/`agentName` reads (and
* `in` probes) from `Object.prototype`, so a prototype write elsewhere in the process could hand
* every leg a delegation axis the mint never stamped. `agentName` is sanitized here — it
* is the only member whose value a model influences.
*/
export declare function mintHookInvocationIdentity(facts: HookInvocationIdentity): HookInvocationIdentity;
/** The executed tool result a PostToolUse hook inspects. */
export interface HookToolOutput {
content: Array<TextContent | ImageContent | DocumentContent>;
details: unknown;
isError: boolean;
}
/**
* A PreToolUse hook result: a {@link PermissionResult} (so a hook can `deny`/`ask`, or `allow` with an
* `updatedInput` rewrite) plus optional `additionalContext` injected into the eventual tool result.
*
* **The provenance members are not hook-settable inputs** (#174). `decisionReason` is the ENGINE's account
* of which layer produced a verdict, and `allowRuleShadowed` is the allow layer's own disclosure that a
* member matched without clearing the ask. The gate stamps both itself — a `decisionReason` on a hook
* result is DISCARDED (a hook ask always reads `"hook"` downstream), and an `allowRuleShadowed` is
* dropped rather than rendered on an approval card as an entry no member matched.
*
* Which of a hook's OTHER members reach the surviving decision depends on which fold arm ran, so read
* the arm, not a single list: an ask promoted from a policy `allow` is rebuilt from the hook's `message`,
* `requiresRealApproval` and `updatedInput`, while a hook ask folded BESIDE a policy ask contributes its
* message and its mandate to the policy's decision and leaves the policy's own rewrite in place (the
* hook's rewrite still reaches execution — through the gate's captured rewrite, not through the decision
* object). Both arms are at the `hookAsk` fold in {@link runToolGate}.
*
* `additionalContext` is on neither list and never was consumed as a decision member: it rides its own
* channel — collected into the gate's `preToolContext` as soon as the hook answers, whichever verdict it
* returned, and drained onto the tool result by the runner. DELIVERY is narrower than collection: the
* runner stashes the context only for a call that will EXECUTE, because a blocked or suspended call never
* reaches the result step that drains it and carries its own model-facing reason instead. The context
* formerly APPEARED on the promoted ask object as an undeclared property, because that fold spread the
* whole hook result; the concurrent arm never carried it, so the two arms disagreed on a member neither
* of them reads. Nothing in the engine reads it off a decision.
*
* PreToolUse lane × settlement table (the gate's phase 1 in {@link runToolGate}; the delegated twin in
* {@link createPreToolUseConstraintPolicy} speaks the same words as a policy layer). "Held" = the ask survives to a person:
* neither an allow rule (`decisionReason: "hook"` is outside the allow layer) nor the classifier (hook asks are
* never classifier-answerable) clears it.
* · returns `allow` (± `updatedInput` / `additionalContext`) → the face's own word (`hookAllowedBy`, naming the registration); the policy
* still adjudicates the FINAL args; the allow layer's hook member may clear an unmandated ask. Not held.
* · returns `ask` (± `updatedInput`, ± `requiresRealApproval`) → folded as `hookAsk`: a policy deny still
* outranks it, a policy allow is PROMOTED to this ask with `"hook"` stamped by the engine; the rewrite rides
* into execution and the real-approval bit crosses delegation. Held.
* · returns `deny` → {@link hookDenyExit}: the hook's OWN refusal — the deny observer does NOT fire, the model
* reads the hook's sentence. Nothing executes.
* · returns `undefined` ("no opinion") or no screening face is installed → no word at all; the policy decides.
* · a handler throws / runs past its budget / breaks the site's contract (an out-of-contract answer, refused by
* {@link screenPreToolUseResult}), or the dispatch is aborted → {@link engineFailClosedExit}: an ENGINE deny — the
* deny observer fires, a crash and a timeout also reach `onError(phase:"hook")` (an abort does not), the model reads
* the engine's envelope. Nothing executes, nothing is held.
*/
export type PreToolUseResult = PermissionResult & {
additionalContext?: string;
};
/** A PostToolUse hook result: replace the tool output and/or append context (both optional). */
export interface PostToolUseResult {
/** If provided, replaces the tool result content array in full. */
updatedOutput?: Array<TextContent | ImageContent | DocumentContent>;
/** Appended to the result as a `<system-reminder>` the model can read. */
additionalContext?: string;
}
/** A UserPromptSubmit hook result: block the submission, or inject context ahead of the prompt. */
export interface UserPromptSubmitResult {
/** Block submission entirely; the task fails with this model-readable reason. */
block?: string;
/** Injected ahead of the user's prompt (wrapped as a `<system-reminder>`). */
additionalContext?: string;
}
/** The outcome of the two-phase tool gate, mapped onto the harness `tool_call` hook return shape. */
export interface ToolGateResult {
/** Block execution (the loop emits an error tool result with `reason`). */
block?: boolean;
/** Model-readable block reason (already `<system-reminder>`-wrapped). */
reason?: string;
/**
* The record of this pass ({@link GateOutcome}): its final disposition, the ask it settled (if any) and
* that ask's origin. Present on BOTH terminal exits, the block and the allow — an approved call is a
* settled approval too, and a caller that saw the record only on refusals would read its absence on an
* execution as "nobody approved this". Absent only on the durable-suspend exit, where the pass decided
* nothing yet. The caller records it on its own per-call sideband so it reaches that call's `tool_end`
* frame, and the deny observer receives the same object: one mint, three faces.
*/
gate?: GateOutcome;
/** The args to execute with — the pass's own immutable copy, the value every judge of the call read (the model's args
* as taken at the gate's entry, or what a hook, a policy or a person rewrote them to); re-validated by the loop. Present
* on every allow exit, whether or not anything rewrote (compare values to tell); absent on a block and a suspend. */
updatedInput?: unknown;
/** The input this pass decided on (what a refusal refused, what an allowed call runs with) as a deep, FROZEN copy taken
* at the exit — never the live args. Beside {@link gate} on both terminal exits; absent on the suspend exit. The deny
* observer gets a detached, writable copy of it. */
finalInput?: unknown;
/** A blocking refusal's structured details, when the refusing station minted any (the shell read station's
* `read_path_denied` / `readonly_out_of_root` cards): the loop attaches them to the error result it mints
* from `reason`, so the refusal reaches `tool_end.structured` / `errorCode` exactly as the tool's own
* refusal of the same finding did. Absent on every other block. */
details?: unknown;
/**
* design/45: the gate routed a policy `ask` to a **durable suspension** (no synchronous approver).
* `suspendAsk` already persisted the checkpoint and aborted the run; the caller records this so the
* task assembles as `status:"suspended"` with the token. Suspend lives ONLY here in the gate's `ask`
* branch — it never becomes a `PermissionResult` action (policy stays pure allow/ask/deny, §15.2).
*/
suspend?: {
token: import("./checkpoint-store.js").CheckpointToken;
gate: import("./checkpoint-store.js").CheckpointGate;
};
/** PreToolUse `additionalContext` to append to this call's eventual tool result (correlated by id). */
preToolContext: string[];
}
/**
* The gate's content-ask routing outcome — deliberately a channel of its OWN, not a
* field on the permission verdict. A permission verdict is folded, re-adjudicated and re-presented by
* layers that know nothing about answers; an answer riding one of those would be dropped by the fold or
* carried into a second adjudication. Three arms, one per thing that can happen:
* · `answered` — a person answered. The gate lets the call execute and the answer is delivered to the
* tool through the run's own one-shot binding (the gate never carries the answer itself).
* · `unavailable` — nobody was reachable and NOTHING was shown (the {@link
* import("./ask-question.js").QuestionUnavailable} contract). Only this arm may be re-routed — the
* gate parks the question for a later human, or answers the honest "it was NOT shown" refusal.
* · `delivery_failure` — delivery was attempted and failed. NEVER re-routed: the question may already
* have been rendered to a person, so parking it would put one question in front of two deciders. It
* is reported to the model through the tool's own coded-failure / degrade exits.
*/
export type ContentAskOutcome = {
kind: "answered";
} | {
kind: "unavailable";
/** True iff the park closure took its SYNC-FIRST DECLINE for this call — it has not yet made a
* real suspend attempt, so re-entering it now IS that attempt rather than a second one. False
* when the park already had its attempt (no live face to prefer, or a durable mandate): the gate
* must not re-enter, because a suspend and a checkpoint write are not idempotent, and a backend
* that committed before surfacing a transport error would be left an orphan row under a second
* token. Exactly one real attempt per call, on every path. */
parkDeclined: boolean;
} | {
kind: "delivery_failure";
code: string;
};
/**
* A durable park that was ATTEMPTED for this call and could not be minted, carrying the reason in the
* words of whatever refused it. Distinct from the `undefined` return, which means "no park attempt
* happened" (no facility armed, or the closure took its sync-first decline) — that case hides nothing,
* while this one does: the gate falls back to the synchronous approver chain either way (the
* compensation contract — an uncommitted checkpoint is a safe fallback — is unchanged), but on a
* headless deployment that fallback is the auto-deny whose text is "no approver is wired". True as far
* as it goes, and the whole reason the call did not park lives somewhere else entirely (the host's
* `onError` face). So the reason travels with the fall-through and the deny quotes it.
*/
export interface ParkAttemptFailed {
/** Why the park could not be minted — a sentence, already worded for a reader. */
parkFailed: string;
}
/**
* design/182 §7 — what the ORG layer answers for ONE call (see `ToolGateInput.orgRules`).
*
* The two arms are distinguished by `status`, NEVER by the absence of a verdict: "the org published no
* rule for this call" and "this deployment cannot see the org's rules" are opposite facts, and reading
* an empty answer as the first one is exactly the governance fail-open the availability contract exists
* to prevent.
*/
export type OrgGateVerdict = {
status: "available";
/** The org rule that speaks for this call, if any. Deny outranks ask; there is no allow bucket. An `unreadable` member says why
* `rule` could not be judged against the call (the message names it): the call could not be READ against it (the lexer's reason;
* `behavior` is `ask`), or the row is one the org layer does not read, standing at its strictest reading (`deny`, or `ask`). */
verdict?: {
behavior: "deny" | "ask";
rule: string;
unreadable?: string;
};
/** design/252 G-2 (additive): the REVISION of the snapshot this answer was read off — the
* published-policy version an auditor reconciles the resulting decision against. Present on the
* available arm whether or not a rule spoke: "revision 41 published nothing about this call" is
* itself the audit answer, and it is a different fact from "we never looked". ABSENT ⇒ the lane
* did not report one (a foreign overlay, or a revision that failed its own domain screen — the
* evidence records that as `"not_reported"` rather than inventing a number). Never adjudication
* input: the verdict above is the whole decision, and dropping this field would leave every
* outcome byte-identical. */
revision?: number;
} | {
status: "unavailable";
/** The overlay's own disclosure lines (engine/deployment-authored), for the operator channel. */
disclosures: readonly string[];
};
/** One member of a {@link PersistedRuleHit} coverage set: a rule's canonical text plus (optionally)
* its add-dot identity. */
export interface PersistedRuleHitRule {
/** The canonical text of one admitting rule. */
readonly rule: string;
/** The rule's LIVE add dots (tombstoned adds already filtered by the store's read). Omitted by a
* lane that has the text but not the dots; the evidence then reports `"not_reported"`. */
readonly dots?: readonly import("./permission-rule-model.js").RuleDot[];
}
/**
* design/252 G-2, re-formed by design/375 and again by the three-behavior model (BREAKING) — the
* structured answer a persisted-rule lane gives: WHICH behavior spoke for the call, and the rule set it
* stands on. For an `allow` the set is the COVERAGE SET that admits the call — a single rule admitting
* the whole command is a one-element set (the historical case); a compound admitted by the conjunction
* of per-segment rules (design/375 §5.1) is the set of those rules, in segment order. For a `deny` or an
* `ask` the set is the one rule of that behavior that reaches the call (the lane's precedence walk —
* deny > ask > allow — already chose it). `rules` is non-empty by contract — an empty set covers nothing
* and is not a hit (the normalizer reads it as a clean negative).
*
* Why dots and not an id: a persisted rule HAS no scalar id — it is a (behavior, text, scope) group whose identity is
* the set of add dots that redeemed it, and two concurrently approved records for one text each carry their own.
*
* The pre-375 single-rule object shape (`{ rule, dots? }`) and the pre-behavior `{ rules }` shape are NOT accepted:
* on this loosening seam an unrecognized answer degrades to a clean negative (more asks), never to a match.
*/
export interface PersistedRuleHit {
/** What the deciding rule(s) say about the call — the closed set's word. */
readonly behavior: import("./permission-rule-model.js").RuleBehavior;
/** The rule set the verdict stands on. Non-empty. */
readonly rules: readonly PersistedRuleHitRule[];
/** A path DENY's coordinates (where the reaching row met the call): the gate's exits mint the refusal's card from it. */
readonly denial?: import("../tools/fs/safety.js").PathDenialEvidence;
}
/**
* design/252 G-2 — the answer a lane gives when it could not READ its store, or a partition of it (no `reason`; the CALL form is `reason`'s).
*
* The store form fails CLOSED: a deny or ask row inside what could not be read may forbid this call and an allow beside it
* is no yes, so the decision becomes a real-approval ask (origin `rule_store_unavailable`, class `rule_unreadable`:
* no automatic lane clears it, `bypassPermissions` keeps it for a person, headless it is refused) until a read succeeds
* again; a deny or ask row that WAS read still stands. The RECORD says `"unavailable"`, not `"no_match"`; the lane
* announces the skipped partition once per run.
*/
export interface PersistedRuleUnreadable {
readonly unreadable: true;
/** WHY the lane could not read: absent for a store (or partition) read failure — the store form, a real-approval ask of
* class `rule_unreadable`; for a CALL the lane could not read against the person's standing deny/ask rows (an
* expansion where a rule reads a word, an unterminated quote, a syntax error) the lexer's reason — the call form, an allow
* tightens to a real-approval ask of the same class. Both carry the origin `rule_store_unavailable`. */
readonly reason?: string;
}
/**
* design/375 §5.2② — the CLEAN NEGATIVE that still carries the per-segment coverage table: no rule
* admits the whole call, and THESE segments are the ones an eligible rule already covers. Minted by
* the lane from the SAME store read that adjudicated the call (snapshot = adjudication snapshot, no
* second read), and consumed only by the suggestion face — which puts on the batch offer exactly the
* segments this table calls uncovered. Its DECISION meaning is identical to "no rule": the seat is
* advisory, and a lane that omits it (or an unreadable one) degrades to all-uncovered, which merely
* over-offers (every offered rule still passes the validator at redemption).
*/
export interface PersistedRuleCoverage {
readonly segmentCoverage: readonly import("./permission-rule-model.js").SegmentCoverage[];
}
/** Every shape a lane may answer with. The retired bare-string form (a single allow rule's text) is read
* by the normalizer as a clean negative, like every other unrecognized shape. */
export type PersistedRuleAnswer = PersistedRuleHit | PersistedRuleCoverage | PersistedRuleUnreadable | undefined;
/** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort
* signal; when the caller also supplies {@link ToolGateInput.callSignal}, the Runner's closures
* additionally bind their waits to that per-call signal (`AbortSignal.any` of the two), so a turn
* interrupt releases a pending ask instead of leaving it hanging on the run's lifetime alone. */
export interface ToolGateInput extends PermissionSeatInputs, SkillGateSeats, AllowEntryGateSeats {
/** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
* isolation scope (onHookError itself, the deny observer, the durable-park observer). The runner
* wires this to an `observer.notify_failed` trace event; absent = counted only (unchanged). */
onNotifyError?: (failure: import("./safe-notify.js").SafeNotifyFailure) => void;
/** The call. `toolName` is the name every policy, hook and name-keyed table of this gate reads — the wire name of the
* tool the call dispatches to: the Runner's station resolves a call made under an alias through the roster
* (`rosterToolName`) before this gate runs; a host driving the gate resolves it the same way. `readArguments` = the loop's
* reading of the call's args: stamped on the call's face, every rewrite this gate adopts is read through it (`argsAsTheToolReads`). */
event: {
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
readArguments?: (args: unknown) => unknown;
};
/** The run/leg identity envelope, put verbatim on every {@link PreToolUseEvent} this
* gate call builds and on every {@link PermissionDeniedPayload} it emits. The Runner supplies its
* per-leg mint; a host driving the gate directly may omit it, and the contexts then carry no
* identity (honest absence — this layer never fabricates a leg). */
identity?: HookInvocationIdentity;
/** design/319 (A ticket) — the run's reminder provenance mark: the gate's own model-facing deny
* reasons ride `formatHookFeedback`, so their open tags must carry the same mark the run's
* system-prompt declaration names. Absent (a host driving the gate directly) ⇒ bare tags. */
reminderMark?: string;
/**
* The leg's mods bus: phase 1, the approved-edit re-screen and (through the Runner) the approved-call re-screen raise
* `classic.PreToolUse` on it; the lanes raise `tool.check` (once per adjudication, never over a deny). Absent, or no
* handler on the event ⇒ nothing raised. A handler that throws, runs past its budget or breaks the site's contract refuses
* the call fail-closed (the event's failure column), and so does a cancel; an observe handler never decides.
*/
modBus?: Pick<import("./mods/kernel/index.js").ModBus, "hasHandlers" | "dispatch">;
/** The inherited installations whose `tool.check` judges this leg's calls too (`inheritedToolChecks`, the rule's one home):
* raised after {@link modBus}, each over the verdict the previous left standing, read by the one comparison — their `allow`
* never clears an ask (a constraint only tightens), and their `ask` goes to each installation's own `settle` (where its
* installation froze the question), never to this gate's resolver; an approval there that edits the call takes this gate's
* approved-edit pass. Absent ⇒ only {@link modBus} is raised. */
inheritedToolChecks?: readonly import("./mods/stations.js").InheritedToolCheck[];
/** The leg's observed permission mode, carried on every `classic.PreToolUse` e this gate call raises. Absent ⇒ the e
* carries none (a host-driven gate with no leg seat). */
observedPermissionMode?: () => PermissionMode;
/** The task's live tracked cwd reader, put on every {@link PreToolUseEvent} this gate call raises
* (see {@link PreToolUseEvent.cwd}). A FUNCTION rather than a string for the reason the policy's own
* threading gives: capturing the value when the gate's wiring is built would freeze the task's
* starting directory and answer for a run that has since `cd`ed. Absent ⇒ the contexts carry no
* `cwd`, which is the honest answer for a run with no tracked one. */
trackedCwd?: () => string | undefined;
/** The leg's target-reading stamp, applied where the gate's request is minted over the final args (a rewrite re-reads it: `withArgsReading`). */
stampCallTarget?: CallTargetStamp;
/** The read-only env capability face put on every {@link PreToolUseEvent} this gate call
* builds ({@link HookEnvCapabilities}). Built ONCE per task by the runner (after the env is minted) and
* passed through unchanged; omitted when the deployment wired no execution environment. */
hookEnv?: HookEnvCapabilities;
/**
* design/388 B6 — the leg's live roster lookup: the face of the mounted tool a call dispatches to, stamped
* on the {@link ToolCallRequest} every policy sees (read at the gate entry, so a run-time roster change is
* honoured on the next call). Absent ⇒ requests carry no face (a bare producer).
*/
toolFaceOf?: (toolName: string) => ToolCallFace | undefined;
/**
* design/384 slice 1 — the PER-CALL abort signal, carried in from the `tool_call` hook event's
* own `signal` seat (the loop's turn-scoped controller; the run abort is composed into it). The
* gate itself never awaits on it — it hands the signal verbatim to the four consuming seats
* below: `adjudicate`, `resolveAsk` and `resolveContentAsk` take it as their optional trailing
* argument; `suspendAsk` (slice 2) reads it as `carry.signal` inside its structural seat
* ({@link AskCarry}), and its park closure composes it into the halt×park fence. The Runner's
* closures compose it with the run signal so a turn interrupt (bare halt / steer-now) releases
* the wait — or, on the durable leg, makes the park concede. Absent (a host driving the gate
* directly, or an unthreaded emit) ⇒ the three synchronous seats are invoked exactly as before —
* same arity, no trailing `undefined` (the threading is a conditional invocation) — the carry has
* no `signal` member, and waits stay bound to the run signal alone: the historical shape.
*/
callSignal?: AbortSignal;
/** The combined tool-policy check (abort-bound), or undefined when no policy is wired (→ allow).
* The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
* through verbatim; an implementation that ignores it keeps the run-level binding. */
adjudicate?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<PermissionResult>;
/** Resolve an `ask` to allow/deny via `onAsk` (abort-bound). Required iff a decision can be `ask`.
* G1 three-value: the result may carry `approverUnavailable` — the live approver affirmed no
* operator is reachable for THIS ask; the gate then re-routes onto the durable park leg (below),
* keeping the carried fail-closed deny when no park is armed. The optional trailing `callSignal`
* (design/384) is {@link ToolGateInput.callSignal}, passed through verbatim. */
resolveAsk: (decision: PermissionResult, req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ResolvedAsk>;
/**
* design/45: route a policy `ask` to a **durable suspension** instead of the synchronous `resolveAsk`
* (onAsk) path. Called in the `ask` branch with the FINAL post-hook args; if it returns a suspend
* directive it has already persisted the checkpoint + aborted the run, and the gate short-circuits to
* `{ suspend }`. Returns `undefined` to fall through to `resolveAsk` (non-durable / declined). A
* {@link ParkAttemptFailed} ALSO falls through to `resolveAsk` — same fallback, same compensation —
* but carries why the mint failed so a resulting deny can name it instead of leaving the model and
* the operator with the fallback's own reason as if it were the cause.
* Omitted entirely → no durable mode (the 1.63 synchronous path, unchanged).
*
* The armed closure itself may also DECLINE (return `undefined` up front) to prefer the live
* synchronous approver: the Runner's closure does so for a plain AND for a SAFETY ask whenever the
* resolved seat is a FUNCTION-valued `onAsk` — even under `durableApproval`, which is the FALLBACK for
* when no live approver is present, not an "always park" mandate — an interactive per-edit ask must
* resolve in-stream, not cost a park/rebuild round-trip. The gate stays a pure fall-through consumer of
* that choice. What KEEPS the durable leg is no function seat (absent, or a blanket `"allow"`/`"deny"`
* string mode — a policy setting is not a live approver), a control-plane `forceDurableGate`, a live
* face that already answered `"unavailable"` for this ask, or an inherited-unavailable marked call.
* `safety` no longer participates in the park/sync decision at all — it only selects the gate KIND
* (`irreversible_ask`) once a park is minted.
*/
suspendAsk?: (req: ToolCallRequest, postHookArgs: unknown,
/** design/80 D-2: present (non-undefined) ONLY when this ask is a SAFETY tighten (the egress/
* irreversibility gate tightened a surviving `allow` to `ask`, `decisionReason === "safety"`). Drives the
* mint-site gate-kind choice — a safety tighten mints `irreversible_ask` (non-budgetable) EVEN WHEN
* `durableApproval` is wired. The object records which axis(es) tightened (persisted as the gate's
* `safetyAxis`). Absent for a plain policy/hook `ask` (→ the normal `human`/`irreversible_ask` choice). */
safety?: import("./checkpoint-store.js").SafetyAxis,
/** The three-valued live-face verdict, widened by design/174 to cover BOTH live faces: true ⇒ the live face this
* ask would have been routed to already answered `"unavailable"` for it — the permission approver for
* a permission ask, the question face for a content ask. Either way there is no live leg left to
* prefer, so the closure must NOT take its sync-first decline and should park durably. All other
* decline/pre-commit-failure paths keep their existing `undefined` fallbacks (the gate then keeps the
* fail-closed deny / typed refusal the caller carries). */
liveFaceUnavailable?: boolean,
/** Present ⇒ the surviving ask carries `requiresRealApproval` and the mint must escalate to
* the non-budgetable `irreversible_ask` kind carrying this bit (a budget resolver must never
* auto-approve what only judgment may clear). `origin` records whether the bit came from an org
* ASK rule, from the org-unavailable tighten (whose resume semantics differ — see
* {@link import("./checkpoint-store.js").RealApprovalGateBit}), or from a policy/hook. */
realApproval?: import("./checkpoint-store.js").RealApprovalGateBit,
/** The allow-layer members that matched and could not clear (the surviving ask's `allowRuleShadowed`)
* — threaded so the park mint's risk descriptor carries the disclosure on the durable route. */
allowRuleShadowed?: readonly import("./allow-rule-shadow.js").AllowRuleShadow[],
/** The surviving ask's `decisionReason` — threaded so the park mint's rule-suggestion door can
* read the ask's origin on the durable route exactly as the synchronous mints read it off the
* decision (a hook-raised ask is one no persisted rule may clear, so its parked card must not
* offer to mint one). */
askDecisionReason?: DecisionReason,
/** The reversibility probe's cause for the tighten (the surviving ask's `probeReason`)
* — threaded so the park mint's risk descriptor carries on the durable route the same account of
* WHY the call was gated that the synchronous card gets. Already neutralized + capped at the gate. */
probeReason?: string,
/** The STRUCTURED cause (the surviving ask's `probeCause`) — same durable-route errand
* as `probeReason`, already validated at the gate. */
probeCause?: import("./checkpoint-store.js").ProbeCause,
/** design/375 §5.2②: the surviving ask's engine-stamped `segmentCoverage` — threaded so the park
* mint's offer factory reads the same coverage snapshot on the durable route as the synchronous
* mints read off the decision (absent ⇒ all segments uncovered ⇒ over-offer, the safe way). */
segmentCoverage?: readonly import("./permission-rule-model.js").SegmentCoverage[],
/** #457 ④: the surviving ask's `matchedAskRule` — threaded for the SAME errand as
* `askDecisionReason`: the park mint's rule-suggestion door must know that an EXPLICIT `ask` rule
* matched this call, because the persisted-rule lane refuses to clear such an ask (the
* `decision.matchedAskRule === undefined` conjunct on its allow arm), so the parked card must not
* offer to mint a rule that would never silence it. APPENDED at the tail deliberately: every
* parameter here is positional and `probeReason` is also a `string`, so inserting this beside its
* logical neighbour would silently re-bind three existing call sites' arguments with no type
* error to catch it. */
matchedAskRule?: string,
/** The surviving ask's engine-stamped `probeMandated` — threaded for the SAME errand as
* `askDecisionReason` and `matchedAskRule`: the park mint's rule-suggestion door must know that
* this ask is one no persisted rule may clear, or the parked card offers a "stop asking me this"
* rule the lane would then refuse to honor. APPENDED at the tail for the reason its neighbour
* states — every parameter here is positional — and typed `boolean` so a mis-bind against the two
* trailing `string` seats is a compile error rather than a silent re-binding. */
probeMandated?: boolean,
/** The ONE structural seat after the twelve positional ones — everything else the park closure needs
* travels inside it ({@link AskCarry}: the per-call signal, the surviving ask's fallback member, its
* origin word), so the tuple does not grow a seat per member. ALWAYS passed by the gate (thirteen
* arguments, every call) — optional on the TYPE only because the ten seats before it are optional and
* a required parameter cannot follow one. The twelve seats above are the historical shape; a host-built
* closure that read the signal as the 13th positional argument now reads `carry.signal`. */
carry?: AskCarry) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
/**
* design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
* before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
* post-policy request (the same args that will execute), and it owns everything about delivery:
* validating the batch before anything is put in front of a person, calling the live face, and binding
* the answer to this call id for the tool to pick up. It returns only WHICH of the three outcomes
* happened — never the answer, which travels the run's own binding.
*
* CONTRACT: it must not throw (a delivery failure is a returned value, so the gate can tell "failed"
* from "nobody home" — the two have opposite re-routing rules). Passed UNCONDITIONALLY by the runner,
* including on runs with no live face: it then answers `unavailable` without contacting anything, which
* is exactly the pre-174 park/refusal behavior. Absent entirely ⇒ the pre-174 path verbatim.
* The optional trailing `callSignal` (design/384) is {@link ToolGateInput.callSignal}, passed
* through verbatim; an implementation that ignores it keeps the run-level binding.
*/
resolveContentAsk?: (req: ToolCallRequest, callSignal?: AbortSignal) => Promise<ContentAskOutcome>;
/**
* design/70: the called tool is egress-marked (`ToolSpec.egress` — an external write: open a PR,
* push, send). The gate tightens a surviving `allow` to `ask` so an egress tool is NEVER
* auto-allowed — execution always passes an explicit ask-resolution (onAsk / durable suspend);
* headless resolves to deny. Filled by the caller from the tool's spec; policies stay egress-blind.
*/
egress?: boolean;
/**
* design/77 §4 (Gate 4): the called tool's irreversibility tier (`ToolSpec.irreversibility`), filled by
* the caller from the tool's spec. After the egress tighten, the gate runs a SECOND deterministic tighten:
* `"always"` tightens a surviving `allow` to `ask`; `"maybe"` calls {@link reversibilityProbe} and tightens
* UNLESS the probe reports reversible (fail-closed on timeout/throw); `"never"`/undefined → untouched.
* Policies stay irreversibility-blind; the gate is the single chokepoint.
*/
irreversibility?: "never" | "maybe" | "always";
/**
* design/77 §4: the `"maybe"`-tier probe from the tool's spec (read at prepare-time, captured here — not a
* tool argument). Called when `irreversibility === "maybe"` && the surviving decision is `allow` (the
* TIGHTEN arm) — and, since #502, when it is already `ask` (the MANDATE arm: the probe is the only
* source of `mandated`, and an ask another layer raised needs it too; there the `reversible` verdict is
* discarded, since a probe may never un-ask another layer's question). Both arms are time-bounded by
* {@link approvalTimeoutMs} (30s default). Fail-closed: anything other than `{ reversible: true }`
* (including a timeout or a throw) tightens to `ask`. A probe is never trusted to AUTO-ALLOW past the gate.
* The invocation set is therefore WIDER than the tighten's: an ask-first policy calls this on every
* `"maybe"`-tier call, and a throwing/hanging probe costs an `onError(phase:"hook")` and the wait there
* too — see `ToolSpec.reversibilityProbe` for the deployment-facing statement of that cost.
*
* An optional `reason` on a TIGHTENING verdict names the cause. The gate neutralizes + caps it and
* carries it onto both approval routes (`AskRequest.probeReason`, `RiskDescriptor.probeReason`) — see
* `ToolSpec.reversibilityProbe`. Display/triage only: `reversible` alone decides the verdict, so a
* probe cannot widen anything through this member.
*/
reversibilityProbe?: (args: unknown) => import("./types.js").ReversibilityVerdict | Promise<import("./types.js").ReversibilityVerdict>;
/** design/77 §4: deadline (ms) for {@link reversibilityProbe}; on timeout the gate fails closed to `ask`.
* ABSENT ⇒ a bounded default applies (30s — the probe wait is never unbounded, even with no
* {@link abortSignal}); a non-finite/negative value is refused loudly (via {@link onHookError}) to
* that same default, never silently reinterpreted. `0` is honored as written (immediate deadline). */
approvalTimeoutMs?: number;
/** design/77 §4: the task abort signal — bounds {@link reversibilityProbe} by the task's real deadline
* (timeout/cancel) in addition to {@link approvalTimeoutMs}; an abort while probing fails closed to `ask`. */
abortSignal?: AbortSignal;
/**
* design/134 R5: a host's own observer for the gate's DENY short-circuit (the Runner's are its leg's handlers on
* {@link modBus}), run as one more observe handler of `classic.PermissionDenied` — that station's columns; a failure
* is disclosed on {@link onNotifyError} as `toolGate.permissionDenied`. Fired ONCE, at the single deny exit of the
* adjudicate chain — a policy deny or a resolved-ask deny. NOT fired for a PreToolUse hook deny (the
* exclusion on {@link PermissionDeniedPayload}, which also records why that exclusion is not a parity
* claim) or a durable suspend (the ask path).
*
* ALSO fired for the engine's fail-closed interception of a THROWING PreToolUse hook, with
* `source:"hook"` — the exclusion is the hook's own deny DECISION, not an engine decision.
*/
permissionDenied?: (payload: PermissionDeniedPayload) => void | Promise<void>;
/**
* Trace sink for a deployment-supplied callback of this gate that failed while the gate holds the fail-closed line
* — a {@link reversibilityProbe} that threw or timed out — and for a malformed {@link approvalTimeoutMs} refused to
* the bounded default (`onError(phase:"hook")`, wired by the caller). A failed `classic.PreToolUse` handler is
* reported by the bus itself. Never affects the outcome (a throwing sink is swallowed). A task-abort rejection
* mid-probe is NOT reported (normal cancellation, not a defect).
*/
onHookError?: (err: unknown) => void;
/**
* design/134 R5: true when this tool's `irreversibility` tier was installed by the ENGINE'S SHELL SEAT
* (the read-boundary judge on every shell face, plus the shape tier on every full shell and the
* `TaskSpec.shellGate: "always"` floor — design/80 D-E; the seat's `shellGatedTools` membership), not an explicit per-tool mark —
* attributes a tighten-deny to `source:"shellGate"` instead of `"safety"`.
*/
shellGated?: boolean;
/**
* design/276 (CC 2.1.233 P11 arm 4): true when the called tool is the peer-message verb — a message
* to another agent (`SendMessage`). ENGINE-FILLED from the wire name at the caller, never a
* configuration knob; a caller's same-named shadow tool is keyed too (for a tighten, over-asking is
* the fail-safe direction). Under an armed {@link autoMode} the gate tightens a surviving `allow` to
* `ask` (the peer-referral tighten below) so the message passes the classifier's eye — its prompt
* already carries the Multi-Agent Coordination exemption, so the ordinary teammate message resolves
* allow and the injection-shaped one is what this member exists to catch. Absent/false, or with no
* armed auto mode, the decision path is byte-identical. Per-run semantic: armed by THIS run's
* resolved caps, never inherited down the delegation chain (CC parity — the mode predicate reads the
* SENDING session's own mode).
*/
peerMessage?: boolean;
/**
* The write-protection judge — ENGINE-BUILT from the deployment's table seat by `createWriteProtectionCheck`
* (write-protect.ts), never a deployment callback (the deployment authors table ROWS, validated loudly at
* compile; the judge's only I/O is the shared canonicalizer over the run's own environment, so it is trusted
* here like the `egress` mark). Judges the FINAL args of a path-confinable write tool by the file the tool
* will write — spelling and resolved real target; a hit demotes a surviving `allow` to `ask`, an unresolvable
* target is refused (the tighten below). `null` SYNCHRONOUSLY for a call it does not cover (no suspension
* point there), a promise for a covered write. Absent ⇒ no table (`[]`) and the decision path is byte-identical.
*/
writeProtectionCheck?: import("./write-protect.js").WriteProtectionCheck;
/**
* The shell READ STATION — ENGINE-BUILT by the hands phase (`createShellReadStation`,
* `src/tools/fs/shell-read-check.ts`) for the shell-running tools of this leg, never a deployment callback.
* Judges the FINAL args of a shell call: ONE parse of the command into its read candidates, each
* established against the execution environment (symlinks resolved, patterns expanded, traversal roots
* enumerated, within a bound) and judged by the leg's read deny seat and its containment roots. A
* POLICY-class member of the fold: a deny-listed read is REFUSED (`deniedBy: "read_boundary"`, composing
* under `deny > ask > allow`), an out-of-root or unestablishable read tightens a surviving allow to a
* MANDATED ask (or marks an existing ask mandated), a read the station declined to judge tightens to an
* ordinary ask on the full shell face (under `auto` the classifier answers it, shown the read-deny table) and
* is refused on the read-only face, and a vouched call is untouched.
* `null` SYNCHRONOUSLY for a call it does not cover (no suspension point there), a promise for a shell
* call. Absent ⇒ no station (no shell mounted) and the decision path is byte-identical.
*/
shellReadCheck?: import("../tools/fs/shell-read-check.js").ShellReadCheck;
/**
* design/143 批2 (A, CC 2.1.207 auto mode): when present, a surviving `ask` is routed to the
* small-model policy CLASSIFIER before any human/durable resolution:
* - verdict `allow` → the ask resolves to allow (`decisionReason:"classifier"`) — no suspend, no onAsk;
* - verdict `block` → deny (`decisionReason:"classifier"`, `source:"classifier"`; CC `automode-blocked`); `parse_error` too;
* - `unavailable` → where `unavailable.route` names a person (`live_approver`, or `durable_park`) the ask goes on, its text saying why;
* else CC's refusal (`cause: "unavailable"`). Never counted by the denial limit (`classifierUnavailableArm`).
* SCOPE (declared semantic): this covers every surviving ask INCLUDING the deterministic egress/
* irreversibility safety tightens — auto mode is the deployment's explicit choice to let the
* classifier be the "explicit ask-resolution" for this session (its rule set covers exactly those
* action classes: outbound writes, irreversible operations, and sensitive-value handling). A
* deployment that wants human-only
* resolution for safety asks simply does not arm auto mode. The classifier can never AUTO-ALLOW by
* failing — only an affirmative `<block>no</block>` allows.
* EXCLUDED from that scope (the ask flows the ORIGINAL chain untouched, exactly as if this field
* were absent for that one call): every ask whose ORIGIN is not the plain `policy` family — the
* closed set and the classifier's per-origin table are in `ask-origin.ts` (an org-constrained ask on
* either org arm, the reserved question tool, a call MARKED unresolvable, a hook-raised or
* hook-promoted ask, an explicit matched `ask` rule, and the classifier's own denial-limit fallback).
* The hook and ask-rule rows are the same pair the persisted-rule lane refuses, so the gate's two
* ask→allow seams answer provenance questions identically.
*/
autoMode?: {
decider: import("./auto-mode.js").AutoModeDecider;
/**
* The per-run DENIAL-LIMIT tracker beside the decider (CC 2.1.250 `FO`/`Wie`; same owner,
* same lifetime). Absent ⇒ the pre-#548 chain byte for byte: every block is a deny, without bound.
* Present ⇒ a block first counts, then judges; the block that reaches a bound becomes the fallback
* ask (`requiresRealApproval: true` + `denialLimitFallback`), and a classifier allow or a person's
* allow of that ask resets the consecutive count. The Runner builds it from
* `RunnerDeps.autoMode.denialLimit`; a host driving the gate directly may hand its own.
*/
denialTracking?: AutoModeDenialTracker;
unavailable?: import("./classifier-unavailable-arm.js").ClassifierUnavailableSeat;
};
/**
* The leg's EFFECTIVE permission mode, read at every arbitration (a steer changes the next call's answer). The
* gate's one mode read point sits after the classifier and the sandbox leg and before the park / resolution:
* `bypassPermissions` releases every non-immune class (`GateDisposition.released`), `dontAsk` refuses every ask
* but a hook's own (`deniedBy: "mode"`), the other modes hand the ask on. ABSENT ⇒ no
* arbitration (a host driving the gate directly keeps the pre-mode chain); the classifier station then reads
* `autoMode` presence alone as the arming fact.
*/
permissionMode?: () => import("./ask-origin.js").PermissionMode;
/** The leg's approval-seat form (`WiringManifest.ask.form`): the deny exit's way-out clause names a declaration, never a missing approver. */
askSeatForm?: import("./wiring-manifest.js").AskSeamForm;
/** The read face the leg resolved, read per call into the class derivation (`roots` withholds the classifier from a `probe` question); absent ⇒ none resolved. */
readFace?: () => import("../tools/fs/containment-face.js").ContainmentFace | undefined;
/** WHO put the tool's egress / irreversibility marks on the seat ({@link import("./ask-origin.js").ToolMarkSource});
* absent or empty with a mark present reads as the deployment's own (fail-closed: an operator's mandate). */
toolMarkSources?: ReadonlySet<import("./ask-origin.js").ToolMarkSource>;
/** The called tool's seat carries its server's declaration that a call must be put to a person
* ({@link import("../engine/loop/types.js").AgentTool.mcpRequiresUserInteraction}): a surviving `allow` is tightened to an `ask`, and every ask on the call carries
* the class `requires_user_interaction` and the real-approval bit (no rule, grant, classifier, bypass mode, blanket seat or policy-settled approver answers it). Absent ⇒ not declared. */
requiresUserInteraction?: true;
/** The deployment's A2A peer-isolation switch: a set switch makes the A2A egress default an operator mandate. */
a2aIsolatePeers?: boolean;
/** Observe a mode RELEASE (fired before the released allow leaves the arbitration point; `readUnestablished`: the read station could not establish what the call reads). Observe-only. */
onModeReleased?: (info: {
toolName: string;
toolCallId: string;
release: import("./gate-outcome.js").ModeRelease;
readUnestablished?: true;
}) => void;
/** An ancestor station's arbitration of this call (the inherited lane arbitrates inside the fold and records
* here); the exit reads it onto the disposition when the gate's own point did not arbitrate. */
ancestorArbitrationOf?: (toolCallId: string) => import("./ask-origin.js").Arbitration | undefined;
/**
* The HEADLESS arm of the denial-limit fallback: the fallback ask was resolved by a deny no
* person made (no approver wired; a blanket `"allow"` refused as no approver; an approver that
* reported unavailable with no park to take it). The fallback had nowhere to go, so the caller — which
* owns the run — stops it (`TaskResult.errorCode = "classifier.denial_limit"`, CC's "too many
* classifier denials in headless mode" abort). The deny itself still stands; this gate only names the
* fact. A TOP-LEVEL seat, deliberately NOT under `autoMode`: the fallback can arrive from an ANCESTOR's
* frozen tracker through the fold (a delegated child with no armed classifier of its own), and that
* child's run is the one that has to stop. Observe-only for the gate: a throwing seat never alters
* the deny.
*/
onHeadlessDenialLimit?: (info: {
toolName: string;
toolCallId: string;
fallback: import("./auto-mode.js").DenialLimitFallback;
}) => void;
/**
* The deny exit nobody judged: the call's approval could not be RESOLVED (`cause` — the approver
* answered `unavailable`, no approver is wired, or an inherited durable mandate has no park facility on
* this leg) and no durable park took the call, so the fail-closed deny STANDS. Fired once per such call
* from the gate's single deny exit, after every park re-route has had its attempt, with the facts the
* `delegation.ask_unresolvable` notice is composed from (`ask-unresolvable-notice.ts`);
* `parkLaneExisted` = a park lane was armed for this run; `remedy` = the caller-side change that would have let the
* call run, present for the causes that say nobody could be asked (`approver_unavailable`, `no_approver_wired`).
* Observe-only: a throwing seat never alters the deny. Absent ⇒ the deny stands silently (a host driving the gate
* directly, no notice sink).
*/
onAskUnresolvable?: (info: {
toolName: string;
toolCallId: string;
cause: import("./ask-unresolvable-notice.js").AskUnresolvableCause;
parkLaneExisted: boolean;
remedy?: string;
}) => void;
/**
* design/153 §2/§7.4 (件4 复审, MED): true means this call's ask is MARKED — an inherited ancestor
* constraint already determined "no synchronous layer may resolve this ask" (the ancestor's frozen
* approver was unreachable, OR an ancestor durable mandate was floated back because THIS task's own
* park facility can honor it). The marker's whole contract is that the ask reaches EITHER the durable
* park leg OR the fail-closed `resolveAskBound` intercept, with no synchronous decision-maker in
* between — the auto-mode classifier is exactly such a decision-maker (`decide()` can `allow`, executing
* the call) and sat strictly BEFORE both marker consumers, so a marked call under an armed classifier
* bypassed the marker's contract entirely. When true, the classifier step is skipped (falls through to
* `suspendAsk`/`resolveAsk` untouched) so a marked ask can only ever be resolved by an operator (durable
* park) or fail closed — never by the classifier.
*/
isMarkedUnresolvable?: (toolCallId: string) => boolean;
/**
* design/179 — the persisted-rule lane. Present only when a deployment wired a rule store; absent it,
* this whole step does not exist and the gate's decision path is byte-identical to what it was.
*
* The lane answers with the BEHAVIOR that spoke ({@link PersistedRuleHit}), and the gate consumes the
* three behaviors at two different places — the upstream arbiter's order, deny > ask > allow, across
* the person's own standing rules:
* · a DENY row refuses the call outright, on EVERY decision (an allow from the policy fold included):
* it is the person's own "never run this" (their settings' deny list, imported), the personal-store
* sibling of an org deny, and it runs right after the org layer — nothing downstream re-opens it;
* · an ASK row is the person's standing "ask me each time": it turns a surviving allow into an ask
* and stamps `matchedAskRule` on it (the same marker an explicit `ask` permission rule stamps), so
* the ask's origin is `ask_rule` — the auto-mode classifier never answers it and a persisted ALLOW
* never clears it; on a call that is already an ask it stamps the marker and leaves the rest;
* · an ALLOW row is the standing form of ONE recorded human approval, so it resolves the asks a real
* person saying "allow" would have resolved — and nothing else. It sits post-fold, at the entry of
* the `ask` branch, BEFORE the classifier and AFTER every tighten: earlier and a tighten would
* simply put the ask back; later and the classifier would already have spoken.
*
* The deny/ask arms tighten and take no conjunct but the two structural ones every lane shares (the
* engine's own content-ask tool is exempt; the lane must be wired). The ALLOW arm's consumption
* predicate is its own, not the classifier's blanket flip:
* · `requiresRealApproval` asks are never consumed — the two integrity gates are the floor, and the
* same flag is what a v2 organization-level ask will carry;
* · a PreToolUse hook's ask is never consumed — that is the deployment's own adjudication, and a rule
* minted for a command does not overrule it;
* · the reserved question tool and a call MARKED unresolvable are excluded for the same reason the
* classifier excludes them: both contracts require that no synchronous decision-maker stands between
* the ask and the park / content route, and this lane is one.
* · #144 (ruled): a MANDATED ask is never consumed — an operator's shellGate:"always" tier and a
* tool's own egress/irreversibility marks are structural requirements, not classifier hesitation
* ("allow rules silence the classifier's questions, never a mandated one"). When a rule matches
* but cannot clear, the surviving ask discloses it (`allowRuleShadowed`, member `persisted`).
* · #502: an ask the call's own reversibility probe declared STRUCTURAL (`probeMandated`) joins that
* family. It is the one PER-CALL member of it, and the reason there has to be one: the built-in
* shell probe raises it for a read outside the session's allowed directories, which is a boundary
* the deployment declared but which arrives wearing the shape tier's own "maybe" —
* the very tier the bullet below keeps as this lane's home turf. Without a per-call member, a
* compound was cleared segment-wise by the conjunction arm and an out-of-root read plus a sender
* ran on two ordinary standing rules with no question asked.
* · #213: an ask carrying `matchedAskRule` (an explicit `ask` permission rule matched this call)
* is never consumed — a person's standing "ask me each time" outranks the memory of one past
* yes, across lanes exactly as the DSL's own deny > ask > allow order says within one. Same
* disclosure as the mandated family when a rule matches anyway.
* What it DOES consume is the classify-DOCTRINE shell ask (the coarse tier "maybe" — the
* don't-ask-again main case this feature exists for) and unmarked bare asks. Consuming such an ask
* also skips the park it would have minted; that IS what a standing approval means, and the
* bullets above are what keep integrity/hook/mandated asks out of that set.
*/
persistedRules?: {
/** The lane's verdict for this call — a {@link PersistedRuleHit} naming the behavior that spoke and
* the rule set it stands on, a coverage-carrying clean negative, an unreadable report, or
* `undefined` (the store was READ and no rule speaks). Must not throw — and a store (or a partition of it) that cannot
* be read answers `{ unreadable: true }`, not `undefined`: it fails closed (a row nobody could read may forbid the call;
* the decision becomes a real-approval ask) and the evidence records `"unavailable"`, not `"no_match"`; the lane owns
* announcing the skip. A call unreadable against the rows answers `{ unreadable: true, reason }` (the call form).
*
* For an ALLOW the set is the COVERAGE SET — each member's canonical text plus its add DOTS. A
* single rule admitting the whole command is a one-element set; a compound admitted by the
* per-segment conjunction (design/375 §5.1) is the set of the covering rules. The dots are a rule's
* durable identity, and the text is not one (it reaches a card sanitized and capped); a lane that
* can name them lets the shadowed-ask evidence say WHICH stored rules matched rather than merely
* what they looked like. The retired shapes — a bare rule TEXT, the pre-375 `{ rule, dots? }` object,
* a `{ rules }` hit without its behavior — are read as a clean negative by the normalizer: a stale
* lane degrades toward asking, never toward an allow. Consulted on EVERY decision (a deny/ask row
* must be able to tighten an allow), so the lane pays one store read per call on the tools it
* speaks for; the runner's lane hands the org layer's read to it, so a governed deployment still
* pays one. */
adjudicate: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
/** Observation sink for an ask a rule coverage resolved — the allow-side disclosure. `rules` is
* the coverage set's canonical texts in reporting order (design/375: a conjunction names every
* rule the decision stands on). Never affects the outcome. */
onResolved?: (info: {
toolName: string;
toolCallId: string;
rules: readonly string[];
}) => void;
};
/**
* Observation sink for a shell call the READ-ONLY reader cleared (`readOnlyShellVerdict`, the
* allow layer's second member beside the persisted allow rule: consulted for the shell tool only, after
* the person's allow rules, under the SAME consumption predicate — never over a mandated ask, a hook's
* ask, an explicit ask rule, governance or a marked call). The allow-side disclosure of "why did this run
* without asking me?" for that arm; `command` is the FINAL command the gate judged (a policy rewrite
* included). Never affects the outcome. Absent ⇒ the arm still runs, silently.
*/
onReadOnlyAllowed?: (info: {
toolName: string;
toolCallId: string;
command: string;
}) => void;
/**
* design/182 §7 — the ORG layer. Present ONLY when the deployment DECLARED org governance (the
* overlay constructor is the boot gate: a governed declaration with no snapshot provider refuses to
* boot). Absent ⇒ the whole step does not exist and the decision path is byte-identical.
*
* The org layer is the outermost TIGHTENING authority, so it runs after every fold and every engine
* tighten and before the ask-resolution chain — "org deny > org ask (non-dismissable) > personal
* allow rule > bare ask". It never loosens: there is structurally no org allow bucket.
*
* The `unavailable` arm is the load-bearing one. An org-governed deployment that cannot reach a
* snapshot fails the WHOLE decision boundary closed — every terminal allow (a deployment-policy
* allow and the no-policy default allow included) becomes an ask carrying
* `decisionReason: "org_unavailable"` AND `requiresRealApproval: true`, and both ask→allow seams (the
* personal-rule lane and the auto-mode classifier) are disarmed. Tightening only the two seams was
* refuted during design: a call that is ALREADY allow never passes through either seam, so it would
* have sailed past the org deny that could not be read. The `requiresRealApproval` bit closes the
* third loosening path — a blanket `onAsk: "allow"` would otherwise clear the synthetic ask.
*/
orgRules?: {
/** Adjudicate this call against the current org overlay. MUST NOT throw — a throw is read as
* `unavailable` (fail-closed), never as "no org rule spoke". */
adjudicate: (req: ToolCallRequest) => Promise<OrgGateVerdict>;
/** True iff the reserved question-tool NAME on this leg resolves to the ENGINE's own content-ask
* tool. It is the exemption's real predicate: the name alone is not, because a caller may mount
* its own tool under that name when the first-party one is not mounted, and such a tool has
* arbitrary side effects (confirmed MED). Absent ⇒ `false` ⇒ the call is governed
* like any other, which is the fail-closed default. */
contentAskToolMounted?: boolean;
/** Disclosure sink for the unavailable arm (trace `permission.org_snapshot_unavailable`). Never
* affects the outcome. */
onUnavailable?: (info: {
toolName: string;
toolCallId: string;
message: string;
}) => void;
};
/**
* F-012 L2 — the SANDBOX ADMISSION leg. Present ONLY when the runner armed the mode: the execution
* env DECLARES isolation (`RemoteExecutionEnv.capabilities.isolation === true` — a Runner-resolved
* fact, never caller self-report) AND the park prerequisites hold (durable park configured + a
* checkpoint store that explicitly declares `redecision.reopen`). Absent ⇒ the whole step does not
* exist and the decision path is byte-identical to a build without it.
*
* Semantics (the ruling): inside a declared sandbox, an engine-authored "confirm a potentially
* destructive local action" ask is answered by containment itself — auto-allow + durable
* disclosure (`permission.sandbox_admitted`), never a headless deny that pollutes the model.
* STRICT dominance governs: the leg admits only when EVERY surviving ask on the call is classified
* `sandbox_local` in the engine's private envelope (deployment-policy asks are `unknown`, hook
* asks `hook`, marked calls `mandate`, `requiresRealApproval` asks external — none admit), and
* the call crosses NO declared boundary (`boundaryCapable`: egress-marked tools and
* protocol-mounted (MCP/A2A) tools — a server's self-report is always boundary-capable). A
* boundary-crossing ask under the armed mode takes the ordinary durable-park leg below — "park to
* a human", never an auto-deny.
*/
sandboxAdmission?: {
/** The ask classes recorded for this call's fold-layer asks (with their source layers), or
* `undefined` when no fold layer asked — the gate's own safety tighten is then the only asker. */
askClassesOf: (toolCallId: string) => ReadonlyArray<{
cls: AskClass;
layer: string;
}> | undefined;
/** This call's tool crosses a DECLARED boundary (egress axis / protocol-mounted) — never admitted. */
boundaryCapable: boolean;
/** The durable disclosure sink — fired on every admission BEFORE the allow is returned. */
onAdmitted: (info: {
toolCallId: string;
toolName: string;
askClasses: ReadonlyArray<{
cls: AskClass;
layer: string;
}>;
boundary: "sandbox_internal";
}) => void;
};
}
/**
* The PreToolUse screening face ({@link PreToolUseScreening}) for ONE call — `classic.PreToolUse` raised on the leg's
* bus, the per-screening e and the fail-closed sentences, built in ONE place for every site that screens a call: the
* gate's phase 1 and approved-edit re-screen (through {@link runToolGate}'s pass) and the resumed leg's re-screen of an
* approved call. Undefined when no handler hangs on the event.
*
* The e: `tool` (the call's wire name), `tool_use_id`, `input` — a deep-frozen copy of the args, so what a handler
* judges is what runs (`input` is pinned; a rewrite is the answer's `updatedInput`) — `agentId` on a delegated leg,
* `cwd` (the task's tracked cwd, read per screening), `env` (the capability face), `identity`, `permissionMode`.
*/
export declare function createPreToolUseScreening(input: Pick<ToolGateInput, "event" | "modBus" | "observedPermissionMode" | "trackedCwd" | "hookEnv" | "identity" | "abortSignal">): PreToolUseScreening | undefined;
/**
* Fold a leg's `classic.PreToolUse` chain into a {@link ToolPolicy} layer, so the screening a task installed can also
* travel down a delegation chain as one more inherited constraint — the constraint MINT. `chain` is the installing
* leg's bus; the policy raises `classic.PreToolUse` on it with the handlers that JUDGE (an observe handler never enters
* a constraint — the participant filter below is that rule's one home).
*
* Why a policy and not a second channel: a delegated child already folds each ancestor's caller policy
* (`InheritedGate.parentConstraints`), and that fold is where `deny > ask > allow`, the rewrite cascade, the
* ancestor-frozen approver and the durable mandate all already live. Expressing the screening in the same shape means
* the child gains no new decision channel — the existing fold consumes it — and the tighten-only property is
* structural rather than restated.
*
* The mapping mirrors the gate's own phase 1 decision for decision:
* - no opinion → `allow`, contributing nothing;
* - `deny` → `deny` in the fold's own words;
* - `ask` → `ask`, stamped `decisionReason:"hook"` (and the mandate, when the answer set it) so every consumer that
* distinguishes a screening ask from a policy ask reads the same fact one level down;
* - `allow`/`ask` with `updatedInput` → the same rewrite, which the fold cascades and re-checks;
* - a failed screening (a handler that threw, ran past its budget or broke the site's contract) or a cancel → the
* fail-closed deny, because a screening that cannot answer must not pass the call. The installing leg's bus
* already reported a crash or a timeout on that leg's error lane.
*
* **Idempotency** ({@link combinePolicies}): a layer's `check` may run more than once for a single tool call (the fold
* scans past an `ask`, a later rewrite re-runs the prefix, an approved edit is re-adjudicated), so a handler with a side
* effect sees that call more than once on those paths. Its own leg's phase 1 has always had the third of these.
*
* Not carried, and not reconstructible in this shape: `additionalContext` (a policy verdict has no channel for
* model-facing text — the context belongs to the run whose gate ran it), the leg identity and the tracked cwd (the
* fold's request carries neither, and the installing leg's would name the wrong run), and the phase-1 observer
* exclusion (a deny raised here is a fold deny: the descendant's deny observer sees it as `source:"policy"`). `env` is
* the capability face of the environment the chain was installed against, captured at the install site.
*
* The chain travels on the minted policy: a descendant's gate raises the installation's `tool.check` on it (a verdict read is
* not a fold layer). A host re-supplying the entry hands back the policy minted here, as it is.
*/
export declare function createPreToolUseConstraintPolicy(chain: Pick<import("./mods/kernel/index.js").ModBus, "hasHandlers" | "dispatch">, env?: HookEnvCapabilities): ToolPolicy;
/**
* The park closure's ONE structural seat — what the gate hands the durable park beyond the twelve
* positional seats. Every member is optional and read by name, so a new thing the park must know
* becomes a member here, never a fourteenth positional argument (each of the last four seats was
* appended "at the tail so nothing re-binds", which is the growth this seat ends).
*/
export interface AskCarry {
/** design/384 slice 2 — {@link ToolGateInput.callSignal}, verbatim: the park closure composes it
* with the run signal into the halt×park fence's cut signal. Absent ⇒ the run-level binding alone. */
readonly signal?: AbortSignal;
/** The surviving ask's `denialLimitFallback` (the classifier denial-limit counts) run through the
* window-less route station ({@link import("./auto-mode.js").unarmedWindow}) when the gate builds the
* carry: a park is not a hand-out to a live approver, so nothing on this lane counts down. The
* decision's member cannot state a window (its type has no such member), and this is the one place
* the `0` a park row shows is written. Persisted on the row so a durable card shows the counts the
* synchronous card shows. Absent then means: not a fallback ask. */
readonly denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
/** The surviving ask's no-ruling member ({@link import("./tool-policy.js").AskRequest.classifierNoRuling}), for the row's twin. */
readonly classifierNoRuling?: import("./auto-mode.js").AutoModeFallbackCause;
/** The surviving ask's engine-stamped origin word ({@link import("./ask-origin.js").AskOrigin}), persisted
* on the row so a durable card is rendered by the same word the synchronous card is. */
readonly origin?: import("./ask-origin.js").AskOrigin;
/** The surviving ask's engine-stamped class ({@link import("./tool-policy.js").AskRequest.askClass}), persisted on the
* row so a durable approval seat answers by the class the synchronous one reads. Present exactly when
* {@link classes} is. */
readonly askClass?: import("./ask-origin.js").AskClass;
/** The whole class set beside it ({@link import("./tool-policy.js").AskRequest.classes}), in `ASK_CLASSES` order. */
readonly classes?: readonly import("./ask-origin.js").AskClass[];
/** The stamped mandate ({@link import("./tool-policy.js").AskRequest.mandated}) — the park site's
* rule-offer facts read it, so a parked mandated ask offers no rule, like the live card. */
readonly mandated?: true;
/** The table word stamped beside it ({@link import("./tool-policy.js").AskRequest.mandate}), for the row's twin. */
readonly mandate?: import("./ask-origin.js").PersistedRuleMandate;
/** The deployment's authorship ({@link import("./tool-policy.js").AskRequest.authoredBy}), for the row's twin. */
readonly authoredBy?: "deployment";
}
/** What a durable row persists of an {@link AskCarry}: the fallback member COPIED (the row never aliases
* the gate's object; its window is already `0` — the gate's carry mint is the one writer of that
* number), the origin word only when it is a member of the closed set, and the class pair only when it is
* well-formed, as a fresh copy (the carry is typed, but the row is the boundary a host reads back through —
* the same screen the summary applies to the word). */
export declare function askCarryRowMembers(carry: AskCarry | undefined): {
denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
classifierNoRuling?: import("./auto-mode.js").AutoModeFallbackCause;
origin?: import("./ask-origin.js").AskOrigin;
askClass?: import("./ask-origin.js").AskClass;
classes?: readonly import("./ask-origin.js").AskClass[];
};
/**
* The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
* structural: a hook's `allow` passes no deny, no ask rule and no mandated question; it is one member of the
* allow layer and clears only the questions an allow rule may clear (a deployment that wants a question put to a
* person whatever a hook says marks it `authoredBy: "deployment"` or `requiresRealApproval`):
*
* 1. **collect** — run the PreToolUse hook, threading any `updatedInput` rewrite into `currentInput`
* and collecting `additionalContext`. A hook `deny` short-circuits to a block immediately; a hook
* `ask` is remembered (it does not short-circuit — a later policy `deny` outranks it).
* 2. **adjudicate** — run the tool policy on the FINAL `currentInput` (never the model's stale args),
* fold it with any remembered hook-ask via `deny > ask > allow`, and resolve a surviving `ask`
* through `onAsk`. The policy ALWAYS runs regardless of the hook's verdict. A policy `allow` may
* itself carry an `updatedInput` rewrite (redact/clamp), applied last over any hook rewrite.
*
* Because both phases run in this one linear function, the ordering — and the "policy is final"
* invariant — is enforced by the call stack, not by registration convention. Returns the gate result
* for the harness `tool_call` hook plus the PreToolUse context to attach to the tool result.
*/
export declare function runToolGate(input: ToolGateInput): Promise<ToolGateResult>;