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
package/src/approval.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval model for gated capabilities — pure, so it is exhaustively testable without pi.
|
|
3
|
+
*
|
|
4
|
+
* `resolve()` computes `gatedBlocked`: capabilities a session legitimately holds but which may not enter
|
|
5
|
+
* a child's grant without a human saying so. This module decides WHAT a yes means — how far it reaches,
|
|
6
|
+
* how long it lasts, and what it is keyed to. It never performs I/O and never prompts; see
|
|
7
|
+
* `approval-store.ts` and `approval-prompt.ts` for those.
|
|
8
|
+
*
|
|
9
|
+
* ADR-0010 records the four decisions encoded here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { WILDCARD } from "./pi-tools.ts";
|
|
13
|
+
import type { Capability, ResolveResult } from "./resolve.ts";
|
|
14
|
+
|
|
15
|
+
/** How far a single yes reaches in time. */
|
|
16
|
+
export type ApprovalScope = "once" | "session" | "always";
|
|
17
|
+
|
|
18
|
+
/** Where a yes came from, for the ledger. These call for different follow-ups, so they stay distinct. */
|
|
19
|
+
export type ApprovalSource = "prompt" | "session" | "persisted" | "inherited";
|
|
20
|
+
|
|
21
|
+
/** Which call site is asking. Determines the scopes offered — see `offeredScopes`. */
|
|
22
|
+
export type ApprovalPath = "definition" | "delegate";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Subject used for delegate-path approvals.
|
|
26
|
+
*
|
|
27
|
+
* The delegate tool has no human-authored subject: the only things naming a child are the task string and
|
|
28
|
+
* the tool list, both chosen by the model. A key the model controls is not a key, so delegate approvals
|
|
29
|
+
* use this fixed literal — "allow write for delegations this session" — and are never persisted.
|
|
30
|
+
* Angle brackets cannot appear in an agent-type name (`parseAgentType` reads an identifier), so this can
|
|
31
|
+
* never collide with a real type.
|
|
32
|
+
*/
|
|
33
|
+
export const DELEGATE_SUBJECT = "<delegate>";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lifetime of a persisted approval.
|
|
37
|
+
*
|
|
38
|
+
* Deliberately a constant rather than an environment variable: a knob that silently extends every gate is
|
|
39
|
+
* the first thing an impatient operator would reach for.
|
|
40
|
+
*/
|
|
41
|
+
export const APPROVAL_TTL_DAYS = 30;
|
|
42
|
+
|
|
43
|
+
const DAY_MS = 86_400_000;
|
|
44
|
+
|
|
45
|
+
/** `tool:write` + `docs-writer` -> `tool:write@docs-writer`. */
|
|
46
|
+
export function approvalKey(capability: Capability, subject: string): string {
|
|
47
|
+
return `${capability}@${subject}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* May this resolution raise an approval dialog?
|
|
52
|
+
*
|
|
53
|
+
* `resolve()` computes `denied` and `gatedBlocked` INDEPENDENTLY (deliberately — each rejection reason is
|
|
54
|
+
* reported rather than masked by whichever filter ran first), and both `decideSpawn` and `planDelegation`
|
|
55
|
+
* refuse outright on `denied` while still attaching the full result. So a request mixing a gated capability
|
|
56
|
+
* with one the session does not hold at all would otherwise prompt a human about the gated one and then be
|
|
57
|
+
* refused anyway for the other.
|
|
58
|
+
*
|
|
59
|
+
* That is not merely noise. A *session* yes is recorded and republished to children, and an *always* yes is
|
|
60
|
+
* written to disk for 30 days — both banked against a spawn that never happened, and both reachable by a
|
|
61
|
+
* model that appends one unheld capability to an otherwise ordinary request. A person is therefore only
|
|
62
|
+
* asked about a spawn that would actually proceed if they said yes.
|
|
63
|
+
*
|
|
64
|
+
* `clipped` is deliberately NOT a bar: it does not refuse a spawn, it just drops those capabilities from
|
|
65
|
+
* the child's grant, so the spawn does proceed and the question is real.
|
|
66
|
+
*/
|
|
67
|
+
export function shouldSeekApproval(result: ResolveResult | undefined): boolean {
|
|
68
|
+
if (!result) return false;
|
|
69
|
+
if (result.denied.length > 0) return false;
|
|
70
|
+
// ADR-0011. A grant retaining a universal capability is refused by `assertNarrowing` no matter what
|
|
71
|
+
// a human says, so asking is worse than useless: the dialog cannot change the outcome, and a
|
|
72
|
+
// `session`- or `always`-scoped yes given there is banked and reused for later spawns that DO
|
|
73
|
+
// proceed. Same harm the `denied` guard above exists to prevent.
|
|
74
|
+
if (result.universal.length > 0) return false;
|
|
75
|
+
return result.gatedBlocked.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Scopes a given call site may offer. `always` requires a **human-authored subject**.
|
|
80
|
+
*
|
|
81
|
+
* ADR-0019. The rule is unchanged; which paths satisfy it is not. `"interceptor"` used to be the only one
|
|
82
|
+
* and ADR-0016 deleted it, leaving `always` offerable from nowhere — 220 lines of persistence no live path
|
|
83
|
+
* could write to. `"definition"` is `delegate({agent})`, where ADR-0017 makes the subject an
|
|
84
|
+
* operator-authored file the session must hold `agent:<name>` to name at all. `"delegate"` is the `tools:`
|
|
85
|
+
* form, where the original reasoning stands untouched: the only things naming that child are the task and
|
|
86
|
+
* the tool list, both model-chosen, and a key the model controls is not a key.
|
|
87
|
+
*/
|
|
88
|
+
export function offeredScopes(path: ApprovalPath): ApprovalScope[] {
|
|
89
|
+
return path === "definition" ? ["once", "session", "always"] : ["once", "session"];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The approval an inheriting child may hold.
|
|
94
|
+
*
|
|
95
|
+
* Intersecting with the child's grant is what keeps ADR-0008 intact once approvals became inheritable:
|
|
96
|
+
* `approved ⊆ grant` at every level, by construction. An approval can therefore never name a capability
|
|
97
|
+
* the session does not hold — it only ever unblocks part of a grant, never widens one.
|
|
98
|
+
*
|
|
99
|
+
* The wildcard is filtered for the same reason `childEnv` filters it out of grants (R-26): inheriting it
|
|
100
|
+
* would let a descendant treat every future gate as pre-approved.
|
|
101
|
+
*/
|
|
102
|
+
/** An approval as it crosses a boundary: the capability, WHO it was for, and HOW LONG it was meant to last. */
|
|
103
|
+
export interface InheritableApproval {
|
|
104
|
+
capability: Capability;
|
|
105
|
+
/** The agent type, or `<delegate>`. Carried because an approval is for a subject, not for a word. */
|
|
106
|
+
subject: string;
|
|
107
|
+
scope: ApprovalScope;
|
|
108
|
+
/**
|
|
109
|
+
* The definition's body digest at the time of approval (ADR-0022). Absent for `<delegate>`, which names
|
|
110
|
+
* no file to hash — and, per ADR-0019, is never persisted or offered `always` for the same reason.
|
|
111
|
+
*/
|
|
112
|
+
bodySha256?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* What a child may inherit, as `capability@subject` keys.
|
|
117
|
+
*
|
|
118
|
+
* ADR-0014 changed this in two ways, and both were cases of a human's explicit choice being discarded
|
|
119
|
+
* one hop down:
|
|
120
|
+
*
|
|
121
|
+
* - **`once` is dropped** (A-S1). The scope chosen was not carried, so a `once` approval was written
|
|
122
|
+
* into the child's `PI_GRANTS_APPROVED` and republished onward — the most conservative answer a human
|
|
123
|
+
* can give produced the least conservative outcome, across an entire descendant subtree.
|
|
124
|
+
* - **The subject is kept** (A-S6). Bare capabilities were published, so a `<delegate>`-subject approval
|
|
125
|
+
* matched *any* subject below. `approvalKey`'s own doc argues at length that a model-controlled name
|
|
126
|
+
* is not a key; erasing the subject made that argument moot exactly where it mattered.
|
|
127
|
+
*
|
|
128
|
+
* The clamp to `grant` is unchanged and still load-bearing: **approval cannot conjure a capability**, so
|
|
129
|
+
* an inherited yes is only ever honoured for something the child independently holds.
|
|
130
|
+
*/
|
|
131
|
+
export function inheritApprovals(approved: InheritableApproval[], grant: Capability[]): string[] {
|
|
132
|
+
const held = new Set(grant);
|
|
133
|
+
return [
|
|
134
|
+
...new Set(
|
|
135
|
+
approved
|
|
136
|
+
.filter((a) => a.scope !== "once" && a.capability !== WILDCARD && held.has(a.capability))
|
|
137
|
+
// A definition subject MUST carry a pin to cross a boundary (ADR-0022, hardened after F1).
|
|
138
|
+
//
|
|
139
|
+
// `verifyInherited` honours an unpinned entry by decision — `<delegate>` names no file and a
|
|
140
|
+
// pre-0.11 parent sends none — so an unpinned entry for a subject that *does* name a file is an
|
|
141
|
+
// approval exempt from the digest check. Enforcing it at the point of PUBLICATION rather than
|
|
142
|
+
// trusting each caller to attach a digest is what makes it structural: two call sites build these
|
|
143
|
+
// entries, one of them forgot, and every fresh approval crossed unpinned as a result. A caller that
|
|
144
|
+
// cannot produce a digest (the definition is not on disk here) now publishes nothing, which is the
|
|
145
|
+
// fail-closed direction.
|
|
146
|
+
.filter((a) => a.subject === DELEGATE_SUBJECT || Boolean(a.bodySha256))
|
|
147
|
+
.map((a) => approvalKey(a.capability, a.subject) + (a.bodySha256 ? `#${a.bodySha256}` : "")),
|
|
148
|
+
),
|
|
149
|
+
].sort();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the inherited set back on the child side.
|
|
154
|
+
*
|
|
155
|
+
* Anything that is not a well-formed `capability@subject` pair is dropped rather than guessed at. An
|
|
156
|
+
* unparseable entry granting nothing is a missing prompt; an unparseable entry granting *something* is a
|
|
157
|
+
* silent escalation, so the direction of the failure is not a matter of taste.
|
|
158
|
+
*/
|
|
159
|
+
export function parseInherited(raw: string | undefined): Map<string, string | undefined> {
|
|
160
|
+
const out = new Map<string, string | undefined>();
|
|
161
|
+
for (const item of (raw ?? "").split(",")) {
|
|
162
|
+
const trimmed = item.trim();
|
|
163
|
+
// ADR-0022 appends `#<sha256>`. Split it off FIRST: a `#` cannot appear in a capability id or in a
|
|
164
|
+
// definition name (identity comes from a path component), so this is unambiguous.
|
|
165
|
+
const hash = trimmed.indexOf("#");
|
|
166
|
+
const key = hash === -1 ? trimmed : trimmed.slice(0, hash);
|
|
167
|
+
const digest = hash === -1 ? undefined : trimmed.slice(hash + 1);
|
|
168
|
+
const at = key.indexOf("@");
|
|
169
|
+
if (at <= 0 || at === key.length - 1) continue;
|
|
170
|
+
if (hash !== -1 && !digest) continue; // `key#` promises a pin and carries none — drop it, do not guess
|
|
171
|
+
out.set(key, digest);
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Drop inherited approvals that were given for different instructions (ADR-0022).
|
|
178
|
+
*
|
|
179
|
+
* The hole this closes: `resolveApprovals` checks `inherited` FIRST and none of it had ever been through
|
|
180
|
+
* `entryVerdict`, so ADR-0019's headline property — an approval is void once the instructions change — held
|
|
181
|
+
* on the one path that persists and neither of the two that do not. A child is a fresh process: it re-reads
|
|
182
|
+
* the definition from disk, so a `git pull` between the parent's approval and the child's spawn meant the
|
|
183
|
+
* child ran a rewritten body under a yes given about the old one, recording `approvalSource: "inherited"`.
|
|
184
|
+
*
|
|
185
|
+
* An entry with **no** digest is honoured, and that is not a hole: `<delegate>` legitimately has none, and
|
|
186
|
+
* neither does a pre-0.11 parent. It is the same trade `entryVerdict` refuses to make for a *persisted*
|
|
187
|
+
* entry — but that entry is 30 days old and read off disk, whereas this one was handed over by a live parent
|
|
188
|
+
* process in the same tree, which is a much shorter chain to trust.
|
|
189
|
+
*/
|
|
190
|
+
export function verifyInherited(
|
|
191
|
+
parsed: ReadonlyMap<string, string | undefined>,
|
|
192
|
+
snapshotOf: (subject: string) => SubjectSnapshot | null,
|
|
193
|
+
): Set<string> {
|
|
194
|
+
const out = new Set<string>();
|
|
195
|
+
for (const [key, digest] of parsed) {
|
|
196
|
+
if (digest === undefined) {
|
|
197
|
+
out.add(key);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const current = snapshotOf(key.slice(key.indexOf("@") + 1));
|
|
201
|
+
if (current && current.bodySha256 === digest) out.add(key);
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** When an approval granted now stops being valid. Computed once at write time and stored, so an entry's
|
|
207
|
+
* lifetime is visible in the file rather than implied by whichever version of the code reads it. */
|
|
208
|
+
export function expiryFor(approvedAt: Date): string {
|
|
209
|
+
return new Date(approvedAt.getTime() + APPROVAL_TTL_DAYS * DAY_MS).toISOString();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A persisted approval, keyed externally by subject and capability. Only the `"definition"` path writes one. */
|
|
213
|
+
export interface ApprovalEntry {
|
|
214
|
+
approvedAt: string;
|
|
215
|
+
expiresAt: string;
|
|
216
|
+
/** The directory the human was sitting in. See `entryVerdict` and R-27. */
|
|
217
|
+
cwd: string;
|
|
218
|
+
/** The agent type's ceiling AT APPROVAL TIME. Load-bearing, not decorative — see `entryVerdict`. */
|
|
219
|
+
grantAtApproval: Capability[];
|
|
220
|
+
/*
|
|
221
|
+
* `taskAtApproval` was here and is GONE (ADR-0021). It stored the model-authored task string, which
|
|
222
|
+
* `src/ledger.ts` forbids in unqualified terms — "the task is not recorded, anywhere, ever" — and the
|
|
223
|
+
* approval store is a worse home for it than the ledger by ADR-0018's own criteria: always-on, outside
|
|
224
|
+
* the repository, kept for 30 days. It also read as a scope it never was, since the entry authorises ANY
|
|
225
|
+
* task for that definition. Both reviewers found it independently. `approval-store.ts`'s `sanitise`
|
|
226
|
+
* strips it from any entry this version rewrites.
|
|
227
|
+
*/
|
|
228
|
+
/**
|
|
229
|
+
* The definition's body digest AT APPROVAL TIME (ADR-0019, using ADR-0018's hash).
|
|
230
|
+
*
|
|
231
|
+
* `grantAtApproval` pins the tools; this pins the INSTRUCTIONS. Without it an `always` approval survives
|
|
232
|
+
* a total rewrite of what the child is told to do, because `ceilingForDefinition` reads only
|
|
233
|
+
* `allowed-tools` — R-35's hazard reappearing inside the persistence layer. Absent means the entry
|
|
234
|
+
* predates 0.10.0 and cannot be verified, which `entryVerdict` treats as changed: fail closed.
|
|
235
|
+
*/
|
|
236
|
+
bodyAtApproval?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type EntryVerdict =
|
|
240
|
+
| "valid"
|
|
241
|
+
| "expired"
|
|
242
|
+
| "foreign-cwd"
|
|
243
|
+
| "type-changed"
|
|
244
|
+
| "instructions-changed"
|
|
245
|
+
| "type-missing";
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* What a subject looks like RIGHT NOW — one lookup, not two (ADR-0019).
|
|
249
|
+
*
|
|
250
|
+
* Deliberately a single snapshot rather than parallel `ceilingOf` / `digestOf` callbacks. R-28 was one
|
|
251
|
+
* call site supplying one argument and omitting another; a shape that cannot be half-supplied is the
|
|
252
|
+
* structural form of remembering.
|
|
253
|
+
*/
|
|
254
|
+
export interface SubjectSnapshot {
|
|
255
|
+
ceiling: Capability[];
|
|
256
|
+
/** SHA-256 of the definition body — `digestDefinition(...).sha256`. */
|
|
257
|
+
bodySha256: string;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface EntryValidityInput {
|
|
261
|
+
entry: ApprovalEntry;
|
|
262
|
+
/** The directory this session is running in. */
|
|
263
|
+
cwd: string;
|
|
264
|
+
now: Date;
|
|
265
|
+
/** The subject's CURRENT ceiling and body digest, or null when it no longer exists. */
|
|
266
|
+
current: SubjectSnapshot | null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Decide whether a persisted approval still means what the human meant.
|
|
271
|
+
*
|
|
272
|
+
* Four ways it can stop meaning that, and each is reported distinctly so `/grants approvals` can explain
|
|
273
|
+
* itself rather than silently showing fewer rows:
|
|
274
|
+
*
|
|
275
|
+
* - `foreign-cwd` — the file was copied or committed and opened somewhere else. Nobody in THIS checkout
|
|
276
|
+
* was asked, so it authorises nothing here (R-27).
|
|
277
|
+
* - `expired` — a gate opened during one project must not still be open next quarter.
|
|
278
|
+
* - `type-changed` — the confused deputy. The key names a file whose contents can change after approval:
|
|
279
|
+
* approve `tool:write@docs-writer` when it declares `read, write`, and later that file
|
|
280
|
+
* gains `bash`. The entry would still match the key while describing something the
|
|
281
|
+
* human never saw.
|
|
282
|
+
* - `instructions-changed` — the same confused deputy, one level deeper. The tools are untouched but the
|
|
283
|
+
* BODY was rewritten, so the child would now be told to do something the human never
|
|
284
|
+
* saw. Only checkable since ADR-0018 gave the body a digest; an entry carrying no body
|
|
285
|
+
* pin lands here too, because unverifiable is not the same as unchanged.
|
|
286
|
+
* - `type-missing` — the type was deleted or renamed; a new file could later claim the same name.
|
|
287
|
+
*/
|
|
288
|
+
export function entryVerdict(input: EntryValidityInput): EntryVerdict {
|
|
289
|
+
if (input.entry.cwd !== input.cwd) return "foreign-cwd";
|
|
290
|
+
const expiresAt = new Date(input.entry.expiresAt).getTime();
|
|
291
|
+
// NaN <= x is false in JS, so an unparseable date would otherwise fail OPEN. A cache we cannot
|
|
292
|
+
// read the expiry of is a cache we do not trust — treat it as expired, per the spec's rule that a
|
|
293
|
+
// broken cache grants nothing.
|
|
294
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= input.now.getTime()) return "expired";
|
|
295
|
+
if (input.current === null) return "type-missing";
|
|
296
|
+
// Compare as sorted lists: reformatting or reordering a `tools:` line is not a change; adding,
|
|
297
|
+
// removing, or renaming a capability is.
|
|
298
|
+
const approved = [...input.entry.grantAtApproval].sort().join(",");
|
|
299
|
+
const current = [...input.current.ceiling].sort().join(",");
|
|
300
|
+
if (approved !== current) return "type-changed";
|
|
301
|
+
// Fail closed on an unpinned entry: it was written before bodies were digested, so "unchanged" is not
|
|
302
|
+
// something this code can assert about it. One re-approval is the honest cost.
|
|
303
|
+
if (input.entry.bodyAtApproval !== input.current.bodySha256) return "instructions-changed";
|
|
304
|
+
return "valid";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface ResolveApprovalsInput {
|
|
308
|
+
/** `ResolveResult.gatedBlocked` — held and within ceiling, but awaiting a human. */
|
|
309
|
+
gated: Capability[];
|
|
310
|
+
subject: string;
|
|
311
|
+
/** Approval KEYS approved for this session, in memory only. */
|
|
312
|
+
sessionApprovals: ReadonlySet<string>;
|
|
313
|
+
/** Persisted entries by key, ALREADY validity-filtered by the store. */
|
|
314
|
+
persisted: ReadonlyMap<string, ApprovalEntry>;
|
|
315
|
+
/** Capabilities approved further up the tree and inherited with the grant. */
|
|
316
|
+
/**
|
|
317
|
+
* `capability@subject` keys inherited from the delegator (ADR-0014).
|
|
318
|
+
*
|
|
319
|
+
* Was `Capability[]` — bare names that matched any subject, and included `once` approvals that were
|
|
320
|
+
* never meant to leave the level they were given at.
|
|
321
|
+
*/
|
|
322
|
+
inherited?: Set<string>;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface ResolveApprovalsResult {
|
|
326
|
+
approved: Capability[];
|
|
327
|
+
/** Gated capabilities still requiring a live human. */
|
|
328
|
+
needsPrompt: Capability[];
|
|
329
|
+
sources: Record<Capability, ApprovalSource>;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Satisfy as much of `gated` as possible without asking anyone.
|
|
334
|
+
*
|
|
335
|
+
* Precedence is inherited -> session -> persisted -> prompt. Order matters only for what gets REPORTED
|
|
336
|
+
* (the ledger's `approvalSource`); any hit satisfies equally. Checking all three before prompting is what
|
|
337
|
+
* stops an orchestrator's tenth delegation from raising a tenth identical dialog.
|
|
338
|
+
*/
|
|
339
|
+
export function resolveApprovals(input: ResolveApprovalsInput): ResolveApprovalsResult {
|
|
340
|
+
// ADR-0014: these are `capability@subject` keys now, not bare capabilities, so an approval given for
|
|
341
|
+
// one subject can no longer satisfy another.
|
|
342
|
+
const inherited = input.inherited ?? new Set<string>();
|
|
343
|
+
const approved: Capability[] = [];
|
|
344
|
+
const needsPrompt: Capability[] = [];
|
|
345
|
+
const sources: Record<Capability, ApprovalSource> = {};
|
|
346
|
+
|
|
347
|
+
for (const capability of [...new Set(input.gated)].sort()) {
|
|
348
|
+
const key = approvalKey(capability, input.subject);
|
|
349
|
+
if (inherited.has(key)) {
|
|
350
|
+
approved.push(capability);
|
|
351
|
+
sources[capability] = "inherited";
|
|
352
|
+
} else if (input.sessionApprovals.has(key)) {
|
|
353
|
+
approved.push(capability);
|
|
354
|
+
sources[capability] = "session";
|
|
355
|
+
} else if (input.persisted.has(key)) {
|
|
356
|
+
approved.push(capability);
|
|
357
|
+
sources[capability] = "persisted";
|
|
358
|
+
} else {
|
|
359
|
+
needsPrompt.push(capability);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { approved, needsPrompt, sources };
|
|
364
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability ids: their grammar, and the two authority predicates over them.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `delegate.ts` when ADR-0024 pushed that file past the 400-line ceiling and
|
|
5
|
+
* `test/file-size.test.ts` refused it — for the second time, and answered the same way as the first: split
|
|
6
|
+
* the file the way the failure message says rather than raise the cap on the guard.
|
|
7
|
+
*
|
|
8
|
+
* The seam is not arbitrary. Three modules outside `delegate.ts` already imported these
|
|
9
|
+
* (`extensions/session.ts` for `DELEGATE_CAPABILITY`, `extensions/delegation.ts` and
|
|
10
|
+
* `extensions/run-delegation.ts` for `maySpawnDefinition`), which is the evidence that they were a separate
|
|
11
|
+
* concern living in the wrong file: *what an id means and who may use it* is a different question from
|
|
12
|
+
* *what one delegation does*.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
|
|
16
|
+
import { WILDCARD } from "./pi-tools.ts";
|
|
17
|
+
|
|
18
|
+
/** The capability that authorises spawning a definition (ADR-0017). `tool:*` satisfies any of them. */
|
|
19
|
+
export const agentCapability = (name: string): Capability => `agent:${name}`;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* May this grant spawn that definition? (ADR-0017.)
|
|
23
|
+
*
|
|
24
|
+
* `resolve()` is exact-match plus subsumption and has no wildcard rule — a wildcard session works only
|
|
25
|
+
* because `deriveOwnGrant` *enumerates* its observed tools alongside `tool:*`. Definitions are not tools,
|
|
26
|
+
* so nothing enumerates them, and the wildcard has to be honoured here explicitly. Without that an
|
|
27
|
+
* UNGOVERNED session would stop being able to spawn, and "governance is opt-in" is the one rule this
|
|
28
|
+
* package must never break by accident.
|
|
29
|
+
*/
|
|
30
|
+
export function maySpawnDefinition(ownGrant: Capability[], name: string): boolean {
|
|
31
|
+
// ADR-0023 adds the middle case. `tool:*` is authority to grant every tool and satisfies this too;
|
|
32
|
+
// `agent:*` is authority to spawn any definition and grants no tools at all, which is the configuration
|
|
33
|
+
// an operator wanting "any of our definitions, narrow tools" previously had to fake with `tool:*`.
|
|
34
|
+
return (
|
|
35
|
+
ownGrant.includes(WILDCARD) ||
|
|
36
|
+
ownGrant.includes(AGENT_WILDCARD) ||
|
|
37
|
+
ownGrant.includes(agentCapability(name))
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The tool name that confers the ability to delegate further. */
|
|
42
|
+
export const DELEGATE_CAPABILITY: Capability = "tool:delegate";
|
|
43
|
+
|
|
44
|
+
/** Accept `read` or `tool:read` or `ext:pkg/tool` and normalise to a capability id. */
|
|
45
|
+
export function normaliseCapability(raw: string): Capability {
|
|
46
|
+
const value = raw.trim();
|
|
47
|
+
if (value.startsWith("tool:") || value.startsWith("ext:") || value.startsWith("skill:") || value.startsWith("agent:")) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
return `tool:${value}`;
|
|
51
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live capability catalog — what *can* be granted, enumerated at runtime.
|
|
3
|
+
*
|
|
4
|
+
* Until now grants were checked against agent-type files alone, which covers `tool:` capabilities and
|
|
5
|
+
* nothing else. That leaves two gaps: extension tools are invisible (so `ext:` grants cannot be
|
|
6
|
+
* validated), and **skills were ungovernable** despite "skills and tools" being half the requirement.
|
|
7
|
+
*
|
|
8
|
+
* Sources, and why each is trusted:
|
|
9
|
+
* - **tools** — the `tools` array of a live provider request. Authoritative: it is exactly what pi sent
|
|
10
|
+
* the model, so it includes extension-registered tools and reflects any `--tools` allowlist already in
|
|
11
|
+
* force. Nothing else can see the real surface.
|
|
12
|
+
* - **skills** — `SKILL.md` directories and top-level `.md` files under pi's skill roots.
|
|
13
|
+
* - **definitions** — spawnable `SKILL.md` agents (ADR-0016), as `agent:<name>`.
|
|
14
|
+
*
|
|
15
|
+
* Provenance note: a provider payload gives tool NAMES, not owning packages, so extension tools cannot be
|
|
16
|
+
* qualified as `ext:<pkg>/<tool>` from that source alone. They are catalogued as `tool:<name>` — which is
|
|
17
|
+
* also how pi's `--tools` matches — and marked `kind: "extension"` for display. `ext:` ids remain
|
|
18
|
+
* supported for hand-authored grants; `deriveOwnGrant` already matches them by bare name.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readdir, stat } from "node:fs/promises";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { loadDefinitions, type SkillDefinition } from "./definitions.ts";
|
|
25
|
+
import { PI_BUILTIN_TOOLS, WILDCARD } from "./pi-tools.ts";
|
|
26
|
+
import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
|
|
27
|
+
|
|
28
|
+
export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType";
|
|
29
|
+
|
|
30
|
+
export interface CatalogEntry {
|
|
31
|
+
capability: Capability;
|
|
32
|
+
kind: CapabilityKind;
|
|
33
|
+
/** Where it was found, for display and debugging. */
|
|
34
|
+
source?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Catalog {
|
|
38
|
+
entries: CatalogEntry[];
|
|
39
|
+
/** Every capability id in the catalog. */
|
|
40
|
+
all: Capability[];
|
|
41
|
+
byKind(kind: CapabilityKind): Capability[];
|
|
42
|
+
has(capability: Capability): boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Split observed tool names into pi built-ins and extension-provided tools. */
|
|
46
|
+
export function classifyToolNames(observed: string[]): CatalogEntry[] {
|
|
47
|
+
const builtins = new Set<string>(PI_BUILTIN_TOOLS);
|
|
48
|
+
return [...new Set(observed)].sort().map((name) => ({
|
|
49
|
+
capability: `tool:${name}`,
|
|
50
|
+
kind: builtins.has(name) ? ("builtin" as const) : ("extension" as const),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Skill roots pi discovers, project first. */
|
|
55
|
+
export function skillDirs(cwd: string): string[] {
|
|
56
|
+
return [join(cwd, ".pi", "skills"), join(homedir(), ".pi", "agent", "skills")];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Discover skills: a directory containing `SKILL.md` is one skill named after the directory; a top-level
|
|
61
|
+
* `.md` file is a skill named after the file. Mirrors pi's documented convention.
|
|
62
|
+
*
|
|
63
|
+
* Directories are not descended into beyond one level, matching pi's rule that a directory containing
|
|
64
|
+
* `SKILL.md` is a single skill rather than a tree to explore.
|
|
65
|
+
*/
|
|
66
|
+
export async function loadSkills(cwd: string): Promise<CatalogEntry[]> {
|
|
67
|
+
const found = new Map<string, CatalogEntry>();
|
|
68
|
+
for (const dir of skillDirs(cwd)) {
|
|
69
|
+
let names: string[];
|
|
70
|
+
try {
|
|
71
|
+
names = await readdir(dir);
|
|
72
|
+
} catch {
|
|
73
|
+
continue; // absent skill root is normal
|
|
74
|
+
}
|
|
75
|
+
for (const name of names.sort()) {
|
|
76
|
+
const path = join(dir, name);
|
|
77
|
+
try {
|
|
78
|
+
const info = await stat(path);
|
|
79
|
+
if (info.isDirectory()) {
|
|
80
|
+
const inner = await readdir(path).catch(() => [] as string[]);
|
|
81
|
+
if (inner.includes("SKILL.md") && !found.has(name)) {
|
|
82
|
+
found.set(name, { capability: `skill:${name}`, kind: "skill", source: path });
|
|
83
|
+
}
|
|
84
|
+
} else if (name.endsWith(".md")) {
|
|
85
|
+
const skill = name.replace(/\.md$/, "");
|
|
86
|
+
if (!found.has(skill)) {
|
|
87
|
+
found.set(skill, { capability: `skill:${skill}`, kind: "skill", source: path });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// An unreadable entry is simply not catalogued; it therefore cannot be granted, which is the
|
|
92
|
+
// fail-closed direction.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...found.values()];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Spawnable definitions, as `agent:<name>` capabilities.
|
|
101
|
+
*
|
|
102
|
+
* A definition is BOTH a skill (loadable into a session) and an agent (spawnable as a child) — ADR-0016
|
|
103
|
+
* collapsed those into one file — so the same `SKILL.md` legitimately appears twice in the catalog under
|
|
104
|
+
* two capability ids. That is not duplication: `skill:review` means "may load these instructions" and
|
|
105
|
+
* `agent:review` means "may spawn a child running them", and a grant can hold either without the other.
|
|
106
|
+
*/
|
|
107
|
+
export function definitionEntries(definitions: Map<string, SkillDefinition>): CatalogEntry[] {
|
|
108
|
+
return [...definitions.values()].map((d) => ({
|
|
109
|
+
capability: `agent:${d.name}`,
|
|
110
|
+
kind: "agentType" as const,
|
|
111
|
+
source: d.source,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
|
|
116
|
+
export function makeCatalog(entries: CatalogEntry[]): Catalog {
|
|
117
|
+
const deduped = new Map<Capability, CatalogEntry>();
|
|
118
|
+
for (const entry of entries) if (!deduped.has(entry.capability)) deduped.set(entry.capability, entry);
|
|
119
|
+
const list = [...deduped.values()].sort((a, b) => a.capability.localeCompare(b.capability));
|
|
120
|
+
const ids = list.map((e) => e.capability);
|
|
121
|
+
const idSet = new Set(ids);
|
|
122
|
+
return {
|
|
123
|
+
entries: list,
|
|
124
|
+
all: ids,
|
|
125
|
+
byKind: (kind) => list.filter((e) => e.kind === kind).map((e) => e.capability),
|
|
126
|
+
has: (capability) => idSet.has(capability),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Build the live catalog. `observedTools` comes from a provider payload; null when not yet seen. */
|
|
131
|
+
export async function buildCatalog(input: {
|
|
132
|
+
cwd: string;
|
|
133
|
+
observedTools: string[] | null;
|
|
134
|
+
}): Promise<Catalog> {
|
|
135
|
+
const [skills, definitions] = await Promise.all([loadSkills(input.cwd), loadDefinitions(input.cwd)]);
|
|
136
|
+
return makeCatalog([
|
|
137
|
+
// pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
|
|
138
|
+
// consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
|
|
139
|
+
// every capability looked "unknown" until the first model call, so the preview refused grants that
|
|
140
|
+
// enforcement would have allowed: R-28's failure shape (a diagnostic disagreeing with the enforcer)
|
|
141
|
+
// reappearing through a different door.
|
|
142
|
+
//
|
|
143
|
+
// The trade-off, stated plainly: in a session started with `--tools read`, this still lists `bash`
|
|
144
|
+
// as an existing capability, so a delegation naming it passes the *unknown* check and is refused by
|
|
145
|
+
// the *grant* check instead ("this session does not hold it"). That is the better error anyway, and
|
|
146
|
+
// the grant check — not this catalog — is the authority. Nothing here grants anything.
|
|
147
|
+
...PI_BUILTIN_TOOLS.map((name) => ({ capability: `tool:${name}` as const, kind: "builtin" as const })),
|
|
148
|
+
...(input.observedTools ? classifyToolNames(input.observedTools) : []),
|
|
149
|
+
...skills,
|
|
150
|
+
...definitionEntries(definitions),
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Capabilities requested that the catalog does not contain.
|
|
156
|
+
*
|
|
157
|
+
* Reported separately from `denied` because the causes differ and so do the fixes: `denied` means the
|
|
158
|
+
* delegator lacks authority, `unknown` means the capability does not exist here — usually a typo or a
|
|
159
|
+
* stale grant referring to an uninstalled package. Silently treating unknown as denied hides that.
|
|
160
|
+
*/
|
|
161
|
+
export function unknownCapabilities(requested: Capability[], catalog: Catalog): Capability[] {
|
|
162
|
+
// Wildcards are GRAMMAR, not entries. Nothing enumerates them into the catalog — `definitionEntries`
|
|
163
|
+
// emits `agent:<name>` per discovered definition and `PI_BUILTIN_TOOLS` contains no `*` — so this check
|
|
164
|
+
// reported `agent:*` as *"not present in this session's catalog (typo, or an uninstalled package?)"* and
|
|
165
|
+
// refused it BEFORE `resolve` could apply ADR-0023's rule. That made the ADR's "a parent holding
|
|
166
|
+
// `agent:*` may hand down `agent:*`" false, and made a definition declaring `allowed-tools: agent:*`
|
|
167
|
+
// unspawnable from any grant. The wildcard is live only at the root without this.
|
|
168
|
+
return requested.filter((c) => c !== WILDCARD && c !== AGENT_WILDCARD && !catalog.has(c)).sort();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Skill name -> absolute path, for `planSpawn`'s `--skill` flags (R-32).
|
|
173
|
+
*
|
|
174
|
+
* Derived from the catalog's own `source` field rather than re-scanning, so what a child is handed
|
|
175
|
+
* cannot drift from what was discovered and offered. A skill entry without a source is omitted, which
|
|
176
|
+
* makes it *unresolvable* rather than silently absent — `planDelegation` refuses on that, because a
|
|
177
|
+
* grant naming a skill the child never receives is a ledger line that lies.
|
|
178
|
+
*/
|
|
179
|
+
export function skillPathsFromCatalog(catalog: Catalog): Record<string, string> {
|
|
180
|
+
const paths: Record<string, string> = {};
|
|
181
|
+
for (const entry of catalog.entries) {
|
|
182
|
+
if (entry.kind !== "skill" || !entry.source) continue;
|
|
183
|
+
paths[entry.capability.slice("skill:".length)] = entry.source;
|
|
184
|
+
}
|
|
185
|
+
return paths;
|
|
186
|
+
}
|