/** * CFG-27 — the two ways the approvals gate **ends a run** rather than answering a tool call. * Both are thrown from `GthAgentRunner.decideToolApproval`, both carry the command and the reason * a person needs to see, and both are re-thrown UNCHANGED by `processMessages` (which otherwise * wraps failures as `Agent processing failed: …`) so the explanation reaches the user intact. * * They are errors rather than {@link @gaunt-sloth/core!core/types.ToolApprovalDecision | ToolApprovalDecision}s on purpose. A decision is * something the model observes as a `ToolMessage` and can respond to; these two are precisely the * cases where the spec says the model gets no move at all: * * - **{@link AttackHaltError}** (§4.2) — an `attack` outcome *halts the run*. "A halt ends the * agent loop. It is not a rejection the model can respond to, and no rung except `bypass` can * turn it into anything else." * - **{@link NonInteractiveEscalationError}** (§6.2) — where no human can answer, *every* * escalation is an immediate non-zero exit carrying the command, the rating and its reason. * There is no prompt, no waiting, and never a timeout into approval. Teams that need specific * commands to run unattended declare them in `approvals.allow` (§3), which is consulted before * the rater and therefore never escalates — for a call inside the project. A shell call whose * `cwd` is outside the project never consults it ([[EXT-199]]), so both stops take a separate * recovery there ([[EXT-201]], `outOfProjectParts`). * * Exit code: neither class sets one. The single-shot runtime (`runSingleShot`) already reports a * thrown run as `ok: false`, and each command entry point turns that into `setExitCode(1)` — so * "immediate non-zero exit carrying the explanation" is what a caller already gets, without a new * exit path that could diverge from the existing one. * * ## [[TUI-C71]] — a stop message is UNTRUSTED TEXT, and it is made safe in two places * * Every string a stop carries except this module's own sentences is written by something other * than the user: the command the agent proposed, the rater's outcome and reason, the * `approvals.escalate` entry that matched, and the whole §5 negotiation. On an `attack` verdict * that is the *worst* text in the system by construction — the rater judged the command's own * structure to evidence deception or obfuscation — so a stop is the one message most likely to * carry a payload crafted to forge terminal output, and the last one that may be printed raw. * * The defence [[TUI-C26]] built has two halves, and they belong at different times: * * 1. **Neutralisation is done HERE, at construction, unconditionally.** It needs no width and no * screen, it is idempotent, and it is the half every consumer needs — a thrown stop reaches the * Ink transcript, `--no-tui` stderr, a session log, the approvals archive (`record.error`), an * eval turn record, AG-UI and a CI job's output, and only some of those are terminals. Doing it * at each render site would mean every present and future consumer had to remember; doing it * once here means none of them can forget. After it, `message` contains no control or * format character at all: a carriage return is the five printable characters `\x0d`, and — * because LF is a control character too — untrusted text cannot even open a new line, let alone * reach column 0 on one. * 2. **The gutter and the width bound are done at RENDER, by the surface** ({@link * approvalStopRows} for the plain surfaces, `` for the Ink TUI). They * cannot be done here: framing is arithmetic against a terminal width, an `Error.message` has * no width, and a block wrapped for an 80-column terminal is wrong for every other one. What * they buy over neutralisation alone is the last case it cannot close — one very long * neutralised line that the *terminal* wraps, whose continuation starts at column 0 carrying * whatever the attacker chose to put at that offset. That route is measured, not theorised: on * a 120-column terminal the neutralised `Command:` line of a hostile command is ~164 columns. * * **Because half of it is the surface's job, the surfaces are enumerated rather than assumed.** * Every place that catches an `ApprovalStopError` and prints it calls {@link approvalStopRows} (or * the Ink component): `runtime/singleShot.ts`, `runtime/conversation.ts`, * `agent/modules/interactiveSessionModule.ts` (both its per-turn catch and its outermost one, which * is where the `-m` path lands), `review/modules/reviewModule.ts`, `app/commands/prCommand.ts` * (which catches what the PR-discovery agent throws) and `app/tui/components/App.tsx`. * **Adding a surface that catches a stop means adding it to that list.** * * **That list is over the ERROR, and the error is not the only way a stop reaches a screen.** Some * consumers take `message` and hand the *string* onward, at which point no `instanceof` can * find it again: `runConversation` records it as a turn's `error`, and `evalRunner` folds that into * an eval case's `reasons`, which `batch/reporters/textReporter.ts` prints — a terminal, reached * without any of its code ever seeing this class. That one frames the text itself. So the honest * statement is not "every other consumer is a non-terminal" but: **a consumer that prints to a * terminal frames what it prints, whether it holds the error or only its text**, and the ones that * legitimately do neither are the file and protocol sinks (the approvals archive, the JUnit/JSON * reports, the AG-UI event) plus `reviewModule`'s `displayDebug` of the raw error, which is a debug * channel. All of them still inherit the neutralisation, which is what keeps the worst case a * wrapped line rather than a repainted screen. * * **The structured fields stay RAW; only the message is neutralised.** That is what answers the * obvious objection to construction-time work — that it stops the error being a faithful record of * what was proposed. `{@link ApprovalStopError.command}`, `reason`, `outcome`, `escalatedBy` and * `negotiation` are the command as the agent wrote it, byte for byte, for any consumer that needs * the truth rather than a screen; the message is the *presentation*, and a presentation that can * repaint the terminal is not one. The render path frames from those raw fields, so its gutter * numbers the command's real lines rather than one escaped line. * * **One parts list, two renderings** ({@link ApprovalStopPart}). The message string and the framed * block are both derived from {@link ApprovalStopError.parts}, so they cannot come to describe one * stop two ways — the same argument `renderNegotiationTranscript` makes for its own two surfaces. * A part is tagged with *who wrote it*, which is the only distinction any of this rests on: the * gate's own prose can be painted as it is because nothing can forge it, and everything else goes * through the shared renderer. * * @module */ import type { ApprovalSubject } from '#src/core/approvals/matcher.js'; /** * One piece of a stop message, tagged with who wrote it. * * - `own` — this module's own sentence. Nothing outside it can influence the text, so a surface * may paint it as it is and let the terminal wrap it. * - `command` — the command the agent proposed. Framed with the site extraction, because its * composition boundaries and substitutions are the decision-relevant positions in it. * - `value` — one untrusted single value (the rater's outcome or reason, the matched * `approvals.escalate` entry). Framed as prose; there are no sites to point at. * - `block` — an untrusted block whose LINE STRUCTURE is a renderer's, not the model's: the §5 * negotiation transcript, whose rows `core/shell/negotiation` builds and whose leaves it has * already collapsed to one line each. Its breaks survive into the message where a `value`'s * would be escaped, because they are the thing that makes it readable at all. * * **A `block` is still neutralised line by line here, and that is deliberate defence in depth.** * Its one production producer (`renderNegotiationTranscript`) has already cleaned every leaf, so * on today's paths this arm changes nothing — but these constructors are public, and a caller * handing one a raw multi-line string must not be the thing that decides whether a cursor can * move. `approvalStop.spec.ts` builds exactly that string so the arm is falsifiable rather than * merely reassuring. * * **The §5.4 VOICE distinction is deliberately not carried here**, and the trade is worth stating * because `negotiation.ts` documents voice preservation as a rule: `renderNegotiationRows` tags * each row `chrome`/`agent`/`rater` so a surface can colour the rater's turns apart from the * agent's. A stop renders the transcript as one undifferentiated untrusted block instead. Framing * a mixed-trust block wholly as untrusted is the safe direction (nothing in it is promoted to the * gate's own voice), the row prefixes still name the speaker in text, and an `Error` carries no * colour to any of the non-terminal consumers anyway. Colouring by voice here would mean * re-deriving the tags from a string that no longer has them. */ export type ApprovalStopPart = { kind: 'own'; text: string; } | { kind: 'command'; label: string; text: string; } | { kind: 'value'; label: string; text: string; } | { kind: 'block'; text: string; }; /** * The stop as terminal rows, for a surface that paints lines rather than components. * * Every row is painted **verbatim, one row per line, and never re-wrapped** — that is the * condition `core/shell/framing` states its column-0 guarantee over, and a caller that joins these * and hands them to something that wraps has undone the whole point. * * `columns` is what the surface reports (`stdout.columns`), not a frame width: it is resolved * through {@link frameWidthFor} here so the plain surfaces, the Ink TUI and the approval dialog * cannot come to disagree about how much of a command a human was shown. When it is below core's * floor the frame is wider than the terminal and the guarantee lapses — {@link narrowTerminalNotice} * leads the block in that case, so it lapses out loud. */ export declare function approvalStopRows(parts: readonly ApprovalStopPart[], options?: { columns?: number; }): string[]; /** * Base class for the two run-ending approvals outcomes, so a caller that wants to present them as * an ending rather than a crash can catch both with one `instanceof`. * * A surface that can frame should render {@link parts} (see {@link approvalStopRows}); one that * cannot shows {@link message}, which is already neutralised and is already the whole explanation. */ export declare abstract class ApprovalStopError extends Error { /** * What ended the run, exactly as the agent proposed it: the command for a shell subject, and the * registered tool name for a gated tool or MCP call. The field name predates the gate widening * past the shell; `subjectParts` is what decides the word the MESSAGE calls it. */ readonly command: string; /** The message's pieces, tagged with who wrote each — see {@link ApprovalStopPart}. */ readonly parts: readonly ApprovalStopPart[]; protected constructor(parts: readonly ApprovalStopPart[], command: string); } /** * [[EXT-199]]/[[EXT-201]] — **where a shell call was going to run, when that is outside the * project.** Both paths are realpath-resolved by `core/shell/cwd`: `cwd` is the directory the * command would have run in, `projectDir` is the session's startup work directory * (`getStartupWorkDir()`: `INIT_CWD`, else the process cwd, captured once), which is what "the * project" means for this rule. It is NOT the config project directory found by up-tree discovery, * so a session launched from a subdirectory has that subdirectory as its project. * * Passed together, as one object, because neither is meaningful without the other and a stop that * named only one would leave the reader unable to see why the gate called it outside. */ export interface OutOfProjectCwd { /** The resolved directory the command would have run in. */ readonly cwd: string; /** The resolved startup work directory — the project root for this rule. */ readonly projectDir: string; } /** * §4.2 — an `attack` outcome: the command's own **structure** evidenced compromise (§4.1.1 — * credential targeting, privilege escalation, persistence, deception, obfuscation). Ends the agent * loop; the model is told nothing and offered nothing. * * **This is what reaches a surface that cannot ask.** An interactive surface is offered [[TUI-C68]] * §6.1's red banner first — `GthAgentRunner.setAttackHaltCallback`, where typing `run anyway` runs * this one command and everything else stops the run — and this error is thrown when no banner is * wired, or when the banner is answered with anything but that phrase. A non-interactive session * wires nothing and so gets this message directly (§6.2). * * The recovery this message names is deliberately the **allow-list**, not `bypass`. §4.2 makes * `approvals.allow` the supported way to run such a command unattended (it is consulted before the * rater, so it never reaches a halt at all); `bypass` also works and is far blunter — it turns off * the rater, the escalation and the halt together, for every command, for the whole run. It is a * last resort, not the answer, and the wording says so in that order. * * [[EXT-201]] — for a call whose `cwd` was outside the project the allow-list does not apply, so * that recovery is replaced by the interactive session's banner, then `bypass`; see * `outOfProjectParts` for the trace showing this halt is reachable there, and why moving the * session is not offered. */ export declare class AttackHaltError extends ApprovalStopError { /** The rater's explanation of what the command's structure showed. */ readonly reason: string; /** * [[EXT-201]] — the out-of-project directories, when the halted call's `cwd` was outside the * project. Its presence swaps the recovery; see `outOfProjectParts`. */ readonly outOfProject: OutOfProjectCwd | undefined; constructor(command: string, reason: string, subject?: ApprovalSubject, outOfProject?: OutOfProjectCwd); } /** * §6.2 — an escalation with nobody to ask (CI, one-shot runs, servers). Fails the build loudly, * with everything a person needs in order to see why. */ export declare class NonInteractiveEscalationError extends ApprovalStopError { /** The rater's outcome, when a rating existed (the unrated rungs have none). */ readonly outcome: string | undefined; /** The rater's explanation, when a rating existed. */ readonly reason: string | undefined; /** * EXT-71 §3.2 — the declared `approvals.escalate` entry that sent this call to a human, when one * did. It changes the recovery the message names: pointing someone at `approvals.allow` when they * themselves wrote an escalate entry sends them to a list that cannot win, since a match on * `escalate` outranks a match on `allow`. */ readonly escalatedBy: string | undefined; /** * [[EXT-29]] §6 — the §5 negotiation that preceded this escalation, rendered, when there was one. * * §6.2's message is the ONLY thing a person sees on this path — there is no prompt to attach a * transcript to — so an unattended run that ended after three rejections would otherwise report * the last command and give no hint that the agent had already been told twice what to fix. */ readonly negotiation: string | undefined; /** * [[EXT-106]] §4 — **the `approvals.allow` entry that would let THIS command run**, rendered in the * object form a user writes in a config file, or `undefined` when none can soundly be derived. * * A refusal that stands has to say what would lift it, and a generic example leaves the reader to * translate it — for a command the deterministic preflight floors, that translation is the entire * remedy. `approvals.allow` is consulted before the rater and therefore before §4.6's preflight, * so it is the one thing that lifts a floor the agent cannot argue past. * * **Derived by the caller, never here.** The caller holds what an entry has to be derived from — * the normalized command for a shell subject (`shellApprovalEntryFor`), the tool's identity and * host for a tool or MCP one (`toolGrantEntry`) — and it is the same derivation the escalation * menu's *always approve* stores, so a message and a menu cannot promise different things about * one call. `undefined` falls back to the general form, which is the right answer whenever a * specific one would be wrong — an entry that is subtly too broad, or simply inert, is worse than * none. That is what a call whose MCP server could not be attributed gets: `toolGrantEntry` * returns `null` for it, because `server` cannot hold the unresolved sentinel. * * **It is untrusted text.** It embeds the command the model proposed, so it is carried as a * labelled `value` part and neutralised with every other untrusted string; putting it in an `own` * part would route model-authored bytes into the one part class a surface may paint raw. */ readonly allowEntry: string | undefined; /** * [[EXT-201]] — the out-of-project directories, when the call's `cwd` was outside the project. * Its presence is what forced the escalation (or is one of two things that did, alongside * {@link escalatedBy}), and it withholds {@link allowEntry} from the message, since * `approvals.allow` is never consulted for such a call. See `outOfProjectParts`. */ readonly outOfProject: OutOfProjectCwd | undefined; constructor(command: string, outcome?: string, reason?: string, escalatedBy?: string, negotiation?: string, allowEntry?: string, subject?: ApprovalSubject, outOfProject?: OutOfProjectCwd); }