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.
- package/CHANGELOG.md +187 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/dist/approval-prompt.d.ts +116 -0
- package/dist/approval-prompt.d.ts.map +1 -0
- package/dist/approval-prompt.js +124 -0
- package/dist/approval-prompt.js.map +1 -0
- package/dist/approval-store.d.ts +149 -0
- package/dist/approval-store.d.ts.map +1 -0
- package/dist/approval-store.js +311 -0
- package/dist/approval-store.js.map +1 -0
- package/dist/approval.d.ts +221 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +244 -0
- package/dist/approval.js.map +1 -0
- package/dist/capabilities.d.ts +31 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +45 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog.d.ts +81 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +164 -0
- package/dist/catalog.js.map +1 -0
- package/dist/definitions.d.ts +101 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +182 -0
- package/dist/definitions.js.map +1 -0
- package/dist/delegate.d.ts +133 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +261 -0
- package/dist/delegate.js.map +1 -0
- package/dist/fanout.d.ts +62 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +84 -0
- package/dist/fanout.js.map +1 -0
- package/dist/file-lock.d.ts +54 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +142 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger-report.d.ts +106 -0
- package/dist/ledger-report.d.ts.map +1 -0
- package/dist/ledger-report.js +157 -0
- package/dist/ledger-report.js.map +1 -0
- package/dist/ledger.d.ts +139 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +94 -0
- package/dist/ledger.js.map +1 -0
- package/dist/pane-reaper.d.ts +49 -0
- package/dist/pane-reaper.d.ts.map +1 -0
- package/dist/pane-reaper.js +120 -0
- package/dist/pane-reaper.js.map +1 -0
- package/dist/pi-tools.d.ts +20 -0
- package/dist/pi-tools.d.ts.map +1 -0
- package/dist/pi-tools.js +21 -0
- package/dist/pi-tools.js.map +1 -0
- package/dist/propagation.d.ts +185 -0
- package/dist/propagation.d.ts.map +1 -0
- package/dist/propagation.js +255 -0
- package/dist/propagation.js.map +1 -0
- package/dist/resolve.d.ts +110 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +181 -0
- package/dist/resolve.js.map +1 -0
- package/dist/run-child.d.ts +50 -0
- package/dist/run-child.d.ts.map +1 -0
- package/dist/run-child.js +109 -0
- package/dist/run-child.js.map +1 -0
- package/dist/run-herdr.d.ts +91 -0
- package/dist/run-herdr.d.ts.map +1 -0
- package/dist/run-herdr.js +306 -0
- package/dist/run-herdr.js.map +1 -0
- package/dist/spawn.d.ts +60 -0
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +116 -0
- package/dist/spawn.js.map +1 -0
- package/extensions/approvals.ts +285 -0
- package/extensions/delegation.ts +237 -0
- package/extensions/grants-command.ts +310 -0
- package/extensions/grants.ts +346 -0
- package/extensions/run-delegation.ts +270 -0
- package/extensions/session.ts +234 -0
- package/package.json +122 -0
- package/src/approval-prompt.ts +222 -0
- package/src/approval-store.ts +383 -0
- package/src/approval.ts +364 -0
- package/src/capabilities.ts +51 -0
- package/src/catalog.ts +186 -0
- package/src/definitions.ts +233 -0
- package/src/delegate.ts +383 -0
- package/src/fanout.ts +96 -0
- package/src/file-lock.ts +142 -0
- package/src/index.ts +59 -0
- package/src/ledger-report.ts +227 -0
- package/src/ledger.ts +214 -0
- package/src/pane-reaper.ts +132 -0
- package/src/pi-tools.ts +25 -0
- package/src/propagation.ts +308 -0
- package/src/resolve.ts +235 -0
- package/src/run-child.ts +149 -0
- package/src/run-herdr.ts +357 -0
- package/src/spawn.ts +156 -0
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
|
|
13
|
+
import { approvalKey, offeredScopes, type ApprovalPath, type ApprovalScope } from "./approval.ts";
|
|
14
|
+
import type { Capability } from "./resolve.ts";
|
|
15
|
+
|
|
16
|
+
/** The slice of pi's `ExtensionUIContext` this module needs. */
|
|
17
|
+
export interface ApprovalUI {
|
|
18
|
+
select(
|
|
19
|
+
title: string,
|
|
20
|
+
options: string[],
|
|
21
|
+
opts?: { timeout?: number; signal?: AbortSignal },
|
|
22
|
+
): Promise<string | undefined>;
|
|
23
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DENY_LABEL = "Deny";
|
|
27
|
+
|
|
28
|
+
export const SCOPE_LABELS: Record<ApprovalScope, string> = {
|
|
29
|
+
once: "Allow once",
|
|
30
|
+
session: "Allow for this session",
|
|
31
|
+
always: "Always allow in this project (30 days)",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface PromptRequest {
|
|
35
|
+
capability: Capability;
|
|
36
|
+
/** Agent type name, or `DELEGATE_SUBJECT` on the delegate path. */
|
|
37
|
+
subject: string;
|
|
38
|
+
path: ApprovalPath;
|
|
39
|
+
/** Shown to the human for context. Never part of a key. */
|
|
40
|
+
task?: string;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Which of the five outcomes a request produced.
|
|
46
|
+
*
|
|
47
|
+
* `scope: null` alone conflates four different kinds of no — a caller that needs to know whether a
|
|
48
|
+
* PERSON actually declined (as opposed to nobody being there to ask, a dismissal, or the dialog itself
|
|
49
|
+
* breaking) cannot recover that from `scope` or from parsing `reason`. Only "declined" means a human said
|
|
50
|
+
* no; the other three are absence-of-signal, not a signal.
|
|
51
|
+
*/
|
|
52
|
+
export type PromptOutcomeKind = "granted" | "no-ui" | "declined" | "dismissed" | "error";
|
|
53
|
+
|
|
54
|
+
export interface PromptOutcome {
|
|
55
|
+
/** The scope the human chose, or null for any form of no. */
|
|
56
|
+
scope: ApprovalScope | null;
|
|
57
|
+
/** Which of the five outcomes this was. Set on every return path. */
|
|
58
|
+
kind: PromptOutcomeKind;
|
|
59
|
+
/** Why, when the answer was no. */
|
|
60
|
+
reason?: string;
|
|
61
|
+
/**
|
|
62
|
+
* True when this caller RODE another caller's answer rather than being asked (R-66).
|
|
63
|
+
*
|
|
64
|
+
* The single-flight queue shares any non-`once` outcome, which is right — a human approving
|
|
65
|
+
* `tool:bash` *for this session* authorised the capability, not one child. What is not right is
|
|
66
|
+
* recording the riders as though each had faced a dialog: the ledger's whole job is answering "did a
|
|
67
|
+
* human authorise this?", and a fan-out of eight wrote eight lines claiming a prompt where there was
|
|
68
|
+
* one. The rider's honest source is `session`, and this flag is how the caller can tell.
|
|
69
|
+
*/
|
|
70
|
+
joined?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ApprovalGateOptions {
|
|
74
|
+
ui: ApprovalUI;
|
|
75
|
+
/** pi's `ctx.hasUI` — false in print/json mode, and therefore in every governed child. */
|
|
76
|
+
hasUI: boolean;
|
|
77
|
+
/** pi's `ctx.mode`, quoted back in the refusal so an operator can see why. */
|
|
78
|
+
mode: string;
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ApprovalGate {
|
|
83
|
+
request(request: PromptRequest): Promise<PromptOutcome>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read `PI_GRANTS_APPROVAL_TIMEOUT`, in SECONDS, into the milliseconds pi expects.
|
|
90
|
+
*
|
|
91
|
+
* `0` or an unparseable value means no timeout: waiting forever denies nothing, so it is the safe
|
|
92
|
+
* interpretation of a value we do not understand.
|
|
93
|
+
*/
|
|
94
|
+
export function timeoutMsFromEnv(raw: string | undefined): number | undefined {
|
|
95
|
+
if (raw === undefined) return DEFAULT_TIMEOUT_MS;
|
|
96
|
+
const seconds = Number.parseInt(raw, 10);
|
|
97
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
|
|
98
|
+
return seconds * 1000;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function labelToScope(label: string, scopes: ApprovalScope[]): ApprovalScope | null {
|
|
102
|
+
return scopes.find((s) => SCOPE_LABELS[s] === label) ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The single-flight queue: one dialog per approval key at a time.
|
|
107
|
+
*
|
|
108
|
+
* `delegate` sets no `executionMode`, so an orchestrator can fan out several children at once and two can
|
|
109
|
+
* hit the same gate simultaneously — otherwise two stacked dialogs asking the identical question.
|
|
110
|
+
*
|
|
111
|
+
* **R-29 — the key does two jobs, and only one of them may be shared.** `approvalKey` is
|
|
112
|
+
* `capability@subject`, and on the delegate path the subject is the constant `DELEGATE_SUBJECT`, which is
|
|
113
|
+
* deliberate: the only things naming a delegated child are the task and the tool list, both model-chosen,
|
|
114
|
+
* and a key the model controls is not a key (`approval.ts:24-32`). That reasoning governs *approval
|
|
115
|
+
* identity* — what a human said yes to, and what may be persisted — and it is right.
|
|
116
|
+
*
|
|
117
|
+
* It does **not** govern de-duplication. Sharing one dialog's outcome is correct for `session` and
|
|
118
|
+
* `always`, which are genuinely answers about the session or the project, and for a decline or an error,
|
|
119
|
+
* which answer everyone. It is **wrong for `once`**, which means *this spawn*. Measured before the fix:
|
|
120
|
+
* four concurrent delegations gating `tool:bash`, one dialog, one click of *Allow once* → four `granted`
|
|
121
|
+
* outcomes, with the human having seen only the first caller's task. That is a confused deputy, and it
|
|
122
|
+
* falsified ADR-0014's decided property that "`once` stops at the boundary".
|
|
123
|
+
*/
|
|
124
|
+
export type InFlightApprovals = Map<string, Promise<PromptOutcome>>;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build gates that SHARE one single-flight queue.
|
|
128
|
+
*
|
|
129
|
+
* This exists because of how the wiring actually calls in. The gate's options depend on the per-call
|
|
130
|
+
* `ExtensionContext` (`ui`, `hasUI`, `mode`), so the extension necessarily builds a gate per invocation —
|
|
131
|
+
* and a gate that owns its queue privately therefore de-duplicates nothing across invocations, which is
|
|
132
|
+
* exactly the case §6.1 exists for (two concurrent `delegate` calls are two separate invocations).
|
|
133
|
+
*
|
|
134
|
+
* The provider separates the two lifetimes: options stay per-call and fresh, the queue is created once and
|
|
135
|
+
* lives as long as the provider. Preferred over a module-level map because that would be shared by every
|
|
136
|
+
* gate in the process — including across tests — for no benefit.
|
|
137
|
+
*/
|
|
138
|
+
export function createApprovalGateProvider(): (options: ApprovalGateOptions) => ApprovalGate {
|
|
139
|
+
const inFlight: InFlightApprovals = new Map();
|
|
140
|
+
return (options) => createApprovalGate(options, inFlight);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param inFlight optional shared single-flight queue — see `createApprovalGateProvider`. Defaults to one
|
|
145
|
+
* private to this gate, which only de-duplicates requests made through this same gate object.
|
|
146
|
+
*/
|
|
147
|
+
export function createApprovalGate(
|
|
148
|
+
options: ApprovalGateOptions,
|
|
149
|
+
inFlight: InFlightApprovals = new Map(),
|
|
150
|
+
): ApprovalGate {
|
|
151
|
+
const ask = async (request: PromptRequest): Promise<PromptOutcome> => {
|
|
152
|
+
const scopes = offeredScopes(request.path);
|
|
153
|
+
const title =
|
|
154
|
+
`grants: approve ${request.capability} for ${request.subject}?` +
|
|
155
|
+
(request.task ? `\n task: ${request.task}` : "");
|
|
156
|
+
|
|
157
|
+
let chosen: string | undefined;
|
|
158
|
+
try {
|
|
159
|
+
chosen = await options.ui.select(title, [DENY_LABEL, ...scopes.map((s) => SCOPE_LABELS[s])], {
|
|
160
|
+
timeout: options.timeoutMs,
|
|
161
|
+
signal: request.signal,
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// A governance layer that errors must deny, not permit. This is a dialog malfunction, not a
|
|
165
|
+
// person's answer — `kind: "error"` keeps it out of "declined".
|
|
166
|
+
return { scope: null, kind: "error", reason: `approval dialog failed, denying (${String(error)})` };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// undefined covers dismissal, timeout, and abort. All are a no, but none is a person saying no.
|
|
170
|
+
if (chosen === undefined) {
|
|
171
|
+
return { scope: null, kind: "dismissed", reason: `approval for ${request.capability} was dismissed` };
|
|
172
|
+
}
|
|
173
|
+
if (chosen === DENY_LABEL) {
|
|
174
|
+
return { scope: null, kind: "declined", reason: `${request.capability} was denied by a human` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const scope = labelToScope(chosen, scopes);
|
|
178
|
+
return scope === null
|
|
179
|
+
? { scope: null, kind: "error", reason: `unrecognised approval choice ${JSON.stringify(chosen)}, denying` }
|
|
180
|
+
: { scope, kind: "granted" };
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
async request(request: PromptRequest): Promise<PromptOutcome> {
|
|
185
|
+
if (!options.hasUI) {
|
|
186
|
+
// Nobody was there to ask — distinct from a person declining, which is why this is its own kind
|
|
187
|
+
// rather than being folded into "declined" (see PromptOutcomeKind).
|
|
188
|
+
return {
|
|
189
|
+
scope: null,
|
|
190
|
+
kind: "no-ui",
|
|
191
|
+
reason:
|
|
192
|
+
`${request.capability} requires approval and this session has no interactive user ` +
|
|
193
|
+
`(mode: ${options.mode}). Pre-approve it in an interactive session, or drop it from the request.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const key = approvalKey(request.capability, request.subject);
|
|
198
|
+
|
|
199
|
+
// R-29. Join an in-flight dialog, but only *keep* its answer if that answer was about more than one
|
|
200
|
+
// spawn. A `once` belongs to whichever caller the human was actually looking at — the title shows
|
|
201
|
+
// that caller's task — so anyone else who joined must ask their own question rather than ride a yes
|
|
202
|
+
// given for somebody else's work. The loop (rather than a single retry) covers the case where a
|
|
203
|
+
// fellow waiter started its own dialog first: we join that one instead of opening a third.
|
|
204
|
+
for (;;) {
|
|
205
|
+
const existing = inFlight.get(key);
|
|
206
|
+
if (!existing) break;
|
|
207
|
+
const outcome = await existing;
|
|
208
|
+
// **`joined` marks the rider, and the ledger depends on it (R-66).** Sharing a non-`once` outcome
|
|
209
|
+
// is correct — the human authorised the capability for the session, not for one child — but the
|
|
210
|
+
// caller then stamped `approvalSource: "prompt"` on every one of them, so a fan-out of eight wrote
|
|
211
|
+
// eight lines each asserting a human was prompted when exactly one was. That is R-46's defect at
|
|
212
|
+
// the concurrency level, and `ledger.ts` calls over-claiming in this direction "the worst available
|
|
213
|
+
// failure". Confirmed by execution: one dialog, eight `granted/session` outcomes.
|
|
214
|
+
if (outcome.scope !== "once") return { ...outcome, joined: true };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const pending = ask(request).finally(() => inFlight.delete(key));
|
|
218
|
+
inFlight.set(key, pending);
|
|
219
|
+
return pending;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
|
|
33
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
34
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
35
|
+
import { withFileLock } from "./file-lock.ts";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
import { basename, dirname, join } from "node:path";
|
|
38
|
+
import { entryVerdict, type ApprovalEntry, type EntryVerdict, type SubjectSnapshot } from "./approval.ts";
|
|
39
|
+
|
|
40
|
+
interface ApprovalFile {
|
|
41
|
+
version: 1;
|
|
42
|
+
approvals: Record<string, ApprovalEntry>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DroppedApproval {
|
|
46
|
+
key: string;
|
|
47
|
+
entry: ApprovalEntry;
|
|
48
|
+
verdict: EntryVerdict;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Look up a subject's current ceiling AND body digest; null when the subject no longer exists.
|
|
53
|
+
*
|
|
54
|
+
* One snapshot rather than two callbacks (ADR-0019) — see `SubjectSnapshot`. Was `CeilingLookup`, which
|
|
55
|
+
* could only ever answer half the question `entryVerdict` needs to ask.
|
|
56
|
+
*/
|
|
57
|
+
export type SubjectLookup = (subject: string) => SubjectSnapshot | null;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where persisted approvals live — **outside the governed workspace** (ADR-0014).
|
|
61
|
+
*
|
|
62
|
+
* It used to be `<cwd>/.pi/grants-approvals.json`, which was self-defeating in this package's own
|
|
63
|
+
* recommended configuration: `PI_GRANTS_GATED=tool:write` means *"may use write, may not pass it down
|
|
64
|
+
* without a human"*, and **a session that may use `write` can write the approvals file**. A reviewer
|
|
65
|
+
* demonstrated it end to end, including authoring a matching agent-type file so `grantAtApproval`
|
|
66
|
+
* compared equal — no dialog, and a ledger line reading `approvalSource: "persisted"`, indistinguishable
|
|
67
|
+
* from a real human approval.
|
|
68
|
+
*
|
|
69
|
+
* A narrowed child does not hold write access to the user's home directory, so it cannot forge an entry
|
|
70
|
+
* here. Each entry still carries its own `cwd` and `entryVerdict` still checks it — that check exists for
|
|
71
|
+
* R-27 and refuses an entry copied between checkouts or machines, which per-project files do not make
|
|
72
|
+
* redundant.
|
|
73
|
+
*
|
|
74
|
+
* **This does not defend against a child holding `bash`** — see ADR-0012, which accepts that such a
|
|
75
|
+
* child can escape governance entirely. The point of this change is to close the *self-defeating* case,
|
|
76
|
+
* not to claim a boundary the package does not have.
|
|
77
|
+
*
|
|
78
|
+
* **ONE FILE PER PROJECT since 0.11.0 (ADR-0020).** It was one shared document keyed only by
|
|
79
|
+
* `capability@subject`, which produced four defects in eight lines — approving in one checkout deleted
|
|
80
|
+
* another's entries (R-41), `revoke --all` cleared every project (R-43), two concurrent writes lost both
|
|
81
|
+
* (R-42), and an unlocked read-modify-write could resurrect a revoked entry (R-49, fixed in 0.13.0). The
|
|
82
|
+
* unfixable one was the keyspace: two checkouts holding definitions of the same name — `review`, `deploy`,
|
|
83
|
+
* i.e. what happens the moment an operator reuses their own conventions — could not both hold an approval,
|
|
84
|
+
* so they took turns indefinitely. Per-project files make the collision **inexpressible** rather than
|
|
85
|
+
* handled, and `revoke --all` cannot name another project's file.
|
|
86
|
+
*
|
|
87
|
+
* The `cwd` is hashed as well as named: the basename keeps the file legible to a human reading the
|
|
88
|
+
* directory, and the hash is what makes it unambiguous, since two checkouts can share a basename.
|
|
89
|
+
*
|
|
90
|
+
* **It took a `cwd` parameter and ignored it until 0.10.2**, which was not a harmless vestige: the unit
|
|
91
|
+
* suite passed a `mkdtemp` directory to it, reasonably believed the result was hermetic, and spent every
|
|
92
|
+
* `npm test` rewriting and clearing the developer's real store in `$HOME` (R-40). That was invisible while
|
|
93
|
+
* the store was unwritable and became destructive the day ADR-0019 made it reachable. The parameter is now
|
|
94
|
+
* real and required, which is the opposite failure mode: forgetting it is a type error.
|
|
95
|
+
*/
|
|
96
|
+
export function approvalsPath(cwd: string): string {
|
|
97
|
+
const slug = (basename(cwd) || "root").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 40);
|
|
98
|
+
// 16 hex = 64 bits, not the 6 this shipped with. ADR-0020 deleted the `foreign-cwd` carry-through on the
|
|
99
|
+
// premise that one file means one directory — so inside a hash collision R-41 returns *with its
|
|
100
|
+
// mitigation removed*: the second project's save deletes the first's entries. At 24 bits a deliberate
|
|
101
|
+
// collision costs about 16.7M hashes, well under a second, and an accidental one arrives at a few
|
|
102
|
+
// thousand governed directories. The premise has to be worth what was removed to rely on it.
|
|
103
|
+
const hash = createHash("sha256").update(cwd, "utf8").digest("hex").slice(0, 16);
|
|
104
|
+
return join(agentDir(), "grants-approvals", `${slug}-${hash}.json`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The shared single-file store, so it can be REPORTED rather than read (ADR-0020).
|
|
109
|
+
*
|
|
110
|
+
* Deliberately not migrated. Splitting it by each entry's own `cwd` would be mechanical and lossless — the
|
|
111
|
+
* trust root is unchanged, unlike ADR-0014's move out of the workspace — but it is code that runs once, is
|
|
112
|
+
* exercised on exactly one input per machine, and lives in the layer with nine recorded defects. Re-approving
|
|
113
|
+
* costs a click; a migration bug costs a silently wrong approval.
|
|
114
|
+
*/
|
|
115
|
+
export function sharedApprovalsPath(): string {
|
|
116
|
+
return join(agentDir(), "grants-approvals.json");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The old in-workspace location, so it can be REPORTED rather than read.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately not migrated. Importing a legacy file would import exactly the entries whose
|
|
123
|
+
* trustworthiness this change exists to remove — a forged approval would survive the fix that was
|
|
124
|
+
* supposed to stop it. The extension names the file and ignores it; re-approving is a few keystrokes and
|
|
125
|
+
* the only honest path.
|
|
126
|
+
*/
|
|
127
|
+
export function legacyApprovalsPath(cwd: string): string {
|
|
128
|
+
return join(cwd, ".pi", "grants-approvals.json");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `$PI_CODING_AGENT_DIR`, or pi's default. Matches how pi-subagents resolves the same directory. */
|
|
132
|
+
function agentDir(): string {
|
|
133
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The subject half of `capability@subject`. Capability ids contain `:` but never `@`. */
|
|
137
|
+
function subjectOf(key: string): string {
|
|
138
|
+
return key.slice(key.indexOf("@") + 1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Guard: reject entries that don't have the shape of an ApprovalEntry. */
|
|
142
|
+
function isValidEntryShape(entry: unknown): entry is ApprovalEntry {
|
|
143
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const obj = entry as Record<string, unknown>;
|
|
147
|
+
return (
|
|
148
|
+
typeof obj.approvedAt === "string" &&
|
|
149
|
+
typeof obj.expiresAt === "string" &&
|
|
150
|
+
typeof obj.cwd === "string" &&
|
|
151
|
+
Array.isArray(obj.grantAtApproval)
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Project every entry through its DECLARED fields on the way out (ADR-0021).
|
|
157
|
+
*
|
|
158
|
+
* The instance this closes: `taskAtApproval` stored the model-authored task string, which `src/ledger.ts`
|
|
159
|
+
* forbids in unqualified terms — *"the task is not recorded, anywhere, ever"*. Removing the field from the
|
|
160
|
+
* type is not enough on its own, because entries are parsed from JSON and a rewrite would carry any
|
|
161
|
+
* undeclared property straight back to disk.
|
|
162
|
+
*
|
|
163
|
+
* A whitelist rather than a delete, so this closes the class: no future field can reach the store by being
|
|
164
|
+
* present on a parsed object, and adding one is a deliberate edit here.
|
|
165
|
+
*/
|
|
166
|
+
function sanitise(valid: Map<string, ApprovalEntry>): Record<string, ApprovalEntry> {
|
|
167
|
+
return Object.fromEntries(
|
|
168
|
+
[...valid].map(([key, e]) => [
|
|
169
|
+
key,
|
|
170
|
+
{
|
|
171
|
+
approvedAt: e.approvedAt,
|
|
172
|
+
expiresAt: e.expiresAt,
|
|
173
|
+
cwd: e.cwd,
|
|
174
|
+
grantAtApproval: e.grantAtApproval,
|
|
175
|
+
...(e.bodyAtApproval !== undefined ? { bodyAtApproval: e.bodyAtApproval } : {}),
|
|
176
|
+
},
|
|
177
|
+
]),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function readFileSafely(cwd: string): Promise<ApprovalFile> {
|
|
182
|
+
try {
|
|
183
|
+
const parsed = JSON.parse(await readFile(approvalsPath(cwd), "utf8")) as unknown;
|
|
184
|
+
if (!parsed || typeof parsed !== "object") return { version: 1, approvals: {} };
|
|
185
|
+
const file = parsed as Partial<ApprovalFile>;
|
|
186
|
+
if (file.version !== 1 || !file.approvals || typeof file.approvals !== "object") {
|
|
187
|
+
return { version: 1, approvals: {} };
|
|
188
|
+
}
|
|
189
|
+
return { version: 1, approvals: file.approvals };
|
|
190
|
+
} catch {
|
|
191
|
+
// Missing is normal; corrupt grants nothing. Either way the caller re-prompts, which is safe.
|
|
192
|
+
return { version: 1, approvals: {} };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface LoadApprovalsInput {
|
|
197
|
+
cwd: string;
|
|
198
|
+
now: Date;
|
|
199
|
+
snapshotOf: SubjectLookup;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Load the approvals valid HERE and NOW, plus the ones that were dropped and why. */
|
|
203
|
+
export async function loadApprovals(
|
|
204
|
+
input: LoadApprovalsInput,
|
|
205
|
+
): Promise<{ valid: Map<string, ApprovalEntry>; dropped: DroppedApproval[] }> {
|
|
206
|
+
const file = await readFileSafely(input.cwd);
|
|
207
|
+
const valid = new Map<string, ApprovalEntry>();
|
|
208
|
+
const dropped: DroppedApproval[] = [];
|
|
209
|
+
|
|
210
|
+
for (const [key, entry] of Object.entries(file.approvals)) {
|
|
211
|
+
// Validate entry shape before passing to entryVerdict. Malformed entries cannot take valid ones
|
|
212
|
+
// with them — one bad shape means that one entry is dropped, the rest are still evaluated.
|
|
213
|
+
if (!isValidEntryShape(entry)) {
|
|
214
|
+
dropped.push({ key, entry: entry as ApprovalEntry, verdict: "expired" });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const verdict = entryVerdict({
|
|
219
|
+
entry,
|
|
220
|
+
cwd: input.cwd,
|
|
221
|
+
now: input.now,
|
|
222
|
+
current: input.snapshotOf(subjectOf(key)),
|
|
223
|
+
});
|
|
224
|
+
if (verdict === "valid") valid.set(key, entry);
|
|
225
|
+
else dropped.push({ key, entry, verdict });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { valid, dropped };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Write the file atomically, and never through a symlink (ADR-0014).
|
|
233
|
+
*
|
|
234
|
+
* Two defects this closes:
|
|
235
|
+
*
|
|
236
|
+
* - **B-I6** — writes followed project-controlled symlinks and were not atomic, so a crash or a
|
|
237
|
+
* concurrent writer could leave a half-written file that the next read discards entirely.
|
|
238
|
+
* - **A-R2** — a corrupt file made the next legitimate write destroy every other entry, because entries
|
|
239
|
+
* are validated on read but pruned only on write. Writing to a temp file and renaming makes the
|
|
240
|
+
* replacement all-or-nothing; `wx` on the temp refuses to follow an existing link.
|
|
241
|
+
*
|
|
242
|
+
* `rename` is atomic within a filesystem, and the temp file is created in the same directory precisely so
|
|
243
|
+
* that holds.
|
|
244
|
+
*/
|
|
245
|
+
async function writeFileSafely(cwd: string, file: ApprovalFile): Promise<boolean> {
|
|
246
|
+
const path = approvalsPath(cwd);
|
|
247
|
+
// Same directory as the target: `rename` is only atomic within one filesystem.
|
|
248
|
+
//
|
|
249
|
+
// **Unique per CALL, not per process.** It was `${path}.${pid}.tmp`, so two concurrent `saveApproval`
|
|
250
|
+
// calls in one process collided: the second's `wx` failed EEXIST, its `catch` unlinked the *first's*
|
|
251
|
+
// in-flight temp, and the first's `rename` then failed ENOENT — **both returned false and nothing was
|
|
252
|
+
// written**, on a perfectly writable file. Measured with two different keys, so it was not limited to
|
|
253
|
+
// the shared-dialog case: any two concurrent writes lost both. `delegate_all` is exactly that shape, and
|
|
254
|
+
// both callers would report "could not persist the approval — it applies for this session only", which
|
|
255
|
+
// named the wrong cause.
|
|
256
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
257
|
+
try {
|
|
258
|
+
await mkdir(dirname(path), { recursive: true });
|
|
259
|
+
// `wx` fails rather than following a pre-existing symlink or clobbering another writer's temp.
|
|
260
|
+
await writeFile(temp, `${JSON.stringify(file, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
261
|
+
await rename(temp, path);
|
|
262
|
+
return true;
|
|
263
|
+
} catch {
|
|
264
|
+
try {
|
|
265
|
+
await unlink(temp);
|
|
266
|
+
} catch {
|
|
267
|
+
/* nothing to clean up */
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Persist one approval, pruning anything THIS session can see has become invalid.
|
|
275
|
+
*
|
|
276
|
+
* Returns false when the write failed. The caller must then downgrade to session scope and warn — NOT
|
|
277
|
+
* refuse the delegation. The human already said yes; refusing work because a cache could not be written
|
|
278
|
+
* would be failing closed on the wrong thing.
|
|
279
|
+
*/
|
|
280
|
+
export async function saveApproval(
|
|
281
|
+
cwd: string,
|
|
282
|
+
key: string,
|
|
283
|
+
entry: ApprovalEntry,
|
|
284
|
+
snapshotOf: SubjectLookup,
|
|
285
|
+
now: Date,
|
|
286
|
+
): Promise<boolean> {
|
|
287
|
+
return underLock(cwd, false, false, async () => {
|
|
288
|
+
const { valid } = await loadApprovals({ cwd, now, snapshotOf });
|
|
289
|
+
valid.set(key, entry);
|
|
290
|
+
// ADR-0020: this file belongs to ONE project, so there is nothing here that another project could own and
|
|
291
|
+
// pruning cannot reach across a boundary. The `foreign-cwd` carry-through 0.10.2 needed is gone with the
|
|
292
|
+
// shared file that made it necessary.
|
|
293
|
+
return writeFileSafely(cwd, { version: 1, approvals: sanitise(valid) });
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Remove one approval, pruning any entries that have since become invalid.
|
|
299
|
+
*
|
|
300
|
+
* Like `saveApproval`, this filters invalid entries so a revoke takes the opportunity to clean up stale
|
|
301
|
+
* ones — the lazy-pruning policy applies to both write paths.
|
|
302
|
+
*
|
|
303
|
+
* **Three outcomes, not two (R-49).** It returned a boolean, and the caller printed
|
|
304
|
+
* *"no persisted approval named X"* for false — which was a **false statement** whenever the cause was a
|
|
305
|
+
* failed write. An operator told there is nothing to revoke, while the approval they are revoking survives,
|
|
306
|
+
* has been told the opposite of the truth about a security control. `"absent"` and `"failed"` are different
|
|
307
|
+
* facts and now say so.
|
|
308
|
+
*/
|
|
309
|
+
/**
|
|
310
|
+
* **Four, and the fourth is the first fix's own smaller copy of R-61.** `failed` asserts the approval is
|
|
311
|
+
* still in effect, which is verified: we found the entry and could not remove it. A **lock timeout happens
|
|
312
|
+
* before the load**, so nothing was ever looked at — reporting `failed` there asserted a fact about an entry
|
|
313
|
+
* that may not exist, which is R-61's shape at lower severity. It errs alarming rather than reassuring, so
|
|
314
|
+
* it is the safe direction to be wrong in; that is a reason to rank it low, not a reason to keep it.
|
|
315
|
+
*/
|
|
316
|
+
export type RevokeOutcome = "revoked" | "absent" | "failed" | "busy";
|
|
317
|
+
|
|
318
|
+
export async function revokeApproval(
|
|
319
|
+
cwd: string,
|
|
320
|
+
key: string,
|
|
321
|
+
snapshotOf: SubjectLookup,
|
|
322
|
+
now: Date,
|
|
323
|
+
): Promise<RevokeOutcome> {
|
|
324
|
+
return underLock<RevokeOutcome>(cwd, "busy", "failed", async () => {
|
|
325
|
+
const { valid } = await loadApprovals({ cwd, now, snapshotOf });
|
|
326
|
+
if (!valid.has(key)) return "absent";
|
|
327
|
+
valid.delete(key);
|
|
328
|
+
return (await writeFileSafely(cwd, { version: 1, approvals: sanitise(valid) })) ? "revoked" : "failed";
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Clear every approval **for this directory**. Returns false if the write failed.
|
|
334
|
+
*
|
|
335
|
+
* Scoped rather than global, and the old behaviour was the surprising one: `/grants revoke --all` wrote an
|
|
336
|
+
* empty file, so revoking in one project silently revoked every other project's approvals too. An operator
|
|
337
|
+
* running it in one checkout is answering for that checkout — there is no interface for "and everywhere
|
|
338
|
+
* else", and it should not be the default reading of a command that names neither.
|
|
339
|
+
*/
|
|
340
|
+
export async function revokeAll(cwd: string): Promise<boolean> {
|
|
341
|
+
return underLock(cwd, false, false, () => writeFileSafely(cwd, { version: 1, approvals: {} }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Hold the store's lock for one read-modify-write (R-49).
|
|
346
|
+
*
|
|
347
|
+
* **The race it closes.** Every write here is load → modify → write, and it was unlocked, so: session 1
|
|
348
|
+
* loads; session 2 revokes; session 1 saves an unrelated approval and **restores the revoked entry** for the
|
|
349
|
+
* rest of its 30 days, with no error and no warning. `approval-store.ts` documents that *"a revoke takes
|
|
350
|
+
* effect immediately — including one performed from another session while this one is running"*, and that
|
|
351
|
+
* sentence was false. Narrow (ADR-0020 scoped it to two sessions in the same directory) and cheap to close,
|
|
352
|
+
* because the lock already existed for the ledger — `src/file-lock.ts`, one implementation, two callers.
|
|
353
|
+
*
|
|
354
|
+
* **A lock this cannot take does NOT fail the work**, which is the opposite of the ledger's choice with the
|
|
355
|
+
* same lock and follows from what the two files are. The ledger is a security control: no audit line, no
|
|
356
|
+
* spawn. This store is a convenience cache (ADR-0020) — the human already said yes, and refusing their work
|
|
357
|
+
* because a cache was busy would be failing closed on the wrong thing. So a timeout yields `busy`, which the
|
|
358
|
+
* caller reports as an ordinary write failure and downgrades to session scope.
|
|
359
|
+
*/
|
|
360
|
+
async function underLock<T>(cwd: string, onBusy: T, onError: T, work: () => Promise<T>): Promise<T> {
|
|
361
|
+
const path = approvalsPath(cwd);
|
|
362
|
+
// **The discriminant is whether anything was READ, not which error was thrown.** Keying on
|
|
363
|
+
// `LockTimeoutError` looked right and was not: `EMFILE` — the classic transient, and one a fan-out of
|
|
364
|
+
// children plus herdr panes produces — happens before the load just as a timeout does, yet took the
|
|
365
|
+
// `failed` branch, whose message asserts the approval "is still in effect" about an entry nobody looked
|
|
366
|
+
// for, and blames a path that is perfectly writable. That is the very defect the fourth outcome was
|
|
367
|
+
// added to prevent, one error code to the left.
|
|
368
|
+
let entered = false;
|
|
369
|
+
try {
|
|
370
|
+
// The lock lives beside the file, so its directory must exist before the lock can be taken — and on a
|
|
371
|
+
// first-ever approval it does not. `writeFileSafely` creates it, which is one step too late: every
|
|
372
|
+
// write failed with ENOENT on the LOCK and was reported as busy. Caught by the existing round-trip
|
|
373
|
+
// tests within a minute of adding the lock, which is the argument for having them.
|
|
374
|
+
await mkdir(dirname(path), { recursive: true });
|
|
375
|
+
return await withFileLock(path, "approvals file", () => {
|
|
376
|
+
entered = true;
|
|
377
|
+
return work();
|
|
378
|
+
});
|
|
379
|
+
} catch {
|
|
380
|
+
// Never reached the load, so nothing may be claimed about the entry — whatever the cause.
|
|
381
|
+
return entered ? onError : onBusy;
|
|
382
|
+
}
|
|
383
|
+
}
|