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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/grants` command — session status, approvals, and ledger integrity.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `extensions/grants.ts` because that file is where every wiring bug in this package has
|
|
5
|
+
* lived (the G7 `NaN` bound, the discarded `isError`, the unconditional `delegate` registration, R-28's
|
|
6
|
+
* omitted argument), and three independent reviewers flagged its size before any of them was found. A
|
|
7
|
+
* read-only diagnostic is the cleanest thing to lift out: nothing calls it, so it cannot be part of a
|
|
8
|
+
* governance path.
|
|
9
|
+
*
|
|
10
|
+
* It takes its dependencies as an **explicit context object** rather than closing over module state. That is
|
|
11
|
+
* the point of the extraction: what this command can see is now written down in one interface instead of
|
|
12
|
+
* being whatever happened to be in scope.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Capability } from "../src/resolve.ts";
|
|
16
|
+
import type { Catalog } from "../src/catalog.ts";
|
|
17
|
+
import type { SkillDefinition } from "../src/definitions.ts";
|
|
18
|
+
import type { GatedPlan } from "./run-delegation.ts";
|
|
19
|
+
import { loadApprovals, revokeAll, revokeApproval, type SubjectLookup } from "../src/approval-store.ts";
|
|
20
|
+
import { verifyLedger } from "../src/ledger.ts";
|
|
21
|
+
|
|
22
|
+
export interface GrantsCommandContext {
|
|
23
|
+
cwd: string;
|
|
24
|
+
governed: boolean;
|
|
25
|
+
ownGrant: Capability[];
|
|
26
|
+
observed: boolean;
|
|
27
|
+
depth: number;
|
|
28
|
+
maxDepth: number;
|
|
29
|
+
ledgerPath?: string;
|
|
30
|
+
catalog: Catalog;
|
|
31
|
+
definitions: Map<string, SkillDefinition>;
|
|
32
|
+
sessionApprovals: Set<string>;
|
|
33
|
+
/** Key → body digest, as `parseInherited` returns it (ADR-0022). Only `.size` is read here. */
|
|
34
|
+
inheritedApprovals: Map<string, string | undefined>;
|
|
35
|
+
/** A definition's current ceiling and body digest, for the store's confused-deputy check (ADR-0019). */
|
|
36
|
+
snapshotOf: SubjectLookup;
|
|
37
|
+
/**
|
|
38
|
+
* What a real `delegate({agent: name})` would do, decided by the code that would do it.
|
|
39
|
+
*
|
|
40
|
+
* Was `delegationContext` plus a `planDelegation` call here, which shared the planner but not the
|
|
41
|
+
* **approval step** — so a definition whose one gated capability was covered by a valid persisted approval
|
|
42
|
+
* was listed as blocked while a spawn proceeded silently (R-38). Injecting the whole preview keeps the
|
|
43
|
+
* R-28 discipline where it belongs: this command asks what would happen instead of working it out.
|
|
44
|
+
*/
|
|
45
|
+
previewDelegation: (name: string) => Promise<GatedPlan>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How many definitions `/grants` previews. Each one runs the real planner, so this bounds work, not truth —
|
|
50
|
+
* and whatever it drops is now stated rather than silently omitted (R-48).
|
|
51
|
+
*/
|
|
52
|
+
const PREVIEW_LIMIT = 12;
|
|
53
|
+
|
|
54
|
+
export const grantsCommand = {
|
|
55
|
+
description:
|
|
56
|
+
"Show this session's capability grant, delegation depth, and known agent-type ceilings; " +
|
|
57
|
+
"/grants approvals | /grants ledger | /grants revoke <key>|--all",
|
|
58
|
+
handler: async (args: string, ctx: any) => {
|
|
59
|
+
// Everything this command may see, named in one place. Previously these were whatever happened to be in
|
|
60
|
+
// the enclosing closure — which is how a diagnostic came to disagree with the enforcer (R-28).
|
|
61
|
+
const {
|
|
62
|
+
cwd, governed, ownGrant, observed, depth, maxDepth, ledgerPath,
|
|
63
|
+
catalog, definitions, sessionApprovals, inheritedApprovals, snapshotOf, previewDelegation,
|
|
64
|
+
} = ctx.grants as GrantsCommandContext;
|
|
65
|
+
|
|
66
|
+
const [sub, target] = args.trim().split(/\s+/).filter(Boolean);
|
|
67
|
+
|
|
68
|
+
if (sub === "ledger") {
|
|
69
|
+
// The detector, made reachable. `verifyLedger` exists because nothing in this package had ever read
|
|
70
|
+
// a ledger back, so a torn line was indistinguishable from a spawn that never happened — and a
|
|
71
|
+
// check an operator cannot run is not a control.
|
|
72
|
+
if (!ledgerPath) {
|
|
73
|
+
ctx.ui.notify("grants: no ledger — set PI_GRANTS_LEDGER to record grants and refusals.", "warning");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const report = await verifyLedger(ledgerPath);
|
|
77
|
+
if (!report.exists) {
|
|
78
|
+
ctx.ui.notify(`grants: ledger ${ledgerPath} does not exist yet (nothing has been delegated).`, "info");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const lines = [
|
|
82
|
+
`grants: ledger ${ledgerPath}`,
|
|
83
|
+
` records ${report.records}`,
|
|
84
|
+
` escalation ${report.escalationAttempts} attempt(s) — grants refused for exceeding what the session held`,
|
|
85
|
+
` integrity ${report.ok ? "OK" : `${report.corrupt.length} UNPARSEABLE LINE(S)`}`,
|
|
86
|
+
];
|
|
87
|
+
for (const bad of report.corrupt.slice(0, 5)) lines.push(` line ${bad.line}: ${bad.text}`);
|
|
88
|
+
// R-51. ADR-0018 advertises that the ledger answers "did these four children run the same
|
|
89
|
+
// instructions?" and "has this definition changed since?" — and nothing read `definitionDigest`, so
|
|
90
|
+
// both needed hand-written jq and the second was not reproducible with `sha256sum` (the digest covers
|
|
91
|
+
// the body, not the frontmatter). The comparison against disk uses the SAME `snapshotOf` the approval
|
|
92
|
+
// store uses, so this listing cannot disagree with what voids an approval.
|
|
93
|
+
if (report.definitions.length > 0) {
|
|
94
|
+
lines.push(` instructions ${report.definitions.length} distinct version(s) across the recorded spawns`);
|
|
95
|
+
for (const d of report.definitions) {
|
|
96
|
+
const here = definitions.get(d.name);
|
|
97
|
+
const current = snapshotOf(d.name);
|
|
98
|
+
// F4: compare the SOURCE too. A ledger path exported once in a shell profile is shared by every
|
|
99
|
+
// project — nothing scopes `PI_GRANTS_LEDGER` per project — so two different `deploy` definitions
|
|
100
|
+
// in two checkouts were reported as one definition that had CHANGED, and the NOTE below called it
|
|
101
|
+
// a finding. `verifyLedger` has carried `source` all along; the listing simply never read it.
|
|
102
|
+
const state =
|
|
103
|
+
current === null
|
|
104
|
+
? "no such definition here"
|
|
105
|
+
: here && here.source !== d.source
|
|
106
|
+
? "another project's definition of the same name"
|
|
107
|
+
: current.bodySha256 === d.sha256
|
|
108
|
+
? "current"
|
|
109
|
+
: "CHANGED since";
|
|
110
|
+
lines.push(` ${d.name} ${d.sha256.slice(0, 12)} ${d.spawns} spawn(s) — ${state}`);
|
|
111
|
+
}
|
|
112
|
+
// Two rows for one name is the finding, not a formatting quirk: the same definition ran under two
|
|
113
|
+
// different bodies inside this ledger.
|
|
114
|
+
// Only versions of the SAME file count as "ran under more than one version" — grouping by name
|
|
115
|
+
// alone turned two projects' same-named definitions into a fabricated instruction change.
|
|
116
|
+
const names = report.definitions.map((d) => `${d.name}\u0000${d.source}`);
|
|
117
|
+
for (const key of [...new Set(names)]) {
|
|
118
|
+
const name = key.slice(0, key.indexOf("\u0000"));
|
|
119
|
+
if (names.filter((n) => n === key).length > 1) {
|
|
120
|
+
lines.push(` NOTE ${name} ran under more than one version of its instructions in this ledger`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// ADR-0020's revisit trigger, made runnable. That ADR keeps the persistence layer on an ASSERTED
|
|
125
|
+
// fatigue argument and named the evidence that would settle it — `persisted` against `prompt` over a
|
|
126
|
+
// few weeks of real use — then said it "needs no new machinery". True of the data, false of the
|
|
127
|
+
// answer: nothing counted it, so the measurement required hand-written jq and never happened. R-51's
|
|
128
|
+
// shape exactly, and the reason that entry exists.
|
|
129
|
+
const { bySource, distinctBySource, unattributed, humanDenied, humanDeniedPairs } = report.approvals;
|
|
130
|
+
const attributed = Object.values(bySource).reduce((sum, n) => sum + n, 0);
|
|
131
|
+
// **Declines are reported on their own, not folded into the approvals block.** They were rendered
|
|
132
|
+
// inside this guard, so a ledger of nothing BUT declines — a session where the operator said no to
|
|
133
|
+
// everything, which is the strongest possible evidence the gate is working and the most alarming
|
|
134
|
+
// shape an audit can take — printed no mention of them at all. The number that argues hardest for
|
|
135
|
+
// this package's gating was invisible in exactly the ledger that argues hardest.
|
|
136
|
+
if (humanDenied > 0) {
|
|
137
|
+
lines.push(
|
|
138
|
+
` declined ${humanDenied} record(s) across ${humanDeniedPairs} distinct capability@subject ` +
|
|
139
|
+
`pair(s) — a human was asked and said no. Pairs is the number of DECISIONS: one *Deny* under a ` +
|
|
140
|
+
`fan-out writes one record per child.`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (attributed > 0 || unattributed > 0) {
|
|
144
|
+
lines.push(
|
|
145
|
+
` approvals ${bySource.prompt} prompt · ${bySource.persisted} persisted · ${bySource.session} session · ` +
|
|
146
|
+
`${bySource.inherited} inherited`,
|
|
147
|
+
);
|
|
148
|
+
// **Two numbers, because one of them would lie.** Counting `persisted` RECORDS as prompts avoided
|
|
149
|
+
// overstates the layer twentyfold in the obvious case: precedence is inherited → session →
|
|
150
|
+
// persisted → prompt, and `session` approvals are in memory and owe the store nothing, so twenty
|
|
151
|
+
// spawns under one persisted entry would have been ONE prompt and nineteen session hits without it.
|
|
152
|
+
// Distinct `capability@subject` pairs bounds it properly. The exact figure needs a session id the
|
|
153
|
+
// ledger does not carry, so the bound is printed AS a bound rather than dressed up as an answer.
|
|
154
|
+
// **Pairs against pairs.** The comparison ADR-0020 asks for is `persisted` vs `prompt`, and the
|
|
155
|
+
// first version printed persisted RECORDS against a denominator of ALL records — so widening a
|
|
156
|
+
// fan-out drove the ratio arbitrarily toward "delete the layer" with zero additional human
|
|
157
|
+
// decisions (`inherited` and `session` are written once per CHILD). Measured: the same two human
|
|
158
|
+
// approvals read as 69% at fan-out 8 and 3.8% at fan-out 512. `distinctBySource.prompt` was being
|
|
159
|
+
// computed and never shown, which is the number that makes the comparison honest.
|
|
160
|
+
lines.push(
|
|
161
|
+
` ADR-0020: ${distinctBySource.persisted} persisted vs ${distinctBySource.prompt} prompted ` +
|
|
162
|
+
`capability@subject pair(s) — the comparison that decides Option 3.`,
|
|
163
|
+
);
|
|
164
|
+
lines.push(
|
|
165
|
+
` (${bySource.persisted} persisted record(s) over ${attributed} attributed. RECORDS ARE AN ` +
|
|
166
|
+
`UPPER BOUND on prompts avoided, not a count: within one session only the first would have been ` +
|
|
167
|
+
`a prompt, and a wide fan-out inflates every per-record figure without any extra human decision. ` +
|
|
168
|
+
`Deleting the layer costs at most one prompt per pair per session.)`,
|
|
169
|
+
);
|
|
170
|
+
if (unattributed > 0) {
|
|
171
|
+
// States what was observed, not a cause. Pre-0.11.1 lines are the COMMON reason, and a torn line,
|
|
172
|
+
// a hand edit or an unrecognised source value produce the same count — asserting the version
|
|
173
|
+
// would be a guess dressed as a fact, in a report whose whole value is that its numbers mean what
|
|
174
|
+
// they say.
|
|
175
|
+
lines.push(
|
|
176
|
+
` ${unattributed} not counted — the record named approved capabilities but no usable ` +
|
|
177
|
+
`per-capability source. Usually a line written before 0.11.1, where one scalar described the ` +
|
|
178
|
+
`whole set and over-claimed "prompt" (R-46); a damaged or hand-edited line reads the same.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!report.ok) {
|
|
183
|
+
// Deliberately not repaired. A corrupt line is evidence; rewriting the file to make it parse
|
|
184
|
+
// would destroy the one artifact an investigation has.
|
|
185
|
+
lines.push(" A corrupt line is EVIDENCE and is left alone. Concurrent writers on a non-POSIX");
|
|
186
|
+
lines.push(" filesystem (drvfs under /mnt, NFS) are the likely cause — move the ledger to a local path.");
|
|
187
|
+
}
|
|
188
|
+
ctx.ui.notify(lines.join("\n"), report.ok ? "info" : "error");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (sub === "approvals") {
|
|
193
|
+
const { valid, dropped } = await loadApprovals({ cwd, now: new Date(), snapshotOf });
|
|
194
|
+
// The count says "valid", and the ignored entries are listed below it — but a reader who stops at
|
|
195
|
+
// the first line would conclude the file is empty, so the ignored total goes on that same line.
|
|
196
|
+
const lines = [
|
|
197
|
+
`grants: ${valid.size} persisted approval${valid.size === 1 ? "" : "s"}` +
|
|
198
|
+
(dropped.length > 0 ? `, ${dropped.length} ignored` : ""),
|
|
199
|
+
];
|
|
200
|
+
for (const [key, entry] of valid) {
|
|
201
|
+
lines.push(` ${key}`);
|
|
202
|
+
lines.push(` approved ${entry.approvedAt}, expires ${entry.expiresAt}`);
|
|
203
|
+
}
|
|
204
|
+
// Dropped entries are SHOWN, not silently omitted — otherwise a revoked-by-expiry approval looks
|
|
205
|
+
// like one that was never given. Malformed entries are also reported as "expired" by the store
|
|
206
|
+
// (a deliberate simplification so it need not extend EntryVerdict); relabel those here so a
|
|
207
|
+
// corrupt entry doesn't read as a timed-out one.
|
|
208
|
+
//
|
|
209
|
+
// This mirrors `isValidEntryShape` in `src/approval-store.ts` (all four required fields) and
|
|
210
|
+
// must be kept in step with it — it is a display-only relabeling of an entry the store already
|
|
211
|
+
// dropped, not a second validity decision, so it stays here rather than moving into `src/`.
|
|
212
|
+
for (const d of dropped) {
|
|
213
|
+
const raw = d.entry as Partial<Record<"approvedAt" | "expiresAt" | "cwd" | "grantAtApproval", unknown>>;
|
|
214
|
+
const shapeCorrupt =
|
|
215
|
+
typeof raw?.approvedAt !== "string" ||
|
|
216
|
+
typeof raw?.expiresAt !== "string" ||
|
|
217
|
+
typeof raw?.cwd !== "string" ||
|
|
218
|
+
!Array.isArray(raw?.grantAtApproval);
|
|
219
|
+
const verdict = shapeCorrupt ? "malformed" : d.verdict;
|
|
220
|
+
lines.push(` (ignored) ${d.key} — ${verdict}`);
|
|
221
|
+
}
|
|
222
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (sub === "revoke") {
|
|
227
|
+
if (target === "--all") {
|
|
228
|
+
const ok = await revokeAll(cwd);
|
|
229
|
+
ctx.ui.notify(
|
|
230
|
+
// "in this project" is not padding: the store is shared by every project on the machine, and
|
|
231
|
+
// this used to clear all of them.
|
|
232
|
+
ok
|
|
233
|
+
? "grants: all persisted approvals for this project revoked"
|
|
234
|
+
: "grants: failed to revoke — could not write the approvals file",
|
|
235
|
+
ok ? "info" : "warning",
|
|
236
|
+
);
|
|
237
|
+
} else if (!target) {
|
|
238
|
+
ctx.ui.notify("grants: usage — /grants revoke <capability>@<agent-type> | --all", "warning");
|
|
239
|
+
} else {
|
|
240
|
+
// R-49. This was a boolean, so a FAILED write printed "no persisted approval named X" — telling an
|
|
241
|
+
// operator that the thing they are revoking does not exist, while it survives. The three outcomes
|
|
242
|
+
// are three different facts and the worst of them was disguised as the most reassuring.
|
|
243
|
+
const outcome = await revokeApproval(cwd, target, snapshotOf, new Date());
|
|
244
|
+
ctx.ui.notify(
|
|
245
|
+
{
|
|
246
|
+
revoked: `grants: revoked ${target}`,
|
|
247
|
+
absent: `grants: no persisted approval named ${target}`,
|
|
248
|
+
// Says only what was checked. `failed` found the entry and could not remove it, so "still in
|
|
249
|
+
// effect" is verified; `busy` never got past the lock, so it claims nothing about the entry at
|
|
250
|
+
// all — asserting it there would be R-61 again, one size smaller.
|
|
251
|
+
failed:
|
|
252
|
+
`grants: ${target} was NOT revoked — the approvals file could not be written. It is still in ` +
|
|
253
|
+
`effect; check that the path is writable and try again.`,
|
|
254
|
+
// Names no cause, because at this point none was established: a lock held by another session,
|
|
255
|
+
// an exhausted file-descriptor table and a read-only path all land here identically, having
|
|
256
|
+
// read nothing. Guessing "another session is writing" would be wrong for two of the three.
|
|
257
|
+
busy:
|
|
258
|
+
`grants: ${target} could not be checked — the approvals file could not be opened for writing ` +
|
|
259
|
+
`(another session may hold it, or the path may not be writable). NOTHING was changed, and ` +
|
|
260
|
+
`this says nothing about whether that approval exists.`,
|
|
261
|
+
}[outcome],
|
|
262
|
+
outcome === "revoked" ? "info" : outcome === "absent" ? "warning" : "error",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { valid } = await loadApprovals({ cwd, now: new Date(), snapshotOf });
|
|
269
|
+
const lines = [
|
|
270
|
+
governed ? "grants: ACTIVE" : "grants: inactive (set PI_GRANTS_GRANT to govern this session)",
|
|
271
|
+
` holding ${ownGrant.join(", ") || "(nothing)"}${observed ? " (observed)" : " (inherited, not yet observed)"}`,
|
|
272
|
+
` depth ${depth} of max ${maxDepth}${maxDepth <= 0 ? " (spawning disabled)" : ""}`,
|
|
273
|
+
` ledger ${ledgerPath ?? "(not recording — set PI_GRANTS_LEDGER)"}`,
|
|
274
|
+
` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +
|
|
275
|
+
`${inheritedApprovals.size > 0 ? `, ${inheritedApprovals.size} inherited` : ""}` +
|
|
276
|
+
` — /grants approvals`,
|
|
277
|
+
` catalog ${catalog.all.length} capabilities — ` +
|
|
278
|
+
`${catalog.byKind("builtin").length} builtin, ${catalog.byKind("extension").length} extension, ` +
|
|
279
|
+
`${catalog.byKind("skill").length} skill, ${catalog.byKind("agentType").length} agent-type`,
|
|
280
|
+
];
|
|
281
|
+
// Runs the REAL planner AND the real approval step over each definition, so this listing cannot
|
|
282
|
+
// disagree with what a spawn would do — the R-28 lesson, kept structural rather than remembered.
|
|
283
|
+
const shown = [...definitions].slice(0, PREVIEW_LIMIT);
|
|
284
|
+
for (const [name] of shown) {
|
|
285
|
+
const { plan, approval } = await previewDelegation(name);
|
|
286
|
+
// Why it is allowed, when a standing approval is the reason. An `allow` that silently depends on a
|
|
287
|
+
// 30-day entry in a file elsewhere is precisely what an operator runs this command to discover, and
|
|
288
|
+
// R-38 was the version of this listing that could not have told them (it said BLOCK instead).
|
|
289
|
+
// R-46: name each capability's own source, since they can differ (persisted for one, a live prompt
|
|
290
|
+
// for another). The scalar this replaced picked one and applied it to the set.
|
|
291
|
+
const because =
|
|
292
|
+
plan.ok && approval && approval.approved.length > 0
|
|
293
|
+
? ` (${approval.approved.map((c) => `${c} approved: ${approval.sources[c] ?? "?"}`).join("; ")})`
|
|
294
|
+
: "";
|
|
295
|
+
lines.push(
|
|
296
|
+
` ${plan.ok ? "allow" : "BLOCK"} ${name}` +
|
|
297
|
+
(plan.ok ? ` ${plan.effective.join(", ")}${because}` : ` — ${plan.reason}`),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
// R-48. The cap is fine; the SILENCE was not. An operator running `/grants` to answer "what can this
|
|
301
|
+
// session spawn without asking me?" — the exact question R-38 was fixed for — cannot tell a definition
|
|
302
|
+
// that was omitted from one that does not exist, and the `catalog … N agent-type` line above
|
|
303
|
+
// contradicts the short list with no explanation. Map order is discovery order, so the entries dropped
|
|
304
|
+
// are the GLOBAL ones (`~/.pi/agent/skills`), which is the least obvious thing to lose.
|
|
305
|
+
if (definitions.size > shown.length) {
|
|
306
|
+
lines.push(` … and ${definitions.size - shown.length} more not shown (first ${PREVIEW_LIMIT} only)`);
|
|
307
|
+
}
|
|
308
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
309
|
+
},
|
|
310
|
+
};
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-daddy — the extension entry point pi loads.
|
|
3
|
+
*
|
|
4
|
+
* Wiring only; every decision lives in `../src/` as a pure function so it can be tested without pi. What
|
|
5
|
+
* remains in *this* file is the part that is genuinely about pi: the hooks, the tripwire, and the four
|
|
6
|
+
* registrations. The session state moved to `./session.ts`, the approval flow to `./approvals.ts`, the two
|
|
7
|
+
* delegation tools to `./delegation.ts` and the `/grants` command to `./grants-command.ts`.
|
|
8
|
+
*
|
|
9
|
+
* That split is not cosmetic. Every wiring bug this package has had lived here — the G7 `NaN` bound, the
|
|
10
|
+
* discarded `isError`, the unconditionally-registered `delegate` (S-5), R-28's omitted argument — and all
|
|
11
|
+
* four were defects of *scope*: a value that was whatever happened to be in the closure at one call site.
|
|
12
|
+
* Each module now takes the session as an argument, so what it can see is written down.
|
|
13
|
+
*
|
|
14
|
+
* Propagation is race-free by construction — see `../src/propagation.ts`. Nothing per-child is pushed:
|
|
15
|
+
* the environment carries only parent-level facts (identical for every sibling), never a value computed
|
|
16
|
+
* for one specific spawn. It is published at session start, and republished whenever this session's own
|
|
17
|
+
* approvals change (a human approves something new for the session) — never per spawn, never keyed to a
|
|
18
|
+
* particular child. Each republish stays safe because the value is still `ownGrant`-shaped: this
|
|
19
|
+
* session's own approvals intersected with its own grant, identical for every sibling regardless of which
|
|
20
|
+
* spawn triggered the human prompt, and `childEnv` clamps it to the grant again on the way out. Each
|
|
21
|
+
* child derives its own grant from the tool array of its first provider request.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync } from "node:fs";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { WILDCARD } from "../src/pi-tools.ts";
|
|
28
|
+
import { AGENT_WILDCARD } from "../src/resolve.ts";
|
|
29
|
+
import { legacyApprovalsPath, sharedApprovalsPath } from "../src/approval-store.ts";
|
|
30
|
+
import { buildCatalog } from "../src/catalog.ts";
|
|
31
|
+
import { loadDefinitions } from "../src/definitions.ts";
|
|
32
|
+
import { appendRecord, buildRecord, verifyLedger } from "../src/ledger.ts";
|
|
33
|
+
import { deriveOwnGrant, observeToolNames } from "../src/propagation.ts";
|
|
34
|
+
import { snapshotOf } from "./approvals.ts";
|
|
35
|
+
import { registerDelegationTools } from "./delegation.ts";
|
|
36
|
+
import { grantsCommand } from "./grants-command.ts";
|
|
37
|
+
import { planWithApprovals } from "./run-delegation.ts";
|
|
38
|
+
import { createGrantsSession } from "./session.ts";
|
|
39
|
+
|
|
40
|
+
const SPAWN_TOOLS = new Set(["Agent", "subagent", "spawn_agent"]);
|
|
41
|
+
|
|
42
|
+
export default function (pi: ExtensionAPI) {
|
|
43
|
+
// The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
|
|
44
|
+
// Only this file can say so about itself, which is why the session takes it rather than deriving it.
|
|
45
|
+
const extensionPath = (() => {
|
|
46
|
+
try {
|
|
47
|
+
return fileURLToPath(import.meta.url);
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
const session = createGrantsSession(extensionPath);
|
|
54
|
+
|
|
55
|
+
// Filled in by `registerDelegationTools` at the bottom of this function. A holder rather than a reordering,
|
|
56
|
+
// because the hooks below have to be registered before the tools and both need to call it — the tool
|
|
57
|
+
// schemas describe which definitions are spawnable, and nothing knows that until a hook has run (R-39).
|
|
58
|
+
const delegation = { refreshSpawnable: () => {} };
|
|
59
|
+
|
|
60
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
61
|
+
session.cwd = ctx.cwd;
|
|
62
|
+
try {
|
|
63
|
+
// Guarded together, and guarded at all because of R-60 rather than because either one throws today:
|
|
64
|
+
// both loaders swallow their own filesystem errors, so this catch is currently unreachable. The point
|
|
65
|
+
// is that "currently" is not a property anyone can rely on — `verifyLedger` was also harmless until
|
|
66
|
+
// the day it was not, and the cost of finding out is every control below this line, silently.
|
|
67
|
+
// Discovery failing is worth its own sentence anyway: a session with no definitions can still
|
|
68
|
+
// delegate by `tools:`, and an operator whose `agent:` spawns have all started failing deserves to
|
|
69
|
+
// know it was the *scan* that broke rather than the grant.
|
|
70
|
+
try {
|
|
71
|
+
session.definitions = await loadDefinitions(ctx.cwd);
|
|
72
|
+
session.catalogReady = buildCatalog({ cwd: ctx.cwd, observedTools: session.observedTools });
|
|
73
|
+
session.catalog = await session.catalogReady;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
ctx.ui.notify(
|
|
76
|
+
`grants: could not read this project's definitions or capability catalog ` +
|
|
77
|
+
`(${error instanceof Error ? error.message : String(error)}) — no SKILL.md definition can be ` +
|
|
78
|
+
`spawned this session, and delegation by tools: is unaffected. Governance itself is unaffected: ` +
|
|
79
|
+
`it is enforced by --tools when a child is spawned.`,
|
|
80
|
+
"error",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
session.publishChildEnv();
|
|
84
|
+
// The definitions now exist, so the `delegate` schema can finally name them (R-39). pi serialises a
|
|
85
|
+
// tool's schema at REQUEST time, not at registration — measured — which is what makes this reach the
|
|
86
|
+
// model at all.
|
|
87
|
+
delegation.refreshSpawnable();
|
|
88
|
+
// A malformed bound is now loud as well as safe. Silently disabling spawning would be just as
|
|
89
|
+
// confusing as silently disabling the limit was dangerous — the operator set the variable, so
|
|
90
|
+
// they need to know it did not take effect (G7 / A-S4).
|
|
91
|
+
if (session.malformedBounds.length > 0) {
|
|
92
|
+
ctx.ui.notify(
|
|
93
|
+
`grants: ${session.malformedBounds.join(" and ")} could not be read as a non-negative integer — ` +
|
|
94
|
+
`spawning is disabled for this session (failing closed)`,
|
|
95
|
+
"warning",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
// ADR-0014: a pre-0.6 in-workspace approvals file is IGNORED, not migrated — importing it would
|
|
99
|
+
// import exactly the entries whose trustworthiness the move exists to remove. Say so, because an
|
|
100
|
+
// operator whose approvals silently stopped applying deserves to know why.
|
|
101
|
+
// ADR-0020: the pre-0.11 single shared store is ignored, not migrated. Same reasoning shape as the
|
|
102
|
+
// legacy file below — an operator whose approvals silently stopped applying must be told why — but a
|
|
103
|
+
// different reason for not migrating: splitting it by `cwd` would be lossless, and it is still declined
|
|
104
|
+
// because one-shot migration code in the layer with nine defects buys less than one re-approval costs.
|
|
105
|
+
try {
|
|
106
|
+
if (existsSync(sharedApprovalsPath())) {
|
|
107
|
+
ctx.ui.notify(
|
|
108
|
+
`grants: ignoring ${sharedApprovalsPath()} — approvals are now stored one file per governed ` +
|
|
109
|
+
`directory (ADR-0020), because a single shared file could not hold two projects' approvals ` +
|
|
110
|
+
`for a same-named definition. Re-approve when next asked. **Deleting the old file is ` +
|
|
111
|
+
`recommended, not merely safe**: entries written by 0.10.x may contain the task text a model ` +
|
|
112
|
+
`composed at approval time, which this version no longer stores anywhere (ADR-0021).`,
|
|
113
|
+
"warning",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
/* never throw into the agent loop */
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
if (existsSync(legacyApprovalsPath(ctx.cwd))) {
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
`grants: ignoring ${legacyApprovalsPath(ctx.cwd)} — approvals now live outside the workspace ` +
|
|
123
|
+
`(it was writable by the very agents it gated). Re-approve when next asked; the old file is ` +
|
|
124
|
+
`safe to delete.`,
|
|
125
|
+
"warning",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
/* never throw into the agent loop */
|
|
130
|
+
}
|
|
131
|
+
// R-47. `gatedBlocked` filters `requested`, and for a definition spawn `requested` is that
|
|
132
|
+
// definition's CEILING — which never contains `agent:<name>`, because the authorisation check
|
|
133
|
+
// (ADR-0017) is a separate, ungated branch. So `PI_GRANTS_GATED=agent:deploy`, written by an operator
|
|
134
|
+
// who read "it attenuates like any other capability" and meant "ask me before deploy runs", produces
|
|
135
|
+
// no dialog and no warning. It DOES bite when a definition passes the id down in its own
|
|
136
|
+
// `allowed-tools`, so the flag half-works — which is worse than not working, and is R-25's shape in
|
|
137
|
+
// the namespace ADR-0017 just promoted out of exactly that state.
|
|
138
|
+
//
|
|
139
|
+
// Warned rather than enforced: making it gate the spawn is a behaviour change and wants a decision.
|
|
140
|
+
// Silence is the part that is indefensible either way.
|
|
141
|
+
// `agent:*` grants no tools, but it authorises every definition in BOTH skill roots — including
|
|
142
|
+
// `~/.pi/agent/skills/`, which other software installs into, so ADR-0017's "an operator-authored
|
|
143
|
+
// file" is not true of everything it covers. Paired with a shell that is every body on disk running
|
|
144
|
+
// with `bash`. `docs/SPEC.md` calls the combination poor and nothing detected it, which is R-47's
|
|
145
|
+
// shape in a control shipped one day later.
|
|
146
|
+
if (session.ownGrant.includes(AGENT_WILDCARD) && session.gated.length === 0 && session.ownGrant.includes("tool:bash")) {
|
|
147
|
+
ctx.ui.notify(
|
|
148
|
+
`grants: PI_GRANTS_GRANT pairs agent:* with tool:bash and gates nothing — every SKILL.md in ` +
|
|
149
|
+
`this project AND in ~/.pi/agent/skills (which other tools install into) may run with a shell. ` +
|
|
150
|
+
`Enumerate the agent: ids you mean, or leave PI_GRANTS_GATED at its default so bash is asked for.`,
|
|
151
|
+
"warning",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const inertGates = session.gated.filter((c) => c.startsWith("agent:"));
|
|
155
|
+
if (inertGates.length > 0) {
|
|
156
|
+
ctx.ui.notify(
|
|
157
|
+
`grants: ${inertGates.join(", ")} in PI_GRANTS_GATED does NOT gate spawning that definition — ` +
|
|
158
|
+
`the authorisation check for a definition is separate and ungated, so a human is never asked. ` +
|
|
159
|
+
`It applies only where a definition passes the id down in its own allowed-tools. To control ` +
|
|
160
|
+
`which definitions may run, withhold the agent: capability from PI_GRANTS_GRANT instead.`,
|
|
161
|
+
"warning",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
// R-34. `verifyLedger` existed and nothing ran it, so a torn line was detectable and undetected —
|
|
165
|
+
// and a check an operator has to know to run is not a control, it is a feature. Setting
|
|
166
|
+
// `PI_GRANTS_LEDGER` already means "I want an audit trail"; noticing that the trail is damaged is
|
|
167
|
+
// part of keeping one.
|
|
168
|
+
//
|
|
169
|
+
// Corruption only, deliberately. The escalation count is a *query* — `/grants ledger` answers it —
|
|
170
|
+
// and reporting historical attempts unprompted at every start is the fatigue shape R-25 names, which
|
|
171
|
+
// ends with the operator ignoring the line that matters.
|
|
172
|
+
//
|
|
173
|
+
// Awaited rather than fired and forgotten: it is one read, on a path that already awaits two
|
|
174
|
+
// directory scans, and awaiting is what guarantees the warning reaches a live `ctx.ui`.
|
|
175
|
+
//
|
|
176
|
+
// R-60. `verifyLedger` RETHROWS every read error that is not ENOENT — right for `/grants ledger`,
|
|
177
|
+
// where an operator asked a direct question and deserves the failure — and this call is the only one
|
|
178
|
+
// that makes it inside the blanket catch below. So an unreadable ledger threw here and cancelled every
|
|
179
|
+
// remaining control **in silence**: no alarm, and not even the `holding [...]` line that is the one
|
|
180
|
+
// sign governance is on. Confirmed by execution — `PI_GRANTS_LEDGER` naming a directory produced ZERO
|
|
181
|
+
// notifications from a governed session. A trail that cannot be read at all is a worse failure than a
|
|
182
|
+
// torn line, and it was the one case this control said nothing about.
|
|
183
|
+
if (session.ledgerPath) {
|
|
184
|
+
try {
|
|
185
|
+
const report = await verifyLedger(session.ledgerPath);
|
|
186
|
+
if (report.exists && !report.ok) {
|
|
187
|
+
ctx.ui.notify(
|
|
188
|
+
`grants: ledger ${session.ledgerPath} has ${report.corrupt.length} unparseable line(s) — ` +
|
|
189
|
+
`first at line ${report.corrupt[0]?.line}. A torn line is indistinguishable from a spawn that ` +
|
|
190
|
+
`never happened, so this audit trail is incomplete. Run /grants ledger for detail; the file is ` +
|
|
191
|
+
`left alone because a corrupt line is evidence.`,
|
|
192
|
+
"error",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
ctx.ui.notify(
|
|
197
|
+
`grants: ledger ${session.ledgerPath} could not be read ` +
|
|
198
|
+
`(${(error as { code?: string }).code ?? String(error)}) — nothing can be verified about this ` +
|
|
199
|
+
`audit trail, and the first spawn will refuse rather than proceed unrecorded. Check that ` +
|
|
200
|
+
`PI_GRANTS_LEDGER names a writable FILE.`,
|
|
201
|
+
"error",
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (session.governed) {
|
|
206
|
+
ctx.ui.notify(
|
|
207
|
+
`grants: depth ${session.depth}/${session.maxDepth}, holding [${session.ownGrant.join(", ") || "nothing"}]`,
|
|
208
|
+
"info",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
// Rule 8 — fail closed, and be LOUD about it. Swallowing is still right: a startup fault must not
|
|
213
|
+
// reach the agent loop. Swallowing SILENTLY is what let R-60 exist, and would let the next one exist
|
|
214
|
+
// too, because every control added above this line is cancelled by any throw before it with no trace.
|
|
215
|
+
// Deliberately says which checks are affected rather than claiming they passed.
|
|
216
|
+
try {
|
|
217
|
+
ctx.ui.notify(
|
|
218
|
+
`grants: session start did not complete — ${error instanceof Error ? error.message : String(error)}. ` +
|
|
219
|
+
`Checks and setup after the failure did not run, so definitions may be missing and a delegation ` +
|
|
220
|
+
`may refuse. The grant itself is unaffected: it is enforced by --tools when a child is spawned.`,
|
|
221
|
+
"error",
|
|
222
|
+
);
|
|
223
|
+
} catch {
|
|
224
|
+
/* a UI that cannot be notified is the one failure there is nowhere to report */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// Observe this session's real tool surface once, and tighten the grant to it. Authoritative because
|
|
231
|
+
// it is exactly what pi sent the model.
|
|
232
|
+
pi.on("before_provider_request", (event) => {
|
|
233
|
+
try {
|
|
234
|
+
if (session.observed) return undefined;
|
|
235
|
+
const names = observeToolNames(event.payload);
|
|
236
|
+
if (names === null) return undefined;
|
|
237
|
+
session.observed = true;
|
|
238
|
+
session.observedTools = names;
|
|
239
|
+
session.ownGrant = deriveOwnGrant(session.inherited, names);
|
|
240
|
+
session.publishChildEnv();
|
|
241
|
+
// The grant can only have narrowed, so what is spawnable can only have shrunk. Refreshed for the NEXT
|
|
242
|
+
// request: this hook runs after the current payload's tools were already serialised.
|
|
243
|
+
delegation.refreshSpawnable();
|
|
244
|
+
// Refresh the catalog now that the real tool surface is known — this is the only moment extension
|
|
245
|
+
// tools become visible, so it is the only moment `ext:`/`tool:` grants can be validated.
|
|
246
|
+
// Keep the handle: a concurrent `delegate` awaits this rather than reading a half-built catalog.
|
|
247
|
+
// The `catch` resolves to the CURRENT catalog rather than rejecting, so a failed refresh degrades
|
|
248
|
+
// to the previous view instead of failing every delegation in the session.
|
|
249
|
+
session.catalogReady = buildCatalog({ cwd: session.cwd, observedTools: names })
|
|
250
|
+
.then((c) => (session.catalog = c))
|
|
251
|
+
.catch(() => session.catalog);
|
|
252
|
+
} catch {
|
|
253
|
+
/* never throw into the agent loop */
|
|
254
|
+
}
|
|
255
|
+
return undefined; // inspect only — never replace the payload
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Tripwire, not a fence — ADR-0016 point 5.
|
|
260
|
+
*
|
|
261
|
+
* This hook used to compute what `@tintinweb/pi-subagents` would grant a child, by re-implementing
|
|
262
|
+
* that package's tool-resolution rules (ADR-0013). That port is gone with this change, and so is
|
|
263
|
+
* R-31: there is no longer another project's private function to keep in step, and no permissive
|
|
264
|
+
* drift when it moves.
|
|
265
|
+
*
|
|
266
|
+
* What remains is the reason not to simply delete the hook. This package is now the spawner, so a
|
|
267
|
+
* third-party spawn tool appearing in a governed session means something can create a descendant that
|
|
268
|
+
* this package does not provision, does not bound by depth, and does not record. Installing one is a
|
|
269
|
+
* single command. **Refusing is cheap and silence is not**, so the tripwire refuses and names itself.
|
|
270
|
+
*
|
|
271
|
+
* It cannot be complete, and says so rather than implying otherwise: `subagents:rpc:spawn` reaches
|
|
272
|
+
* `manager.spawn()` over the event bus and never produces a `tool_call` at all (ADR-0013 Finding 6),
|
|
273
|
+
* so a tool-name check cannot see it. This catches the ordinary case loudly; it is not a boundary.
|
|
274
|
+
*/
|
|
275
|
+
pi.on("tool_call", async (event) => {
|
|
276
|
+
if (!session.governed || !SPAWN_TOOLS.has(event.toolName)) return undefined;
|
|
277
|
+
|
|
278
|
+
const reason =
|
|
279
|
+
`grants: "${event.toolName}" spawns sub-agents outside this session's governance — refused. ` +
|
|
280
|
+
`This session grants capabilities by spawning them itself (\`delegate\`), so a child created by ` +
|
|
281
|
+
`another extension would hold whatever that extension decided, with no grant, no depth bound and ` +
|
|
282
|
+
`no ledger entry. Use \`delegate\` instead. If you meant to run ungoverned, unset PI_GRANTS_GRANT.`;
|
|
283
|
+
|
|
284
|
+
if (session.ledgerPath) {
|
|
285
|
+
// Recorded like any other refusal: an audit that omits the spawns we turned away cannot answer
|
|
286
|
+
// "did anything try to get around this?", which is the one question a tripwire exists to answer.
|
|
287
|
+
await appendRecord(
|
|
288
|
+
{ path: session.ledgerPath, strict: true },
|
|
289
|
+
buildRecord({
|
|
290
|
+
parentId: `d${session.depth}`,
|
|
291
|
+
childId: `${event.toolName}@d${session.depth + 1}`,
|
|
292
|
+
depth: session.depth + 1,
|
|
293
|
+
agentType: event.toolName,
|
|
294
|
+
// The wildcard is the honest record: an unknown spawner was going to hand this child whatever
|
|
295
|
+
// IT decided, and we have no way to know what that would have been.
|
|
296
|
+
requested: [WILDCARD],
|
|
297
|
+
parentGrant: session.ownGrant,
|
|
298
|
+
result: { effective: [], denied: [WILDCARD], clipped: [], gatedBlocked: [], universal: [], subsumedBy: [] },
|
|
299
|
+
blocked: true,
|
|
300
|
+
reason,
|
|
301
|
+
now: new Date(),
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return { block: true, reason };
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Governed delegation. Unlike the tripwire above this PROVISIONS: the grant is an argument, so the
|
|
309
|
+
// orchestrator hands each child exactly the capabilities it should have. Registered only when this
|
|
310
|
+
// session may delegate, so withholding `tool:delegate` genuinely makes a session a leaf.
|
|
311
|
+
//
|
|
312
|
+
// Registration necessarily happens HERE, before any hook has run, so the tools cannot yet know which
|
|
313
|
+
// definitions exist — hence `refreshSpawnable`, called from both hooks above once they do. R-39 is what
|
|
314
|
+
// happens without it: every model in every governed session is told `Available: none`.
|
|
315
|
+
delegation.refreshSpawnable = registerDelegationTools(pi, session).refreshSpawnable;
|
|
316
|
+
|
|
317
|
+
pi.registerCommand("grants", {
|
|
318
|
+
...grantsCommand,
|
|
319
|
+
// Built per invocation and spelled out field by field, rather than passing the session whole: what a
|
|
320
|
+
// read-only diagnostic may see is a decision, and `GrantsCommandContext` is where it is recorded.
|
|
321
|
+
handler: (args, ctx) =>
|
|
322
|
+
grantsCommand.handler(args, {
|
|
323
|
+
...ctx,
|
|
324
|
+
grants: {
|
|
325
|
+
cwd: session.cwd,
|
|
326
|
+
governed: session.governed,
|
|
327
|
+
ownGrant: session.ownGrant,
|
|
328
|
+
observed: session.observed,
|
|
329
|
+
depth: session.depth,
|
|
330
|
+
maxDepth: session.maxDepth,
|
|
331
|
+
ledgerPath: session.ledgerPath,
|
|
332
|
+
catalog: session.catalog,
|
|
333
|
+
definitions: session.definitions,
|
|
334
|
+
sessionApprovals: session.sessionApprovals,
|
|
335
|
+
inheritedApprovals: session.inheritedApprovals,
|
|
336
|
+
snapshotOf: (subject: string) => snapshotOf(session, subject),
|
|
337
|
+
// The REAL delegation path, minus the one thing a diagnostic must never do. `ctx: null` is what
|
|
338
|
+
// says so: stored approvals count exactly as they would for a spawn, and no human is asked
|
|
339
|
+
// (R-38). Passing `ctx` here would let `/grants` raise a dialog, and passing `hasUI: false` would
|
|
340
|
+
// make every gated definition report "no interactive user" instead of what actually blocks it.
|
|
341
|
+
previewDelegation: (name: string) =>
|
|
342
|
+
planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
}
|