import type { ApprovalEntry, McpToolApprovalEntry, ShellApprovalEntry, ToolApprovalEntry, ToolAnnotationHint } from '#src/config/shell-policy.js'; import type { ShellApprovalGateNotice } from '#src/config/shell-policy.js'; import { type EffectiveToolAnnotations, type McpToolApprovalSubject, type ToolApprovalSubject } from '#src/core/approvals/matcher.js'; import { type ToolApprovalScope } from '#src/core/types.js'; /** * The scopes a grant can be REMEMBERED at. `once` is absent by construction: it persists nothing, * so it never becomes a grant. */ export type ApprovalGrantScope = Exclude; /** * §3 — one thing the human granted: the entry that will be matched, plus what the approvals UI * must be able to show about it (*what* was granted, *when*, and *at what scope*). * * The metadata is display only. The `entry` is the whole of what decides. */ export interface ApprovalGrant { /** §3.1 — the entry, in the one grammar. */ entry: ApprovalEntry; /** ISO-8601 instant the grant was made. */ grantedAt: string; /** `session` for the life of this runner instance, `always` for the persisted store. */ scope: ApprovalGrantScope; /** * §4.7.4 — **the effective annotation set (§4.7.1) this tool grant was made under**, so a later * `tools/list` that weakens it can be seen to have done so ({@link annotationWeakenings}) and the * approvals UI can show what the user believed they were granting. * * Absent on a `shell` grant, which has no annotations, and on a tool grant restored from a file * written before this field existed — in both cases there is nothing to compare, so the grant * simply stands. * * **A private copy, always**, made on the way into the store: an effective set is something the * source may hand out afresh or a caller may hold, and a snapshot that aliased either would let * one grant's record be rewritten by something outside it — which is the same class of bug as a * source returning the shared fail-closed constant instead of a copy. */ annotations?: EffectiveToolAnnotations; } /** * §3.1/§6 — the entry the escalation menu writes for a shell command: **that command, and only * that command**. * * The single place a menu grant's entry is built, so the line the prompt shows the human ("this is * what will be stored") and the line that lands in the store cannot drift apart. */ export declare function shellGrantEntry(command: string): ShellApprovalEntry; /** * §4.7.4/§6 — the entry the escalation menu writes for a **tool** call: the tool's identity, plus * the host where the call carries one. * * The counterpart of {@link shellGrantEntry} and the single place a tool grant's entry is built, so * the line the prompt shows the human and the line that lands in the store cannot drift apart. * * **Identity, never arguments.** A grant recording a full argument signature would never match a * second time — not a narrower grant, a useless one. That is knowingly broader than the shell's * exact-command grant, with one bound: on the shell path §4.6's escape carries the host inside the * command string, so it is host-scoped by construction, while a tool-identity-only grant on a fetch * tool would be every host, forever. * * `server` on an `mcpTool` grant is the user's own `mcpServers` config key (§4.7.5). Nothing a * server declares about its own name participates, and a grant for one server's tool can never be * claimed by another server's same-named tool because the other server sits under a different key. * * **Returns `null` for a call whose server could not be resolved.** {@link UNRESOLVED_MCP_SERVER} * is the empty string, which `server` (`z.string().min(1)`) cannot hold: such an entry would be * written to the file and then silently dropped by the grammar's own validator on the next read, so * the human would be told their approval was remembered when it was not. A call nobody can attribute * to a server is not one anything can remember. */ export declare function toolGrantEntry(subject: ToolApprovalSubject | McpToolApprovalSubject): ToolApprovalEntry | McpToolApprovalEntry | null; /** * §4.7.4 — which hints moved in the weakening direction between the set a grant was made under and * the set that holds now. Empty means the grant still stands. * * **Only the four booleans are compared, so schema and description changes invalidate nothing** — * not by a rule that exempts them, but because a snapshot is four booleans and a description is not * one of them. Descriptions churn on every server release, and a grant that dissolved on churn * would teach users that grants are worthless. * * **An untrusted server's declaration change likewise invalidates nothing**, again by construction: * §4.7.1 makes an untrusted server's effective set the constant fail-closed default, so both sides * of this comparison are that constant however the server re-declares itself. Only a **trusted** * server can move an effective value — which is exactly where invalidation matters, since the * trusted server is the one whose rug-pull would otherwise ride an existing grant. */ export declare function annotationWeakenings(snapshot: EffectiveToolAnnotations, current: EffectiveToolAnnotations): ToolAnnotationHint[]; /** * §4.7.1/§4.7.4 — **can WITHDRAWING trust in this hint weaken an effective set?** * * Withdrawing trust pushes a hint back to its MCP fail-closed default, and every weakening move * *ends* at that default, so the answer does not depend on what a server declared: it is exactly * whether the move (not-the-default → the default) is one of `WEAKENING_MOVES`. Three of the * four hints answer yes — `readOnlyHint`, `openWorldHint` and `destructiveHint`, whose fail-closed * default is `true`, so a server whose `destructiveHint: false` was believed becomes destructive * again the moment it is not. `idempotentHint` is the only one that answers no, because its entry * in the table is `NO_WEAKENING_MOVE`. * * **It asks {@link annotationWeakenings} rather than restating the table.** A second statement of * which moves weaken is how a warning comes to describe a rule the gate no longer has — which * matters most here, since this decides whether the human is told their saved approvals are about * to be withdrawn. */ export declare function trustWithdrawalWeakens(hint: ToolAnnotationHint): boolean; /** * §4.7.4 — the notice a weakened grant is removed with. **It names the tool, the server and the * hint that moved**, because the human approved a tool *as annotated*: a tool that re-annotates * itself into a more dangerous shape is a different proposition wearing the same name, and a notice * that did not say which name changed would be indistinguishable from the gate malfunctioning. * * It takes the **entry** rather than the call's subject, so it describes the grant that was actually * withdrawn — including its host bound, where it had one. One call may withdraw both a host-bound * grant and a tool-only one, and two notices that could not be told apart would be worse than one. * The entry is rendered by {@link describeApprovalEntry}, the same one-liner every other provenance * message uses. */ export declare function describeWeakenedGrant(entry: ApprovalEntry, weakened: readonly ToolAnnotationHint[], snapshot: EffectiveToolAnnotations, current: EffectiveToolAnnotations): string; /** * Read one persisted grant defensively. The **entry is validated by the config grammar's own * schema** — one grammar, one validator — so a malformed entry is dropped here and can never reach * the matcher. Timestamp and scope are coerced rather than validated: they decide nothing, and * discarding a real grant over a missing timestamp would cost an approval the human already gave. * The annotation snapshot is the exception ({@link readAnnotationSnapshot}), because it decides. */ /** * Read one stored grant, or `null` when the value is not one: no readable entry, or an annotation * snapshot that is present and malformed (see `readAnnotationSnapshot`). * * Exported because the conversation-grants document a resume restores * (`core/approvals/conversationGrants.ts`) stores grants in this same shape, and one reader for * both files is what keeps the two from accepting different things. */ export declare function readGrant(value: unknown, fallbackTime: string): ApprovalGrant | null; /** * A set of grants with entry-identity semantics. Pure data + membership: it holds what the human * granted and answers *is this the same grant*, never *does this grant cover that command*. * * **What it holds is private to it in BOTH directions.** A grant record is copied on the way in, so * nothing a caller kept can be written through into what the gate matches against; and no accessor * hands back a record a consumer can write through either. Making that structural here rather than * a habit at each call site is the whole of the guarantee: a consumer reading grants to display, * log or sort them is not thinking about the store, and that is exactly the consumer that would * otherwise rewrite it by accident. * * **The out direction is enforced two ways, and the difference is the hot path** ([[EXT-76]]): * * - {@link find} and {@link list} hand back **copies**. They serve displays, the persisted file and * the conversation record — called when a human asks or a grant changes, never per call — so a * copy costs nothing that matters, and it leaves those consumers holding an ordinary mutable * record, which is what a public accessor of this package promises them. * - {@link entries} hands back the stored entries themselves, **frozen**. The matcher reads it on * *every gated call*, so copying there would allocate a record per grant per call for a consumer * that only ever reads; a freeze is the same protection at no per-call cost, and it is the louder * one — a stray write throws instead of being quietly absorbed by a copy nobody looks at again. * * Freezing everything and copying nowhere would be cheaper still, and is rejected because * {@link find} and {@link list} feed public accessors whose records are handed to other packages: * a frozen record there is an observable contract change for every consumer of them, bought for a * saving on paths that are not hot. */ export declare class ApprovalGrantStore { /** Each one deep-copied and frozen by {@link add}; only this array is ever mutated. */ private readonly grants; constructor(initial?: readonly ApprovalGrant[]); /** * Add a grant. Returns whether it was new — an identical entry is not stored twice. * * **The whole record is copied on the way in**, so what the store holds is private to it whatever * the caller passed: the {@link ApprovalGrant.annotations} snapshot may be a live object the * caller keeps, or (were a source ever to regress) the shared fail-closed constant; the `entry` is * shared by construction, since the `always` path hands the same one to this store and to the * persisted store. A store aliasing any of them would let one grant's record be rewritten from * outside it. Copying here rather than at each call site makes that structural instead of a habit * every caller must keep. * * The copy is then frozen, which is what {@link entries} rests on — and it is the copy that is * frozen, never the caller's object, so nothing outside this store becomes read-only. */ add(grant: ApprovalGrant): boolean; /** * A private copy of the grant stored under this entry's identity, or `undefined`. * * **Identity, never a match decision** — the same de-duplication question {@link add} asks. It * answers *"is this the same grant"*, and whether a grant covers a call remains * `resolveApprovalRules`'s alone. * * A **copy** rather than the stored record: what this returns reaches a display through public * accessors, and a consumer that reads a grant in order to render it is the one least likely to * be thinking about whose object it holds. */ find(entry: ApprovalEntry): ApprovalGrant | undefined; /** * Drop the grant stored under this entry's identity. Returns whether one was there. * * §4.7.4's invalidation is a **removal** rather than a skip, and that is load-bearing: {@link add} * de-duplicates by entry identity, so a grant left in place while being ignored would silently * swallow the human's re-approval of the same tool — the grant would appear to be re-made and the * stale snapshot would keep invalidating it. */ remove(entry: ApprovalEntry): boolean; /** * Every grant, in the order they were made — a fresh array of private copies, so neither the list * nor the records in it are the store's. See {@link find} for why this side copies rather than * freezes. */ list(): ApprovalGrant[]; /** * Just the entries, for handing to the matcher as one of its rule lists. * * **The stored entries themselves, frozen** — the one accessor that does not copy, because it is * the one on the hot path: the matcher reads it on every gated call, and its rule lists are * read-only by type and by use. A write to one of these throws; a consumer that needs to change * an entry wants a grant record from {@link list} and its own copy of it. */ entries(): ApprovalEntry[]; /** How many grants are held. */ size(): number; } /** Optional seams for {@link PersistedApprovalGrants}. */ export interface PersistedApprovalGrantsOptions { /** * Where the v1→v2 migration notice goes — the established {@link ShellApprovalGateNotice} shape * the runner forwards to `statusUpdate`. Absent means the migration happens silently, which is * why the runner always passes one. */ onNotice?: (notice: ShellApprovalGateNotice) => void; /** * Whether a file holding the v1 `prefixes` array is migrated to `exact` entries. **True by * default, and set false by the deny store** ([[EXT-107]]), for two independent reasons: * * - **`prefixes` was never a format on the deny side.** A deny file carrying one was hand-written * by analogy, and turning a guess into standing refusals — with a notice worded for saved * *approvals*, which is the only notice there is — would report a migration that did not * describe what happened. * - **A migration WRITES.** The deny store is read at every rung, `bypass` included, because a * refusal is resolved before the bypass return; the allow store is not read there precisely so * that a session with the gate switched off never rewrites the project's grant file. Leaving * this on would give the deny store the one load path that writes. */ legacyPrefixMigration?: boolean; /** * [[EXT-143]] — **what this file holds, in the noun a user reads** in a load-failure notice * (`unreadableFileNotice`, `skippedEntriesNotice`). The allow store passes * `'approvals'` and the deny store `'refusals'`. * * It is a word, not a behaviour, and that is what keeps the class list-agnostic: nothing here * reads it, compares against it or decides by it. A store still never learns which list it is — * the file path decides that and the runner decides which list it hands to the matcher — but a * message that could not name what was lost would be a message the reader cannot act on, since * the two files fail in opposite directions. * * Omitting it is safe: `savedNoun` then says something true of either file rather than * guessing. What is NOT safe is defaulting it to one side, so it does not. */ holds?: 'approvals' | 'refusals'; } /** * The persisted (`always`) grant store, backed by a JSON file whose path is injected (the runner * resolves it via fileUtils → `.gsloth/.gsloth-settings/shell-allowlist.json` for approvals and * `…/shell-denylist.json` for refusals) so tests can point it at a temp dir. * * **One class, two files.** Which list a store's entries belong to is the caller's question, not * this class's: it holds {@link ApprovalGrant} records and writes them back. The only thing either * side configures is {@link PersistedApprovalGrantsOptions.legacyPrefixMigration}, which the deny * store turns off. * * ## The v1 migration, and the direction it goes * * The shipped v1 file held `prefixes: string[]` — classified command PREFIXES, so a stored * `npm test` also auto-approved `npm test --watch`. Each prefix migrates to an **`exact` entry for * the same string**, with **one** notice naming the file. * * That narrows what the file grants, on purpose: a v1 prefix was broader than what the human was * actually shown when they answered the prompt, so narrowing it costs at worst a re-prompt and * never an execution — the direction every ambiguity in this design resolves. The migrated file is * rewritten as v2 immediately, so the notice is a one-time event rather than a per-session one; a * write that fails (read-only checkout) is not fatal — the grants are in force for this session and * the notice simply appears again next time. */ export declare class PersistedApprovalGrants { private readonly store; private readonly filePath; private readonly onNotice; private readonly holds; /** * [[EXT-144]] — **what the load found in the file**, which decides whether it may ever be * rewritten ({@link StoreReadState}). Anything but `readable` is a file whose load failed, exactly * the outcomes `unreadableFileNotice` reports, and none of them may be written over. * * **An entry-level loss is deliberately not one of them.** A file whose `grants` array parsed but * held one malformed member is a file this version *can* read: the rest of it is in force, which * `skippedEntriesNotice` states to the user as a promise, and blocking every future save * over one cosmetic typo would disable a working feature to protect an entry the reader has * already been pointed at — by position and quoted text for the first * {@link SKIPPED_ENTRIES_NAMED}, and by a count of the rest beyond that. */ private readonly readState; /** * [[EXT-151]] — the top-level keys the read found and this version does not use, written back on * every rewrite so a save cannot delete the parts of a user's file we do not recognise. */ private readonly preserved; /** * Whether the file is believed to hold what this store holds — true after a read that succeeded * or a write that landed, false after one that was refused or threw. * * It exists so {@link add} can answer honestly for an entry it did not have to write: "already * held" is only "already in the file" if the file ever received it, and on a read-only checkout it * did not. */ private inSync; constructor(filePath: string, options?: PersistedApprovalGrantsOptions); /** * Read the file, and **report anything it lost on the way** ([[EXT-143]]). * * Every outcome that drops something a human saved reaches {@link * PersistedApprovalGrantsOptions.onNotice}: an unreadable or unparseable file, a file whose shape * this version does not recognise — a v1 `prefixes` file on the deny side, where the migration is * deliberately off, is one — and any individual entry the grammar rejects. **Silence is reserved * for the outcomes that lost nothing**, of which there are three: a file that is not there, one * that reads cleanly, and one whose entry list is absent or empty ({@link holdsSavedEntries}). * * The recovery is unchanged and deliberately unchanged: a failure here yields fewer grants rather * than throwing. **What that degrades to is not the same on the two sides**, which is why the * notice describes the loss and not an outcome — a lost `always` approval means the human is asked * again, while a lost `always` refusal means nothing refuses, and at `bypass` the call simply runs. */ private static load; /** * Each v1 prefix → an `exact` entry for the same string, with ONE notice naming the file. * * **A prefix this cannot migrate is REMOVED from the file, and is named** ([[EXT-151]]). A member * that is not a string, or that normalizes to nothing, becomes no entry — and the rewrite below is * what makes that permanent. That is a real loss on a path where nothing else reports one: the * skipped-entries notice belongs to the v2 reader and never fires here. It is reported at * {@link StatusLevel.ERROR} on the same filterability axis every other loss on this seam uses. * * **And the migration notice no longer claims that nothing was removed while removing something.** * That sentence is kept for the case where it is true — which is the ordinary case, and where the * reassurance is worth having — and dropped where a member went. */ private static migrateFromV1; /** Every grant, each a private copy — {@link ApprovalGrantStore.list}. */ list(): ApprovalGrant[]; /** * Just the entries, for handing to the matcher as one of its rule lists. Frozen, and for the * reason {@link ApprovalGrantStore.entries} gives — this is the same hot path one wrapper down. */ entries(): ApprovalEntry[]; /** How many grants are persisted. */ size(): number; /** * [[EXT-144]] — **may this store write its file at all?** False when the load failed, in which * case every write is refused. * * Public because the caller has to know *before* it builds the record it is about to hand over: * a grant is stamped with the scope it actually got, the runner's in-memory stores can hold the * very object they are passed, and a scope corrected after the fact would be corrected inside * somebody else's store. Asking first is what lets an answer that cannot be written down be * recorded as the session-only thing it is. * * **It answers about the FILE being readable, never about a write succeeding.** A store whose * path cannot be written — a read-only checkout — still answers true here, because nothing was * lost by reading it and the next write may well land. */ canPersist(): boolean; /** * Add a grant and persist the whole store. A duplicate entry rewrites nothing. * * **Returns whether the grant is now recorded in the file** ([[EXT-144]]), which is not the same * question as whether it is in force — it is in force either way, held here and in the runner's * session store. `false` says only that a restart will not find it. */ add(grant: ApprovalGrant): boolean; /** A private copy of the grant held under this entry's identity, or `undefined`. */ find(entry: ApprovalEntry): ApprovalGrant | undefined; /** * Drop the grant held under this entry's identity and rewrite the file. * * **Returns whether the deletion reached the FILE** ([[EXT-149]]) — the mirror of what {@link add} * answers, and not the same question as whether the entry is still in force here. The in-memory * removal happens either way and is what lifts the entry for this session; `false` says only that * a restart will find it again. It used to return `true` after a write that threw, which is how * the `/approvals` lift came to report a deletion that had not happened. * * `false` is also the answer when there was no such grant. The two are distinguishable with * {@link find} beforehand, and the caller that reports to a user has already established the entry * was there — it is offering to lift something it just listed. * * The write is what makes §4.7.4's invalidation a one-time event: a removal held only in memory * would be undone by the next session reloading the same stale snapshot, so the user would be * told their grant had been withdrawn once per session, forever. * * **The [[EXT-144]] refusal cannot strand a removal half-done, and by construction rather than by * a check here:** a store that could not read its file is empty — the load recovered nothing, and * {@link add} takes back what it could not write — so there is never a grant to remove, and this * returns before reaching the write. If that invariant is ever broken, restore it rather than * teaching this method to unwind. */ remove(entry: ApprovalEntry): boolean; /** * Write the file, and **report whether the write landed**. Never throws: the grants are already * in force for this session, and a read-only checkout must not end a run over a bookkeeping * write. * * **Never throws is not never says.** [[EXT-149]] — a swallowed throw left the surfaces that had * already promised the user something as the last word on a file that never changed, so a failed * write is reported (`failedWriteNotice`) in the words of whichever {@link WritePurpose} * asked for it. * * ## [[EXT-144]] — it refuses to write over a file it could not read * * This is a WHOLE-FILE rewrite from what the store holds, and a store whose load failed holds * nothing. So without this guard one saved answer replaces every entry in the file with itself: * a trailing comma — the characteristic failure of a file people hand-edit and commit — becomes * unrecoverable loss at the next prompt, with no backup and nothing on disk to go back to. * * **The deny side is why it refuses rather than saving a copy first.** A user whose refusals have * silently stopped applying reaches for *always reject*, and that keystroke is what would make the * loss permanent. Refusing leaves the file exactly as they left it: fix the comma and all of it * comes back, with nothing to reconcile and nothing needing to have been copied in advance. * * **A sibling `.corrupt` copy was the alternative, and it is worse in this system**, because it * would leave the user holding a merge they cannot perform — the live file with the one entry they * just answered, a copy beside it with the twenty they had, and no tool to combine them. It would * also write a second file into a directory people commit, and it would make the file parse again, * so the load-time error that is the user's only signal would go quiet while nineteen refusals * stayed out of force. And its guarantee is conditional on a write that can itself fail, which is * the same read-only checkout this method already has to survive. * * The cost is a re-prompt and nothing else, which is the direction every ambiguity in this design * resolves — and it is the same degradation the load side already makes ([[EXT-107]]: a store that * cannot be read means `always` becomes `session`), now applied consistently to the write. */ private tryPersist; }