/** * @packageDocumentation * Shell / dev-tools policy: the {@link GthDevToolsConfig} type plus all the resolvers * that interpret it (shell enablement, timeouts, output budget, per-command dev-tools * selection), and — since CFG-27 — the {@link ApprovalsConfig} value and its resolver * {@link resolveApprovals} (the five-rung ladder, the rater's identity profile, and the declared * allow/deny lists). * * It also hosts the GitHub review-tool registry resolvers — {@link isGhReadFileToolEnabled} and * {@link getGhReadFileMaxBytes} — which read the same {@link GthConfig.builtInTools} registry * through the same per-command-then-root pick as the shell accessors. They live here, in core, * rather than beside the tool in `@gaunt-sloth/review` (their only caller), so that one place owns * how a `builtInTools` entry is normalised and resolved; a copy sitting next to the tool would be * free to drift from the precedence every other tool in the registry obeys. * * CFG-18 — the dev/shell tools are now configured through the unified {@link GthConfig.builtInTools} * registry (`string[] | Record`), NOT the removed per-command * `commands..devTools` key. {@link GthDevToolsConfig} is therefore no longer an on-disk shape: * it is the internal, resolved view that {@link getEffectiveDevToolsConfig} builds from the effective * `builtInTools` registry, and that `GthDevToolkit` + the shell accessors below consume. This * keeps the toolkit/accessor surface stable while the single config surface is `builtInTools`. */ import { type GthCommand, StatusLevel } from '#src/core/types.js'; import type { GthConfig } from '#src/config/types.js'; /** * CFG-18 — the per-tool config object carried as a value in the {@link GthConfig.builtInTools} * registry (the object form's values, alongside a bare boolean that enables/force-disables a tool). * Heterogeneous by tool, modelled as one permissive object (all fields optional) rather than a * discriminated union so the registry can carry every tool's shape: * - the fixed dev-command tools (`run_tests`/`run_lint`/`run_build`/`run_single_test`) read * {@link command} — the shell command to run; its presence enables the tool; * - `run_shell_command` reads the EXT-9/12 execution knobs ({@link enabled}/{@link timeout}/ * {@link maxTimeout}/{@link maxOutputBytes}); * - `gth_grep` reads {@link fileSet} (GS2-51) — which corpus to search; * - a plain built-in tool (`gth_checklist`, `gth_web_fetch`, …) reads {@link enabled} (or is * toggled with a bare boolean in the registry). * * CFG-26 — the APPROVAL knobs (`allowlist`, `persistAllowlist`, `judge`, `yolo`) are gone from * here and live in the top-level {@link ApprovalsConfig}. They were fields of the object shared by * EVERY built-in tool, so `gth_grep: { yolo: true }` used to validate; approvals are a property of * the session, not of one tool's registry entry. */ export interface BuiltInToolConfig { /** * Enable / force-disable this tool. For `run_shell_command` the resolution is `enabled ?? default` * (EXT-12: default ON in `code` mode, OFF elsewhere), so an object entry WITHOUT `enabled` still * defaults ON in `code`; `enabled: false` is the hard escape hatch that disables it even in `code`. * For a plain built-in tool, `enabled: false` removes it from the loaded set. */ enabled?: boolean; /** The shell command for a fixed dev-command tool (`run_tests`/`run_lint`/`run_build`/`run_single_test`). */ command?: string; /** `run_shell_command`: per-command wall-clock timeout (ms). See {@link SHELL_DEFAULT_TIMEOUT_MS}. */ timeout?: number; /** * `run_shell_command` (EXT-125): the CEILING, in ms, on a time budget the MODEL asks for in a * tool call's `timeoutMs` argument. {@link timeout} is what a call gets when it asks for nothing; * this is the most it may ask for. See {@link SHELL_DEFAULT_MAX_TIMEOUT_MS} and * {@link getShellMaxTimeoutMs}, which spells out how the two interact. */ maxTimeout?: number; /** `run_shell_command`: captured-output byte budget. See {@link SHELL_DEFAULT_MAX_OUTPUT_BYTES}. */ maxOutputBytes?: number; /** * `gth_gh_read_file` (CFG-52): ceiling on the DECODED file text the tool returns, in bytes. * Over it, the content is truncated at the boundary and carries a marker naming the tool and the * cap, so the model knows the file is incomplete. See {@link GH_READ_FILE_DEFAULT_MAX_BYTES}. * * Named `maxBytes` rather than the shell's `maxOutputBytes` because this is a file read (cf. * `read_file`'s byte cap) rather than captured command output. */ maxBytes?: number; /** * ANY tool (TUI-C105): how many lines of this tool's output the COLLAPSED preview shows, before * the `… (+N more lines)` overflow marker. `0` collapses the call to its summary line alone (and * then no overflow marker is drawn — at an explicitly requested depth of 0 a second line would * defeat the setting). Overrides the root {@link GthConfig.toolOutputPreviewLines}; absent, that * default applies, and absent too the built-in 10. * * **This is a DIFFERENT LAYER from {@link maxBytes}, and setting one never does the other's job.** * `maxBytes` caps what a tool RETURNS TO THE MODEL — it changes the conversation. `previewLines` * caps what is SHOWN TO THE HUMAN — the model receives the tool's full result either way. A user * who sets `maxBytes` to quieten the terminal has instead truncated the file the model is * reasoning about; a user who sets `previewLines` to save tokens has saved none. * * Example — keep the review tool's output to one line: * `{ "builtInTools": { "gth_gh_read_file": { "previewLines": 0 } } }`. */ previewLines?: number; /** * `gth_grep` (GS2-51): which corpus the content-search tool scans, applied consistently to BOTH * execution engines (native ripgrep and the in-process JS fallback): * - `gitignore` (DEFAULT) — respect `.gitignore`/`.ignore` and skip hidden dot-files. This is the * best code-search UX and is already ripgrep's own default, so rg-present machines see NO * behaviour change; only the corpus *selection* becomes explicit. * - `all` — scan everything except the noise dirs (`node_modules`/`dist`/`.git`/`.idea`); for rg * this passes `--no-ignore --hidden`. * * Example: `{ "builtInTools": { "gth_grep": { "fileSet": "all" } } }`. * * NOTE: under `gitignore` the JS fallback is a best-effort approximation (skip noise dirs + hidden * dot-files); it does NOT parse arbitrary `.gitignore` rules the way rg does. See the residual * rg-vs-JS divergence note in `gthGrepTool.ts`. */ fileSet?: 'gitignore' | 'all'; } /** * CFG-18 — the widened `builtInTools` setting. Either the legacy `string[]` (each named tool * enabled) or a registry keyed by tool name whose values **enable** (`true`), **force-disable** * (`false`), or **configure** ({@link BuiltInToolConfig}) each tool. This single key replaces the * former split of `builtInTools: string[]` (which tools are on) + per-command `devTools` (how each * dev/shell tool is configured). * * Example — keep the checklist, add web fetch, and configure the shell: * ```json * { "builtInTools": { * "gth_checklist": true, * "gth_web_fetch": true, * "run_shell_command": { "timeout": 300000 } * } } * ``` * Turn the (code-mode default-on) shell OFF: `{ "builtInTools": { "run_shell_command": false } }`. */ export type BuiltInToolsSetting = string[] | Record; /** * The fixed dev-command tools: each maps a `command` string (from its {@link BuiltInToolConfig}) * to a run_* tool emitted by `GthDevToolkit`. */ export declare const DEV_COMMAND_TOOL_NAMES: readonly ['run_tests', 'run_lint', 'run_build', 'run_single_test']; /** The opt-in general-purpose shell tool name. */ export declare const SHELL_TOOL_NAME = "run_shell_command"; /** * All dev/shell tool names carried in the {@link GthConfig.builtInTools} registry. These are emitted * by `GthDevToolkit` via the dev-tools bucket, NOT loaded as plain built-in tools — so * `getBuiltInTools` skips them (a `run_shell_command` entry in `builtInTools` is legitimate, not an * "unknown built-in tool"). */ export declare const DEV_TOOL_NAMES: readonly string[]; /** * CFG-52 — the `gh api` file-read tool the review agent gets on a GitHub PR. The tool itself is * built in `@gaunt-sloth/review` and stays there: it binds to the PR under review, which the * `AVAILABLE_BUILT_IN_TOOLS` factory contract (`tool.get(config)`) cannot supply. Only its NAME * lives here, because two packages need it and neither may depend on the other — `review` to decide * whether to inject it, and `agent`'s `getBuiltInTools` to SKIP it (see * {@link EXTERNALLY_EMITTED_BUILT_IN_TOOL_NAMES}). */ export declare const GH_READ_FILE_TOOL_NAME = "gth_gh_read_file"; /** * Built-in tool names that are legitimate {@link GthConfig.builtInTools} entries but are NOT loaded * by `getBuiltInTools`, because the tool is constructed elsewhere: the dev/shell tools come from * `GthDevToolkit`, and {@link GH_READ_FILE_TOOL_NAME} is built by the review module with the * PR context bound in. Without the skip, configuring one of these prints * `Unknown built-in tool: ` on EVERY command's run — including the ones that never load it. */ export declare const EXTERNALLY_EMITTED_BUILT_IN_TOOL_NAMES: readonly string[]; /** * CFG-52 — default ceiling on the DECODED file text {@link GH_READ_FILE_TOOL_NAME} returns: * 600 KiB, roughly 10K lines of code. Deliberately generous, because the tool exists precisely for * the case where the diff truncated and the reviewer needs the whole file; a cap that bites in * normal use would defeat it. Override per entry with * `{ "builtInTools": { "gth_gh_read_file": { "maxBytes": 200000 } } }`. */ export declare const GH_READ_FILE_DEFAULT_MAX_BYTES = 614400; /** The commands that can carry {@link GH_READ_FILE_TOOL_NAME}; it is inert everywhere else. */ export type GhReadFileCommand = 'pr' | 'review'; /** * TUI-C105 — how many lines of `toolName`'s output the COLLAPSED preview shows, resolved * **per-tool override → global default → `fallback`**. * * The per-tool override rides the {@link GthConfig.builtInTools} registry through the SAME * per-command-then-root pick as every other entry here, so a tool's display depth is configured * beside its other knobs rather than on a second per-tool surface (CFG-18's whole point). The * global default is a root key, because the issue behind this asks for less tool-output noise * generally and the review tool is only the worst offender. * * **`0` is a legitimate value** — it means "summary line only", which is the setting this exists * to provide. So the guard is `>= 0`, deliberately NOT the `> 0` that * {@link getGhReadFileMaxBytes} applies to a byte cap where zero would mean "return nothing". * A negative, non-finite or non-numeric value is meaningless here and falls back, the same way * every other resolver in this module treats an out-of-range value. * * `fallback` is passed in rather than read from a constant here because the canonical cap belongs * to the render layer (`core/toolDisplay`'s `TOOL_OUTPUT_PREVIEW_LINES`), and importing it into * config would close a cycle — the render layer is what calls this. Duplicating the number instead * would give the project two sources of truth for one cap. * * `command` is the run's command when one is known; `undefined` reads the root registry only. */ export declare function getToolPreviewLines(config: Pick | undefined, toolName: string, command: GthCommand | undefined, fallback: number): number; /** * CFG-52 — whether the review agent gets {@link GH_READ_FILE_TOOL_NAME} on this run. * * **Opt-OUT: absence means enabled.** The tool is bound to the PR's own head repo and ref, reads * nothing local, and no-ops gracefully when `gh` is missing — the content it can reach is the * content the review is already about — so making it opt-in would turn it off for every existing * `gth pr` user in exchange for a flag most would never find. That is why absence is resolved here * rather than through {@link isBuiltInToolEntryEnabled}, which reads an absent entry as OFF. * * `{ "builtInTools": { "gth_gh_read_file": false } }` turns it off; an object entry configures it * (and, like any other tool, `{ "enabled": false }` also disables). */ export declare function isGhReadFileToolEnabled(config: Pick | undefined, command: GhReadFileCommand): boolean; /** * CFG-52 — the decoded-text byte ceiling for {@link GH_READ_FILE_TOOL_NAME}, falling back to * {@link GH_READ_FILE_DEFAULT_MAX_BYTES}. Only the object form can override it, and an * out-of-range / non-numeric value falls back to the default — the same guard * {@link getShellMaxOutputBytes} applies. */ export declare function getGhReadFileMaxBytes(config: Pick | undefined, command: GhReadFileCommand): number; /** * Normalize the widened {@link BuiltInToolsSetting} to a plain lookup keyed by tool name. The array * form maps each name to `true`; the object form passes through unchanged; absent → `{}`. */ export declare function normalizeBuiltInTools(builtInTools: BuiltInToolsSetting | undefined): Record; /** * Whether a plain built-in tool's registry entry is enabled: a bare `true`, or an object entry that * is not `{ enabled: false }` (configuring a tool enables it). A bare `false` force-disables it. * Dev/shell tools ({@link DEV_TOOL_NAMES}) are NOT resolved through this — they go through * {@link getEffectiveDevToolsConfig} / {@link isShellToolEnabled}. */ export declare function isBuiltInToolEntryEnabled(value: boolean | BuiltInToolConfig | undefined): boolean; /** * Config for `GthDevToolkit` — the INTERNAL, resolved dev/shell view (CFG-18: no longer an * on-disk shape; built from the {@link GthConfig.builtInTools} registry by * {@link getEffectiveDevToolsConfig}). Tools are not applied when the config is empty. Only active * in `code`/`exec` mode (and `ask --write`). */ export interface GthDevToolsConfig { /** * Optional shell command to run tests. * Not applied when config is not provided. */ run_tests?: string; /** * Optional shell command to run static analysis (lint). * Not applied when config is not provided. */ run_lint?: string; /** * Optional shell command to run the build. * Not applied when config is not provided. */ run_build?: string; /** * Optional shell command to run a single test file. * Supports command interpolation with the `${testPath}` placeholder. * Example: "npm test -- ${testPath}" or "jest ${testPath}" * Example: "npm test" - the test will simply be appended * Not applied when config is not provided. */ run_single_test?: string; /** * Opt-in general-purpose shell tool (`run_shell_command`). Unlike the fixed * `run_*` commands above, this lets the agent run ARBITRARY shell commands it * composes itself — the agentic-coding escape hatch the filesystem tools alone * do not give (they read and write files but run nothing). * * EXT-12 — default: ON in `code` mode, OFF elsewhere. When this is ABSENT/undefined, * `code` mode emits the tool (still GATED behind the approval gate — the absent-config * default NEVER implies bypass); `exec` / `ask --write` keep it OFF. An EXPLICIT value * always wins: `shell: false` (or `{ enabled: false }`) is a hard escape hatch that fully * disables it even in `code`. Accepts a bare boolean or an `{ enabled }` object. * * Because the model chooses the command, every invocation is gated behind the CFG-26 * approvals gate (LangChain `humanInTheLoopMiddleware`) * UNLESS `approvals.mode: "bypass"` turns the gate off. The gate — not string-filtering — is * the guardrail, so the command is passed through verbatim (pipes / `$` / `;` are all * legitimate). * * The object form also tunes the EXT-9 Tier-1 hardening applied to every run * (these have safe defaults so bare `shell: true` is already hardened): * - `timeout`: per-command wall-clock limit in MILLISECONDS before the child * (and its process group) is killed. Default {@link SHELL_DEFAULT_TIMEOUT_MS}. * - `maxTimeout`: the CEILING in MILLISECONDS on a budget the MODEL asks for in a call's * `timeoutMs` argument (EXT-125). Default {@link SHELL_DEFAULT_MAX_TIMEOUT_MS}; resolved by * {@link getShellMaxTimeoutMs}, which is what keeps a model from granting itself an unbounded * wait. A call asking for more than the ceiling is refused WITHOUT running. * - `maxOutputBytes`: byte budget for the captured output returned to the model * (head + tail window; the middle is dropped and the full output spilled to a * temp file). Default {@link SHELL_DEFAULT_MAX_OUTPUT_BYTES}. Live terminal * streaming is never capped. * * A hardcoded hardline blocklist of catastrophic commands (rm -rf /, mkfs, dd * to a block device, fork bomb, …) is refused even under * `approvals.mode: "bypass"`; that floor is not configurable. * * On-disk (CFG-18) these live on the `run_shell_command` entry of `builtInTools`, e.g. * `{ "builtInTools": { "run_shell_command": true } }` or * `{ "builtInTools": { "run_shell_command": { "timeout": 300000, "maxOutputBytes": 200000 } } }`. * Since EXT-125 the same entry also carries `maxTimeout`. * * CFG-26 — the approval knobs that used to live here (`allowlist`, `persistAllowlist`, * `judge`, `yolo`) moved to the top-level `approvals` block ({@link ApprovalsConfig}); read * them through {@link resolveApprovals}, never from this object. */ shell?: boolean | { enabled?: boolean; timeout?: number; maxTimeout?: number; maxOutputBytes?: number; }; } /** * Default per-command shell timeout (ms) when {@link GthDevToolsConfig.shell} * does not specify one. ~120s suits typical build/test/git steps without * hanging the agent forever on a stuck command. */ export declare const SHELL_DEFAULT_TIMEOUT_MS = 120000; /** * EXT-125 — default CEILING (ms) on a time budget the MODEL asks for in a shell tool call's * `timeoutMs` argument, when {@link GthDevToolsConfig.shell} does not set `maxTimeout`. * * Ten minutes, because the longest thing a repo's own dev commands plausibly need is a full test * suite, and `run_tests` is the likeliest of them to want that. Five times the * {@link SHELL_DEFAULT_TIMEOUT_MS} default gives that room while still bounding a stuck command at * an interval a human notices. * * **The ceiling matters more than the default does.** The default is what a call gets for asking * nothing and is easy to raise in config for one project; the ceiling is the only thing standing * between a model and an unbounded wait, so it is the number to argue about. */ export declare const SHELL_DEFAULT_MAX_TIMEOUT_MS = 600000; /** * Default byte budget for shell output captured into the ToolMessage returned to * the model (head + tail window). ~100KB keeps a noisy log from blowing the * context window; the full output is spilled to a temp file when this is exceeded. */ export declare const SHELL_DEFAULT_MAX_OUTPUT_BYTES = 100000; /** * Normalize the {@link GthDevToolsConfig.shell} opt-in (bare boolean or * `{ enabled }`) to a plain boolean. Centralized so tool emission and interrupt * wiring agree on what "shell enabled" means. * * EXT-12 / CFG-18 — default-resolution is `enabled ?? default`. An EXPLICIT `enabled` always wins * (a bare boolean, or the object form's `enabled`), so `shell: false` / `{ enabled: false }` remains * a hard escape hatch that fully disables the tool. When `enabled` is ABSENT — whether `shell` is * undefined OR an object that omits `enabled` (e.g. `{ timeout: 300000 }`, i.e. a * `{ "run_shell_command": { "timeout": 300000 } }` registry entry) — the per-mode default applies: * ON in `code` mode (still gated — the per-command approval interrupt is wired separately and is NOT * bypassed by this), OFF everywhere else (`exec`, `ask --write`, …). This is the CFG-18 change from * the old `enabled === true` object semantics: configuring the shell no longer silently turns it off. * The default is `code`-mode only because `code` is the interactive agentic-coding surface where a * TTY can answer the approval prompt; the absent-config default never implies yolo. * * @param command The active command, so the absent-config default can be scoped to `code`. * Omit (or pass a non-`code` command) to keep the historical OFF-by-default behaviour. */ export declare function isShellToolEnabled(devTools: GthDevToolsConfig | undefined, command?: GthCommand | undefined): boolean; /** * Resolve the per-command shell timeout (ms) from config, falling back to * {@link SHELL_DEFAULT_TIMEOUT_MS}. Only the object form can override it; a bare * `shell: true` uses the default. Non-positive / non-finite values are ignored. */ export declare function getShellTimeoutMs(devTools: GthDevToolsConfig | undefined): number; /** * EXT-125 — resolve the CEILING (ms) on a time budget a shell tool call may ask for in its * `timeoutMs` argument. Only the object form can override it; non-positive / non-finite values are * ignored, exactly as {@link getShellTimeoutMs} treats `timeout`. * * **Never below {@link getShellTimeoutMs}, and that clamp is the point.** The budget a call gets * for asking nothing is already granted to every command, so a ceiling under it would mean a model * asking for a legal-looking number got LESS time than staying silent would have given it, and its * repair move would be to stop naming a budget — the opposite of what the argument exists for. The * rule is therefore: **naming a budget can never leave a call worse off than not naming one.** * * The case that reads oddly and is nonetheless correct: `{ "maxTimeout": 60000 }` with no * `timeout` resolves to 120000, not 60000. The user has already granted every command 120 s by * leaving the default in place; a 60 s ceiling on top of that forbids nothing. A user who means * "no command may run longer than a minute" sets `timeout` as well, and then the ceiling holds at * 60000 because the max of the two is 60000. A user who means "the model may not raise the budget * at all" sets `maxTimeout` equal to `timeout`. * * **What this guarantees is the safety property the argument needs:** the ceiling is a function of * CONFIG alone — the larger of the user's explicit ceiling and the budget the user already grants * by default — and never of anything the model sent. So no sequence of tool calls can widen it, and * a model cannot grant itself wall-clock the user had not already granted every command. */ export declare function getShellMaxTimeoutMs(devTools: GthDevToolsConfig | undefined): number; /** * Resolve the captured-output byte budget from config, falling back to * {@link SHELL_DEFAULT_MAX_OUTPUT_BYTES}. Only the object form can override it. * Non-positive / non-finite values are ignored. */ export declare function getShellMaxOutputBytes(devTools: GthDevToolsConfig | undefined): number; /** * Resolve the {@link GthDevToolsConfig} that applies to the active command from the unified * {@link GthConfig.builtInTools} registry (CFG-18 — replaces the removed per-command `devTools`). * Mirrors the per-command selection used by `builtInToolsConfig.getDefaultTools`: `exec` → * `commands.exec`, `ask --write` → `commands.ask`, `code` → `commands.code`; `undefined` elsewhere * (the dev/shell tools are inert there). The effective registry for the scope is the per-command * `builtInTools` if set, else the root `builtInTools` — matching `getEffectiveConfig`'s replace * merge. Shared in core so the runner's allow-list/judge gates stay in lockstep with where the * shell tool is actually emitted. */ export declare function getEffectiveDevToolsConfig(config: Pick | undefined, command: GthCommand | undefined): GthDevToolsConfig | undefined; /** * CFG-27 (spec §1, §2) — **the ladder**. There is ONE approvals setting and it is a single ordered * ladder; each rung fully determines behaviour. There are no severity thresholds, no strictness * levels and no independent rater on/off switch. * * | # | Rung | Rater | LLM cost | * |---|---|---|---| * | 1 | `manual` | no | none | * | 2 | `write` | no | none | * | 3 | `assisted` | yes | 1 call per gated call | * | 4 | `auto` | yes | 1–2 calls per gated call | * | 5 | `bypass` | no | none | * * Rungs 1, 2 and 5 are fully deterministic: no model is consulted, so behaviour is reproducible * and costs nothing. * * **CFG-39 — four postures plus one modifier, not five peers.** `write` is not a rung on a trust * ladder: it is the same posture as `manual` with a different auto-granted set. The four postures * a user chooses between are {@link APPROVAL_POSTURES} (`manual` → `assisted` → `auto`, plus * `bypass`), and that ordering is legible in the names themselves. `write` remains fully settable * — via `/approvals write` and via config — and simply does not occupy a row in quick access. * This constant keeps ALL FIVE members: it is the type's domain, the set * {@link resolveInterruptToolNames} unions over, and what makes `write` settable at all. * * **`bypass` is NOT a higher-autonomy rung than `auto`** (§2.5). Both let the agent act * without asking; `bypass` is the same autonomy with the checks removed. The ordering below is the * order the rungs are *offered* in, and must never be presented as though `auto` were an * incomplete `bypass`. * * Identifiers are lower-case single words (§9.1) because the same token must work as a config * value, a slash-command argument and a CLI flag — a space breaks the last two. Display names are * capitalised; see {@link APPROVAL_RUNG_LABELS}. */ export declare const APPROVAL_RUNGS: readonly ['manual', 'write', 'assisted', 'auto', 'bypass']; /** One rung of {@link APPROVAL_RUNGS}. */ export type ApprovalRung = (typeof APPROVAL_RUNGS)[number]; /** * §9.1 rule / §10 rule 4 — the display spelling of each rung, with spaces. An identifier and a * label do not have to match and only one of them has to survive a shell, so user-facing prose * uses these and never the kebab-case identifiers. */ export declare const APPROVAL_RUNG_LABELS: Record; /** * CFG-39 — **the four postures**, in the order they are offered: the rows `/approvals` presents * when it asks the user to choose one. * * `write` is deliberately absent, and its absence is the whole point of the reframe. It is not a * rung on a trust ladder — it is `manual`'s posture with a different auto-granted set, so listing * it as a fifth peer is what made a four-point ladder read as five indistinguishable ones. It stays * fully settable via `/approvals write` and via config; it simply leaves quick access. * * **This is a presentation list, never a policy one.** Every predicate, every `Record` and the interrupt set are built from {@link APPROVAL_RUNGS}, which keeps all five. Using this * constant to decide behaviour would silently un-settle `write`. */ export declare const APPROVAL_POSTURES: readonly ["manual", "assisted", "auto", "bypass"]; /** * CFG-39 — the one-line note that `write` exists, shown as picker CHROME beside the `manual` row. * * It lives here rather than appended to {@link APPROVAL_RUNG_DESCRIPTIONS}`.manual` on purpose: the * descriptions are one surface's copy shared by six, so a sentence about quick-access mechanics * would follow the mode into the status display and the tool-description layer, where it means * nothing. */ export declare const APPROVAL_WRITE_MODIFIER_HINT: string; /** * §10 — what each mode is **for**, in at most two sentences, shown wherever a mode is chosen or * displayed. * * **The first sentence is load-bearing and must stand alone.** The `/approvals` picker, the text * fallback and the usage hint all render one line per mode through `firstSentence`, so sentence * one has to answer "what is this mode for" on its own and sentence two carries the qualification. * Keep it short enough to read as a menu row. * * **Say what the mode is for, not only what it permits.** A description that lists permissions * invites the category error these modes actually suffer: Manual reads as "the safe one", so it * gets picked for a long unattended run — where the deciding is done by a human, and a human is the * fastest-degrading decider in the system. Manual and Write are bounded-volume tools and their copy * says so. * * The wording is constrained by four normative rules (state what the mode PERMITS, state the * allow-list carve-out, never claim safety this system cannot deliver, use the display spelling) * plus §8.1 — the hardline floor is real but is NEVER advertised, so descriptions cite only * protections the user can inspect and extend, i.e. the deny list. Six further constraints bind * every edit here: * * 1. **No description may imply containment.** The gate protects against accidents, not intent, and * a working-folder claim collapses the moment the agent has a shell — `write_file` refuses a * path that `touch` then writes. The narrow true form (the built-in file *tools* are confined) * is stated once, on `write`, alongside the fact that the shell is not confined that way. * 2. **Claims are scoped to the session the user is in — ratified by Andrew on 2026-08-13** as the * standing rule for every in-product approval promise ([[CFG-40]]), so it is a decision and not * a habit inherited from whichever string happened to be written last. What makes it true * rather than merely convenient: these strings render on terminal surfaces only, and every * surface that renders them is a session a person is sitting in, so a claim scoped to *this * session* describes the only place the sentence appears rather than hedging around a * falsehood. Unscoped it would not: a sentence whose subject is "Gaunt Sloth" and whose claim * is that it always asks is false over the AG-UI server, which drives the agent itself and * drains no approval interrupt ([[EXT-54]] — worth fixing on its own merits, never as a gate on * this copy). * * **The ACP server does ask, and that does not re-open this rule.** It drives * `GthAgentRunner`, registers a per-turn tool-approval callback and raises * `session/request_permission`, so a human there really is asked. What it puts in front of them * is assembled from the rater's verdict, the matched `approvals.escalate` entry and the grant * preview — never from `APPROVAL_RUNG_DESCRIPTIONS` or any other posture copy — so no string * governed here renders on a server surface, and the ruling's conclusion is untouched. * * **That is also the boundary: the moment an approvals string is rendered by a server surface * this ruling stops covering it**, and the scoped sentence has to be re-earned there rather * than inherited from here. * 3. **`assisted` MUST keep the sentence saying files are still rewritten and deleted without * asking** — it sounds safer than it is, and that clause is the correction. * 4. **A qualification may not live in the second sentence alone.** The picker, the text fallback * and the usage hint all render `firstSentence` and nothing else, so the opener is the whole * message on the three surfaces a user reads while *choosing* a mode. An opener that sells a * behavioural difference the product does not have is not rescued by a sentence two those * surfaces never print — check a wording by rendering it, not by reading the constant. * 5. **`auto` is the mode a reader most wants to hear is quiet, and it is not.** It settles some * risky commands with the rater instead of interrupting — that difference is real and may be * stated — but the copy MUST also say that a bounded exchange ends at the user, and MUST NOT * promise the user watches it happen. The rounds reach a person at the escalation, all of them * at once, and nothing renders them before that ([[TUI-C26]]), so a sentence implying a live * commentary would be describing a screen that does not exist. * 6. **Every rated rung states a LIMIT ON USE in its opener, and `recoverable` is the word that * carries it** ([[CFG-48]]). A rated rung settles some risky commands without asking anyone, so * the sentence offering it has to say what kind of work that is acceptable for — and a * *terminus* is not a limit on use. "then asks you" and "anything riskier comes to you" say * where the exchange ends; they do not say when the mode is a bad idea, which is what the three * UNRATED rungs each say ("not a mode to leave running", "a bounded stretch", "a throwaway * environment you would not mind losing"). Constraint 4 is why it must be the opener: the three * surfaces a user chooses from print `firstSentence` and nothing else. * * **"Unrated", not "deterministic", and the distinction is this file's own.** The complement of * {@link isRatedRung} is `{manual, write, bypass}` — the rungs that consult no rater. That is NOT * the set {@link isDeterministicRung} names, which is `{manual, write}`: `bypass` gates nothing at * all, so it is not a rung that decides deterministically, it is the absence of a decision. Prose * elsewhere in the repo calls the three "deterministic"; against the predicate exported a hundred * lines below, that reading is wrong, so this constraint says "unrated" and means the complement. * * **A lexical test watches the word, and its failure is not a false positive.** "Carries a * limit-on-use clause" has no mechanical predicate, so the rule names its own carrier token * instead: `packages/app/spec/tui/slashCommands.spec.ts` — *"every rated rung's opener says what * kind of work it is for, and only the rated ones do"* — takes the rated set from * {@link isRatedRung} rather than a literal pair, so a sixth rated rung is required to carry a * clause on the day it is added, and asserts the three unrated rungs do NOT carry the token, so * that spreading `recoverable` across all five cannot satisfy it. Rewriting this copy is fine; * dropping the word is the thing that must not happen quietly, because that is exactly how * Auto's cautionary clause was lost once already — in an ordinary rewrite that broke no test. * * Everything these two sentences cannot hold lives at {@link APPROVAL_PROTECTION_DOCS_URL}, which * the surfaces print beside the copy rather than each description repeating it. */ export declare const APPROVAL_RUNG_DESCRIPTIONS: Record; /** * The page that carries what these modes do and do NOT protect you from — the reasoning the * two-sentence descriptions deliberately do not hold. * * **A GitHub blob URL, matching the one other user-facing runtime doc link in this package** (the * 2.0 migration pointer in `config/schema.ts`). The docs site publishes the same page at * `https://gauntsloth.app/docs/guides/what-approvals-protect-you-from/`; move this constant there * once that path serves the page, and nothing else changes. */ export declare const APPROVAL_PROTECTION_DOCS_URL = "https://github.com/pukeko-robotics/gaunt-sloth/blob/main/docs/guides/what-approvals-protect-you-from.md"; /** * The docs pointer as the surfaces print it: **label and URL as two separate lines**, never one * joined string. Every notice surface takes `lines: string[]` and renders one line each, so a bare * URL on its own line is the only form that survives a narrow pane without the break landing * mid-path — and it is what lets a terminal that linkifies URLs pick the whole thing up. * * It lives beside the descriptions rather than inside them for {@link APPROVAL_WRITE_MODIFIER_HINT}'s * reason: one line about where to read more, repeated into all five descriptions, would follow each * mode into the tool-description layer and the status display, where it is noise. */ export declare const APPROVAL_PROTECTION_DOCS_LINES: readonly string[]; /** Narrowing type guard for a raw string that may name a rung. */ export declare function isApprovalRung(value: unknown): value is ApprovalRung; /** The rungs at which every gated call is rated by the model (§2.3, §2.4). */ export declare function isRatedRung(rung: ApprovalRung): boolean; /** * [[EXT-29]] (§5) — the rung at which a `destructive` rating opens a **negotiation** with the rater * instead of going to the human: the agent may revise the command or justify it, the rater re-rates * with the exchange in view, and only a spent bound reaches a person. * * **This is the one predicate that separates `auto` from `assisted`, and it is deliberately ONE.** * Three places have to agree about it — the decision mapping * ({@link @gaunt-sloth/core!core/shell/rater.mapVerdictToAction | mapVerdictToAction}, which returns `reject` here and * `escalate` at `assisted`), the rating prompt (§5.2's wording rules are addressed to the agent, so * they are turned on by this and not by whether a transcript happens to exist yet), and the runner * that counts the rounds. Two of them agreeing and the third not is exactly how the two rated rungs * would drift back into being the same posture with different names. * * **It answers about the RUNG, never about a particular call.** [[EXT-106]] §3 withholds the * negotiation from a command §4.6's deterministic preflight floors — one that cannot reach * `approve` however the agent argues — and both the decision and the prompt read that through * {@link @gaunt-sloth/core!core/shell/rater.isNegotiableCall | isNegotiableCall}, which composes this predicate with the * preflight. Anything asking *"may the agent argue about THIS call"* wants that function; this one * is a fact about the ladder and stays free of the shell module. * * **[[EXT-171]] — and `isNegotiableCall` is not the only thing that can withhold a negotiation.** * The decision also escalates when the gate never obtained a rating at all — the rater module's * `RaterDecisionOptions.failClosedCause` — which is a fact about the CALL rather than about the * command, so no function over the command text could carry it. A reader looking for "why did this * `destructive` not open a round at `auto`?" has two answers to check, not one. * * Written as a `=== 'auto'` test rather than as "rated but not assisted" so that a sixth rung has to * be classified deliberately rather than inheriting a negotiation by omission. */ export declare function isNegotiatingRung(rung: ApprovalRung): boolean; /** * [[EXT-106]] (§4.6) — the rung at which **the user's own verbatim words lift the open-world * floor**: where every host a command names was named by the human themselves, the deterministic * preflight does not floor it, and the user is *warned* that it ran rather than *asked* whether it * may. * * **`auto` and nothing else, and the scope is load-bearing rather than a default.** * {@link DEFAULT_APPROVAL_RUNG} is `assisted` for every command including `exec`, whose whole * purpose is running a prompt file read from disk — and file contents, piped stdin and a whole diff * all arrive as `human` messages. A carve-out reaching `assisted` would therefore be live on the * default rung with no config edit at all, on exactly the verbs whose "user message" is a file. * `auto` is chosen deliberately, by someone who has read what the rung does. * * It answers about the RUNG, never about a particular call: whether THIS command is carved is * {@link @gaunt-sloth/core!core/shell/provenance.carvedOpenWorldHosts | carvedOpenWorldHosts}, which composes this predicate with * the open-world preflight and the user's retained messages. This one is a fact about the ladder and * stays free of the shell module, exactly as {@link isNegotiatingRung} does. * * Written as a `=== 'auto'` test rather than as "the negotiating rung", even though the two agree * today: they answer different questions — *may the agent argue* versus *does the user's word lift a * deterministic floor* — and collapsing them into one would carry a sixth rung into both by * omission. */ export declare function isUserProvenanceRung(rung: ApprovalRung): boolean; /** * `COMMAND_CARRIES_USER_PROVENANCE` as a predicate — **and it fails closed**: anything not * positively established as the user's own words answers `false`, `undefined` included. * * The `?? false` coalesce and the `undefined` arm are the fail-closed half, not defensive noise. * `undefined` is a runner initialized without a command — today the `gth pr` change-requirements * discovery agent, which passes `owningCommand: 'pr'` for its label and deliberately no `command` * at all — and a lookup outside {@link GthCommand} can only reach here from an untyped consumer of * the published barrel. Both must answer "not the user's words", because a carve-out granted by * default is a carve-out nobody chose. * * Callers pass `command ?? owningCommand`, so a command-less helper agent is classified by the verb * it serves rather than by the absence of a verb. */ export declare function commandCarriesUserProvenance(command: GthCommand | undefined): boolean; /** * The rungs that decide a gated call **without a model** — `manual` and `write` (§2.1, §2.2). * Everything they do not auto-grant goes to the human, so these are the two rungs a user picks in * order to read and approve every tool call themselves. * * The complement of {@link isRatedRung} plus `bypass`; written out rather than negated so that a * sixth rung would have to be classified deliberately instead of defaulting into this set. */ export declare function isDeterministicRung(rung: ApprovalRung): boolean; /** * **The one rule: does `rung` gate this tool — i.e. must this call be decided rather than simply * run?** Everything else in this area is a projection of this predicate over a set of names. * * - The shell is gated whenever the shell gate is on, at EVERY rung (`bypass` included, so §2.5's * deny list can still fire — see the `bypass` arm of `GthAgentRunner.decideToolApproval`). * - At the two **deterministic** rungs, a tool is gated when the rung's own grant does not cover its * access class ({@link isAccessClassGrantedAtRung}). At `manual` that leaves only the built-in * READ tools free; at `write`, the built-in read and write tools. The write built-ins, the shell, * MCP tools and custom/agent-authored tools all escalate to the human. * - At `assisted`, `auto` and `bypass` nothing but the shell is gated. **That split is * deliberate and load-bearing, not tidiness.** At a rated rung a gated non-shell call reaches the * `subject.kind !== 'shell'` arm of `GthAgentRunner.decideToolApproval`, which floors it at * `destructive` and sends it to the human *with no rating call*, because §4.3 keeps the rater on * the shell until [[EXT-30]]. Gating there would silently turn every MCP call at `assisted` into * a human prompt — a UX change belonging to EXT-30, not to the two rungs whose published * descriptions this predicate makes true. * * `gateShell` only ever WIDENS the result. At a deterministic rung the shell is gated by its own * (absent) access class if it is bound at all, so `gateShell: false` does not exempt it — an * exemption keyed to one tool NAME is the defect class this predicate exists to remove. In practice * a disabled shell tool is never bound, so the two agree. * * **This takes no bound toolset**, which is what lets `GthAgentRunner` ask it about a single * arriving call: the runner sees only the names the graph registered, and a graph builder that * registers tools of its own leaves them off that list. A decision that consulted a bound list * would grant such a tool at `manual` purely because the runner could not see it. */ export declare function isToolGatedAtRung(options: { toolName: string; rung: ApprovalRung; gateShell: boolean; }): boolean; /** * **The LIVE gated set: which bound tools the rung in force actually gates.** What a decision is * measured against — the tool descriptions the model reads (§4.5) and the rater's granted-tools * summary (§4.4) are both built from this, so neither can tell the model a tool is free while the * gate escalates it. * * **It is NOT what the agent wires into the interrupt.** That is * {@link resolveInterruptToolNames}, and the two are different sets on purpose: the interrupt is * installed once, at agent init, while `/approvals ` moves the rung underneath it for the rest * of the session. A set that carried the rung would be frozen at the rung the session started on — * and since the default is `assisted`, typing `/approvals manual` would leave exactly the write * tools this design escalates ungated. So the interrupt is wired rung-independently and * `GthAgentRunner.decideToolApproval` consults {@link isToolGatedAtRung} against the LIVE rung. * * **Derived from the bound toolset, never a hand-written list.** A static list of built-ins would * leave MCP, custom and agent-authored tools out — the exact tools with no access class and so the * exact tools the deterministic rungs must escalate. `boundToolNames` must therefore be the FINAL * toolset the graph is handed, including any tool the graph builder registers itself, which by * definition never appears in the array gsloth passes it. * * Order is stable: the shell first, then bound order. Duplicates are collapsed, so a caller may pass * overlapping name sources without deduplicating first. */ export declare function resolveGatedToolNames(options: { rung: ApprovalRung; gateShell: boolean; boundToolNames: readonly string[]; }): readonly string[]; /** * `COMMAND_ANSWERS_APPROVALS` as a predicate. An unset command is a session driven by * `GthAgentRunner` (nothing else leaves it unset), so it answers approvals. * * **`?? true` is the fail-safe default, not defensive noise.** The lookup yields `undefined` for a * value outside {@link GthCommand}, and `undefined` is falsy — which would tell the caller to * install NO approval interrupt, the one direction this predicate must never fail in. TypeScript * makes that unreachable from inside this repo, but the function is re-exported from the public * `@gaunt-sloth/core/config.js` barrel, so an untyped consumer can reach it. The coalesce restores * runtime totality without weakening the compile-time totality the record already gives. */ export declare function commandAnswersApprovals(command: GthCommand | undefined): boolean; /** * **The interrupt set: which tool names the agent wires into the approval interrupt.** Every bound * tool that ANY rung could gate — the union of {@link resolveGatedToolNames} over * {@link APPROVAL_RUNGS}, which in practice is the shell plus every bound tool that is not a * built-in READ tool. * * One derivation for every backend, for the same reason {@link resolveShellApprovalGate} is one: a * set computed twice is a set that drifts. * * **Deliberately rung-independent.** The interrupt is installed once, when the agent is built, and * `/approvals ` then moves the rung for the rest of the session without rebuilding it. Only a * set that covers every rung can survive that: the interrupt fires and * `GthAgentRunner.decideToolApproval` decides on the rung in force, which is where the rung has * always been read. **Wiring wider does not gate wider** — a call the live rung does not gate is * approved there with no rating call and no prompt, so `assisted`, `auto` and `bypass` behave * exactly as they do when the interrupt holds the shell alone. * * **Only for a command that answers approvals** ({@link commandAnswersApprovals}). A surface that * drains nothing gets the shell-gate set instead, because a rung-independent set there parks tool * calls nobody can reach. */ export declare function resolveInterruptToolNames(options: { gateShell: boolean; boundToolNames: readonly string[]; }): readonly string[]; /** * EXT-71 §3.1 — the **subject** axis of a rule entry, and only that. The schema twin is * `APPROVAL_ENTRY_TYPES` in `config/schema.ts`. What holds the two together is * `approvalEntrySchema.spec.ts`, where a list of `ApprovalEntry`-typed literals is parsed by that * schema: a value either side stops accepting fails there. That is a weaker pin than a direct * equality assertion — it catches a narrowing, not a widening on one side alone. */ export type ApprovalEntryType = 'shell' | 'tool' | 'mcpTool'; /** EXT-71 §3.1 — the **comparison** axis of a rule entry, and only that. */ export type ApprovalMatcher = 'exact' | 'glob' | 'regexp' | 'hint'; /** * §4.7 — the four MCP `ToolAnnotations` hint names, and the whole vocabulary. It is the same list * on both sides of the design: what a `hint` pattern may name ({@link ApprovalHintPattern}) and what * a user may believe from a server ({@link McpServerApprovalsConfig.trustAnnotations}). * * **The schema twin `HINT_ANNOTATION_KEYS` in `config/schema.ts` is a deliberate duplicate, and the * reason is layering, not oversight.** Neither file may import the other. `schema.ts` states in its * own header that it must stay pure and cwd/fs-independent because it feeds `z.toJSONSchema()`, and * importing this module would pull `core/types.js` and the whole runtime policy surface into it; * importing `schema.ts` here would in turn pull zod into every module that only wanted a policy * type. So the vocabulary is written once per layer on purpose — do not "simplify" it by making one * import the other. * * What keeps the two honest instead is the equality assertion in `mcpApprovalsBlock.spec.ts`, which * fails the moment they drift. Drift matters in one direction especially: a name the config accepts * but the derivation never reads fails silently, and it fails toward trusting. Change one list, * change the other. */ export declare const TOOL_ANNOTATION_HINTS: readonly ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint']; /** One of {@link TOOL_ANNOTATION_HINTS}. */ export type ToolAnnotationHint = (typeof TOOL_ANNOTATION_HINTS)[number]; /** * EXT-71 §3.1 — a `hint` pattern: the four MCP `ToolAnnotations` booleans, each mapped to the * value it must EFFECTIVELY hold (§4.7.1). All named hints must match (AND within the entry); * hints not named are unconstrained; `false` is the spelling of negation. At least one must be * named — an empty object is a config error, never a match-everything. */ export interface ApprovalHintPattern { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean; } /** Fields every rule entry may carry, whatever its subject. */ interface ApprovalEntryCommon { /** * §3.2 — whether the rater still reviews a call this entry matched, honored at the rater rungs * and inert at the deterministic ones. Valid on EVERY type. Absent takes the §3.2 default: an * entry skips the rater only to the extent that it recorded what the rater would have seen, so * `shell` + `exact` defaults to `false` and everything else to `true`. */ rate?: boolean; } /** §3.1 — a `shell` entry: a command, compared against the normalized command string. */ export interface ShellApprovalEntry extends ApprovalEntryCommon { type: 'shell'; /** `hint` is absent on purpose — a command carries no tool annotations. */ matcher: 'exact' | 'glob' | 'regexp'; pattern: string; } /** §3.1 — a `tool` entry: a built-in or custom in-process tool, compared against the tool name. */ export interface ToolApprovalEntry extends ApprovalEntryCommon { type: 'tool'; matcher: ApprovalMatcher; pattern: string | ApprovalHintPattern; /** §4.7.4 — optional exact-match bound on the call's host. Tool subjects only. */ host?: string; } /** §3.1 — an `mcpTool` entry: one server's tool. */ export interface McpToolApprovalEntry extends ApprovalEntryCommon { type: 'mcpTool'; matcher: ApprovalMatcher; pattern: string | ApprovalHintPattern; /** * §4.7.5 — **required**, and the user's own key in `mcpServers`: the only identity a server has * that is stable, unique and user-authored. The literal `*` means every server, which is why a * configured server may not be named `*`. */ server: string; /** §4.7.4 — optional exact-match bound on the call's host. */ host?: string; } /** * EXT-71 §3.1 — **one entry** in `allow`, `deny` or `escalate`. All three lists take the same * shape; the list an entry sits in decides only what a match DOES (deny over escalate over allow). * * `type`, `matcher` and `pattern` are always required — no field is inferred and no entry reads * two ways. The runtime validator is `approvalEntrySchema` in `config/schema.ts`, which is * stricter than TypeScript can be: it rejects unknown fields, an empty or unknown-key `hint` * pattern, and a `regexp` that does not compile or is over the length cap. */ export type ApprovalEntry = ShellApprovalEntry | ToolApprovalEntry | McpToolApprovalEntry; /** * EXT-70 §4.7.1/§9 — the approvals relationship with ONE MCP server, keyed by the user's own * `mcpServers` config key (§4.7.5 — the only identity a server has that is stable, unique and * user-authored; nothing a server declares about itself ever participates). * * `trustAnnotations` names the hints that are BELIEVED from that server. It is a list rather than a * boolean because trusting `readOnlyHint` while distrusting `openWorldHint` is a coherent position * and the common one, and because a single "trusted server" flag throws that distinction away for * nothing. **Absent or empty means what the default means: nothing external is believed** — every * hint of that server's collapses to the MCP fail-closed default, so its declarations cannot * perturb any rule. */ export interface McpServerApprovalsConfig { /** §4.7.1 — the hints believed from this server. Absent or empty trusts nothing. */ trustAnnotations?: ToolAnnotationHint[]; } /** * EXT-70 §4.7/§9 — the `approvals.mcp` block: the per-server relationship, keyed by the user's own * `mcpServers` config key. * * **`defaults` applies to servers NOT named under `servers`** — §9's own gloss. A server that names * itself states its own relationship in full, so `{"jira": {}}` trusts nothing however permissive * `defaults` is: trust by omission is exactly the failure §4.7.1 exists to prevent, and the fail- * closed direction is the one a silent config edit must fall in. * * A key here is **not** validated against `mcpServers`. A user may write the policy before adding * the server, and coupling the two would make config ORDER matter. * * This block cannot live inside `mcpServers`, which is modelled permissively because it carries * runtime objects. */ export interface McpApprovalsConfig { /** The relationship with every server not named under {@link servers}. */ defaults?: McpServerApprovalsConfig; /** Per-server relationships, keyed by the user's own `mcpServers` config key (§4.7.5). */ servers?: Record; } /** * On-disk `approvals` object form (root or per command). The **scalar form is exactly sugar for * `{ mode: }`** (§9.1) — the union exists so the extras have a home when they are needed, * not so there are two ways to say the same thing. */ export interface ApprovalsObjectConfig { /** The rung. Absent = {@link DEFAULT_APPROVAL_RUNG}. */ mode?: ApprovalRung; /** * §9.1 — the identity profile the rater runs under, as a **bare name** (strict resolution, * GS2-62: a name that does not resolve is a hard config error, never a silent fallback). * Omitted = the main model. It is the only rater knob; nesting a one-field object is what this * design removed. */ rater?: string; /** * [[EXT-127]] §9.1 — the identity profile the **alignment checker** runs under, as a bare name * with the same strict resolution {@link rater} takes. * * **It is a second key rather than a widened `rater` because there are now two models with * different cost profiles.** The classifier is cheap and rates every gated command; the checker * is reached only once the classifier has declined, and pointing it at a larger model is the * reason the split exists at all. * * Omitted = the `rater` profile, applied by {@link resolveApprovals} at the read site rather * than defaulted into the on-disk shape. **There is no second timeout**: `raterTimeoutMs` is the * budget for one model call and a check is one call. */ alignmentChecker?: string; /** §3 — declared allow-list: what the human has trusted. Read-only input. */ allow?: ApprovalEntry[]; /** §3 — declared deny-list: what never runs. Read-only input; applies under `bypass` too. */ deny?: ApprovalEntry[]; /** * §3/§3.2 — declared escalate list: a match always asks the human, whatever the rung would have * done, and with no rating call. Read-only input; inert under `bypass` (§2.5). */ escalate?: ApprovalEntry[]; /** * EXT-66 — wall-clock budget (ms) for ONE rating call. Absent = {@link @gaunt-sloth/core!core/shell/rater.RATER_DEFAULT_TIMEOUT_MS | RATER_DEFAULT_TIMEOUT_MS} * (30s), which is a hosted-model number: a local rater is knowably slower, and when it runs out * of time the gate escalates, so an unreachable timeout turns the permissive rung into one that * asks about everything while every layer reports success. */ raterTimeoutMs?: number; /** * EXT-70 §4.7/§9 — the per-server MCP relationship. Read through * `createEffectiveToolAnnotationSource` (`core/approvals/annotations.ts`), which is the ONE place * an effective annotation set is derived; nothing else re-reads this block. */ mcp?: McpApprovalsConfig; } /** On-disk `approvals` value: the rung on its own, or the object when the extras are needed. */ export type ApprovalsConfig = ApprovalRung | ApprovalsObjectConfig; /** * The fully-defaulted approvals posture for one command. `allow`/`deny`/`escalate` are the * **declared** lists straight from config — read-only input that the runner merges with the * runtime stores the escalation menu writes, and that is never written back to config (§9.1). */ export interface ResolvedApprovals { /** The rung in force. */ rung: ApprovalRung; /** Identity profile the rater runs under, or `undefined` for the session model. */ rater?: string; /** * [[EXT-127]] — identity profile the **alignment checker** runs under, or `undefined` for the * session model. * * **This one IS defaulted here, unlike every other field on this object, and the exception is * deliberate.** Its default is not a constant but another resolved field — the `rater` above — * so leaving it undefined would push "which profile does the checker use?" out to every reader, * and a second reader that forgot the fallback would silently rate on the session model while * the user believed they had configured a checker. Nothing about the effective-config snapshot * changes, because the default is applied to this resolved view and never written back into the * `approvals` object the `/config` panel renders. */ alignmentChecker?: string; /** Declared allow-list entries (§3.1). Empty when none are declared. */ allow: ApprovalEntry[]; /** Declared deny-list entries (§3.1). Empty when none are declared. */ deny: ApprovalEntry[]; /** Declared escalate-list entries (§3.1). Empty when none are declared. */ escalate: ApprovalEntry[]; /** * EXT-66 — wall-clock budget (ms) for one rating call, or `undefined` to let the rater apply * `RATER_DEFAULT_TIMEOUT_MS`. Left `undefined` rather than defaulted here so the effective-config * snapshot does not churn, exactly as `rater` is. */ raterTimeoutMs?: number; /** * EXT-70 §4.7 — the declared per-server MCP relationship, or `undefined` when no scope states * one (which reads the same as an empty block: nothing external is believed). Left `undefined` * rather than defaulted here for the same reason `rater` is — so the effective-config snapshot * does not churn. */ mcp?: McpApprovalsConfig; } /** * CFG-26 — how many command prefixes the allow-list holds, for the `/approvals` display. * `always: undefined` means the persisted store has not been loaded — rendered `—` rather than a * misleading `0`, since a display must never create the store. */ export interface AllowlistCounts { session: number; always: number | undefined; } /** * [[EXT-107]] — **where a refusal in force came from.** Three sources, three lifetimes, three * owners, and the `/approvals` display keeps them apart because the answer to *"how do I get rid of * this?"* is different for each: * * - `config` — a line the user wrote in `approvals.deny`. Lives as long as the file does and is * removed by editing it; nothing at run time may quietly drop it. * - `session` — the escalation menu's *always reject*, held only for this runner instance. Gone * at exit whether or not anyone lifts it. * - `persisted` — the same choice, written to the project's deny file, so it is still in force in * the next session and for anyone who checks the file in. * * A merged list that cannot say which is which makes the removal control impossible to build * honestly: it would either offer to lift a config line it cannot lift, or hide the saved refusal * behind one it can. */ export type ApprovalRefusalOrigin = 'config' | 'session' | 'persisted'; /** * [[EXT-107]] — one refusal in force, for the `/approvals` display and the removal it offers. * * {@link index} is 1-based and is the number `/approvals undeny ` takes. Display and removal * read the SAME list in the same order, so a number on screen and a number in the command can never * name different entries — the failure a separately-built removal index would eventually produce, * silently, on a refusal. */ export interface ApprovalRefusal { /** 1-based position in the rendered list, and the argument the removal command takes. */ index: number; /** The entry, through `describeApprovalEntry` — the one-liner every provenance message uses. */ description: string; /** Which of the three lists this refusal is held in. */ origin: ApprovalRefusalOrigin; /** ISO-8601 instant the refusal was recorded. Absent on a configured entry, which has none. */ recordedAt?: string; } /** * [[EXT-107]] — what `/approvals undeny ` did, so the notice describes what actually happened * rather than what was asked for. * * `stillConfigured` is the case that would otherwise mislead: a command can be refused by a saved * entry *and* by a line in `approvals.deny`, and lifting the first leaves the second refusing it. * Reporting the removal without saying so would tell the user they had lifted a refusal that is * still in force. * * `stillSaved` is the same failure one layer down ([[EXT-149]]): the lift is real for this session * and the file rewrite that was meant to make it permanent did not land, so the entry returns in the * next session. It is the difference between *lifted* and *lifted for good*, and the store is the * only thing that knows which happened. */ export type ApprovalRefusalLift = { outcome: 'lifted'; description: string; origin: 'session' | 'persisted'; /** Whether the user's own `approvals.deny` still matches this entry. */ stillConfigured: boolean; /** * Whether the project's saved-refusals file still holds this entry — true only when the * removal could not be written, which is what brings it back in the next session. */ stillSaved: boolean; } | { outcome: 'configured'; description: string; } | { outcome: 'unknown'; index: number; count: number; }; /** * EXT-70 §4.7.1 — what one server's annotations are believed on, for display. * * `configured` says whether {@link server} is a key under `mcpServers` in the loaded config. It is * an advisory rather than a validity test: §9 deliberately does NOT check `approvals.mcp` keys * against `mcpServers`, so a user may write the policy before adding the server and config ORDER * never matters. What it buys interactively is that a mistyped key — which trusts nothing while * reading as though it did — can be pointed out rather than swallowed. */ export interface McpServerAnnotationTrust { /** §4.7.5 — the user's own `mcpServers` config key. */ server: string; /** The hints believed from this server, resolved through `defaults` where it is not named. */ trusted: ToolAnnotationHint[]; /** Whether this key names a server in the loaded config's `mcpServers`. */ configured: boolean; } /** EXT-70 §4.7.1 — the whole believed-annotation picture, for the `/approvals` display. */ export interface McpAnnotationTrustView { /** §9 — the hints believed from servers NOT named under `servers`. */ defaults: ToolAnnotationHint[]; /** Per-server, for every key either the config names or the policy does. */ servers: McpServerAnnotationTrust[]; } /** * EXT-70 §4.7.1/§4.7.4 — the outcome of believing (or ceasing to believe) hints from one server, * so the surface that asked can report exactly what landed and what it costs. * * **`weakening` is the field that keeps the human un-surprised.** Withdrawing trust pushes a hint * back to its fail-closed default, which for three of the four is a *weakening*, so the saved * approvals made for that server while the hint was believed will be invalidated (§4.7.4) at the * next call to that tool. That has to be said where the user withdraws trust, not only in the * notice that arrives later. */ export interface McpAnnotationTrustChange extends McpServerAnnotationTrust { /** Hints this change started believing (absent from the previous set). */ added: ToolAnnotationHint[]; /** Hints this change stopped believing (present in the previous set). */ removed: ToolAnnotationHint[]; /** * §4.7.4 — the subset of {@link removed} whose withdrawal can weaken an effective set, and * therefore invalidate a grant. Empty on a grant of trust, which can never weaken: every * weakening move ends at the fail-closed default, and believing a hint only ever moves away * from it. */ weakening: ToolAnnotationHint[]; /** * §4.7.4 — the saved approvals for this server that the *resulting* trust actually weakens, each * rendered by `describeApprovalEntry`. They are the ones that will be withdrawn, with the §4.7.4 * notice, at the next call to that tool. * * **A prediction, never a deletion.** Invalidation stays scoped to the call being decided, because * a sweep would read every held grant against a source that can only answer for the tools * registered right now — a server that happened to be offline would read as having weakened * everything it ever declared, and the grants would go. Listing them is safe where deleting them * is not. * * **It over-reports in two distinct ways, and both are the same trade.** The comparison is each * grant's recorded snapshot against the set in force *now*, not the set before this change against * the set after it. So (1) a server that is offline when trust moves declares nothing, resolves to * the fail-closed constant, and every grant it holds is named; and (2) a grant already weakened * for some other reason — an earlier withdrawal, a `tools/list` that took a hint back — is named * under whichever withdrawal happens to run next, including one that moved nothing relevant to it. * A named grant is therefore one that the trust now in force weakens, which is what the user needs * to know; it is not a claim that *this* withdrawal is what weakened it. Reading it as the latter * is how a test comes to assert causation the field never promised. * * Empty is likewise not "nothing is at risk" but "nothing this session can see is": with * {@link weakening} non-empty the rule still holds for any grant made while those hints were * believed, which is what the notice says in that case. Counted read-only, like * {@link AllowlistCounts}: the persisted store is consulted only when it is already loaded. */ invalidates: string[]; } /** * §1.1 — **the default rung is `assisted`, everywhere.** It is the default in every interactive * context, it does NOT vary with the configured model, and there is no separate non-interactive * default. What changes without a human is what an escalation *does* (§6.2: an immediate non-zero * exit, never an approval), not which rung the session starts on. A context-dependent default * would reintroduce exactly the hidden branching this ladder exists to remove. */ export declare const DEFAULT_APPROVAL_RUNG: ApprovalRung; /** * CFG-27 — resolve the effective {@link ResolvedApprovals} for the active command. * * There is no defaults *matrix*: §1.1 makes `assisted` the default in every context, so this * resolver neither detects nor accepts a "context". Precedence is the only thing it decides, and * §9.1 splits it in two: * * - **The scalars — `mode`, `rater`, `raterTimeoutMs` — are replaced** when the per-command value * states them and **inherited from the root when it does not**. So the scalar sugar * `"code": { "approvals": "bypass" }` is exactly `{ mode: 'bypass' }` merged over the root: it * sets the rung and nothing else. * - **`deny` and `escalate` never replace: they CONCATENATE across every scope.** A * command-specific `deny` *adds to* the root's. Removing an inherited prohibition for one command * is deliberately not expressible. * - **`allow` is REPLACED when the per-command value states its own, and inherited when it does * not.** A per-command scope may therefore narrow what runs unprompted, and may never widen what * is prohibited. * - **`mcp` (EXT-70 §4.7) follows `allow`, not the restrictive lists**: replaced when the * per-command value states it, inherited when it does not. Believing a hint is a PERMISSIVE act * in both directions — it can make an `allow` hint entry fire and can make a `deny` hint entry * stop firing — so it merges the way the permissive list does, and a per-command scope can * narrow the session's trust (`"mcp": {}` believes nothing) but never inherits half of it by * accident. Deep-merging the two scopes' `servers` maps was rejected for the same reason: it * would leave a deliberately distrustful per-command block silently carrying the root's trust. * * **The two halves differ because the costs differ (§3.1), not for tidiness.** A missed allow entry * escalates and a missed deny entry falls through to the rater — neither is an execution — while a * too-broad allow entry *runs, unrated and unprompted*. Concatenating the restrictive lists fails * toward a prompt; concatenating the permissive one fails toward an execution, and would leave a * deliberately restrictive per-command rung with no way to shed the root's standing grants. Do not * "regularize" these three into one policy: the direction each list fails in is the whole design. * * On the restrictive side the pressure runs the other way (§11.1f). Were the per-command value to * replace the root wholesale, the friendliest spelling of "stop asking me about `code`" would also * delete every `deny` entry — at the one rung where the deny list and the §8 floor are the only * checks left. A prohibition a nested config key can quietly delete is not a hardline. * * Concatenation order cannot change any outcome (`resolveApprovalRules` consults every deny entry * before any escalate entry and every escalate entry before any allow entry), so root-first is a * convention for readability — matching `GthAgentRunner.approvalRuleLists`, where the declared * entries precede the runtime grants — and never a precedence. * * Defaults are applied HERE, at the read site, rather than in `DEFAULT_CONFIG` — so the * effective-config snapshot the `/config` panel renders never churns (à la GS2-34 * `injectModelContext` / GS2-63 `output.header`). * * This is the per-command half. The cross-LAYER half (a project config's lists adding to a global * config's rather than replacing them) is the additive-array policy in `config/loader.ts`; both are * needed, since either alone still loses a list silently. * * @param command The active command; selects the per-command block. */ export declare function resolveApprovals(config: Pick | undefined, command: GthCommand | undefined): ResolvedApprovals; /** A status notice a backend should surface after resolving the shell approval gate. */ export interface ShellApprovalGateNotice { /** Severity to pass to the agent's `statusUpdate` callback. */ level: StatusLevel; /** The user-facing message. */ message: string; } /** The resolved shell approval-gate policy: whether to gate, and what to tell the user. */ export interface ShellApprovalGateDecision { /** * Whether `run_shell_command` must be wired behind the per-command approval interrupt * (langchain's `humanInTheLoopMiddleware`). */ gateShell: boolean; /** The notice to surface, when this configuration warrants one. */ notice?: ShellApprovalGateNotice; } /** * EXT-52 — the ONE shell approval-gate policy every agent backend resolves * (`GthLangChainAgent` = lean/default). It decides whether the opt-in `run_shell_command` tool is * gated behind the per-command approval interrupt, and which status notice (if any) the backend * should surface. A backend contributes only HOW it installs the interrupt; the policy and its * user-facing copy live here so no two can drift (and so a later rename of this config surface has * one place to change). * * CFG-27 — **the tool is gated whenever it is enabled, at every rung including `bypass`.** CFG-26 * used to leave it UNGATED under `bypass` outside interactive `code`, which the ladder cannot * afford: §2.5 makes the declared **deny list the one check `bypass` keeps**, and a deny entry can * only fire if the call reaches `GthAgentRunner.decideToolApproval` — an ungated call never does. * Gating unconditionally also keeps the rung switchable mid-session (`/approvals `), since a * tool wired without the interrupt cannot be re-gated without rebuilding the agent. * * What each rung then does is decided in `decideToolApproval`, not here: * • `bypass` — deny list, then approve without prompting or rating. * • `manual`/`write` — deny list, allow-list, else escalate to the human. * • `assisted`/`auto` — deny list, allow-list, then the rater. * * **This decides the SHELL's gating only, and it is not the whole gated set.** With the shell tool * disabled — or on a non-dev-tools command (chat/api/…) — nothing about the shell is gated and * nothing is announced, but at `manual` and `write` {@link resolveGatedToolNames} still gates * every bound tool the rung does not auto-grant, so an MCP call in a plain `chat` session is * escalated there. Read `gateShell` as "does the shell need the interrupt", never as "is the * interrupt needed at all". * * Shell enablement itself is resolved through {@link getEffectiveDevToolsConfig} + * {@link isShellToolEnabled}, so the gate stays in lockstep with where `GthDevToolkit` actually * emits the tool; the posture comes from {@link resolveApprovals}, so this and the runner can * never disagree about which rung is in force. */ export declare function resolveShellApprovalGate(config: Pick | undefined, command: GthCommand | undefined): ShellApprovalGateDecision; /** * EXT-195 — which of the three path/shell/commit notes the resolved toolset earns. * * The three do not share one predicate. The cwd note is about the path namespace, and its own * text says both the filesystem tools and `run_shell_command` operate on those same real paths, * so it is earned by the shell OR by any filesystem capability other than `'none'`. The OS/shell * note and the commit note describe `run_shell_command` (git commit rides on that tool), so both * are earned only when that tool is registered. * * Shell enablement is the SAME pair {@link resolveShellApprovalGate} uses * ({@link getEffectiveDevToolsConfig} + {@link isShellToolEnabled}), so the prompt stays in * lockstep with where the shell tool is actually emitted. `filesystem` is read off the config the * caller already merged — the agent passes `this.config`, the command-merged effective config the * tool resolver sees. One function, so a second backend cannot inherit half of the rule. */ export declare function resolveSystemPromptNoteGates(config: Pick | undefined, command: GthCommand | undefined): { cwd: boolean; osShell: boolean; commit: boolean; }; export {};