@sema-agent/core
Version:
Stateless, task-oriented AI agent core
1,821 lines • 138 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 { GateOutcome } from "./gate-outcome.js";
import { type PreToolUseScreening } from "./runner/gate-exit.js";
export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./rule-lane-reads.js";
export { cloneObserverInput } from "./runner/gate-exit.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 SafeNotifier } from "./safe-notify.js";
import type { PermissionSeatInputs } from "./mode-policy.js";
import type { SkillGateSeats } from "./skill-grant.js";
import type { AllowEntryGateSeats } from "./allow-entry.js";
/**
* In-process hook seam (design/37) — a provider-agnostic interception layer modeled on CC's hooks,
* kept process-internal (no shell/HTTP executors, no settings files). The original tool-call trio:
*
* - {@link Hooks.preToolUse} runs before a tool executes. It may **rewrite** the args (an `allow` with
* `updatedInput`), **restrict** the call (`deny`/`ask`), and/or inject `additionalContext`. It is a
* pre-filter only: a hook's `allow` does NOT circumvent the tool-policy gate — the policy still runs on
* the (possibly rewritten) args and has the final say (load-bearing invariant; see {@link runToolGate}).
* - {@link Hooks.postToolUse} runs after a tool executes. It may replace the result content
* (`updatedOutput`) and/or append `additionalContext`.
* - {@link Hooks.userPromptSubmit} runs before the objective becomes a user message. It may `block`
* submission (the task fails with a model-readable reason) or inject `additionalContext` ahead of it.
*
* The seam has since grown well past the trio — each later member carries its own contract below:
* {@link Hooks.stop} / {@link Hooks.stopFailure} (run-end pushback / API-error terminal observer),
* {@link Hooks.postToolUseFailure} and {@link Hooks.postToolBatch} (failure + batch-boundary taps),
* {@link Hooks.preCompact} / {@link Hooks.postCompact} (compaction gate + observer),
* {@link Hooks.permissionDenied} (deny observer), and the {@link Hooks.preToolUseObservational}
* declaration flag.
*
* 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).** Three repos
* have reasoned from the sentence "CC fails open on hook failures, exit code 2 excepted". That sentence
* is TRUE OF ONE LANE and false as a general statement, and the difference lands exactly on this seam.
* CC runs hooks of seven kinds; the `command` kind is a shell script, and for it a spawn failure, a
* non-zero exit and a per-hook timeout are all non-blocking — the call proceeds. But CC ALSO has an
* in-process CALLBACK lane, which is the one structurally equivalent to this interface, and there a
* PreToolUse callback that throws or times out is FAIL-CLOSED, with its own message stating that the
* tool call was not executed. (Two further details the short sentence hides: the decision channel is
* stdout JSON, with the exit code only a fallback — so exit 0 can block and a non-zero non-2 exit with
* valid JSON does not; and the callback lane's UserPromptSubmit timeout BLOCKS the prompt.)
*
* So this seam's fail-closed postures are not a sema invention to be justified against CC — they are
* the same posture CC takes on the same kind of lane: a `preToolUse` that throws or does not answer
* refuses the call, a `userPromptSubmit` that throws or does not answer refuses the prompt, and the
* OBSERVATION seats swallow and disclose because an observer has nothing to fail closed about. Anyone
* quoting the short sentence to argue this layer "should" fail open is quoting the script lane.
*/
export interface Hooks {
preToolUse?(toolName: string, input: unknown, ctx: HookToolContext): PreToolUseResult | undefined | Promise<PreToolUseResult | undefined>;
/**
* Deployment SELF-DECLARATION: this deployment's {@link preToolUse} is a pure OBSERVER — a tracer, an
* audit sink, a metrics counter — that never judges a call. Declaring it changes exactly one thing:
* the face stops being folded into the DELEGATION chain (`InheritedGate.parentConstraints`), so a
* delegated child inherits no screening entry for it.
*
* Why the declaration exists. The delegation fold (issue #33) keys on PRESENCE — "a preToolUse is
* wired" — because a callback cannot be inspected for whether it will ever return a verdict. That
* reading is right for a screening face and wrong for an observer, and the difference is not free:
* every inherited entry is one more OPAQUE constraint on the child, recorded on a durable park as
* `parentConstraintCount`. A deployment that flips on a default-OFF diagnostic tracer therefore also
* changes the shape every parked child's resume has to re-supply. The declaration is how a deployment
* says which of the two it wired, on the same footing as every other thing the library trusts a
* deployment to state about its own wiring.
*
* What does NOT change: the callback still runs in THIS task's own tool gate, on every call, with the
* same context and the same crash posture (a throw is still the fail-closed deny). Observation is
* untouched; only the inherited constraint goes away.
*
* The face also receives a DETACHED copy of the arguments rather than the live object, because the
* gate reads a rewrite off reference identity and an in-place edit would otherwise be a second channel
* that outlives the dropped return value. That detachment is BEST-EFFORT, in the same sense (and by the
* same helper) as every other observe-only payload here: a graph `structuredClone` cannot take — a
* function, a live handle — falls back to a shallow copy whose nested objects still alias, and a
* `SharedArrayBuffer` shares its memory even when the clone succeeds. Phase-1 arguments are the model's
* JSON on the ordinary path, which clones whole; the residual is reachable only through values a
* DEPLOYMENT put there (its own rewrite, or driving `runToolGate` directly), and the face and the
* deployment are one trust principal — this seam helps a deployment keep its own declaration, it is not
* a boundary against an untrusted party.
*
* If a declared-observational face RETURNS SOMETHING ANYWAY, the return is REFUSED, not obeyed: the
* call proceeds as if the hook had no opinion (`undefined`), and the deployment's `onError` face is
* told (`phase:"hook"`, classification `observational-hook-verdict-ignored`). That covers the whole
* result object — an `additionalContext` injection is a run-affecting capability too, so a face that
* declared it only watches does not get to write into the model's transcript either. The refusal is
* loud on purpose: a declaration that could be quietly contradicted would be a way to keep a
* screening face's verdicts while shedding the delegation constraint they belong to.
*/
preToolUseObservational?: true;
postToolUse?(toolName: string, input: unknown, output: HookToolOutput, ctx: HookToolContext): PostToolUseResult | undefined | Promise<PostToolUseResult | undefined>;
/**
* design/373 §4.3 (D2) ⚠️ WIDENED INVOCATION SET: this seat now screens EVERY user-lane entrance,
* not just the objective — a live `TaskStream.steer`, a wake resume's message (pre-CAS, at the
* verb), and a parked steer's resume redelivery (any draining resume kind) all pass through it, discriminated by
* {@link UserPromptSubmitContext.source}. A hook that blocks unconditionally therefore starts
* refusing steers too — branch on `ctx.source` (+ `ctx.actor`) to scope a verdict. The screen's
* domain is caller/operator PROSE only: engine-authored frames (task notifications, diagnostics,
* recall) never pass here — external `notify()` text takes the sanitized DATA-frame lane instead
* (ruled: notification frames are not prompts; see the notify contract). The same input can
* legally arrive twice under two sources (live + parked redelivery) — idempotency is the hook's,
* keyed on `ctx.inputId`. Block semantics per entrance: objective ⇒ the task ends `blocked`;
* live steer ⇒ typed `steering.blocked_by_hook` to the steer caller; resume message ⇒ typed
* pre-CAS refusal (the checkpoint stays pending); parked redelivery ⇒ the frame is withheld
* with a `steering.parked_input_blocked` notice + a `delivery:"blocked"` human_input account,
* and the resume itself proceeds (a blocked instruction must never wedge a wake).
*
* ⚠️ RE-ENTRANT within one leg: the steer verb goes live the moment the run's handle publishes —
* BEFORE the objective seat runs — so a steer screen can overlap the objective screen (and a
* resume-message screen, which fires pre-CAS at the verb) in flight. A stateful implementation
* (per-call temp files, a single in-flight latch, a shared accumulator) must tolerate concurrent
* entry; only steer-vs-steer is serialized. And that serialization has a LATENCY face: queued
* steers screen strictly FIFO, so each waits behind its predecessor's ENTIRE hook bound (the
* resolved `timeoutMs`, default 600s) — a slow or wedged screen delays every steer behind it by
* up to the full bound, which is a reason to declare a short `timeoutMs` on deployments that
* screen steers.
*/
userPromptSubmit?(prompt: string,
/** #281 件A (additive): the run/leg identity envelope — an implementation declaring only
* `(prompt)` keeps working. Always supplied on the engine's emission. design/373: carries the
* entrance discriminator (`source`/`inputId`/`actor`) — see {@link UserPromptSubmitContext}. */
ctx?: UserPromptSubmitContext): UserPromptSubmitResult | undefined | Promise<UserPromptSubmitResult | undefined>;
/**
* roadmap #5 (CC 198 Stop hook, :473831-): runs when the agent WOULD OTHERWISE END its run (no
* more tool calls, steering and follow-up queues dry). Return `{ block: reason }` to PUSH BACK —
* the reason is injected as a model-readable message and the run continues another turn (external
* adjudication: "the tests still fail", "the deliverable is missing X"). Return `undefined` to let
* the run end. Guardrails (CC-exact): consecutive blocks are capped (default 8 — the loop then
* overrides and ends with a warning); `ctx.stopHookActive` is true when the run is ALREADY
* continuing due to a previous block — a hook that ignores it and blocks unconditionally loops
* until the cap. `maxTurns` still outranks everything.
*/
stop?(ctx: StopHookContext): StopHookResult | undefined | Promise<StopHookResult | undefined>;
/**
* design/134 (CC PostToolUseFailure parity, SDK 0.3.202): runs after a tool EXECUTION fails —
* mutually exclusive with {@link postToolUse} (success → postToolUse; failure → this). Fires ONLY
* for genuine execution failures: a gate/policy block, a plan-mode deny, a pre-execution abort or a
* durable suspend triggers NEITHER callback (CC scopes those to the PermissionDenied event domain,
* not implemented here). Capability is additionalContext-only (CC-exact): the text is appended to
* the error tool result as a `<system-reminder>`; the failure itself cannot be rewritten.
*/
postToolUseFailure?(toolName: string, input: unknown, failure: HookToolFailure, ctx: HookToolContext): PostToolUseFailureResult | undefined | Promise<PostToolUseFailureResult | undefined>;
/**
* design/134 (CC PostToolBatch parity, SDK 0.3.202): fires ONCE after every tool call in a batch
* (one assistant turn's calls, design/120 stream-inline included) has resolved, before the next
* model request. Per-tool postToolUse/postToolUseFailure fire first; the batch callback fires after,
* at the turn boundary. A zero-tool turn does not fire; an aborted/steered-away boundary does not
* fire (same gate family as design/133 attachments). additionalContext is injected once for the
* whole batch as a boundary `<system-reminder>` (sanitized, shares the attachment byte cap).
*
* RB-467 (test-AI black-box repro, board 2305: ONE injection ate a whole subsequent batch): a batch
* landing on a boundary where the engine itself injected (final-verification nudge / finalize
* write-out) is now DELIVERED with `meta.injectedThisTurn` set, instead of being suppressed — for a
* deployment observer, any suppressed boundary is lost audit data; attribution belongs to the
* observer. On such a boundary the hook's `additionalContext` is IGNORED (the boundary already
* carries an engine injection; stacking observer text there is the double-inject this gate
* originally guarded against — that half of the old behavior stands).
*/
postToolBatch?(batch: PostToolBatchCall[], meta?: {
injectedThisTurn: "final_verification" | "finalize";
},
/** #281 件A (additive): the run/leg identity envelope, on its OWN parameter — `meta`'s presence
* is an existing signal ("the engine injected at this boundary") that must not become
* always-true just because identity rides along. Always supplied on the engine's emission. */
ctx?: PostToolBatchContext): PostToolBatchResult | undefined | Promise<PostToolBatchResult | undefined>;
/**
* design/134 (CC PreCompact parity): runs before each compaction, AFTER the trigger gate and a
* valid cut point are confirmed (so every preCompact corresponds to a compaction that would
* actually happen) and before the summary LLM call. `trigger`: "auto" (threshold) | "manual"
* (/compact) | "forced" (promptTooLong recovery and trim-pressure — compaction is not optional
* there). Return `{ block }` to skip this compaction (honored on auto/manual ONLY; ignored with a
* trace on forced — blocking a compaction the provider already demanded would kill the run).
* `additionalInstructions` is merged (appended) into the summarization instructions (CC
* mergeHookInstructions shape). A THROWING preCompact is swallowed on every path (traced, treated
* as no-block): an observer bug must never feed the compaction breaker or kill PTL recovery.
*/
preCompact?(ctx: PreCompactContext): PreCompactResult | undefined | Promise<PreCompactResult | undefined>;
/**
* design/134 (CC PostCompact parity): observe-only, after the compaction landed (summary appended
* to the session). No return capability (CC-exact); a throw is swallowed and traced.
*/
postCompact?(ctx: PostCompactContext): void | Promise<void>;
/**
* StopFailure (the last engine-owned event of the registry-core 0.1.51 hooks contract; CC semantics:
* "the turn ended because of an API error"). Fires ONCE, after the result is assembled, when the task
* terminates `failed` BECAUSE the model/API layer errored (a brain error terminal —
* auth / network / rate_limit / server_error / …). It does NOT fire for engine-owned failure classes:
* budget, timeout/abort, max-turns, degenerate/walltime cutoffs (deliberate engine cuts), storage
* conflict, blocked, or a durable suspend — those are not API errors. Observe-only (CC-exact: the
* contract defines no StopFailure output capability); a throwing callback is swallowed + traced
* (design/134 discipline — an observer bug must never rewrite a terminal state).
*/
stopFailure?(ctx: StopFailureContext): void | Promise<void>;
/**
* design/134 R5 (CC PermissionDenied parity, SDK 0.3.202): runs when the tool gate's
* adjudicate chain DENY-SHORT-CIRCUITS a tool call — a ToolPolicy deny, a gate tighten (egress /
* irreversibility / coarse shellGate) whose `ask` resolved to deny (headless auto-deny included), or the
* plan-mode write-deny. Closes the observation blind spot where a blocked call fires NEITHER postToolUse
* NOR postToolUseFailure (R3 scoping). Does NOT fire for:
* - a **durable suspend** (the ask path — CC surfaces that via a `can_use_tool` control_request);
* - a **PreToolUse hook deny** (CC-exact: "PreToolUse hook denies circumvention canUseTool and are not
* covered here").
* DOES fire for a **PreToolUse hook CRASH** (RB-442, `source:"hook"`): a throwing hook is intercepted by
* the ENGINE, which then makes its OWN fail-closed deny decision — likewise a hook result the decision
* receipt REFUSES (out of contract, undetachable). The R5 exclusion above covers the hook's own DENY
* DECISION — not an engine decision that merely happened near a hook.
* Observe-only v1 (CC-exact payload `{toolName, input, toolCallId, reason}` + our `source` enum standing
* in for CC `decision_reason_type`): no `retry` capability (recorded — retry semantics vs our adjudicate
* chain need their own design), no additionalContext. A throwing callback is swallowed + traced via
* `onError(phase:"hook")` — an observer bug must never alter the deny outcome (design/134 discipline).
*
* **HOW WIDE THIS SEAT IS, stated honestly (anchoring re-check 2026-08-23).** The exclusion sentence
* above is quoted from the SDK's TYPE-LEVEL documentation, and the whole R5 scoping argument was
* built on it without ever being checked against a shipped bundle. Against CC 2.1.223's runtime the
* picture is different in a way that matters for how this comment may be USED: CC emits its
* PermissionDenied event from exactly ONE site, guarded to the auto-mode CLASSIFIER's refusals —
* a rule deny, a mode deny and a headless auto-deny emit nothing at all. So "a hook's own deny is
* excluded" is TRIVIALLY true there (almost everything is excluded), and it must not be read as
* evidence that this seat's TRIGGER SURFACE matches CC's. It does not: sema fires on four engine
* emissions with seven `source` values, deliberately WIDER. The width is the useful direction for an
* observation seat — the blind spot R3 named is real and a narrower seat would not close it — and it
* is a divergence rather than a parity claim. The exclusion itself stays, on its own merit: a hook's
* deny is the HOOK's decision, and this seat exists to attribute the ENGINE's.
*/
permissionDenied?(payload: PermissionDeniedPayload): void | Promise<void>;
/**
* The TIME BOUND every seat on this object runs under, in milliseconds. Absent ⇒
* {@link DEFAULT_HOOK_TIMEOUT_MS}. It is the analog of the CC hook record's own `timeout` field —
* declared once per hook record there, once per hook OBJECT here, because this seam's record IS the
* object. The unit is ms rather than CC's seconds: every other deadline this library takes ends in
* `Ms`, and one unit per codebase beats one unit per upstream.
*
* **Why the seam has a bound at all.** Every callback above is DEPLOYMENT code awaited on the
* engine's critical path, and one that never returns used to stop the whole run — the tool gate
* awaited `preToolUse` bare, so a hung screening face left the call unsettled and the task's own
* timeout/cancel could not end it. The diagnosis was an internal asymmetry rather than a guess: the
* SAME callback installed as an inherited constraint on a delegated child was already bounded, because
* that path folds it into a `ToolPolicy` and the policy wait is abort-raced — and the sibling seam in
* this very file (`reversibilityProbe`) had already been given a default deadline for the identical
* reason. Policies were bounded, probes were bounded, hooks were not.
*
* **What a bound does NOT change.** A seat that answers keeps its answer, byte for byte; a seat that
* throws keeps its existing crash posture (this member changes nothing about a throw). The bound is
* consulted only when a seat has not answered yet, and what happens then is per-seat and stated on
* each member's own contract — decision seats fail CLOSED (`preToolUse` refuses the call the way a
* crash does; `userPromptSubmit` refuses the prompt), observation seats are ABANDONED and the engine
* proceeds as if the seat had returned nothing. Either way the deployment is told on its `onError`
* lane, because a silently-dropped seat is the failure mode this member exists to make visible.
*
* **The seat also gets a signal.** An expired wait would otherwise leave the callback running with
* nobody reading its answer — an orphan of the deployment's own making. So every context carries an
* optional `signal` that aborts when this bound elapses (or the task aborts), and an implementation
* that honors it can cancel the read it was waiting on. Honoring it is optional; a seat that ignores
* it is exactly as bounded as before, the engine simply stops waiting.
*
* **WHAT THE BOUND IS ON, stated narrowly because the difference is not academic.** It bounds the
* engine's WAIT, not the callback's CPU. A hook that blocks the thread SYNCHRONOUSLY — a spin loop, a
* synchronous exec, a synchronous parse of something enormous — cannot be preempted by this bound or
* by any other in-process mechanism, because the timer that would fire is queued behind the code that
* is not yielding; the ordering of the timer's installation makes no difference to that. In-process
* is the seam's whole premise (it is what makes a hook able to rewrite args and return a verdict),
* and preemption is what it trades away. A deployment that must survive a hook it does not trust to
* yield runs that hook somewhere with its own thread — a worker, a process, a service — and awaits
* the result from here. What this member DOES bound is every asynchronous wait, which is where the
* failure this was written for lives: a callback that awaits an unreachable host, a lock, a queue.
*
* **Value discipline** (bad values are loud, never silently reinterpreted): a non-finite, negative, or
* above-`setTimeout`-ceiling (2147483647) value is REFUSED to the default and reported on the
* deployment's error lane — the same treatment, and the same ceiling, the gate's `approvalTimeoutMs`
* seat gives its own garbage. `0` is honored as written (every seat expires immediately), because a
* deployment that writes zero has said something, and guessing that it meant "unbounded" would be the
* one reading this member cannot support.
*/
timeoutMs?: number;
}
/**
* The bound a {@link Hooks} seat runs under when the deployment declares none ({@link Hooks.timeoutMs}).
*
* The value is CC's own hook default (600s) rather than a number invented here. It is deliberately
* GENEROUS: the seat bound's job is to make a wedge terminal, not to police how long a legitimate hook
* may think — the task's own abort signal is what ends a hung DECISION seat promptly on a run that has a
* deadline, and this ceiling is what ends it on a run that does not. A deployment whose hooks should
* answer in a second says so with `timeoutMs`.
*/
export declare const DEFAULT_HOOK_TIMEOUT_MS = 600000;
/** `setTimeout`'s own ceiling: above it the platform silently clamps to ~1ms, which would turn a
* deployment's "very long bound" into an instant expiry it never asked for. Refused loudly instead
* (same ceiling, same posture as the gate's `approvalTimeoutMs`). */
export declare const MAX_HOOK_TIMEOUT_MS = 2147483647;
/**
* How long an observation seat still gets when the task is cancelled `elapsed` ms into its wait — the
* grace, capped by what is LEFT of the declared deadline.
*
* The `remaining` argument, not the whole deadline, is the correction: arming a fresh
* `min(GRACE, timeoutMs)` from the abort moment restarts the clock, so an abort landing near the
* deadline pushes the seat's absolute end PAST the number the deployment declared — up to double it for
* a sub-second bound — on precisely the cancelled-task settlement path the grace exists to shorten. The
* two contract sentences this file already carries ("never an extension", "a deployment that declared a
* SHORTER `timeoutMs` keeps its own number") were true only in the aborted-on-entry case, where elapsed
* is zero and the two formulas coincide. Now they are true everywhere.
*
* Clamped at zero: a deadline already spent is no grace at all, not a negative timer.
*/
export declare function postAbortGraceMs(remainingMs: number): number;
/**
* Read {@link Hooks.timeoutMs} as a usable deadline: the declared value when it is one, the default
* when it is absent, and the default LOUDLY when it is garbage (bad-value loudness — a knob's bad value
* must never resolve silently to the default).
*
* `owner` is the record the value was read off; passing it bounds the disclosure to once per record
* (see {@link badHookTimeoutReported}). Omit it where the caller already bounds its own channel.
*/
export declare function resolveHookTimeoutMs(supplied: number | undefined, report?: (err: unknown) => void, owner?: object): number;
/** The ten {@link Hooks} callback seats, named so a bound wait can say WHICH one did not answer. */
export type HookSeatName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "postToolBatch" | "userPromptSubmit" | "stop" | "stopFailure" | "preCompact" | "postCompact" | "permissionDenied"
/** design/380 O2 — not a {@link Hooks} record seat: the `RunnerDeps.resumePreflight` deployment
* callback, run through the same bounded-wait home so the pre-CAS resume ladder cannot grow its
* own dialect of "a deployment callback gets a deadline". */
| "resumePreflight";
/** What one bounded seat invocation produced: the seat's own value, or the fact that the wait ended
* first. A seat that THREW is neither — the throw propagates out of {@link runHookSeat} unchanged, so
* each site's existing crash arm keeps owning it. */
export type HookSeatOutcome<T> = {
readonly expired: false;
readonly value: T;
} | {
readonly expired: true;
readonly cause: "timeout" | "aborted";
};
/** How one seat invocation is bounded. */
export interface HookSeatBound {
/** The deployment's declared bound ({@link Hooks.timeoutMs}); re-validated here, so a caller that
* already validated pays nothing and a caller that did not cannot pass garbage through. */
timeoutMs?: number;
/** The task abort signal. It is ALWAYS forwarded to the seat (so an implementation can cancel its own
* underlying wait) and it ALWAYS shortens the wait — the two seat kinds differ only in HOW MUCH.
* Supply it wherever the caller has one: a seat with no signal is bounded by its deadline alone, and
* a ten-minute default deadline is not a cancellation story. */
signal?: AbortSignal;
/** True for a seat whose answer is MOOT once the task is ending — the DECISION seats. Those settle as
* `aborted` the instant the task is cancelled. An observation seat leaves this false on purpose:
* `postToolUseFailure` fires precisely BECAUSE the task aborted (`isInterrupt`), and cancelling it
* outright would delete the observation the seat exists to deliver — it gets
* {@link POST_ABORT_GRACE_MS} instead of the full deadline. */
abortEnds?: boolean;
/** Where a bad {@link timeoutMs} is disclosed. The EXPIRY itself is not reported here — each site
* words its own, because the sentence a deployment needs includes what the engine then did. */
onBadTimeout?: (err: unknown) => void;
/** The hook record the bound was read off, for once-per-record bad-value disclosure. */
owner?: object;
}
/**
* Run ONE {@link Hooks} seat invocation under its bound — the single home of "a deployment callback
* gets a deadline", so the ten seats cannot grow ten dialects of one wait.
*
* Shape notes, each one load-bearing:
* · the seat is invoked SYNCHRONOUSLY, in this turn, exactly where the bare `await` used to be — a
* callback that reads its own mutable state before its first `await` must see the turn it always saw;
* · a SYNCHRONOUS throw propagates unchanged (it never reached a deadline, and the site's crash arm is
* the contract for it), and so does an asynchronous rejection;
* · the seat's signal fires when — and ONLY when — this function stops waiting, and its promise is then
* abandoned with a rejection sink attached (an abandoned seat that fails later must not surface as an
* unhandled rejection somewhere else). The task's own signal is never forwarded raw: for an
* OBSERVATION seat the task being cancelled is not a reason to stop, it is the reason the seat is
* firing at all;
* · the TASK signal ends the wait on both kinds of seat, differently. A DECISION seat
* ({@link HookSeatBound.abortEnds}) settles as `aborted` the moment the task is cancelled, INCLUDING
* when it was already cancelled on entry — a verdict about a call that will not run decides nothing,
* and waiting the full deadline for one holds a cancelled task open for as long as the deadline says.
* On the entry case the callback is still INVOKED (an audit face keeps seeing the attempt) and its
* answer is discarded: settling on the cancellation rather than on whichever of the two happened to
* land first is what makes the arm a rule instead of a race.
* An OBSERVATION seat is not cancelled by the abort — it fires precisely BECAUSE the task aborted
* (`isInterrupt`), and racing it against that same abort would delete the observation it exists to
* deliver — but it does not get the full deadline either: the abort collapses its wait to
* {@link POST_ABORT_GRACE_MS}. Prompt observers still land; a hung one can no longer hold a cancelled
* task's settlement for the length of the whole bound.
*/
export declare function runHookSeat<T>(seat: HookSeatName, bound: HookSeatBound, call: (signal: AbortSignal) => T | Promise<T>): Promise<HookSeatOutcome<T>>;
/**
* The ONE wording home for "the engine stopped waiting for a hook seat", so the ten sites cannot drift
* into ten sentences about one fact. `consequence` is the half only the site knows: what the engine did
* instead.
*
* The CAUSE is named, not glossed. A deadline that elapsed and a task that was cancelled are different
* facts to whoever reads this — the first says a hook is too slow or stuck and wants fixing, the second
* says nothing about the hook at all — and one sentence covering both would have to be false about one
* of them. (It was: the first version of this said "did not answer within its Nms bound" on every
* expiry, including the ones where the bound was never reached.)
*/
export declare function hookSeatExpiredError(seat: HookSeatName, timeoutMs: number, cause: "timeout" | "aborted", consequence: string): Error;
/** The payload a {@link Hooks.permissionDenied} callback observes (CC-exact fields + the gate outcome). */
export interface PermissionDeniedPayload {
/** #281 件A — the run/leg identity envelope. Present on every ENGINE emission (the gate's single
* deny exit, the hook-crash intercept, the plan-mode and compliance denies); absent only when a
* host drives {@link runToolGate} without `ToolGateInput.identity`. */
identity?: HookInvocationIdentity;
toolName: string;
/** The FINAL (post-hook-rewrite / post-policy-rewrite) args the chain adjudicated — what would have
* executed; not necessarily the model's original args. */
input: unknown;
toolCallId: string;
/** Human/model-readable deny reason (CC `reason`) — the RAW decision text, NOT `<system-reminder>`-wrapped. */
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 (our `decision_reason_type` analog for the refusing half); when the refusal came through
* an ask resolution, `settlement` says how the wait ended and `origin` who asked. Nothing is read out of
* an absence: a direct policy / hook / plan-mode / compliance deny carries no settlement because it
* settled none.
*/
gate: GateOutcome;
/** {@link HookToolContext.permissionMode}, read when the deny is observed. */
permissionMode?: PermissionMode;
/** {@link HookSeatSignal} — this invocation's own abort signal. On an OBSERVATION seat the deny has
* already happened and nothing this callback does can change it, so the signal says exactly one
* thing: stop reading, nobody is waiting for your answer any more. */
signal?: AbortSignal;
}
/** Context for {@link Hooks.stopFailure} — aligned with the TaskResult error face (observe-only). */
export interface StopFailureContext {
/** #281 件A — the run/leg identity envelope (always present on the engine's emission; the seat is
* optional only because the envelope's whole contract is). See {@link HookInvocationIdentity}. */
identity?: HookInvocationIdentity;
/** Human-readable error message (the assembled `errorMessage`; any `[code]` prefix already stripped). */
error: string;
/** Machine-readable kind — the assembled `errorCode` lifted from the brain's `[code]` prefix
* (auth / network / rate_limit / server_error / …); absent when the provider stamped no code. */
errorKind?: string;
/** Turns completed when the failure ended the run. */
turns: number;
/** {@link HookSeatSignal} — this invocation's own abort signal. */
signal?: AbortSignal;
}
/**
* The abort signal every hook seat's context carries (`ctx.signal`), documented once here and
* referenced from each seat.
*
* It carries exactly one meaning: **the engine has stopped waiting for this invocation.** It is not the
* task's abort signal and it never fires early on the task's behalf — a DECISION seat's wait ends when
* the task is cancelled, so its signal fires then; an OBSERVATION seat's wait ends at its deadline or
* after the post-abort grace, so its signal fires there and NOT at the cancellation itself. The
* difference is deliberate: a `postToolUseFailure` fires precisely BECAUSE the task aborted, and a
* signal that went off at the cancellation would tell a cooperative observer to abandon the very
* observation it was invoked to deliver.
*
* Honoring it is optional and it changes nothing about the engine's own behavior — the engine stops
* waiting either way. What it is for is the deployment's side of that: a seat that was awaiting a
* network read, a lock or a subprocess can cancel it instead of leaving an orphan running with nobody
* left to read its answer. Absent when a host drives a seat outside the engine's own invocation (no
* bound, nothing to signal).
*/
export type HookSeatSignal = AbortSignal;
/** 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;
}
/** Context for {@link Hooks.preCompact} (design/134). */
export interface PreCompactContext {
/** #281 件A — the run/leg identity envelope. Present when the Runner drives the compaction (its
* wrapper injects it); honestly absent when a deployment drives the compaction module directly
* (presence law arm 3 on {@link HookInvocationIdentity}). */
identity?: HookInvocationIdentity;
/** "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;
/** {@link HookSeatSignal} — this invocation's own abort signal. */
signal?: AbortSignal;
}
/** 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;
}
/** Context for {@link Hooks.postCompact} (design/134, observe-only). */
export interface PostCompactContext {
/** #281 件A — same presence law as {@link PreCompactContext.identity}. */
identity?: HookInvocationIdentity;
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;
/** {@link HookSeatSignal} — this invocation's own abort signal. */
signal?: AbortSignal;
}
/** Context for the {@link Hooks.stop} hook (CC `stop_hook_active` parity). */
export interface StopHookContext {
/** #281 件A — the run/leg identity envelope. The load-bearing member for THIS seat: a process-level
* `deps.hooks.stop` fires in root, delegated-child and resume legs alike, and previously could not
* tell which run it was being asked to end (matrix row 10/13 residual). Always present on the
* engine's stop-gate emission. */
identity?: HookInvocationIdentity;
/** True when this run is already continuing because a previous stop() blocked — check it and
* return success (undefined) once your condition can't be improved, or you will loop to the cap. */
stopHookActive: boolean;
/** Consecutive blocks so far in this run (resets when a stop() call lets the run proceed). */
consecutiveBlocks: number;
/**
* A view of the conversation this hook is being asked to judge — the counters alone cannot serve a
* hook that asks anything ABOUT THE RUN (the prompt-shaped hooks a settings file declares).
*
* LAZY on purpose: a hook that only reads the counters pays nothing, and a hook that needs the
* conversation gets it in full rather than through a truncation policy the engine would have to invent
* on its behalf. Callers decide what to send to a model and how much — that decision has a budget and a
* trust tier attached, and both belong to the caller.
*
* Absent when the runner cannot supply one (a stop gate wired without a session).
*/
getBranch?: () => Promise<readonly SessionTreeEntry[]>;
/** The run's background tasks still in flight (pending / running / parked under this run's owner triple — the
* post-compaction reminder's projection; CC `SubagentStop.background_tasks`). LAZY like {@link getBranch};
* `[]` = none in that projection, ABSENT = no view. A parked child here waits on an approval out of band. */
backgroundTasks?: () => ReadonlyArray<{
id: string;
description?: string;
status: string;
}>;
/** {@link HookToolContext.permissionMode}. */
permissionMode?: PermissionMode;
/** {@link HookSeatSignal} — this invocation's own abort signal. */
signal?: AbortSignal;
}
/** 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 HookToolContext.env} (the hook-env capability seam).
*
* **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.** Hooks are 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;
/**
* #281 件A — the UNIFIED IDENTITY ENVELOPE every hook invocation can carry (`ctx.identity`): which
* run, which leg, and where in the delegation tree the hook is firing. 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 field names.
*
* WHY: `deps.hooks` is PROCESS-level — one `stop`/`preToolUse`/`permissionDenied` seat serves the
* root run, every delegated child and every resume leg at once, and before this envelope none of
* those invocations could say which leg they belonged to. Additive: every seat is
* `identity?: HookInvocationIdentity`, so a consumer that never reads it sees nothing new.
*
* PRESENCE LAW (which invocations carry it — stated here once, referenced per seat):
* · PRESENT on every ENGINE-driven invocation of every hook seat: the tool gate's PreToolUse
* screenings (phase 1 + the approval-edit re-screen), postToolUse / postToolUseFailure,
* postToolBatch, userPromptSubmit, stop, stopFailure, preCompact / postCompact (the Runner's
* wrapper injects it), and every engine `permissionDenied` emission (gate deny exit, hook-crash
* intercept, plan-mode deny, compliance deny). The engine mints ONE frozen envelope per prepared
* leg ({@link mintHookInvocationIdentity} — single mint home) and every station reuses it.
* · HONESTLY ABSENT (no fabrication) at exactly four places, each structurally unable to know:
* 1. a HOST driving the exported {@link runToolGate} directly without supplying
* `ToolGateInput.identity` — the engine cannot know a leg it is not running;
* 2. an inherited PreToolUse screening face riding a DESCENDANT's constraint fold
* ({@link createPreToolUseConstraintPolicy}) — the fold consumes it as a `ToolPolicy`, whose
* request shape 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 — those contexts are built below the runner, which is where the envelope lives;
* 4. the PRE-CAS durable-resume observation of a declared-observational PreToolUse face
* (resumeStream's edited-args leg) — it runs BEFORE the resume leg prepares, so the leg's
* envelope does not exist yet, and minting a partial one off the checkpoint would guess at
* the delegation axes (the row persists `isDelegatedChild` alone; `insideFork`/`agentName`/
* `parentToolCallId` are internals-only, and a wrong guess is worse than an honest
* absence). The SAME face is re-consulted with full identity by the resumed leg's own gate.
*
* 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;
/** Identifying context passed to tool hooks. */
export interface HookToolContext {
toolCallId: string;
toolName: string;
/** #281 件A — the run/leg identity envelope; see {@link HookInvocationIdentity} for the presence
* law (present on engine-driven gates; absent on a host-driven gate without
* `ToolGateInput.identity` and on the delegation-fold twin). */
identity?: HookInvocationIdentity;
/**
* The read-only path-resolution face over the env the hands run against (see
* {@link HookEnvCapabilities} for the why, and for why it is capabilities rather than the env object).
*
* ABSENT when the deployment wired no execution environment at all (no `executionEnv` /
* `executionEnvFactory`: the run has no hands, and the engine's internal no-I/O placeholder is not an
* environment to report). Present ⇒ inspect the individual members: each one is itself optional.
*/
env?: HookEnvCapabilities;
/**
* The TASK's tracked working directory at this call — the base the hand tools will resolve a RELATIVE
* path against, read live off the same ref the tool policy reads (so a hook and a policy judging the
* same call judge it against the same base).
*
* It is NOT {@link HookEnvCapabilities.cwd}, and the difference is the reason this member exists: that
* one is the ENVIRONMENT's own working directory, which never moves when the shell `cd`s or the run
* enters a worktree. A hook is documented — on the capability face itself — as the seat that judges
* "is this write inside the workspace", and it was the only seat in the gate that could not resolve a
* relative target the way the tool would. CC ships its own `cwd` to every hook event for the same
* reason; this is that field, named for the value it actually carries.
*
* ABSENT when the run has no tracked cwd to report — no real write hands (read-only or no shell), a
* host driving {@link runToolGate} without one, or the delegation-fold twin (which runs inside a
* DESCENDANT's policy fold and would otherwise report the install site's directory, i.e. the wrong
* machine's answer). Absence means "this engine cannot tell you", never "the root".
*/
cwd?: string;
/** The leg's EFFECTIVE permission mode when the callback is invoked — the mode seat's `observed` read (it arms
* no classifier on the leg or an ancestor, so it moves no decision); a steer, a plan-mode entry or exit and a
* resumed leg's restored word show on the next callback. Stamped by the Runner on its own legs' contexts (this
* one, {@link PermissionDeniedPayload}, {@link UserPromptSubmitContext}, {@link PostToolBatchContext},
* {@link StopHookContext}); ABSENT ⇔ no leg seat: a host-driven {@link runToolGate} (its
* `ToolGateInput.permissionMode` is the arbitration's reader, never called for this), the delegation-fold twin,
* the `"resume_message"` screen (before the resumed leg exists). Not a version signal. The compaction seats and
* {@link StopFailureContext} carry none (the reference's hook input omits it there). */
permissionMode?: PermissionMode;
/** {@link HookSeatSignal} — this invocation's own abort signal. A FRESH one per screening pass, like
* the context object it rides: phase 1 and the approval-edit re-screen are two bounded waits, and a
* shared signal would let the first pass's expiry cancel the second. */
signal?: AbortSignal;
}
/** 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.
*/
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;
}
/** #281 件A — the context of a {@link Hooks.userPromptSubmit} invocation (second parameter,
* additive). An object so later additions stay additive; design/373 §4.3 (D2) grew the
* entrance-discrimination trio below. */
export interface UserPromptSubmitContext {
/** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
identity?: HookInvocationIdentity;
/** {@link HookToolContext.permissionMode} (absent on `"resume_message"`, screened before the leg exists). */
permissionMode?: PermissionMode;
/** {@link HookSeatSignal} — this invocation's own abort signal. */
signal?: AbortSignal;
/**
* design/373 §4.3 (D2) — WHICH user-lane entrance this text is entering through. The screen now
* sits at every user-lane entrance, so a hook that used to see only the objective can (and, if it
* blocks unconditionally, MUST) branch on this: `"objective"` = the task's opening prompt (the
* pre-373 only caller); `"steer"` = a live mid-run TaskStream.steer; `"resume_message"` = the
* message riding a wake resume (screened at the verb, pre-CAS); `"parked_redelivery"` = a parked
* steer being re-screened on the CURRENT process's hook when a resume redelivers it — ANY resume
* kind that drains parked steers (policy_ask / dry_run_review / plan_review / resource_limit,
* wake included), because a park's screening cannot be carried across processes (hooks are
* process-local config). ⚠️ DOUBLE ARRIVAL IS CONTRACTUAL:
* the same input can reach a hook twice with different sources (screened live as `"steer"`,
* parked, re-screened as `"parked_redelivery"`); idempotency is the hook's to hold — a stateful
* hook keys on {@link inputId}. `"followUp"` is a RESERVED spelling (spec seat only): the Runner
* face has no user-lane followUp verb today, so no invocation carries it yet — if/when that face
* lands it arrives screened under this name (never a silent widening).
* Absent ⇒ an emission from a pre-373 engine (old engines stamped nothing); every current engine
* seat stamps its name, so treat absence as "version unknown", never as a sixth entrance.
*/
source?: "objective" | "steer" | "resume_message" | "followUp" | "parked_redelivery";
/**
* design/373 §4.3 (D2) — the screened input's correlation id. Present on EVERY entrance except
* the objective seat (no id exists on that face — nothing is fabricated there):
* - `"steer"` — the effective `inputId` (caller-supplied or engine-minted — the SAME id the
* `human_input` event and any parked copy carry);
* - `"parked_redelivery"` — the parked entry's STORED id (stable across redeliveries);
* - `"resume_message"` — always present too: the caller's id verbatim when it supplied one,
* otherwise MINTED PER CALL by the wake validator. ⚠️ Per call, not per message: a retried wake
* that supplies no caller id and REACHES this seat re-screens identical text under a FRESH id.
* A dedup/quota hook keying its ledger here gets cross-attempt stability only when the CALLER
* supplies the id — a host that retries wakes should mint its own. What the engine guarantees
* instead (#472) is that the seat is not reached needlessly: it sits BELOW every retryable
* pre-CAS refusal on the resume ladder, so an attempt the engine was going to refuse anyway
* (a full governance window most of all) costs ZERO invocations — the retry loop that refusal
* invites cannot bill this hook per attempt.
* This is the dedup key for the double-arrival contract on {@link source}: a quota-charging hook
* that must not double-charge one input keys its ledger here.
*/
inputId?: string;
/**
* design/373 §4.3 (D2) — the API-boundary actor snapshot attributed to this input (the same
* snapshot the `human_input` event reports), when the caller supplied one. Attribution only,
* never authority — a hook wanting to exempt its own supervisor lane branches on this PLUS
* {@link source}, and absence means "anonymous caller", exactly as on the event face.
*/
actor?: ActorAssertion;
}
/** #281 件A — the context of a {@link Hooks.postToolBatch} invocation (third parameter, additive —
* deliberately NOT folded into `meta`, whose presence already means "the engine injected here"). */
export interface PostToolBatchContext {
/** The run/leg identity envelope — see {@link HookInvocationIdentity} for the presence law. */
identity?: HookInvocationIdentity;
/** {@link HookToolContext.permissionMode}. */
permissionMode?: PermissionMode;
/** {@link HookSeatSignal} — this invocation's own abort signal. */
signal?: AbortSignal;
}
/** 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;
/** Rewritten args to execute with (re-validated by the loop); omitted when nothing rewrote. */
updatedInput?: 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";
/** The EXACT batch that was put in front of the person, retained by the resolver as an isolated
* snapshot. The gate executes this and nothing else, so "what was shown" and "what runs" are the
* same object graph by construction rather than by two parties agreeing not to mutate one. */
presentedInput: unknown;
} | {
kind: "unavailable";
/** The retained snapshot, when the resolver got far enough to make one (i.e. it actually waited
* on a callback). The park persists THIS, not the caller's live object: the wait is a window in
* which whoever owns the rewrite can change it, and a checkpoint holding bytes the gate never
* adjudicated is one a later human would answer as a different question. Absent when no wait
* happened, in which case there is no window and the live object is still the validated one. */
presentedInput?: unknown;
/** 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;
/** The retained snapshot, when the resolver got far enough to make one. Executed for the same
* reason the answered arm's is: the bound failure names the batch it belongs to, and executing a
* different one would drop it and re-ask the person. Absent when the refusal happened before a
* snapshot could be taken. */
presentedInput?: unknown;
};
/**
* 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 (the lexer's reason) says the call could not be READ against `rule` — an
* org rule that may reach it — so `behavior` is `ask` and the message names the reason. */
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 set is the identity; a joined string would not be one.
*
* 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 — the safe failure shape for a lane that missed the same-window upgrade.
*/
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[];
}
/**
* design/252 G-2 — the answer a lane gives when it could not READ its store (no `reason`; the CALL form is `reason`'s).
*
* The store form's DECISION meaning is identical to "no rule": the store's rows — allow, ask and deny alike — take
* no part in this call's decision (skipped, the way a settings file with errors is skipped whole; the lane announces
* it, and the rows apply again once a read succeeds). What it changes is the RECORD: without it, an unreadable store
* and a store that holds no matching rule are the same answer (`undefined`), and the ask's evidence would report one
* as the other — `"no_match"` ("we looked and found nothing") vs `"unavailable"` ("we could not look").
*/
export interface PersistedRuleUnreadable {
readonly unreadable: true;
/** WHY the lane could not read: absent for a store read failure (its rows take no part); 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 — `programRunReachOf`'s `unreadable` word), the lexer's reason — the call then fails closed: an allow
* tightens to a real-approval ask of origin `rule_store_unavailable`, the reason in its message. */
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. */
event: {
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
};
/** #281 件A — the run/leg identity envelope, put verbatim on every {@link HookToolContext} 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;
preToolUse?: Hooks["preToolUse"];
/**
* The bound the {@link preToolUse} screenings run under — {@link Hooks.timeoutMs}, carried in from
* the hook record the caller resolved it off (the gate receives the callback, not the record). Absent
* ⇒ {@link DEFAULT_HOOK_TIMEOUT_MS}. A garbage value is refused loudly to that default via
* {@link onHookError}, exactly as {@link approvalTimeoutMs} is.
*
* On elapse the screening FAILS CLOSED — the call does not execute — and the block reason names the
* hook and the bound. That direction is not a new posture for this seat: a hook that THROWS is
* already the fail-closed deny here, and a hook that never answers has told the gate strictly less
* than one that crashed. A face the deployment declared observational is not exempt, for the same
* reason its throw is not: the declaration says its VERDICTS do not count, and a wait that never
* ends is not a verdict.
*/
hookTimeoutMs?: number;
/** The task's live tracked cwd reader, put on every {@link HookToolContext} this gate call builds
* (see {@link HookToolContext.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 read-only env capability face put on every {@link HookToolContext} 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: observer for the gate's DENY short-circuit ({@link Hooks.permissionDenied}), pre-wrapped
* by the caller (swallow + onError) so it never throws. 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 Hooks.permissionDenied}, which also records why that exclusion is not a parity
* claim) or a durable suspend (the ask path). runToolGate still try/catches defensively: an observer
* must never alter the deny outcome.
*
* 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 PreToolUse hook that THREW — the deployment-facing half of the crash
* (`onError(phase:"hook")`, wired by the caller). The model-facing half is the fail-closed block reason;
* this one carries the exception object itself to whoever runs the deployment, because a crashing hook is
* a bug someone has to fix and the model-facing summary is bounded/sanitized. Never affects the outcome
* (a throwing sink is swallowed).
*
* ALSO fired for a {@link reversibilityProbe} that threw or timed out (same species — a
* deployment-supplied callback failing while the gate holds the fail-closed line) and for a
* malformed {@link approvalTimeoutMs} refused to the bounded default. 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.approvalSeat` (a person, live or parked) 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;
/** 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 that cannot be
* read answers `{ unreadable: true }`, not `undefined`: the DECISION is the same (its rows take no part, as
* for a thrown or timed-out lane) but the evidence records `"unavailable"`, not `"no_match"`; the lane owns
* announcing the skip. A call unreadable against the rows answers `{ unreadable: true, reason }` (fails closed).
*
* 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;
};
}
/**
* Fold a {@link Hooks.preToolUse} callback into a {@link ToolPolicy} layer, so a screening face a task
* installed for ITSELF can also travel down a delegation chain as one more inherited constraint.
*
* Why a policy and not a second hook 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 face 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 (see {@link runToolGate}) decision for decision:
* - no opinion (`undefined`) → `allow`, contributing nothing;
* - `deny` → `deny` in the fold's own words (the fold short-circuits, and the gate's deny exit supplies
* the same `formatHookFeedback` envelope phase 1 would have);
* - `ask` → `ask`, stamped `decisionReason:"hook"` so every consumer that distinguishes a screening ask
* from a policy ask reads the same fact one level down — notably the persisted-rule lane, which must
* not let a standing allow-rule resolve a screening ask;
* - `allow`/`ask` with `updatedInput` → the same rewrite, which the fold cascades to later layers and
* re-checks against the narrowing layers;
* - a THROW → the fail-closed deny with the RB-442 attribution text (naming the hook and the
* non-execution), because a screening face that cannot answer must not pass the call.
*
* **The callback inherits the fold's idempotency requirement** ({@link combinePolicies}): a layer's
* `check` may run more than once for a single tool call — the fold scans past an `ask` looking for a
* later `deny`, a rewrite emitted by a LATER layer re-runs the prefix that never saw the final args, and
* an approved operator edit is re-adjudicated. A callback whose consultation has a side effect (an audit
* row, a counter) will therefore see that call more than once on those paths. Its own gate's phase 1 has
* always had the second of these (an approved edit is re-screened there too).
*
* Two properties are deliberately NOT carried, and neither is reconstructible in this shape:
* - `additionalContext` — a policy verdict has no channel for model-facing text. Context a screening
* face injects is a property of the run whose gate ran it; a descendant's transcript does not receive
* it. (The decision half — the part that restricts — is what travels.)
* - the phase-1 observer exclusion — a deny raised here is a fold deny, so the descendant's
* `permissionDenied` observer sees it with `source:"policy"`. That exclusion is about a hook deciding
* in ITS OWN gate; here the descendant's policy fold is what refused the call.
*
* `env` is the capability face of the environment the callback was installed against — captured at the
* install site, not rebuilt in the descendant, for the same reason an inherited policy closure keeps its
* own captured environment (the descendant may run against a different root). `onCrash` is the install
* site's deployment error lane: the gate's phase 1 routes a crashing callback to `onError(phase:"hook")`
* so a broken screening face is distinguishable from a deliberate refusal, and the folded form has to
* reach the same lane from inside a descendant's fold.
*/
export declare function createPreToolUseConstraintPolicy(preToolUse: NonNullable<Hooks["preToolUse"]>, env?: HookEnvCapabilities, onCrash?: (err: unknown) => void,
/** The install site's {@link Hooks.timeoutMs}. The folded form was ALREADY bounded by the descendant's
* abort race — that asymmetry (bounded as an inherited constraint, unbounded in its own gate) is what
* named the hole in the first place — but an abort bound is not a deadline: on a descendant with no
* walltime and no cancel, the race never fires. So the same seat bound rides here too, and the two
* installations of one callback are now bounded the same way for the same reason. */
timeoutMs?: number): 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 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;
origin?: import("./ask-origin.js").AskOrigin;
askClass?: import("./ask-origin.js").AskClass;
classes?: readonly import("./ask-origin.js").AskClass[];
};
/**
* The PreToolUse screening seat ({@link PreToolUseScreening}) for ONE call — seat runner, seat bound, per-screening
* context and the fail-closed / deny 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 face is wired. `hookSeatMs` is the caller's validated bound.
*/
export declare function createPreToolUseScreening(preToolUse: Hooks["preToolUse"] | undefined, input: Pick<ToolGateInput, "event" | "trackedCwd" | "hookEnv" | "identity" | "onHookError" | "abortSignal">, hookSeatMs: number, notifier: SafeNotifier): PreToolUseScreening | undefined;
/**
* 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>;