/** * Persistence for `always`-scoped approvals — the package's only mutable state outside the ledger. * * DESIGN NOTE, because it is easy to get backwards: this file is a CONVENIENCE CACHE, not a security * control. The security decision was already made by a human at the moment of approval. So a failure here * must never fail the work — an unwritable file downgrades the approval to session scope (see the boolean * return of `saveApproval`), and an unreadable one simply grants nothing. * * Read on demand, never cached at session start, so **a revoke takes effect at the next gate check** — * including a revoke performed from another session while this one is running. * * That sentence used to read *"takes effect immediately"* and claimed two different things, one of which was * false and one of which is impossible: * * - **False, and fixed (R-49).** Every write is load → modify → write and it was unlocked, so a save could * restore an entry another session had just revoked. Writes now hold the same file lock the ledger uses * (`src/governance/file-lock.ts`, `underLock` below). * - **Impossible, and stated rather than fixed.** A spawn whose gate check has already passed is not * retracted by a revoke arriving microseconds later. No lock closes that: the read has to finish before * the spawn starts, so there is always an instant where the decision is made and the process is not yet * running. Inherent to revoking anything, not a gap in this one. * * Reads deliberately take no lock. A read that loses a race sees the previous state, which is exactly what * "at the next gate check" already means. * * **One file per project** (ADR-0020), and **no model-authored text, ever** (ADR-0021) — see `approvalsPath` * and `sanitise` for why each of those is a decision rather than a detail. * * Pruning is deliberately lazy: `loadApprovals` never writes, so a read is a read. Invalid entries are * dropped from the file on the next `saveApproval` or `revokeApproval`. */ import { type ApprovalEntry, type EntryVerdict, type SubjectSnapshot } from "../kernel/approval.ts"; export interface DroppedApproval { key: string; entry: ApprovalEntry; verdict: EntryVerdict; } /** * Look up a subject's current ceiling AND body digest; null when the subject no longer exists. * * One snapshot rather than two callbacks (ADR-0019) — see `SubjectSnapshot`. Was `CeilingLookup`, which * could only ever answer half the question `entryVerdict` needs to ask. */ export type SubjectLookup = (subject: string) => SubjectSnapshot | null; /** * Where persisted approvals live — **outside the governed workspace** (ADR-0014). * * It used to be `/.pi/grants-approvals.json`, which was self-defeating in this package's own * recommended configuration: `PI_DADDY_GATED=tool:write` means *"may use write, may not pass it down * without a human"*, and **a session that may use `write` can write the approvals file**. A reviewer * demonstrated it end to end, including authoring a matching agent-type file so `grantAtApproval` * compared equal — no dialog, and a ledger line reading `approvalSource: "persisted"`, indistinguishable * from a real human approval. * * A narrowed child does not hold write access to the user's home directory, so it cannot forge an entry * here. Each entry still carries its own `cwd` and `entryVerdict` still checks it — that check exists for * R-27 and refuses an entry copied between checkouts or machines, which per-project files do not make * redundant. * * **This does not defend against a child holding `bash`** — see ADR-0012, which accepts that such a * child can escape governance entirely. The point of this change is to close the *self-defeating* case, * not to claim a boundary the package does not have. * * **ONE FILE PER PROJECT since 0.11.0 (ADR-0020).** It was one shared document keyed only by * `capability@subject`, which produced four defects in eight lines — approving in one checkout deleted * another's entries (R-41), `revoke --all` cleared every project (R-43), two concurrent writes lost both * (R-42), and an unlocked read-modify-write could resurrect a revoked entry (R-49, fixed in 0.13.0). The * unfixable one was the keyspace: two checkouts holding definitions of the same name — `review`, `deploy`, * i.e. what happens the moment an operator reuses their own conventions — could not both hold an approval, * so they took turns indefinitely. Per-project files make the collision **inexpressible** rather than * handled, and `revoke --all` cannot name another project's file. * * The `cwd` is hashed as well as named: the basename keeps the file legible to a human reading the * directory, and the hash is what makes it unambiguous, since two checkouts can share a basename. * * **It took a `cwd` parameter and ignored it until 0.10.2**, which was not a harmless vestige: the unit * suite passed a `mkdtemp` directory to it, reasonably believed the result was hermetic, and spent every * `npm test` rewriting and clearing the developer's real store in `$HOME` (R-40). That was invisible while * the store was unwritable and became destructive the day ADR-0019 made it reachable. The parameter is now * real and required, which is the opposite failure mode: forgetting it is a type error. */ export declare function approvalsPath(cwd: string): string; /** * The shared single-file store, so it can be REPORTED rather than read (ADR-0020). * * Deliberately not migrated. Splitting it by each entry's own `cwd` would be mechanical and lossless — the * trust root is unchanged, unlike ADR-0014's move out of the workspace — but it is code that runs once, is * exercised on exactly one input per machine, and lives in the layer with nine recorded defects. Re-approving * costs a click; a migration bug costs a silently wrong approval. */ export declare function sharedApprovalsPath(): string; /** * The old in-workspace location, so it can be REPORTED rather than read. * * Deliberately not migrated. Importing a legacy file would import exactly the entries whose * trustworthiness this change exists to remove — a forged approval would survive the fix that was * supposed to stop it. The extension names the file and ignores it; re-approving is a few keystrokes and * the only honest path. */ export declare function legacyApprovalsPath(cwd: string): string; export interface LoadApprovalsInput { cwd: string; now: Date; snapshotOf: SubjectLookup; } /** Load the approvals valid HERE and NOW, plus the ones that were dropped and why. */ export declare function loadApprovals(input: LoadApprovalsInput): Promise<{ valid: Map; dropped: DroppedApproval[]; }>; /** * Persist one approval, pruning anything THIS session can see has become invalid. * * Returns false when the write failed. The caller must then downgrade to session scope and warn — NOT * refuse the delegation. The human already said yes; refusing work because a cache could not be written * would be failing closed on the wrong thing. */ export declare function saveApproval(cwd: string, key: string, entry: ApprovalEntry, snapshotOf: SubjectLookup, now: Date): Promise; /** * Remove one approval, pruning any entries that have since become invalid. * * Like `saveApproval`, this filters invalid entries so a revoke takes the opportunity to clean up stale * ones — the lazy-pruning policy applies to both write paths. * * **Three outcomes, not two (R-49).** It returned a boolean, and the caller printed * *"no persisted approval named X"* for false — which was a **false statement** whenever the cause was a * failed write. An operator told there is nothing to revoke, while the approval they are revoking survives, * has been told the opposite of the truth about a security control. `"absent"` and `"failed"` are different * facts and now say so. */ /** * **Four, and the fourth is the first fix's own smaller copy of R-61.** `failed` asserts the approval is * still in effect, which is verified: we found the entry and could not remove it. A **lock timeout happens * before the load**, so nothing was ever looked at — reporting `failed` there asserted a fact about an entry * that may not exist, which is R-61's shape at lower severity. It errs alarming rather than reassuring, so * it is the safe direction to be wrong in; that is a reason to rank it low, not a reason to keep it. */ export type RevokeOutcome = "revoked" | "absent" | "failed" | "busy"; export declare function revokeApproval(cwd: string, key: string, snapshotOf: SubjectLookup, now: Date): Promise; /** * Clear every approval **for this directory**. Returns false if the write failed. * * Scoped rather than global, and the old behaviour was the surprising one: `/grants revoke --all` wrote an * empty file, so revoking in one project silently revoked every other project's approvals too. An operator * running it in one checkout is answering for that checkout — there is no interface for "and everywhere * else", and it should not be the default reading of a command that names neither. */ export declare function revokeAll(cwd: string): Promise; //# sourceMappingURL=approval-store.d.ts.map