pi-daddy 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +187 -0
  2. package/LICENSE +21 -0
  3. package/README.md +584 -0
  4. package/dist/approval-prompt.d.ts +116 -0
  5. package/dist/approval-prompt.d.ts.map +1 -0
  6. package/dist/approval-prompt.js +124 -0
  7. package/dist/approval-prompt.js.map +1 -0
  8. package/dist/approval-store.d.ts +149 -0
  9. package/dist/approval-store.d.ts.map +1 -0
  10. package/dist/approval-store.js +311 -0
  11. package/dist/approval-store.js.map +1 -0
  12. package/dist/approval.d.ts +221 -0
  13. package/dist/approval.d.ts.map +1 -0
  14. package/dist/approval.js +244 -0
  15. package/dist/approval.js.map +1 -0
  16. package/dist/capabilities.d.ts +31 -0
  17. package/dist/capabilities.d.ts.map +1 -0
  18. package/dist/capabilities.js +45 -0
  19. package/dist/capabilities.js.map +1 -0
  20. package/dist/catalog.d.ts +81 -0
  21. package/dist/catalog.d.ts.map +1 -0
  22. package/dist/catalog.js +164 -0
  23. package/dist/catalog.js.map +1 -0
  24. package/dist/definitions.d.ts +101 -0
  25. package/dist/definitions.d.ts.map +1 -0
  26. package/dist/definitions.js +182 -0
  27. package/dist/definitions.js.map +1 -0
  28. package/dist/delegate.d.ts +133 -0
  29. package/dist/delegate.d.ts.map +1 -0
  30. package/dist/delegate.js +261 -0
  31. package/dist/delegate.js.map +1 -0
  32. package/dist/fanout.d.ts +62 -0
  33. package/dist/fanout.d.ts.map +1 -0
  34. package/dist/fanout.js +84 -0
  35. package/dist/fanout.js.map +1 -0
  36. package/dist/file-lock.d.ts +54 -0
  37. package/dist/file-lock.d.ts.map +1 -0
  38. package/dist/file-lock.js +142 -0
  39. package/dist/file-lock.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +7 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ledger-report.d.ts +106 -0
  45. package/dist/ledger-report.d.ts.map +1 -0
  46. package/dist/ledger-report.js +157 -0
  47. package/dist/ledger-report.js.map +1 -0
  48. package/dist/ledger.d.ts +139 -0
  49. package/dist/ledger.d.ts.map +1 -0
  50. package/dist/ledger.js +94 -0
  51. package/dist/ledger.js.map +1 -0
  52. package/dist/pane-reaper.d.ts +49 -0
  53. package/dist/pane-reaper.d.ts.map +1 -0
  54. package/dist/pane-reaper.js +120 -0
  55. package/dist/pane-reaper.js.map +1 -0
  56. package/dist/pi-tools.d.ts +20 -0
  57. package/dist/pi-tools.d.ts.map +1 -0
  58. package/dist/pi-tools.js +21 -0
  59. package/dist/pi-tools.js.map +1 -0
  60. package/dist/propagation.d.ts +185 -0
  61. package/dist/propagation.d.ts.map +1 -0
  62. package/dist/propagation.js +255 -0
  63. package/dist/propagation.js.map +1 -0
  64. package/dist/resolve.d.ts +110 -0
  65. package/dist/resolve.d.ts.map +1 -0
  66. package/dist/resolve.js +181 -0
  67. package/dist/resolve.js.map +1 -0
  68. package/dist/run-child.d.ts +50 -0
  69. package/dist/run-child.d.ts.map +1 -0
  70. package/dist/run-child.js +109 -0
  71. package/dist/run-child.js.map +1 -0
  72. package/dist/run-herdr.d.ts +91 -0
  73. package/dist/run-herdr.d.ts.map +1 -0
  74. package/dist/run-herdr.js +306 -0
  75. package/dist/run-herdr.js.map +1 -0
  76. package/dist/spawn.d.ts +60 -0
  77. package/dist/spawn.d.ts.map +1 -0
  78. package/dist/spawn.js +116 -0
  79. package/dist/spawn.js.map +1 -0
  80. package/extensions/approvals.ts +285 -0
  81. package/extensions/delegation.ts +237 -0
  82. package/extensions/grants-command.ts +310 -0
  83. package/extensions/grants.ts +346 -0
  84. package/extensions/run-delegation.ts +270 -0
  85. package/extensions/session.ts +234 -0
  86. package/package.json +122 -0
  87. package/src/approval-prompt.ts +222 -0
  88. package/src/approval-store.ts +383 -0
  89. package/src/approval.ts +364 -0
  90. package/src/capabilities.ts +51 -0
  91. package/src/catalog.ts +186 -0
  92. package/src/definitions.ts +233 -0
  93. package/src/delegate.ts +383 -0
  94. package/src/fanout.ts +96 -0
  95. package/src/file-lock.ts +142 -0
  96. package/src/index.ts +59 -0
  97. package/src/ledger-report.ts +227 -0
  98. package/src/ledger.ts +214 -0
  99. package/src/pane-reaper.ts +132 -0
  100. package/src/pi-tools.ts +25 -0
  101. package/src/propagation.ts +308 -0
  102. package/src/resolve.ts +235 -0
  103. package/src/run-child.ts +149 -0
  104. package/src/run-herdr.ts +357 -0
  105. package/src/spawn.ts +156 -0
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Plan, gate, audit and run ONE governed child — the whole of a delegation except its tool surface.
3
+ *
4
+ * Split from `extensions/delegation.ts` when ADR-0019 pushed that file to 403 lines and
5
+ * `test/file-size.test.ts` refused it. Raising the cap the day after adding it would have neutered the
6
+ * guard, so the file was split the way the failure message said to. The seam is the natural one: this
7
+ * module is *what a delegation does*, `delegation.ts` is *how pi is told about it*.
8
+ *
9
+ * Everything reads the live session through the object it is handed; nothing here keeps its own copy of
10
+ * the grant, the catalog or the definitions, because a copy taken at load time is a copy taken before the
11
+ * tool surface is observed.
12
+ */
13
+
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { Type } from "typebox";
16
+ import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
17
+ import { maySpawnDefinition, planDelegation } from "../src/delegate.ts";
18
+ import { MAX_CHILDREN_PER_CALL, childSpawnId, splitBudget } from "../src/fanout.ts";
19
+ import { appendRecord, buildRecord } from "../src/ledger.ts";
20
+ import { mergeChildEnv } from "../src/propagation.ts";
21
+ import type { Capability } from "../src/resolve.ts";
22
+ import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
23
+ import { runHerdrPane } from "../src/run-herdr.ts";
24
+ import { obtainApprovals, republishable, snapshotOf, type ApprovalOutcome, type ApprovalUIContext } from "./approvals.ts";
25
+ import { ENV_HERDR_KEEP_PANE, ENV_HERDR_WORKSPACE, type GrantsSession } from "./session.ts";
26
+
27
+ /** What one child was asked to do. The shape both tools accept, per child. */
28
+ interface ChildSpec {
29
+ task: string;
30
+ agent?: string;
31
+ tools?: string[];
32
+ model?: string;
33
+ }
34
+
35
+ /**
36
+ * The slice of pi's `ExtensionContext` a delegation needs.
37
+ *
38
+ * It extends `ApprovalUIContext` rather than being cast to it at the call site. pi hands `execute` its full
39
+ * context, so `ui`/`hasUI`/`mode` were always present — but the local type omitted them and an `as never`
40
+ * bridged the gap, which is the same "a value that was whatever happened to be in scope" shape the module
41
+ * header lists four defects for.
42
+ */
43
+ interface DelegationToolContext extends ApprovalUIContext {
44
+ cwd: string;
45
+ model?: { provider: string; id: string };
46
+ }
47
+
48
+ /** A planned delegation, plus whatever approvals contributed to it. */
49
+ export interface GatedPlan {
50
+ plan: ReturnType<typeof planDelegation>;
51
+ /** Absent when the gate was never reached — i.e. the plan succeeded or failed for another reason. */
52
+ approval?: ApprovalOutcome;
53
+ }
54
+
55
+ /**
56
+ * Plan a delegation and satisfy its gate as far as approvals allow.
57
+ *
58
+ * **Spelled once, on purpose.** The enforcer and the `/grants` listing both come through here, so a preview
59
+ * cannot claim an outcome a spawn would not produce (R-38, and R-28 before it). The two differ in exactly
60
+ * one respect, which is the one thing a read-only diagnostic must not do: pass `ctx: null` and no human is
61
+ * asked — stored approvals still count, and the plan's own reason is left to speak for whatever is left.
62
+ *
63
+ * Deliberately NOT pre-filling `approved` on the first plan: pre-filling would satisfy an inherited-approval
64
+ * gate silently, before `gatedBlocked` ever surfaced, so `obtainApprovals` would never run and the ledger
65
+ * would lose the `approvalSource: "inherited"` record ADR-0010 relies on as inheritance's compensating
66
+ * control. `approved ⊆ grant` holds regardless — this is about the audit trail, not privilege.
67
+ */
68
+ export async function planWithApprovals(
69
+ session: GrantsSession,
70
+ request: ChildSpec & { model?: string },
71
+ extra: Record<string, unknown>,
72
+ ctx: ApprovalUIContext | null,
73
+ signal?: AbortSignal,
74
+ ): Promise<GatedPlan> {
75
+ // Spelled ONCE. It is asked for twice — when the human is prompted, and when the answer is fed back into
76
+ // the re-plan — and two spellings of one argument is the defect R-28 was.
77
+ const approvalSubject = request.agent ?? DELEGATE_SUBJECT;
78
+
79
+ let plan = planDelegation(request, { ...(await session.delegationContext()), ...extra });
80
+ if (plan.ok || !shouldSeekApproval(plan.result)) return { plan };
81
+
82
+ let approval: ApprovalOutcome | undefined;
83
+ try {
84
+ approval = await obtainApprovals(
85
+ session,
86
+ plan.result?.gatedBlocked ?? [],
87
+ // ADR-0019. A definition IS a human-authored subject — operator-written, and nameable only by a
88
+ // session holding `agent:<name>` (ADR-0017) — so the approval is keyed to it and `always` is on
89
+ // offer. The `tools:` form keeps `<delegate>` and keeps being denied `always`, because there the
90
+ // original reasoning is untouched: the model chose both the task and the tool list.
91
+ approvalSubject,
92
+ request.agent ? "definition" : "delegate",
93
+ ctx,
94
+ request.task,
95
+ signal,
96
+ );
97
+ const outcome = approval;
98
+ if (outcome.approved.length > 0) {
99
+ plan = planDelegation(request, {
100
+ // The scope is the REAL one: a `once` approval still authorises this spawn, and
101
+ // `inheritApprovals` then keeps it from reaching the child. See ADR-0014. R-29 is what makes
102
+ // this safe under fan-out: a `once` is consumed by exactly one concurrent caller.
103
+ ...(await session.delegationContext([
104
+ ...republishable(session),
105
+ ...outcome.approved.map((capability) => ({
106
+ capability,
107
+ subject: approvalSubject,
108
+ // F1b: this capability's OWN scope. It was `outcome.scope` — one variable overwritten by the
109
+ // last capability answered — so approving A `once` and B `session` re-stamped A as `session`
110
+ // and handed a whole subtree an approval a human gave for a single spawn. That is ADR-0014's
111
+ // A-S1 defect, reopened by a mixed answer.
112
+ scope: outcome.scopes[capability] ?? ("once" as const),
113
+ // F1a: and the pin. Without it every freshly-approved capability crossed to the child
114
+ // UNPINNED, and `verifyInherited` honours an unpinned entry by decision — so ADR-0022's
115
+ // headline property was false on the hot path, for the approvals it was written to cover.
116
+ // Taken from this session's snapshot, the same source `republishable` uses.
117
+ bodySha256: snapshotOf(session, approvalSubject)?.bodySha256,
118
+ })),
119
+ ])),
120
+ ...extra,
121
+ });
122
+ }
123
+ if (!plan.ok && approval.reason) plan = { ...plan, reason: approval.reason };
124
+ } catch (error) {
125
+ plan = { ...plan, reason: `grants: approval flow failed, denying (${String(error)})` };
126
+ }
127
+
128
+ return { plan, approval };
129
+ }
130
+
131
+ interface DelegationOutcome {
132
+ ok: boolean;
133
+ text: string;
134
+ reason?: string;
135
+ granted: Capability[];
136
+ depth: number;
137
+ exitCode: number | null;
138
+ }
139
+
140
+ /**
141
+ * Plan, gate, audit and run ONE governed child. Shared by `delegate` and `delegate_all`.
142
+ *
143
+ * Extracted rather than copied, for the reason R-28 exists: this is where the grant is resolved, the
144
+ * human is asked, and the ledger is written, and two call sites spelling that out separately is how one
145
+ * of them comes to omit a step. `delegate_all` differs from `delegate` only in running several of these
146
+ * concurrently and reporting each outcome — not in any governance rule.
147
+ *
148
+ * Returns an outcome instead of throwing, because a fan-out must be able to report "three succeeded, one
149
+ * was refused". `delegate` converts a failure back into a throw to keep its own contract, which matters:
150
+ * `AgentToolResult` has no `isError` field, so a returned error is silently discarded by pi.
151
+ */
152
+ export async function runOneDelegation(
153
+ session: GrantsSession,
154
+ spec: ChildSpec,
155
+ ids: { parentId: string; childId: string },
156
+ budget: number | undefined,
157
+ ctx: DelegationToolContext,
158
+ signal: AbortSignal | undefined,
159
+ ): Promise<DelegationOutcome> {
160
+ // pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
161
+ // alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
162
+ const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
163
+ const request = { task: spec.task, agent: spec.agent, tools: spec.tools, model: spec.model ?? defaultModel };
164
+ const extra = { fanoutBudget: budget, spawnId: ids.parentId, childSpawnId: ids.childId };
165
+
166
+ // Planning and the gate live in `planWithApprovals`, shared with the `/grants` preview so the two cannot
167
+ // disagree (R-38). This call is the enforcing one: it passes `ctx`, so a human CAN be asked.
168
+ let { plan, approval: approvalOutcome } = await planWithApprovals(session, request, extra, ctx, signal);
169
+
170
+ // G6 / B-I3: no `&& plan.result` guard — `planDelegation` always carries one now.
171
+ if (session.ledgerPath) {
172
+ await appendRecord(
173
+ { path: session.ledgerPath, strict: true },
174
+ buildRecord({
175
+ // F8: real ids, not depth labels. Four concurrent siblings used to produce four lines identical
176
+ // except `ts`, so the ledger could not be joined to a result, a process, or the child's own
177
+ // lines one level down.
178
+ parentId: ids.parentId,
179
+ childId: ids.childId,
180
+ depth: plan.childDepth,
181
+ agentType: spec.agent ?? "delegate",
182
+ requested: plan.requested,
183
+ parentGrant: session.ownGrant,
184
+ result: plan.result,
185
+ blocked: !plan.ok,
186
+ reason: plan.reason,
187
+ approved: approvalOutcome?.approved,
188
+ approvalSources: approvalOutcome?.sources,
189
+ approvalScopes: approvalOutcome?.scopes,
190
+ humanDenied: approvalOutcome?.humanDenied,
191
+ gateOutcome: approvalOutcome?.gateOutcome,
192
+ // ADR-0018: taken from the PLAN, never re-derived here. The B-I3 lesson — a call site that
193
+ // recomputed the digest could record one the planner never used.
194
+ definitionDigest: plan.definitionDigest,
195
+ now: new Date(),
196
+ }),
197
+ ).catch((error) => {
198
+ // G6 / A-R4 + B-I2: fail closed. This path PROVISIONS, so an unrecorded delegation would be a
199
+ // child running with granted capabilities and no audit line.
200
+ plan = { ...plan, ok: false, reason: `grants: ledger write failed, denying — ${String(error)}` };
201
+ });
202
+ }
203
+
204
+ if (!plan.ok) {
205
+ return { ok: false, text: "", reason: plan.reason, granted: [], depth: plan.childDepth, exitCode: null };
206
+ }
207
+
208
+ // G8: bounded output, a wall-clock timeout with SIGTERM->SIGKILL escalation, and an abort observed
209
+ // even if it happened before we got here. See src/run-child.ts for why each one exists.
210
+ //
211
+ // ADR-0016 point 6: two executors, one plan. `runChild` is the default because it needs nothing
212
+ // installed; herdr gives the same governed argv a VISIBLE, attachable pane. Opt-in per session rather
213
+ // than auto-detected — a governed run must not silently relocate because a binary is on PATH.
214
+ const output = session.useHerdr
215
+ ? await runHerdrPane({
216
+ args: plan.args.slice(0, -1),
217
+ // The task is delivered as a prompt, so it never reaches argv at all. `plan.args` still ends
218
+ // with the neutralised task (planSpawn is executor-agnostic), hence the slice — and the leading
219
+ // space `neutralisePrompt` added is stripped because there is no parser to defend against here.
220
+ prompt: plan.args[plan.args.length - 1].trimStart(),
221
+ // Grant/depth/ledger go on the PANE: `herdr agent start` has no --env, but a pane's environment
222
+ // reaches the shell that launches the agent (docs/probes/g16-herdr).
223
+ env: plan.env,
224
+ cwd: ctx.cwd,
225
+ name: `${spec.agent ?? "delegate"}-${ids.childId}`,
226
+ workspace: process.env[ENV_HERDR_WORKSPACE],
227
+ signal,
228
+ timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
229
+ keepPane: process.env[ENV_HERDR_KEEP_PANE] === "1",
230
+ })
231
+ : await runChild({
232
+ command: "pi",
233
+ args: plan.args,
234
+ // Explicit per-child env — the parent's own grant vars must not leak in. A plain spread would
235
+ // not achieve that: a key `plan.env` does not set is a key the parent's value survives into, so
236
+ // `mergeChildEnv` strips every governance variable first and lets only the plan put them back.
237
+ env: mergeChildEnv(process.env, plan.env),
238
+ cwd: ctx.cwd,
239
+ signal,
240
+ timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
241
+ });
242
+
243
+ // G8: a child that failed is reported as a failure. A non-zero exit, a timeout and a truncated flood
244
+ // all used to come back as ordinary tool results, so the orchestrator read them as answers.
245
+ if (output.spawnError || output.aborted || output.timedOut || output.code !== 0) {
246
+ const why = output.spawnError
247
+ ? `could not be started: ${output.spawnError}`
248
+ : output.aborted
249
+ ? "was cancelled"
250
+ : output.timedOut
251
+ ? "exceeded its time limit and was killed"
252
+ : `exited with code ${output.code}`;
253
+ return {
254
+ ok: false,
255
+ text: output.text.trim(),
256
+ reason: `the sub-agent ${why}`,
257
+ granted: plan.effective,
258
+ depth: plan.childDepth,
259
+ exitCode: output.code,
260
+ };
261
+ }
262
+
263
+ return {
264
+ ok: true,
265
+ text: output.text.trim(),
266
+ granted: plan.effective,
267
+ depth: plan.childDepth,
268
+ exitCode: output.code,
269
+ };
270
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The session object — the one place this extension's state is named.
3
+ *
4
+ * Split out of `extensions/grants.ts`, which is where every wiring bug in this package has lived: the G7
5
+ * `NaN` bound, the discarded `isError`, the unconditionally-registered `delegate` (S-5) and R-28's omitted
6
+ * argument. Those four share a shape — a value that was *whatever happened to be in scope* at one call
7
+ * site. A closure over a dozen `let`s cannot be reviewed as a whole; an object whose fields are written
8
+ * down can, and it is the same move `grants-command.ts` was extracted under.
9
+ *
10
+ * Configuration is parsed once, at load time, and is `readonly`. The handful of genuinely mutable fields
11
+ * are the ones the hooks in `grants.ts` update as the session learns about itself — the grant tightens when
12
+ * the real tool surface is observed, the catalog and definitions arrive at `session_start`. Every other
13
+ * module reads them **through this object**, live, rather than capturing a copy at load time; capturing a
14
+ * copy of `ownGrant` before observation is exactly how a stale upper bound would become an enforced one.
15
+ */
16
+
17
+ import { parseInherited, type InheritableApproval } from "../src/approval.ts";
18
+ import { createApprovalGateProvider } from "../src/approval-prompt.ts";
19
+ import { makeCatalog, skillPathsFromCatalog, type Catalog } from "../src/catalog.ts";
20
+ import type { SkillDefinition } from "../src/definitions.ts";
21
+ import { DELEGATE_CAPABILITY, type DelegationContext } from "../src/delegate.ts";
22
+ import { budgetFromEnv } from "../src/fanout.ts";
23
+ import { WILDCARD } from "../src/pi-tools.ts";
24
+ import {
25
+ childEnv,
26
+ depthConfig,
27
+ deriveOwnGrant,
28
+ gatedFromEnv,
29
+ ENV_APPROVED,
30
+ ENV_DEPTH,
31
+ ENV_FANOUT,
32
+ ENV_GATED,
33
+ ENV_GRANT,
34
+ ENV_LEDGER,
35
+ ENV_MAX_DEPTH,
36
+ ENV_PARENT_ID,
37
+ parseList,
38
+ } from "../src/propagation.ts";
39
+ import type { Capability } from "../src/resolve.ts";
40
+ import { republishable } from "./approvals.ts";
41
+
42
+ /**
43
+ * Run governed children in herdr panes instead of captured child processes (ADR-0016 point 6).
44
+ *
45
+ * Opt-in, and deliberately not auto-detected from `herdr` being on PATH: where a governed child executes
46
+ * is an operator decision, and a run that silently relocates because a binary appeared is exactly the kind
47
+ * of invisible change this package exists to prevent. Both executors enforce the identical grant — the
48
+ * plan is the same, only the place it runs differs.
49
+ */
50
+ export const ENV_HERDR = "PI_GRANTS_HERDR";
51
+ /** herdr workspace for spawned panes. Omitted lets herdr choose. */
52
+ export const ENV_HERDR_WORKSPACE = "PI_GRANTS_HERDR_WORKSPACE";
53
+ /** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */
54
+ export const ENV_HERDR_KEEP_PANE = "PI_GRANTS_HERDR_KEEP_PANE";
55
+
56
+ export interface GrantsSession {
57
+ /** False when `PI_GRANTS_GRANT` is unset: the session holds the wildcard and nothing is governed. */
58
+ readonly governed: boolean;
59
+ /** The upper bound handed down by the delegator, before this session's own tools are observed. */
60
+ readonly inherited: Capability[];
61
+ readonly depth: number;
62
+ readonly maxDepth: number;
63
+ /** Bound variables that could not be read as non-negative integers — spawning is disabled, loudly. */
64
+ readonly malformedBounds: string[];
65
+ readonly gated: Capability[];
66
+ readonly ledgerPath?: string;
67
+ readonly useHerdr: boolean;
68
+ /** This session's ledger identity; children descend from it (F8). */
69
+ readonly ownSpawnId: string;
70
+ /** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
71
+ readonly fanoutBudget: number;
72
+ /** Whether `delegate` / `delegate_all` are registered at all (S-5). Decided on the INHERITED grant. */
73
+ readonly mayDelegate: boolean;
74
+ /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
75
+ readonly extensionPath?: string;
76
+
77
+ /** Approval keys approved for this session. In memory only — this dies with the process. */
78
+ readonly sessionApprovals: Set<string>;
79
+ /**
80
+ * Approvals inherited from the delegator, already clamped to this session's grant upstream.
81
+ *
82
+ * Key → body digest (ADR-0022), where the digest is absent for `<delegate>` and for a pre-0.11 parent.
83
+ * Deliberately kept RAW here and verified at the point of use (`storedApprovals`), because verification
84
+ * needs `session.definitions`, which does not exist until `session_start` — and this object is built
85
+ * before any hook has run.
86
+ */
87
+ readonly inheritedApprovals: Map<string, string | undefined>;
88
+ /** ONE single-flight queue for the whole session — see `obtainApprovals` for why it lives here. */
89
+ readonly approvalGateFor: ReturnType<typeof createApprovalGateProvider>;
90
+
91
+ /** Set at `session_start`; `process.cwd()` until then. */
92
+ cwd: string;
93
+ /** This session's own grant. Starts as the inherited upper bound, tightened once tools are observed. */
94
+ ownGrant: Capability[];
95
+ observed: boolean;
96
+ observedTools: string[] | null;
97
+ /** ADR-0016: `SKILL.md` definitions, keyed by name. The format this package spawns from now. */
98
+ definitions: Map<string, SkillDefinition>;
99
+ catalog: Catalog;
100
+ /**
101
+ * The in-flight catalog build, so `delegate` can wait for it instead of racing it.
102
+ *
103
+ * G7 / A-R5. The refresh in `before_provider_request` was fire-and-forget, so a `delegate` call
104
+ * early in a session could read a catalog that was still empty and refuse a perfectly valid grant
105
+ * as an "unknown capability". It failed closed, which is why it was Important rather than Critical,
106
+ * but non-deterministically: the same delegation succeeded or failed on timing alone.
107
+ */
108
+ catalogReady: Promise<Catalog>;
109
+
110
+ /**
111
+ * The one place a delegation context is built — and therefore the one place each field is spelled.
112
+ *
113
+ * R-28 is why this is a builder rather than an object literal at each call site. On the path this
114
+ * replaced, three call sites passed `extensionTools` and the one that ENFORCED did not, so every
115
+ * ordinary narrow definition was refused with a reason that misstated the file, while `/grants`
116
+ * cheerfully reported the opposite. The defect was in an argument list, and nothing tested argument
117
+ * lists. A builder makes the omission unspellable instead of merely corrected.
118
+ *
119
+ * `/grants` uses it too, deliberately: the listing runs the REAL planner over the REAL context, so a
120
+ * diagnostic that disagrees with enforcement is not expressible.
121
+ */
122
+ delegationContext(approved?: InheritableApproval[]): Promise<DelegationContext>;
123
+
124
+ /**
125
+ * Publish what children inherit. Written once at session start, and republished whenever this
126
+ * session's own approvals change (see `obtainApprovals`) — never once per spawn. That distinction is
127
+ * what keeps this race-free: every value ever written here is a PARENT-level fact (this session's own
128
+ * grant, intersected with its own approvals), identical for every sibling no matter which spawn
129
+ * prompted the human. A value scoped to one specific child is never written to this global channel.
130
+ */
131
+ publishChildEnv(): void;
132
+ }
133
+
134
+ /**
135
+ * Parse the environment once and build the session every other module reads through.
136
+ *
137
+ * `extensionPath` is passed in rather than derived here: it must name the file **pi loads as the
138
+ * extension**, so a child granted `tool:delegate` can be started with `-e <that file>`. `grants.ts` is that
139
+ * file, and only `grants.ts` can say so about itself.
140
+ */
141
+ export function createGrantsSession(extensionPath: string | undefined): GrantsSession {
142
+ // Governance is opt-in: with PI_GRANTS_GRANT unset the session holds the wildcard and nothing is
143
+ // blocked. This extension must never silently tighten a normal workflow.
144
+ const grantRaw = process.env[ENV_GRANT];
145
+ const governed = grantRaw !== undefined;
146
+ const inherited: Capability[] = governed ? parseList(grantRaw) : [WILDCARD];
147
+ // G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield
148
+ // `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off.
149
+ const bounds = depthConfig(process.env[ENV_DEPTH], process.env[ENV_MAX_DEPTH]);
150
+ const { depth, maxDepth } = bounds;
151
+
152
+ const emptyCatalog = makeCatalog([]);
153
+
154
+ const session: GrantsSession = {
155
+ governed,
156
+ inherited,
157
+ depth,
158
+ maxDepth,
159
+ malformedBounds: bounds.malformed,
160
+ // ADR-0012: `bash` is gated by DEFAULT — but only in a governed session. An ungoverned one
161
+ // (no PI_GRANTS_GRANT) still blocks nothing, so "governance is opt-in" holds exactly where it always
162
+ // did. Inside a session the operator already chose to govern, handing a child `bash` hands it an
163
+ // ungoverned-descendant escape hatch, and doing that silently is what changes here.
164
+ // `PI_GRANTS_GATED=""` turns the default off; absent and empty are deliberately distinguishable.
165
+ gated: governed ? gatedFromEnv(process.env[ENV_GATED]) : parseList(process.env[ENV_GATED]),
166
+ ledgerPath: process.env[ENV_LEDGER],
167
+ useHerdr: process.env[ENV_HERDR] === "1",
168
+ // `ownSpawnId` comes from the parent (F8), so ids form one tree across process boundaries instead of
169
+ // every level restarting at `d0` and the ledger becoming unjoinable.
170
+ ownSpawnId: process.env[ENV_PARENT_ID]?.trim() || `d${depth}`,
171
+ // The cardinality bound ADR-0008 never had: it attenuates downward like depth, so a subtree can never
172
+ // create more descendants than its root was given — with no shared state, no lock and no counter file.
173
+ fanoutBudget: budgetFromEnv(process.env[ENV_FANOUT]),
174
+ /**
175
+ * Review finding S-5, fixed. The comment on the tools has always claimed conditional registration; the
176
+ * call was unconditional, `DELEGATE_CAPABILITY` was imported and never used, and "withhold it and the
177
+ * child is a leaf" was simply untrue on this path.
178
+ *
179
+ * Decided on the INHERITED grant rather than `ownGrant`, because registration happens at load time,
180
+ * before any tools are observed. An ungoverned session registers it as before.
181
+ */
182
+ mayDelegate: !governed || inherited.includes(DELEGATE_CAPABILITY) || inherited.includes(WILDCARD),
183
+ extensionPath,
184
+
185
+ sessionApprovals: new Set<string>(),
186
+ inheritedApprovals: parseInherited(process.env[ENV_APPROVED]),
187
+ approvalGateFor: createApprovalGateProvider(),
188
+
189
+ cwd: process.cwd(),
190
+ ownGrant: deriveOwnGrant(inherited, null),
191
+ observed: false,
192
+ observedTools: null,
193
+ definitions: new Map<string, SkillDefinition>(),
194
+ catalog: emptyCatalog,
195
+ catalogReady: Promise.resolve(emptyCatalog),
196
+
197
+ delegationContext: async (approved?: InheritableApproval[]) => ({
198
+ ownGrant: session.ownGrant,
199
+ depth: session.depth,
200
+ maxDepth: session.maxDepth,
201
+ gated: session.gated,
202
+ ledgerPath: session.ledgerPath,
203
+ extensionPath: session.extensionPath,
204
+ catalog: await session.catalogReady,
205
+ // R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
206
+ // Derived from the catalog's own `source`, so it cannot drift from what was discovered.
207
+ skillPaths: skillPathsFromCatalog(await session.catalogReady),
208
+ // ADR-0016: operator-authored SKILL.md definitions, so `delegate({agent})` can name one.
209
+ definitions: session.definitions,
210
+ // The herdr executor drives the child after starting it, so its plan must NOT carry `--print`.
211
+ // Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and
212
+ // an executor quietly rewriting it would make the record describe a spawn that did not happen.
213
+ interactive: session.useHerdr,
214
+ ...(approved ? { approved } : {}),
215
+ }),
216
+
217
+ publishChildEnv: () => {
218
+ const env = childEnv({
219
+ ownGrant: session.ownGrant,
220
+ depth: session.depth,
221
+ maxDepth: session.maxDepth,
222
+ gated: session.gated,
223
+ ledgerPath: session.ledgerPath,
224
+ approved: republishable(session),
225
+ // G7 / B-I8: an ungoverned session publishes nothing, so "governance is opt-in" holds for
226
+ // descendants too. Previously it exported its own observed tool surface as their grant.
227
+ governed: session.governed,
228
+ });
229
+ for (const [key, value] of Object.entries(env)) process.env[key] = value;
230
+ },
231
+ };
232
+
233
+ return session;
234
+ }
package/package.json ADDED
@@ -0,0 +1,122 @@
1
+ {
2
+ "name": "pi-daddy",
3
+ "version": "0.13.0",
4
+ "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "subagents",
8
+ "capabilities",
9
+ "least-privilege",
10
+ "governance",
11
+ "audit"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/mojomanyana/pi-daddy.git",
18
+ "directory": "packages/pi-daddy"
19
+ },
20
+ "homepage": "https://github.com/mojomanyana/pi-daddy#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/mojomanyana/pi-daddy/issues"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./resolve": {
33
+ "types": "./dist/resolve.d.ts",
34
+ "default": "./dist/resolve.js"
35
+ },
36
+ "./ledger": {
37
+ "types": "./dist/ledger.d.ts",
38
+ "default": "./dist/ledger.js"
39
+ },
40
+ "./spawn": {
41
+ "types": "./dist/spawn.d.ts",
42
+ "default": "./dist/spawn.js"
43
+ },
44
+ "./delegate": {
45
+ "types": "./dist/delegate.d.ts",
46
+ "default": "./dist/delegate.js"
47
+ },
48
+ "./catalog": {
49
+ "types": "./dist/catalog.d.ts",
50
+ "default": "./dist/catalog.js"
51
+ },
52
+ "./propagation": {
53
+ "types": "./dist/propagation.d.ts",
54
+ "default": "./dist/propagation.js"
55
+ },
56
+ "./definitions": {
57
+ "types": "./dist/definitions.d.ts",
58
+ "default": "./dist/definitions.js"
59
+ },
60
+ "./fanout": {
61
+ "types": "./dist/fanout.d.ts",
62
+ "default": "./dist/fanout.js"
63
+ },
64
+ "./pi-tools": {
65
+ "types": "./dist/pi-tools.d.ts",
66
+ "default": "./dist/pi-tools.js"
67
+ },
68
+ "./run-herdr": {
69
+ "types": "./dist/run-herdr.d.ts",
70
+ "default": "./dist/run-herdr.js"
71
+ },
72
+ "./run-child": {
73
+ "types": "./dist/run-child.d.ts",
74
+ "default": "./dist/run-child.js"
75
+ },
76
+ "./approval": {
77
+ "types": "./dist/approval.d.ts",
78
+ "default": "./dist/approval.js"
79
+ },
80
+ "./approval-store": {
81
+ "types": "./dist/approval-store.d.ts",
82
+ "default": "./dist/approval-store.js"
83
+ },
84
+ "./approval-prompt": {
85
+ "types": "./dist/approval-prompt.d.ts",
86
+ "default": "./dist/approval-prompt.js"
87
+ }
88
+ },
89
+ "files": [
90
+ "dist",
91
+ "extensions",
92
+ "src",
93
+ "README.md",
94
+ "CHANGELOG.md",
95
+ "LICENSE"
96
+ ],
97
+ "scripts": {
98
+ "build": "tsc -p tsconfig.json",
99
+ "typecheck": "tsc -p tsconfig.check.json",
100
+ "test": "node --test test/*.test.ts",
101
+ "test:integration": "node --test test-integration/*.it.ts",
102
+ "prepack": "npm run build",
103
+ "test:smoke": "node scripts/smoke-installed.mjs"
104
+ },
105
+ "main": "./dist/index.js",
106
+ "types": "./dist/index.d.ts",
107
+ "pi": {
108
+ "extensions": [
109
+ "./extensions/grants.ts"
110
+ ]
111
+ },
112
+ "peerDependencies": {
113
+ "@earendil-works/pi-coding-agent": ">=0.83.0",
114
+ "typebox": ">=1.0.0"
115
+ },
116
+ "devDependencies": {
117
+ "@earendil-works/pi-coding-agent": ">=0.83.0",
118
+ "@types/node": "^22.0.0",
119
+ "typebox": ">=1.0.0",
120
+ "typescript": "^5.7.0"
121
+ }
122
+ }