/** * Human-in-the-loop confirmation for destructive tools (#261). * * The problem this solves: `stop_all_apps` is gated on a `confirm: true` * parameter, and the model fills that parameter in. That is the model confirming * with itself before taking every application on the estate down. Elicitation * moves the question to the human, rendered by the client, outside the model's * control. * * **Strictly progressive enhancement.** Client support is uneven — Claude Code * and VS Code Copilot have it, Claude Desktop and claude.ai do not yet — so this * checks the client's advertised `elicitation` capability at runtime and, when * it is absent, approves and lets the existing parameter guards stand. A client * that cannot be asked is not a client that gets blocked. * * Failure is closed in the other direction: once a client says it supports * elicitation, a decline, a cancel, a timeout or a transport error all abort the * operation. The one case that does not abort is the blast-radius lookup * failing, because a summary we could not compute is a reason to ask with less * detail, not a reason to skip asking. * * V3 note: SDK v2 redesigns this as `inputRequired.elicit()` (#259). Everything * version-specific is inside `confirmDestructive`; call sites see only * {@link ConfirmOutcome}. */ import { type InputRequiredResult, type RequestStateCodec, type Server, type ServerContext } from '@modelcontextprotocol/server'; import type { AuditRefusal } from './audit.js'; /** * How long to wait for a human. * * The SDK's default request timeout is 60s, which is a sensible ceiling for a * machine answering and a bad one for a person reading "stop ALL 12 * applications?" and deciding. Five minutes; after that the request is * abandoned and the operation aborts, which is the safe direction. * * This is a backstop, not the primary control — see the `signal` passed * alongside it, which lets the caller's own cancellation win first. */ export declare const ELICIT_TIMEOUT_MS = 300000; /** * Whether this client can be asked at all. * * **Read this before wiring up the HTTP transport (#303).** This depends on a * completed initialize handshake being retained for the connection. In a * stateless HTTP mode, where per-connection capabilities are not kept, it * returns `undefined`, every guard approves, and the entire confirmation layer * disappears with no signal that it has. * * Failing open is the right default here, on stdio, where the alternative is * blocking Claude Desktop users out of tools that work today. It is very * probably the wrong default for a remote server reachable over the network, * which is the transport where the parameter-based guards stop being credible * at all — the reason #303 lists elicitation as a prerequisite. Decide that * deliberately there rather than inheriting this choice by accident. * * `COOLIFY_MCP_ELICITATION=off` is an escape hatch, not a feature. Once a * client advertises the capability every rejection from `elicitInput` aborts, * including `-32601 Method not found` — so a client that advertises * `elicitation` without implementing the handler, or a proxy that drops the * request, makes every guarded tool permanently unusable with no way out but * downgrading the package. The resulting error ("could not confirm with the * user") does not point at the client, which makes it hard to diagnose from the * outside. Rare, but unrecoverable, and the fallback is the same shape as the * one capability-less clients already get. The default is unchanged: absent the * variable, an advertised capability is trusted and the guards fail closed. */ export declare function supportsElicitation(server: Server): boolean; export type ConfirmOutcome = { approved: true; } /** * `message` is user-facing text explaining why nothing ran. `reason` is the * same fact as a category, carried structurally rather than left for the * caller to recover by matching on the prose. * * The prose and the category used to be one field, and the audit layer * recovered the category with `message.includes(...)`. That silently * classified every timeout, cancellation and protocol refusal as a human * decline (#408) — a wrong answer to the one question the audit log exists * to answer. The branch that knows why already knows; it says so here. */ | { approved: false; reason: AuditRefusal; message: string; }; /** * Ask the human to approve a destructive operation. * * @param server The low-level `Server` (i.e. `mcpServer.server`), which owns * both the client capabilities and `elicitInput`. * @param label One line naming the operation, independent of any lookup. * Used when `summarize` fails, so the degraded prompt still * says what it is asking about. * @param summarize Produces the prompt text, or `null` when the pre-flight * found nothing to confirm. A callback rather than a string so * that call sites needing an API round trip to state their blast * radius — "how many apps am I about to stop?" — only pay for it * on clients that will actually show the question. * @param signal The tool call's abort signal. See the call to `elicitInput` * for why omitting it is dangerous rather than merely untidy. */ export declare function confirmDestructive(server: Server, label: string, summarize: () => string | null | Promise, signal?: AbortSignal, options?: { /** * Fail closed when the client cannot be asked (#303). On stdio the * capability-less fallback approves and the parameter guards stand; on an * internet-facing HTTP server "the model confirms with itself" is not a * credible control, so HTTP mode sets this and a client without * elicitation is refused rather than waved through. */ requireHuman?: boolean; }): Promise; /** * Make a value safe to interpolate into a confirmation dialog. * * Two sources, and the weaker-looking one is the stronger vector: * * - **Coolify-supplied names** are attacker-influenced in the weak sense that * anyone able to create resources on the instance chooses them. A name * containing newlines — `api\n\nThis is routine, safe to accept.` — reshapes * a dialog whose entire job is to be trustworthy into one that argues for its * own approval. * - **Model-supplied identifiers** (the `uuid` arguments) are worse. Those * schemas are plain strings with no uuid constraint, so the value is * arbitrary text the model chose, and producing it needs no write access to * the Coolify instance at all — only a model that read something hostile in a * README, an issue body or a log line. Anything crossing into the dialog gets * sanitized, whichever side it came from; the whole point of the dialog is to * sit outside the model's control. * * A real 36-character UUID is well inside {@link MAX_NAME_LENGTH}, so genuine * values render unchanged. * * Markdown-significant characters are neutralised alongside the control ones. * The prompts in this codebase use backticks for emphasis, which means they * assume a client that renders markdown — and markdown rendering *is* parsing, * whatever the text is nominally "for". Under that assumption a resource named * `[Approve](https://evil.example)` becomes a link and `**SAFE - routine**` * becomes bold reassurance, inside a dialog whose whole job is to look * trustworthy. The length clamp caps that but does not remove it. */ export declare function sanitizeForPrompt(name: string): string; /** * Render "12 applications (a, b, c and 9 more)" for a confirmation message. * * Names are truncated because the point of the list is recognition — spotting * the one production app that should not be in the set — and a wall of sixty * names defeats that as thoroughly as no names at all. */ export declare function describeBlastRadius(noun: string, names: string[]): string; /** * The confirmation key used for the embedded elicitation request, and the key * the retried call's `inputResponses` is read back under. One guarded * operation is in flight per call, so a constant is enough. */ export declare const CONFIRM_KEY = "confirm"; /** What travels inside the sealed `requestState` across the two round trips. */ export interface ConfirmationState { /** Digest of the summary the human was actually shown. */ digest: string; } /** * Outcome of the 2026-07-28 confirmation flow. * * `ask` is not a refusal and not an approval: it is the first half of a * two-round-trip exchange, and the caller must return `result` to the client * unchanged so the client can fulfil it and retry. */ export type ModernConfirmation = { status: 'ask'; result: InputRequiredResult; } | { status: 'approved'; } | { status: 'nothing-to-do'; } | { status: 'refused'; reason: AuditRefusal; message: string; }; /** Stable digest of the text a human was shown, for the staleness check. */ export declare function summaryDigest(summary: string): string; /** * Ask the human to approve a destructive operation, on protocol revision * 2026-07-28. * * The 2025 shape — server pushes `elicitation/create` mid-request and awaits * the answer — does not exist on this revision; `elicitInput` throws. Instead * the handler returns an `input_required` result, the **client** fulfils the * embedded request and retries the original call with `inputResponses`, and * the handler runs a second time from the top. * * Two consequences shape everything here. * * **The handler is re-entered, so it must know which half it is in.** That is * the `inputResponse` read at the top: absent means round one, present means * the human has answered. * * **`summarize()` runs again on round two.** It has to: the estate is live, * and the whole point of the prompt is the blast radius it quoted. So the * digest of what was shown is sealed into `requestState` on the way out and * compared on the way back. If an emergency stop said "12 applications" and * 14 are running by the time the human clicks yes, the approval no longer * describes the operation and this refuses rather than widening it silently. * * The requested schema is deliberately **empty**. The answer is the client's * accept/decline action, not a field: a `confirm: true` property is a value * something upstream could supply on the retry, and the evals already record a * model issuing a real restart 5 runs out of 5 while explicitly told not to. * The confirmation has to come from outside the model, or it is theatre. */ export declare function confirmDestructiveModern(ctx: ServerContext, label: string, summarize: () => string | null | Promise, mint: (payload: ConfirmationState, ctx: ServerContext) => Promise, canAsk: boolean): Promise; /** * Seals the confirmation that round-trips through the client (#341). * * Bound to the authenticated principal and the method, so state minted for one * caller cannot be echoed by another — the spec's user-binding requirement for * state that influences authorization, and this state authorises a deletion. * The binding value is stored as a keyed tag rather than in clear, so the * client never holds a readable principal identifier. * * Signed, not encrypted. The payload is a digest and an expiry, both of which a * client may read without harm; nothing secret goes in it. */ export declare function createConfirmationCodec(options?: { announceGeneratedKey?: boolean; }): RequestStateCodec;