pi-daddy 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/dist/approval-prompt.d.ts +116 -0
  5. package/dist/approval-prompt.d.ts.map +1 -0
  6. package/dist/approval-prompt.js +124 -0
  7. package/dist/approval-prompt.js.map +1 -0
  8. package/dist/approval-store.d.ts +149 -0
  9. package/dist/approval-store.d.ts.map +1 -0
  10. package/dist/approval-store.js +311 -0
  11. package/dist/approval-store.js.map +1 -0
  12. package/dist/approval.d.ts +221 -0
  13. package/dist/approval.d.ts.map +1 -0
  14. package/dist/approval.js +244 -0
  15. package/dist/approval.js.map +1 -0
  16. package/dist/capabilities.d.ts +31 -0
  17. package/dist/capabilities.d.ts.map +1 -0
  18. package/dist/capabilities.js +45 -0
  19. package/dist/capabilities.js.map +1 -0
  20. package/dist/catalog.d.ts +81 -0
  21. package/dist/catalog.d.ts.map +1 -0
  22. package/dist/catalog.js +164 -0
  23. package/dist/catalog.js.map +1 -0
  24. package/dist/definitions.d.ts +101 -0
  25. package/dist/definitions.d.ts.map +1 -0
  26. package/dist/definitions.js +182 -0
  27. package/dist/definitions.js.map +1 -0
  28. package/dist/delegate.d.ts +133 -0
  29. package/dist/delegate.d.ts.map +1 -0
  30. package/dist/delegate.js +261 -0
  31. package/dist/delegate.js.map +1 -0
  32. package/dist/fanout.d.ts +62 -0
  33. package/dist/fanout.d.ts.map +1 -0
  34. package/dist/fanout.js +84 -0
  35. package/dist/fanout.js.map +1 -0
  36. package/dist/file-lock.d.ts +54 -0
  37. package/dist/file-lock.d.ts.map +1 -0
  38. package/dist/file-lock.js +142 -0
  39. package/dist/file-lock.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +7 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ledger-report.d.ts +106 -0
  45. package/dist/ledger-report.d.ts.map +1 -0
  46. package/dist/ledger-report.js +157 -0
  47. package/dist/ledger-report.js.map +1 -0
  48. package/dist/ledger.d.ts +139 -0
  49. package/dist/ledger.d.ts.map +1 -0
  50. package/dist/ledger.js +94 -0
  51. package/dist/ledger.js.map +1 -0
  52. package/dist/pane-reaper.d.ts +49 -0
  53. package/dist/pane-reaper.d.ts.map +1 -0
  54. package/dist/pane-reaper.js +120 -0
  55. package/dist/pane-reaper.js.map +1 -0
  56. package/dist/pi-tools.d.ts +20 -0
  57. package/dist/pi-tools.d.ts.map +1 -0
  58. package/dist/pi-tools.js +21 -0
  59. package/dist/pi-tools.js.map +1 -0
  60. package/dist/propagation.d.ts +185 -0
  61. package/dist/propagation.d.ts.map +1 -0
  62. package/dist/propagation.js +255 -0
  63. package/dist/propagation.js.map +1 -0
  64. package/dist/resolve.d.ts +110 -0
  65. package/dist/resolve.d.ts.map +1 -0
  66. package/dist/resolve.js +181 -0
  67. package/dist/resolve.js.map +1 -0
  68. package/dist/run-child.d.ts +50 -0
  69. package/dist/run-child.d.ts.map +1 -0
  70. package/dist/run-child.js +109 -0
  71. package/dist/run-child.js.map +1 -0
  72. package/dist/run-herdr.d.ts +91 -0
  73. package/dist/run-herdr.d.ts.map +1 -0
  74. package/dist/run-herdr.js +306 -0
  75. package/dist/run-herdr.js.map +1 -0
  76. package/dist/spawn.d.ts +60 -0
  77. package/dist/spawn.d.ts.map +1 -0
  78. package/dist/spawn.js +116 -0
  79. package/dist/spawn.js.map +1 -0
  80. package/extensions/approvals.ts +285 -0
  81. package/extensions/delegation.ts +237 -0
  82. package/extensions/grants-command.ts +310 -0
  83. package/extensions/grants.ts +346 -0
  84. package/extensions/run-delegation.ts +270 -0
  85. package/extensions/session.ts +234 -0
  86. package/package.json +122 -0
  87. package/src/approval-prompt.ts +222 -0
  88. package/src/approval-store.ts +383 -0
  89. package/src/approval.ts +364 -0
  90. package/src/capabilities.ts +51 -0
  91. package/src/catalog.ts +186 -0
  92. package/src/definitions.ts +233 -0
  93. package/src/delegate.ts +383 -0
  94. package/src/fanout.ts +96 -0
  95. package/src/file-lock.ts +142 -0
  96. package/src/index.ts +59 -0
  97. package/src/ledger-report.ts +227 -0
  98. package/src/ledger.ts +214 -0
  99. package/src/pane-reaper.ts +132 -0
  100. package/src/pi-tools.ts +25 -0
  101. package/src/propagation.ts +308 -0
  102. package/src/resolve.ts +235 -0
  103. package/src/run-child.ts +149 -0
  104. package/src/run-herdr.ts +357 -0
  105. package/src/spawn.ts +156 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The approval dialog — the only place a human enters the capability-governance path.
3
+ *
4
+ * Kept out of `extensions/grants.ts` deliberately: that file is wiring only, and this holds decisions
5
+ * (what to offer, what a dismissal means, what happens with nobody watching) that must be testable
6
+ * without pi. `ApprovalUI` is the minimal slice of pi's `ExtensionUIContext` needed here, so a test can
7
+ * supply a plain object.
8
+ *
9
+ * Everything here denies on uncertainty. pi already agrees: in non-interactive modes it installs a
10
+ * no-op UI context whose `select` resolves undefined, so even a missed `hasUI` check would deny.
11
+ */
12
+ import { type ApprovalPath, type ApprovalScope } from "./approval.ts";
13
+ import type { Capability } from "./resolve.ts";
14
+ /** The slice of pi's `ExtensionUIContext` this module needs. */
15
+ export interface ApprovalUI {
16
+ select(title: string, options: string[], opts?: {
17
+ timeout?: number;
18
+ signal?: AbortSignal;
19
+ }): Promise<string | undefined>;
20
+ notify(message: string, type?: "info" | "warning" | "error"): void;
21
+ }
22
+ export declare const DENY_LABEL = "Deny";
23
+ export declare const SCOPE_LABELS: Record<ApprovalScope, string>;
24
+ export interface PromptRequest {
25
+ capability: Capability;
26
+ /** Agent type name, or `DELEGATE_SUBJECT` on the delegate path. */
27
+ subject: string;
28
+ path: ApprovalPath;
29
+ /** Shown to the human for context. Never part of a key. */
30
+ task?: string;
31
+ signal?: AbortSignal;
32
+ }
33
+ /**
34
+ * Which of the five outcomes a request produced.
35
+ *
36
+ * `scope: null` alone conflates four different kinds of no — a caller that needs to know whether a
37
+ * PERSON actually declined (as opposed to nobody being there to ask, a dismissal, or the dialog itself
38
+ * breaking) cannot recover that from `scope` or from parsing `reason`. Only "declined" means a human said
39
+ * no; the other three are absence-of-signal, not a signal.
40
+ */
41
+ export type PromptOutcomeKind = "granted" | "no-ui" | "declined" | "dismissed" | "error";
42
+ export interface PromptOutcome {
43
+ /** The scope the human chose, or null for any form of no. */
44
+ scope: ApprovalScope | null;
45
+ /** Which of the five outcomes this was. Set on every return path. */
46
+ kind: PromptOutcomeKind;
47
+ /** Why, when the answer was no. */
48
+ reason?: string;
49
+ /**
50
+ * True when this caller RODE another caller's answer rather than being asked (R-66).
51
+ *
52
+ * The single-flight queue shares any non-`once` outcome, which is right — a human approving
53
+ * `tool:bash` *for this session* authorised the capability, not one child. What is not right is
54
+ * recording the riders as though each had faced a dialog: the ledger's whole job is answering "did a
55
+ * human authorise this?", and a fan-out of eight wrote eight lines claiming a prompt where there was
56
+ * one. The rider's honest source is `session`, and this flag is how the caller can tell.
57
+ */
58
+ joined?: boolean;
59
+ }
60
+ export interface ApprovalGateOptions {
61
+ ui: ApprovalUI;
62
+ /** pi's `ctx.hasUI` — false in print/json mode, and therefore in every governed child. */
63
+ hasUI: boolean;
64
+ /** pi's `ctx.mode`, quoted back in the refusal so an operator can see why. */
65
+ mode: string;
66
+ timeoutMs?: number;
67
+ }
68
+ export interface ApprovalGate {
69
+ request(request: PromptRequest): Promise<PromptOutcome>;
70
+ }
71
+ /**
72
+ * Read `PI_GRANTS_APPROVAL_TIMEOUT`, in SECONDS, into the milliseconds pi expects.
73
+ *
74
+ * `0` or an unparseable value means no timeout: waiting forever denies nothing, so it is the safe
75
+ * interpretation of a value we do not understand.
76
+ */
77
+ export declare function timeoutMsFromEnv(raw: string | undefined): number | undefined;
78
+ /**
79
+ * The single-flight queue: one dialog per approval key at a time.
80
+ *
81
+ * `delegate` sets no `executionMode`, so an orchestrator can fan out several children at once and two can
82
+ * hit the same gate simultaneously — otherwise two stacked dialogs asking the identical question.
83
+ *
84
+ * **R-29 — the key does two jobs, and only one of them may be shared.** `approvalKey` is
85
+ * `capability@subject`, and on the delegate path the subject is the constant `DELEGATE_SUBJECT`, which is
86
+ * deliberate: the only things naming a delegated child are the task and the tool list, both model-chosen,
87
+ * and a key the model controls is not a key (`approval.ts:24-32`). That reasoning governs *approval
88
+ * identity* — what a human said yes to, and what may be persisted — and it is right.
89
+ *
90
+ * It does **not** govern de-duplication. Sharing one dialog's outcome is correct for `session` and
91
+ * `always`, which are genuinely answers about the session or the project, and for a decline or an error,
92
+ * which answer everyone. It is **wrong for `once`**, which means *this spawn*. Measured before the fix:
93
+ * four concurrent delegations gating `tool:bash`, one dialog, one click of *Allow once* → four `granted`
94
+ * outcomes, with the human having seen only the first caller's task. That is a confused deputy, and it
95
+ * falsified ADR-0014's decided property that "`once` stops at the boundary".
96
+ */
97
+ export type InFlightApprovals = Map<string, Promise<PromptOutcome>>;
98
+ /**
99
+ * Build gates that SHARE one single-flight queue.
100
+ *
101
+ * This exists because of how the wiring actually calls in. The gate's options depend on the per-call
102
+ * `ExtensionContext` (`ui`, `hasUI`, `mode`), so the extension necessarily builds a gate per invocation —
103
+ * and a gate that owns its queue privately therefore de-duplicates nothing across invocations, which is
104
+ * exactly the case §6.1 exists for (two concurrent `delegate` calls are two separate invocations).
105
+ *
106
+ * The provider separates the two lifetimes: options stay per-call and fresh, the queue is created once and
107
+ * lives as long as the provider. Preferred over a module-level map because that would be shared by every
108
+ * gate in the process — including across tests — for no benefit.
109
+ */
110
+ export declare function createApprovalGateProvider(): (options: ApprovalGateOptions) => ApprovalGate;
111
+ /**
112
+ * @param inFlight optional shared single-flight queue — see `createApprovalGateProvider`. Defaults to one
113
+ * private to this gate, which only de-duplicates requests made through this same gate object.
114
+ */
115
+ export declare function createApprovalGate(options: ApprovalGateOptions, inFlight?: InFlightApprovals): ApprovalGate;
116
+ //# sourceMappingURL=approval-prompt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-prompt.d.ts","sourceRoot":"","sources":["../src/approval-prompt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAA8B,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAClG,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAE/C,gEAAgE;AAChE,MAAM,WAAW,UAAU;IACzB,MAAM,CACJ,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,EAAE,EACjB,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,GAChD,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IAC/B,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,IAAI,CAAC;CACpE;AAED,eAAO,MAAM,UAAU,SAAS,CAAC;AAEjC,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAItD,CAAC;AAEF,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,UAAU,CAAC;IACvB,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,YAAY,CAAC;IACnB,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,WAAW,GAAG,OAAO,CAAC;AAEzF,MAAM,WAAW,aAAa;IAC5B,6DAA6D;IAC7D,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IAC5B,qEAAqE;IACrE,IAAI,EAAE,iBAAiB,CAAC;IACxB,mCAAmC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,EAAE,EAAE,UAAU,CAAC;IACf,0FAA0F;IAC1F,KAAK,EAAE,OAAO,CAAC;IACf,8EAA8E;IAC9E,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;CACzD;AAID;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAK5E;AAMD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,MAAM,iBAAiB,GAAG,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,IAAI,CAAC,OAAO,EAAE,mBAAmB,KAAK,YAAY,CAG3F;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,GAAE,iBAA6B,GACtC,YAAY,CAwEd"}
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The approval dialog — the only place a human enters the capability-governance path.
3
+ *
4
+ * Kept out of `extensions/grants.ts` deliberately: that file is wiring only, and this holds decisions
5
+ * (what to offer, what a dismissal means, what happens with nobody watching) that must be testable
6
+ * without pi. `ApprovalUI` is the minimal slice of pi's `ExtensionUIContext` needed here, so a test can
7
+ * supply a plain object.
8
+ *
9
+ * Everything here denies on uncertainty. pi already agrees: in non-interactive modes it installs a
10
+ * no-op UI context whose `select` resolves undefined, so even a missed `hasUI` check would deny.
11
+ */
12
+ import { approvalKey, offeredScopes } from "./approval.js";
13
+ export const DENY_LABEL = "Deny";
14
+ export const SCOPE_LABELS = {
15
+ once: "Allow once",
16
+ session: "Allow for this session",
17
+ always: "Always allow in this project (30 days)",
18
+ };
19
+ const DEFAULT_TIMEOUT_MS = 120_000;
20
+ /**
21
+ * Read `PI_GRANTS_APPROVAL_TIMEOUT`, in SECONDS, into the milliseconds pi expects.
22
+ *
23
+ * `0` or an unparseable value means no timeout: waiting forever denies nothing, so it is the safe
24
+ * interpretation of a value we do not understand.
25
+ */
26
+ export function timeoutMsFromEnv(raw) {
27
+ if (raw === undefined)
28
+ return DEFAULT_TIMEOUT_MS;
29
+ const seconds = Number.parseInt(raw, 10);
30
+ if (!Number.isFinite(seconds) || seconds <= 0)
31
+ return undefined;
32
+ return seconds * 1000;
33
+ }
34
+ function labelToScope(label, scopes) {
35
+ return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
36
+ }
37
+ /**
38
+ * Build gates that SHARE one single-flight queue.
39
+ *
40
+ * This exists because of how the wiring actually calls in. The gate's options depend on the per-call
41
+ * `ExtensionContext` (`ui`, `hasUI`, `mode`), so the extension necessarily builds a gate per invocation —
42
+ * and a gate that owns its queue privately therefore de-duplicates nothing across invocations, which is
43
+ * exactly the case §6.1 exists for (two concurrent `delegate` calls are two separate invocations).
44
+ *
45
+ * The provider separates the two lifetimes: options stay per-call and fresh, the queue is created once and
46
+ * lives as long as the provider. Preferred over a module-level map because that would be shared by every
47
+ * gate in the process — including across tests — for no benefit.
48
+ */
49
+ export function createApprovalGateProvider() {
50
+ const inFlight = new Map();
51
+ return (options) => createApprovalGate(options, inFlight);
52
+ }
53
+ /**
54
+ * @param inFlight optional shared single-flight queue — see `createApprovalGateProvider`. Defaults to one
55
+ * private to this gate, which only de-duplicates requests made through this same gate object.
56
+ */
57
+ export function createApprovalGate(options, inFlight = new Map()) {
58
+ const ask = async (request) => {
59
+ const scopes = offeredScopes(request.path);
60
+ const title = `grants: approve ${request.capability} for ${request.subject}?` +
61
+ (request.task ? `\n task: ${request.task}` : "");
62
+ let chosen;
63
+ try {
64
+ chosen = await options.ui.select(title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])], {
65
+ timeout: options.timeoutMs,
66
+ signal: request.signal,
67
+ });
68
+ }
69
+ catch (error) {
70
+ // A governance layer that errors must deny, not permit. This is a dialog malfunction, not a
71
+ // person's answer — `kind: "error"` keeps it out of "declined".
72
+ return { scope: null, kind: "error", reason: `approval dialog failed, denying (${String(error)})` };
73
+ }
74
+ // undefined covers dismissal, timeout, and abort. All are a no, but none is a person saying no.
75
+ if (chosen === undefined) {
76
+ return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was dismissed` };
77
+ }
78
+ if (chosen === DENY_LABEL) {
79
+ return { scope: null, kind: "declined", reason: `${request.capability} was denied by a human` };
80
+ }
81
+ const scope = labelToScope(chosen, scopes);
82
+ return scope === null
83
+ ? { scope: null, kind: "error", reason: `unrecognised approval choice ${JSON.stringify(chosen)}, denying` }
84
+ : { scope, kind: "granted" };
85
+ };
86
+ return {
87
+ async request(request) {
88
+ if (!options.hasUI) {
89
+ // Nobody was there to ask — distinct from a person declining, which is why this is its own kind
90
+ // rather than being folded into "declined" (see PromptOutcomeKind).
91
+ return {
92
+ scope: null,
93
+ kind: "no-ui",
94
+ reason: `${request.capability} requires approval and this session has no interactive user ` +
95
+ `(mode: ${options.mode}). Pre-approve it in an interactive session, or drop it from the request.`,
96
+ };
97
+ }
98
+ const key = approvalKey(request.capability, request.subject);
99
+ // R-29. Join an in-flight dialog, but only *keep* its answer if that answer was about more than one
100
+ // spawn. A `once` belongs to whichever caller the human was actually looking at — the title shows
101
+ // that caller's task — so anyone else who joined must ask their own question rather than ride a yes
102
+ // given for somebody else's work. The loop (rather than a single retry) covers the case where a
103
+ // fellow waiter started its own dialog first: we join that one instead of opening a third.
104
+ for (;;) {
105
+ const existing = inFlight.get(key);
106
+ if (!existing)
107
+ break;
108
+ const outcome = await existing;
109
+ // **`joined` marks the rider, and the ledger depends on it (R-66).** Sharing a non-`once` outcome
110
+ // is correct — the human authorised the capability for the session, not for one child — but the
111
+ // caller then stamped `approvalSource: "prompt"` on every one of them, so a fan-out of eight wrote
112
+ // eight lines each asserting a human was prompted when exactly one was. That is R-46's defect at
113
+ // the concurrency level, and `ledger.ts` calls over-claiming in this direction "the worst available
114
+ // failure". Confirmed by execution: one dialog, eight `granted/session` outcomes.
115
+ if (outcome.scope !== "once")
116
+ return { ...outcome, joined: true };
117
+ }
118
+ const pending = ask(request).finally(() => inFlight.delete(key));
119
+ inFlight.set(key, pending);
120
+ return pending;
121
+ },
122
+ };
123
+ }
124
+ //# sourceMappingURL=approval-prompt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-prompt.js","sourceRoot":"","sources":["../src/approval-prompt.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Persistence for `always`-scoped approvals — the package's only mutable state outside the ledger.
3
+ *
4
+ * DESIGN NOTE, because it is easy to get backwards: this file is a CONVENIENCE CACHE, not a security
5
+ * control. The security decision was already made by a human at the moment of approval. So a failure here
6
+ * must never fail the work — an unwritable file downgrades the approval to session scope (see the boolean
7
+ * return of `saveApproval`), and an unreadable one simply grants nothing.
8
+ *
9
+ * Read on demand, never cached at session start, so **a revoke takes effect at the next gate check** —
10
+ * including a revoke performed from another session while this one is running.
11
+ *
12
+ * That sentence used to read *"takes effect immediately"* and claimed two different things, one of which was
13
+ * false and one of which is impossible:
14
+ *
15
+ * - **False, and fixed (R-49).** Every write is load → modify → write and it was unlocked, so a save could
16
+ * restore an entry another session had just revoked. Writes now hold the same file lock the ledger uses
17
+ * (`src/file-lock.ts`, `underLock` below).
18
+ * - **Impossible, and stated rather than fixed.** A spawn whose gate check has already passed is not
19
+ * retracted by a revoke arriving microseconds later. No lock closes that: the read has to finish before
20
+ * the spawn starts, so there is always an instant where the decision is made and the process is not yet
21
+ * running. Inherent to revoking anything, not a gap in this one.
22
+ *
23
+ * Reads deliberately take no lock. A read that loses a race sees the previous state, which is exactly what
24
+ * "at the next gate check" already means.
25
+ *
26
+ * **One file per project** (ADR-0020), and **no model-authored text, ever** (ADR-0021) — see `approvalsPath`
27
+ * and `sanitise` for why each of those is a decision rather than a detail.
28
+ *
29
+ * Pruning is deliberately lazy: `loadApprovals` never writes, so a read is a read. Invalid entries are
30
+ * dropped from the file on the next `saveApproval` or `revokeApproval`.
31
+ */
32
+ import { type ApprovalEntry, type EntryVerdict, type SubjectSnapshot } from "./approval.ts";
33
+ export interface DroppedApproval {
34
+ key: string;
35
+ entry: ApprovalEntry;
36
+ verdict: EntryVerdict;
37
+ }
38
+ /**
39
+ * Look up a subject's current ceiling AND body digest; null when the subject no longer exists.
40
+ *
41
+ * One snapshot rather than two callbacks (ADR-0019) — see `SubjectSnapshot`. Was `CeilingLookup`, which
42
+ * could only ever answer half the question `entryVerdict` needs to ask.
43
+ */
44
+ export type SubjectLookup = (subject: string) => SubjectSnapshot | null;
45
+ /**
46
+ * Where persisted approvals live — **outside the governed workspace** (ADR-0014).
47
+ *
48
+ * It used to be `<cwd>/.pi/grants-approvals.json`, which was self-defeating in this package's own
49
+ * recommended configuration: `PI_GRANTS_GATED=tool:write` means *"may use write, may not pass it down
50
+ * without a human"*, and **a session that may use `write` can write the approvals file**. A reviewer
51
+ * demonstrated it end to end, including authoring a matching agent-type file so `grantAtApproval`
52
+ * compared equal — no dialog, and a ledger line reading `approvalSource: "persisted"`, indistinguishable
53
+ * from a real human approval.
54
+ *
55
+ * A narrowed child does not hold write access to the user's home directory, so it cannot forge an entry
56
+ * here. Each entry still carries its own `cwd` and `entryVerdict` still checks it — that check exists for
57
+ * R-27 and refuses an entry copied between checkouts or machines, which per-project files do not make
58
+ * redundant.
59
+ *
60
+ * **This does not defend against a child holding `bash`** — see ADR-0012, which accepts that such a
61
+ * child can escape governance entirely. The point of this change is to close the *self-defeating* case,
62
+ * not to claim a boundary the package does not have.
63
+ *
64
+ * **ONE FILE PER PROJECT since 0.11.0 (ADR-0020).** It was one shared document keyed only by
65
+ * `capability@subject`, which produced four defects in eight lines — approving in one checkout deleted
66
+ * another's entries (R-41), `revoke --all` cleared every project (R-43), two concurrent writes lost both
67
+ * (R-42), and an unlocked read-modify-write could resurrect a revoked entry (R-49, fixed in 0.13.0). The
68
+ * unfixable one was the keyspace: two checkouts holding definitions of the same name — `review`, `deploy`,
69
+ * i.e. what happens the moment an operator reuses their own conventions — could not both hold an approval,
70
+ * so they took turns indefinitely. Per-project files make the collision **inexpressible** rather than
71
+ * handled, and `revoke --all` cannot name another project's file.
72
+ *
73
+ * The `cwd` is hashed as well as named: the basename keeps the file legible to a human reading the
74
+ * directory, and the hash is what makes it unambiguous, since two checkouts can share a basename.
75
+ *
76
+ * **It took a `cwd` parameter and ignored it until 0.10.2**, which was not a harmless vestige: the unit
77
+ * suite passed a `mkdtemp` directory to it, reasonably believed the result was hermetic, and spent every
78
+ * `npm test` rewriting and clearing the developer's real store in `$HOME` (R-40). That was invisible while
79
+ * the store was unwritable and became destructive the day ADR-0019 made it reachable. The parameter is now
80
+ * real and required, which is the opposite failure mode: forgetting it is a type error.
81
+ */
82
+ export declare function approvalsPath(cwd: string): string;
83
+ /**
84
+ * The shared single-file store, so it can be REPORTED rather than read (ADR-0020).
85
+ *
86
+ * Deliberately not migrated. Splitting it by each entry's own `cwd` would be mechanical and lossless — the
87
+ * trust root is unchanged, unlike ADR-0014's move out of the workspace — but it is code that runs once, is
88
+ * exercised on exactly one input per machine, and lives in the layer with nine recorded defects. Re-approving
89
+ * costs a click; a migration bug costs a silently wrong approval.
90
+ */
91
+ export declare function sharedApprovalsPath(): string;
92
+ /**
93
+ * The old in-workspace location, so it can be REPORTED rather than read.
94
+ *
95
+ * Deliberately not migrated. Importing a legacy file would import exactly the entries whose
96
+ * trustworthiness this change exists to remove — a forged approval would survive the fix that was
97
+ * supposed to stop it. The extension names the file and ignores it; re-approving is a few keystrokes and
98
+ * the only honest path.
99
+ */
100
+ export declare function legacyApprovalsPath(cwd: string): string;
101
+ export interface LoadApprovalsInput {
102
+ cwd: string;
103
+ now: Date;
104
+ snapshotOf: SubjectLookup;
105
+ }
106
+ /** Load the approvals valid HERE and NOW, plus the ones that were dropped and why. */
107
+ export declare function loadApprovals(input: LoadApprovalsInput): Promise<{
108
+ valid: Map<string, ApprovalEntry>;
109
+ dropped: DroppedApproval[];
110
+ }>;
111
+ /**
112
+ * Persist one approval, pruning anything THIS session can see has become invalid.
113
+ *
114
+ * Returns false when the write failed. The caller must then downgrade to session scope and warn — NOT
115
+ * refuse the delegation. The human already said yes; refusing work because a cache could not be written
116
+ * would be failing closed on the wrong thing.
117
+ */
118
+ export declare function saveApproval(cwd: string, key: string, entry: ApprovalEntry, snapshotOf: SubjectLookup, now: Date): Promise<boolean>;
119
+ /**
120
+ * Remove one approval, pruning any entries that have since become invalid.
121
+ *
122
+ * Like `saveApproval`, this filters invalid entries so a revoke takes the opportunity to clean up stale
123
+ * ones — the lazy-pruning policy applies to both write paths.
124
+ *
125
+ * **Three outcomes, not two (R-49).** It returned a boolean, and the caller printed
126
+ * *"no persisted approval named X"* for false — which was a **false statement** whenever the cause was a
127
+ * failed write. An operator told there is nothing to revoke, while the approval they are revoking survives,
128
+ * has been told the opposite of the truth about a security control. `"absent"` and `"failed"` are different
129
+ * facts and now say so.
130
+ */
131
+ /**
132
+ * **Four, and the fourth is the first fix's own smaller copy of R-61.** `failed` asserts the approval is
133
+ * still in effect, which is verified: we found the entry and could not remove it. A **lock timeout happens
134
+ * before the load**, so nothing was ever looked at — reporting `failed` there asserted a fact about an entry
135
+ * that may not exist, which is R-61's shape at lower severity. It errs alarming rather than reassuring, so
136
+ * it is the safe direction to be wrong in; that is a reason to rank it low, not a reason to keep it.
137
+ */
138
+ export type RevokeOutcome = "revoked" | "absent" | "failed" | "busy";
139
+ export declare function revokeApproval(cwd: string, key: string, snapshotOf: SubjectLookup, now: Date): Promise<RevokeOutcome>;
140
+ /**
141
+ * Clear every approval **for this directory**. Returns false if the write failed.
142
+ *
143
+ * Scoped rather than global, and the old behaviour was the surprising one: `/grants revoke --all` wrote an
144
+ * empty file, so revoking in one project silently revoked every other project's approvals too. An operator
145
+ * running it in one checkout is answering for that checkout — there is no interface for "and everywhere
146
+ * else", and it should not be the default reading of a command that names neither.
147
+ */
148
+ export declare function revokeAll(cwd: string): Promise<boolean>;
149
+ //# sourceMappingURL=approval-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"approval-store.d.ts","sourceRoot":"","sources":["../src/approval-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAOH,OAAO,EAAgB,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAO1G,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,aAAa,CAAC;IACrB,OAAO,EAAE,YAAY,CAAC;CACvB;AAED;;;;;GAKG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,EAAE,MAAM,KAAK,eAAe,GAAG,IAAI,CAAC;AAExE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CASjD;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,IAAI,MAAM,CAE5C;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEvD;AAmED,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,IAAI,CAAC;IACV,UAAU,EAAE,aAAa,CAAC;CAC3B;AAED,sFAAsF;AACtF,wBAAsB,aAAa,CACjC,KAAK,EAAE,kBAAkB,GACxB,OAAO,CAAC;IAAE,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAAC,OAAO,EAAE,eAAe,EAAE,CAAA;CAAE,CAAC,CAwB5E;AA4CD;;;;;;GAMG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,aAAa,EACpB,UAAU,EAAE,aAAa,EACzB,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,OAAO,CAAC,CASlB;AAED;;;;;;;;;;;GAWG;AACH;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC;AAErE,wBAAsB,cAAc,CAClC,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,aAAa,EACzB,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,aAAa,CAAC,CAOxB;AAED;;;;;;;GAOG;AACH,wBAAsB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAE7D"}