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
package/README.md ADDED
@@ -0,0 +1,584 @@
1
+ # pi-daddy
2
+
3
+ **Capability governance for pi sub-agents.** A grant can only ever *shrink* as it passes down a delegation
4
+ tree, so a sub-agent can never hold more of the **tool surface** than its parent — enforced by **pi's own
5
+ `--tools` allowlist**, with an append-only ledger of what was granted and what was refused whenever one is
6
+ configured.
7
+
8
+ Both qualifiers are load-bearing and are not buried: a child granted `bash` can escape governance entirely
9
+ (ADR-0012, measured), and the ledger is opt-in. See *What this governs, and what it does not*.
10
+
11
+ > **0.13.0 is the first published release.** Earlier versions were developed in-repo and never shipped, so
12
+ > the breaking changes in the changelog describe how this package arrived at its current behaviour rather
13
+ > than anything you need to migrate from.
14
+ >
15
+ > It has been reviewed twice — once by its author against written hypotheses, then by four independent
16
+ > agents each given one hypothesis to attack. That pass found eight further defects, including a file lock
17
+ > that admitted two writers into its critical section, and all of them are fixed here. The reasoning for
18
+ > every decision is in **[CHANGELOG.md](./CHANGELOG.md)** and in the `docs/06-decisions/` ADRs upstream.
19
+ >
20
+ > **Known gaps are stated rather than implied** — see *Status* at the end of this file. The largest is
21
+ > deliberate: a child granted `bash` escapes governance entirely, by decision.
22
+
23
+ ## What this governs, and what it does not
24
+
25
+ **It governs the tool surface: which tools pi exposes to a model.** That part is structural, not advisory —
26
+ `--tools` is enforced by pi core, and an `-e`-loaded extension cannot re-add its own tool past it
27
+ (measured). A child granted `read` has no write tool, and no prompt can talk it into having one.
28
+
29
+ **It does not contain an agent that holds an execution primitive.** A child granted `bash` can run
30
+ `env -u PI_GRANTS_GRANT pi …` and obtain a completely ungoverned descendant — no ledger entry, no depth
31
+ increment, no grant. Measured, not theorised: `docs/probes/g5-bash-escape`. `env -u` is incidental; the
32
+ mechanism is *"the child can execute programs"*, and governance state lives in that program's environment.
33
+ Containing **that** is the operating system's job, and is out of scope here (**ADR-0012**).
34
+
35
+ So: **`bash` is gated by default in a governed session** — a human is asked before any child receives it —
36
+ and gating is closed under subsumption, so gating `write` gates `bash` too. Neither makes the escape
37
+ impossible. Both stop it happening silently, which is the difference that matters when the realistic threat
38
+ is a confused or prompt-injected agent rather than a determined one.
39
+
40
+ ## The invariant
41
+
42
+ ```
43
+ effective = ( requested ∩ parentGrant ∩ ceiling ) \ (gated \ approved)
44
+ ```
45
+
46
+ Escalation is impossible **by construction**, not by policy. The root holds the full catalog, so grants are
47
+ free from the top; every level below can only subtract. No policy engine, no LLM on the security path.
48
+
49
+ Depth control falls out for free: spawning is itself a capability. Withhold `tool:delegate` and the child is
50
+ a leaf — it receives neither `delegate` nor `delegate_all`.
51
+
52
+ **Cardinality is bounded separately** (ADR-0008, amended 2026-08-12). The invariant above says what a child
53
+ may *hold* and nothing about how many children exist; a blocking `delegate` bounded that to one by accident,
54
+ and fan-out removes the accident. `PI_GRANTS_FANOUT` is a **subtree budget**: a call
55
+ spends from `B` before dividing the remainder among the children, so no *subtree* can exceed what its root
56
+ held. A per-call cap of K with depth D would still permit K^D — the same exponential wearing a smaller
57
+ number — so the bound is subtractive instead, and composes across processes with no shared state.
58
+
59
+ **It is not a session total, and the distinction is measurable:** the value is read once from the
60
+ environment and never decremented, so one session may issue successive `delegate_all` calls at the full
61
+ width. What is bounded is the shape of any *one* tree, not how many trees a turn builds. Bound depth with
62
+ `PI_GRANTS_MAX_DEPTH`; nothing bounds the number of turns.
63
+
64
+ ## Why pi's `--tools` is the enforcement point
65
+
66
+ Measured, not assumed (probes 9–11 in `docs/probes/pi-fabric-eval`):
67
+
68
+ - `pi --tools read -e npm:pi-fabric` → the model **cannot** call `fabric_exec`.
69
+ - `pi --no-tools -e npm:pi-fabric` → likewise blocked.
70
+ - No flag → `fabric_exec` works.
71
+
72
+ So pi core hard-blocks extension tools, and an explicitly `-e`-loaded extension **cannot re-add its own tool
73
+ past the allowlist**. That is why this package needs no runtime inside the descendant: it computes the
74
+ allowlist and hands it to pi.
75
+
76
+ ## Definitions are Agent Skills, and `allowed-tools` is the ceiling
77
+
78
+ A sub-agent is a skill you spawn. Definitions are `SKILL.md` files under pi's own skill roots — a directory
79
+ containing `SKILL.md` is one definition named after the **directory**, and a top-level `.md` is one named
80
+ after the **file**:
81
+
82
+ ```
83
+ .pi/skills/deploy/SKILL.md project, wins on a name collision
84
+ ~/.pi/agent/skills/review/SKILL.md global
85
+ ```
86
+
87
+ ```markdown
88
+ ---
89
+ name: docs-writer
90
+ description: Fixes documentation typos.
91
+ allowed-tools: Read, Write
92
+ ---
93
+ Fix typos in the documentation. Do not restructure anything.
94
+ ```
95
+
96
+ The body becomes the child's system prompt (`--append-system-prompt`); `allowed-tools` becomes its ceiling.
97
+
98
+ | `allowed-tools` | Ceiling |
99
+ | :--- | :--- |
100
+ | **absent** | **undeclared — not spawnable at all.** The refusal names the file. |
101
+ | empty | nothing; a child with no tools |
102
+ | `Read, Write` or `Read Write` | `tool:read`, `tool:write`. Space-separated is the spec's form; commas are tolerated because it is what people type |
103
+ | `ext:pkg/tool`, `skill:x`, `agent:y` | passed through **as written** |
104
+ | anything else | lowercased and prefixed `tool:` — so `Glob` becomes `tool:glob`, which the catalog then refuses as unknown |
105
+ | contains a pattern, e.g. `Bash(git:*)` | **refused, not reinterpreted** |
106
+
107
+ **Two of those rows are the load-bearing ones.**
108
+
109
+ *Absent means undeclared.* Under the pi-subagents frontmatter this package used to read, a missing `tools:`
110
+ key meant pi's **full default toolset**, so an undeclared definition was the most powerful kind and every
111
+ parse failure produced a wildcard — the direction that caused R-28 and review finding F18. The sense is now
112
+ inverted: a typo or an unreadable YAML form costs a refusal instead of a grant.
113
+
114
+ *A sub-tool pattern is refused because every reinterpretation of it is wrong.* pi's `--tools` matches whole
115
+ tool names, so granting bare `bash` for `Bash(git:*)` would **widen** a deliberately narrow declaration,
116
+ dropping it would silently **narrow** and yield a child that mysteriously cannot work, and matching the
117
+ pattern inside a wrapper would be a security control implemented by string-matching a shell command.
118
+
119
+ **Identity comes from the path, never the frontmatter `name`.** pi keys skills by their directory, so
120
+ trusting the frontmatter lets our view and the loader's disagree about which file a name refers to. The spec
121
+ requires them to match anyway, so a mismatch is the file's defect and not something to honour.
122
+
123
+ ## Universal capabilities
124
+
125
+ `fabric_exec` is treated as **universal** — granting it is equivalent to granting the whole catalog, because
126
+ it reaches `pi.write`, `pi.bash`, and unrestricted `agents.run`. This is measured, not theoretical: a child
127
+ granted `tools: []` (nothing at all) plus `recursive: true` still spawned a grandchild that wrote to disk.
128
+
129
+ `assertNarrowing()` therefore **throws** if a supposedly narrow grant contains one. A narrow grant with
130
+ `fabric_exec` in it is full authority wearing a narrow grant's clothing.
131
+
132
+ Narrowing is checked **before** the gate, and the order is load-bearing rather than stylistic: because
133
+ `assertNarrowing` refuses whatever a human says, the old order reported *"requires explicit approval"* for a
134
+ spawn that could never be approved — telling the operator to go and find a human who cannot help. For the
135
+ same reason **no dialog is raised** for a spawn retaining a universal capability: asking would be worse than
136
+ useless, since a `session`- or `always`-scoped *yes* given there is banked and reused for later spawns that
137
+ **do** proceed.
138
+
139
+ A delegator that legitimately holds `fabric_exec` and knowingly wants a child to have it **cannot** spawn
140
+ that child. `assertNarrowing`'s `allowUniversal` flag exists but is deliberately not plumbed through; the
141
+ first real need for that override is the evidence it should be added.
142
+
143
+ ## The two tools
144
+
145
+ ```
146
+ delegate({ task: "summarise src/", agent: "docs-writer" }) // preferred: an operator-authored definition
147
+ delegate({ task: "summarise src/", tools: ["read"] }) // when no definition fits
148
+ delegate_all({ children: [ {…}, {…}, {…} ] }) // several at once, each independently governed
149
+ ```
150
+
151
+ - **Prefer `agent`.** Its capabilities *and* its instructions were written by the operator, and the session
152
+ must hold `agent:<name>` to name it at all. `tools:` is the escape hatch: the model chooses the tool list,
153
+ which is why that form can never persist an approval (see below).
154
+ - **You cannot grant what you do not hold.** Refusals name the capability and are recorded.
155
+ - **Spawning is itself a capability.** Grant `delegate` and the child can sub-delegate; withhold it and the
156
+ child is a leaf — the extension is only passed to children that hold it, so the machinery isn't even
157
+ present. `PI_GRANTS_MAX_DEPTH` remains a backstop.
158
+ - **A refusal is a tool *error*, not an answer.** Both tools throw, because `AgentToolResult` has **no
159
+ `isError` field** — pi sets it only when `execute` throws, and a normal return is hardcoded
160
+ `isError: false`. Until 0.5.0 the tool returned `isError: true`, which was silently discarded, so every
161
+ refusal this package made was recorded by pi as a **successful** tool call. Found by the integration suite
162
+ on its first run.
163
+ - **A child cannot outlive or overwhelm you.** Output is capped (1 MiB), there is a wall-clock timeout
164
+ (`PI_GRANTS_CHILD_TIMEOUT`, default 600s) with `SIGTERM` → `SIGKILL` escalation so a child cannot ignore
165
+ its way past it, an abort is honoured even if it arrived before the spawn, and a child that exits
166
+ non-zero, times out or is cancelled comes back as a **tool error naming which** — not as an answer.
167
+ - **Fan-out is synchronous and bounded.** At most 8 children per call, and the subtree budget bounds the
168
+ total across the whole tree. Every child goes through the same plan-gate-audit path as a single
169
+ `delegate`; `delegate_all` adds only cardinality and sibling identity. **There is deliberately no
170
+ background mode** (ADR-0015): fan-out carried most of the value and background carries nearly all of the
171
+ lifecycle holes, and because the turn still owns its children the parent cannot exit before them, the
172
+ tool-call signal is still live, and there are no ids to dangle across a compaction.
173
+ - **One child can be refused while its siblings succeed**, and every outcome is reported. A fan-out that
174
+ hid its refusals would let an orchestrator summarise four reviews when only three happened.
175
+
176
+ **Verified live, with a real model** (`test-integration/delegation.it.ts`):
177
+
178
+ | Scenario | Result |
179
+ | :--- | :--- |
180
+ | Holds `read,write,delegate`; delegates `tools:["read"]`; child told to write a file | **No file exists.** `--tools` is the enforcement point and this is what it buys |
181
+ | Holds `read,delegate`; tries `tools:["read","write"]` | Tool **error**: `cannot grant tool:write — this session does not hold it (capability escalation blocked)`; ledger `denied:["tool:write"]`, `blocked:true` |
182
+ | `PI_GRANTS_LEDGER` pointed somewhere unwritable | Delegation **refused** — asking for an audit trail makes it a precondition |
183
+
184
+ ### Two executors, one plan
185
+
186
+ Default is a captured child process. `PI_GRANTS_HERDR=1` runs each child in a visible, attachable **herdr**
187
+ pane instead — the same governed argv, the same `--tools` enforcement, somewhere you can watch it.
188
+
189
+ Opt-in and never auto-detected: where a governed child executes is an operator decision, not a consequence
190
+ of what happens to be on `PATH`. Constraints found by building it are in `docs/probes/g16-herdr` — herdr has
191
+ no `--env` (the grant rides on the pane, which the agent's shell inherits), `agent start` types argv into a
192
+ shell so a multi-line argument must be staged to a file, and `agent wait --until idle` matches the state the
193
+ agent was *already* in, so settling requires a state counter to advance.
194
+
195
+ ## Approving a gated capability
196
+
197
+ `gated` capabilities are ones a session holds but may not pass on without a human saying so.
198
+
199
+ ```
200
+ grants: approve tool:bash for deploy?
201
+ task: roll out the staging build
202
+
203
+ > Deny
204
+ Allow once
205
+ Allow for this session
206
+ Always allow in this project (30 days)
207
+ ```
208
+
209
+ **What a yes is *about* differs by call form, and that decides which scopes are offered.**
210
+
211
+ - **`delegate({agent: X})`** is approved against **`X` itself** — an operator-authored file the session must
212
+ hold `agent:X` to name at all. That is a human-authored subject, so `always` is on offer.
213
+ - **`delegate({tools: […]})`** is approved against the constant `<delegate>` and is offered only `once` and
214
+ `session`. The only things naming that child are the task and the tool list, both model-chosen, and **a
215
+ key the model controls is not a key.**
216
+
217
+ **A persisted approval is void the moment what it was granted for changes.** It pins both the definition's
218
+ `allowed-tools` and its body digest, and `/grants approvals` reports *which* check failed rather than
219
+ silently showing fewer rows:
220
+
221
+ | Verdict | Means |
222
+ | :--- | :--- |
223
+ | `type-changed` | `allowed-tools` gained or lost a capability after approval |
224
+ | `instructions-changed` | the tools are untouched but the **body** was rewritten — or the entry carries no body pin at all, which fails closed |
225
+ | `expired` | past its 30 days. An unparseable expiry is treated as expired: a cache we cannot read the expiry of is a cache we do not trust |
226
+ | `foreign-cwd` | the entry belongs to another directory. Nobody in *this* checkout was asked |
227
+ | `type-missing` | the definition was deleted or renamed, and a new file could later claim the name |
228
+
229
+ **The store lives outside the governed workspace**, one file per governed directory under
230
+ `$PI_CODING_AGENT_DIR/grants-approvals/` (default `~/.pi/agent/`). It used to sit at `<cwd>/.pi/grants-approvals.json`, which was self-defeating in
231
+ this package's own recommended configuration: `PI_GRANTS_GATED=tool:write` means *"may use write, may not
232
+ pass it down without a human"* — and **a session that may use `write` can write the approvals file.** A
233
+ reviewer forged an entry end to end, including a matching definition so the ceiling compared equal, and got
234
+ a ledger line reading `approvalSource: "persisted"` that was indistinguishable from a real approval. A
235
+ legacy in-workspace file is **ignored, not migrated**, with a warning naming it: importing it would import
236
+ exactly the entries the move exists to stop trusting. This does **not** defend against a child holding
237
+ `bash` (ADR-0012); it closes the self-defeating case, not a boundary the package does not have.
238
+
239
+ Writes are atomic (temp file plus `rename`, in the same directory so the rename really is atomic) and refuse
240
+ to follow a symlink, so a crash or a concurrent writer cannot leave a half-written file, and a corrupt file
241
+ cannot make the next legitimate write destroy every other entry.
242
+
243
+ **Other properties worth knowing:**
244
+
245
+ - **An approval rides down the tree with the grant**, intersected with what each child actually receives at
246
+ every hop, so `approved ⊆ grant` holds at every level. An approval unblocks part of a grant; it can never
247
+ widen one. `once` is **dropped** on inheritance — the most conservative answer a human can give must not
248
+ produce the least conservative outcome — and the subject is **kept**, so a `<delegate>` approval no longer
249
+ matches any subject one hop down.
250
+ - **Concurrent callers share one dialog per `capability@subject`, but only share the *answer* when it was
251
+ about more than one spawn.** `session`, `always`, a decline and an error answer everyone; **a `once` is
252
+ consumed by exactly one caller** and the rest are asked their own question. Measured before the fix: four
253
+ concurrent delegations gating `tool:bash`, one dialog, one click of *Allow once* → four grants, with the
254
+ human having seen only the first caller's task.
255
+ - **Holding `tool:*` is authority to grant widely, never authority to skip a human.** A gate is the
256
+ operator's, not the delegator's. A wildcard holder reaches the ordinary dialog like anyone else.
257
+ - **A child can never be asked anything.** It runs `--print` with no interactive user, so a gate it hits has
258
+ only two outcomes: satisfied by an inherited approval, or refused with a reason naming the fix. This is
259
+ pi's own behaviour — non-interactive modes install a no-op UI context whose `select` resolves `undefined`
260
+ — so a background delegation hitting a gate is refused, not hung.
261
+ - **The ledger distinguishes three flavours of "no"**: `denied` (an agent asked for more than it holds — an
262
+ escalation attempt), `humanDenied` (a person was asked and declined — working as designed), and
263
+ `gatedBlocked` with no `approvalSource` (nobody was there to ask — an operator should pre-approve).
264
+ `humanDenied` is set only for a genuine decline, never for a dismissal, a timeout, or a dialog error —
265
+ those get their own outcome kinds so a caller can tell them apart.
266
+
267
+ ```
268
+ /grants approvals list them, with why any are being ignored
269
+ /grants revoke tool:bash@deploy take one back
270
+ /grants revoke --all
271
+ ```
272
+
273
+ **Pruning is lazy and scoped to one project.** Entries are validated on read and removed only on write, so
274
+ an expired or type-changed entry lingers in the file until the next approval or revoke. It cannot reach
275
+ another project: since ADR-0020 each governed directory has its own file, which is what makes
276
+ `/grants revoke --all` mean *this project* rather than *this machine*.
277
+
278
+ ### Verified live, end to end
279
+
280
+ The whole lifecycle is exercised by `test-integration/approval.it.ts` against a real pi process, and its
281
+ model tier watches a real model, a real dialog and a real file. In one test, in this order: the model called
282
+ `delegate({agent: "bash-user"})`; the dialog was raised naming the **definition** as its subject with
283
+ *Always allow* on offer; the entry landed on disk pinning the ceiling *and* the body digest; the ledger
284
+ recorded `approvalScope: "always"`, `approvalSource: "prompt"`; a **different pi process** then ran the same
285
+ delegation with **zero dialogs** and a ledger line reading `approvalSource: "persisted"`; and after
286
+ rewriting the body — frontmatter byte-identical, so only the digest can catch it — the dialog was raised
287
+ again and the dismissed delegation failed.
288
+
289
+ Seven further tests in that file cost no model tokens and cover the reload and every void reason above.
290
+
291
+ **What this does not establish.** Dialogs are driven through `pi --mode rpc`, which is the same
292
+ `ctx.ui.select` call the TUI dialog serves; **the TUI's own rendering is not exercised.** The earlier
293
+ transcripts in `docs/probes/approval-ux` describe an interceptor path that no longer exists — they are kept
294
+ as the record of that run and are not a description of this version.
295
+
296
+ ## Running it
297
+
298
+ ```bash
299
+ # `agent:` ids say WHICH definitions this session may spawn (0.8.0); `tool:` ids say what it may grant them.
300
+ PI_GRANTS_GRANT="agent:review,tool:read,tool:grep,tool:find,tool:ls,tool:delegate" \
301
+ PI_GRANTS_LEDGER=.pi/grants.jsonl \
302
+ PI_GRANTS_MAX_DEPTH=2 \
303
+ pi -e ./extensions/grants.ts
304
+ ```
305
+
306
+ `/grants` shows the session's grant, its depth, the catalog by kind, and an allow/BLOCK verdict per known
307
+ definition — computed by **the same function a real spawn uses**, so the diagnostic cannot disagree with the
308
+ enforcer. That is not cosmetic, and it has been got wrong twice. R-28 was a `/grants` that reported "allow"
309
+ for spawns the enforcement path refused with a reason misstating the definition file. **R-38 was the same
310
+ shape one layer up**: the listing shared the *planner* with enforcement but not the *sequence*, so a
311
+ definition covered by a valid persisted approval was reported as blocked while a real spawn proceeded with
312
+ no human in the loop. Both paths now go through one `planWithApprovals`, differing in a single argument —
313
+ the preview never asks a human and never claims one is missing — and an `allow` that rests on a standing
314
+ approval says so:
315
+
316
+ ```
317
+ allow deploy tool:bash, tool:read (tool:bash approved: persisted)
318
+ BLOCK undeclared — agent "undeclared" declares no `allowed-tools`, so it cannot be spawned — add
319
+ one to …/.pi/skills/undeclared/SKILL.md. An undeclared capability set is treated
320
+ as NONE, never as everything.
321
+ ```
322
+
323
+ `/grants ledger` reads the ledger back and reports its integrity — record count, escalation attempts, any
324
+ unparseable lines with line numbers, and **which instructions actually ran**: records grouped by definition
325
+ digest, each compared against the file on disk (`current` / `CHANGED since`), which is what makes ADR-0018's
326
+ `definitionDigest` answerable rather than decorative. It exists because nothing in this package had ever read a ledger
327
+ back, so a torn line was indistinguishable from a spawn that never happened. A corrupt line is **evidence**
328
+ and is left alone rather than repaired. Nothing runs this check automatically.
329
+
330
+ ### The tripwire
331
+
332
+ In a **governed** session the `tool_call` hook refuses third-party spawn tools (`Agent`, `subagent`,
333
+ `spawn_agent`) and records the refusal, because such a spawn would create a descendant this package did not
334
+ provision, does not bound by depth, and does not record. Installing such an extension is a single command,
335
+ so refusing is cheap and silence is not.
336
+
337
+ **It is a tripwire, not a boundary, and the difference is measured:** `subagents:rpc:spawn` reaches
338
+ `manager.spawn()` over the event bus and never produces a `tool_call` at all (ADR-0013 Finding 6), so a
339
+ tool-name check cannot see it. It catches the ordinary case loudly. It is not containment.
340
+
341
+ **Governance is opt-in.** With `PI_GRANTS_GRANT` unset, the session holds the wildcard and nothing is
342
+ blocked — this extension must never silently tighten a normal workflow. Since 0.5.0 that holds for
343
+ **descendants** too: an ungoverned session publishes no governance variables at all. It previously
344
+ exported its own observed tool surface as its children's grant, so "inactive" governance quietly governed
345
+ everything below it.
346
+
347
+ ### Configuration, and how it fails
348
+
349
+ | Variable | Default | Notes |
350
+ | :--- | :--- | :--- |
351
+ | `PI_GRANTS_GRANT` | unset → ungoverned | Presence is what switches governance on. |
352
+ | `PI_GRANTS_MAX_DEPTH` | `2` | Child-depth bound. `0` disables spawning. |
353
+ | `PI_GRANTS_DEPTH` | `0` | This session's own depth; set by the parent, not by hand. |
354
+ | `PI_GRANTS_GATED` | **`tool:bash`** in a governed session | Capabilities needing human approval. Set to `""` to gate nothing. Gating is closed under subsumption, so this also covers `write`/`edit`/`read`/`grep`/`find`/`ls` (ADR-0012). |
355
+ | `PI_GRANTS_APPROVED` | unset | Inherited `capability@subject#sha256` entries; set by the parent, clamped to the child's own grant, and honoured only against the definition body the child itself loaded (ADR-0022). |
356
+ | `PI_GRANTS_APPROVAL_TIMEOUT` | `120` (seconds) | How long a dialog waits. `0` or an unreadable value means **no timeout**: waiting forever denies nothing, so it is the safe reading of a value we do not understand. |
357
+ | `PI_GRANTS_LEDGER` | unset → not recording | **Setting this makes the ledger load-bearing** — see below. |
358
+ | `PI_GRANTS_CHILD_TIMEOUT` | `600` (seconds) | Wall-clock limit for a child. Inherited by descendants — an operator preference, deliberately *not* attenuating state. |
359
+ | `PI_GRANTS_FANOUT` | `8` | **Subtree budget**: total descendants this session may create. Attenuates downward like depth. Malformed or `0` falls back to the default — a bound a typo can switch off is not a bound. |
360
+ | `PI_GRANTS_PARENT_ID` | `d0` | This session's ledger id; set by the parent. Makes sibling records joinable into a tree. |
361
+ | `PI_GRANTS_HERDR` | unset | `1` runs children in visible **herdr** panes instead of captured processes. Opt-in, never auto-detected: where a governed child executes is an operator decision, not a consequence of what is on `PATH`. |
362
+ | `PI_GRANTS_HERDR_WORKSPACE` | unset | herdr workspace for spawned panes. |
363
+ | `PI_GRANTS_HERDR_KEEP_PANE` | unset | `1` keeps each child's pane for inspection. Off by default: a fan-out would flood the workspace. |
364
+ | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | pi's own variable, not ours — but it decides where persisted approvals live, so it is listed here. |
365
+
366
+ **A malformed value disables spawning; it never falls back to a default.** An unreadable
367
+ `PI_GRANTS_MAX_DEPTH` or `PI_GRANTS_DEPTH` yields `maxDepth: 0` and a startup warning naming the
368
+ variable. Before 0.5.0 these were read with `parseInt`, which accepts numeric prefixes (`"2abc"` → `2`)
369
+ and otherwise gives `NaN` — and since every comparison against `NaN` is false, a typo did not tighten the
370
+ depth limit, it **removed** it.
371
+
372
+ **Configuring a ledger makes it a precondition, not a log.** If `PI_GRANTS_LEDGER` is set and the write
373
+ fails, the delegation is **refused**. Asking for an audit trail is an explicit act, and `ledger.ts` has
374
+ always documented that an unrecorded grant should fail closed; until 0.5.0 both call sites silently
375
+ swallowed the error. Sessions with no ledger configured are unaffected. Concurrent appends are serialised by
376
+ a lock file with a short timeout — failing closed beats hanging — and a lock abandoned by a killed process
377
+ is broken after 10s.
378
+
379
+ ## The ledger
380
+
381
+ Append-only JSONL. One record per governed decision, **including refusals**, which are the interesting ones.
382
+
383
+ Ids are hierarchical and derived: a child of `d0` is `d0.1`, its own second child `d0.1.2`. Ancestry reads
384
+ from the id alone with no join, and it is reproducible, so two runs of the same fan-out produce a diffable
385
+ ledger. `denied` non-empty is the one designated escalation signal — **an agent asking for what it does not
386
+ hold is an escalation attempt, and it is invisible without a record.**
387
+
388
+ **Privacy is a property of this file, and the boundary is exact: capability ids, counts and identifiers only
389
+ — never prompts, tool arguments or results.** The `definitionDigest` is on the identifier side of that line:
390
+ it names a version of operator-authored text already committed to a repository. The **task** is on the other
391
+ side and is never recorded, in any field.
392
+
393
+ ## Propagation is race-free by construction
394
+
395
+ An earlier version wrote each child's computed grant into `process.env`. The environment is process-global,
396
+ so concurrent spawns could read each other's values — a real hole. The fix removes the need for a per-child
397
+ channel rather than building one:
398
+
399
+ 1. **Everything published to the environment is a parent-level fact** — the parent's own grant, the child
400
+ depth (`parent + 1`), the configured bounds, and this session's own approvals. Identical for every
401
+ sibling, so there is nothing to race on. It is written once at session start and republished only when
402
+ this session's own approvals change, never per spawn.
403
+ 2. **Each child is spawned with its own explicit `env` object.** It is built by stripping every
404
+ `PI_GRANTS_*` variable from this process's environment and then applying the plan, so the plan is the
405
+ only source of all of them — a key the plan does not set cannot let the parent's value through.
406
+ 3. **Each child derives its own grant on arrival**: `inheritedGrant ∩ ownObservedTools`, where the observed
407
+ set comes from the `tools` array of its first provider request — authoritative, because it is exactly
408
+ what pi sent the model. (A session's first provider request always precedes its first tool call, so the
409
+ grant is settled before it can delegate.) Only `tool:` and `ext:` ids are filtered this way; an
410
+ observation says nothing about `skill:` or `agent:` (R-36).
411
+
412
+ The invariant holds transitively — `own = observed ∩ inherited ⊆ inherited` — and it doubles as defence in
413
+ depth: a child clamps itself even if it were handed too much.
414
+
415
+ **The wildcard is held but never inherited.** A root may hold `tool:*` (authority to grant anything), but
416
+ handing it down would let every descendant reacquire the full catalog and make attenuation meaningless
417
+ below the root. Children inherit the enumerated grant only. A wildcard root that has not yet observed its
418
+ tools hands children an empty grant — fail closed.
419
+
420
+ **The task never touches argv.** pi dispatches `@file` and `-flag` on the *first character* of an argv
421
+ element, and `@file` is read **before any tool exists**, so `--tools` cannot stop it: a task beginning `@`
422
+ made pi read an arbitrary file into a child holding no tools at all. The task is now the final argv element,
423
+ prefixed with one space, unconditionally — a positional guarantee rather than a pattern match against pi's
424
+ current parser. Reproduced and closed; `docs/probes/g1-argv`.
425
+
426
+ ## Functional subsumption: `bash` is not one capability among eight
427
+
428
+ pi's **default** tool surface is `read`, `bash`, `edit`, `write` — measured, not assumed (`grep`, `find`,
429
+ and `ls` exist but are not default). So a definition declaring `Read, Grep, Find, Ls` would look
430
+ like an escalation from any normal parent, despite being strictly weaker.
431
+
432
+ It isn't, because **`bash` can run `grep`, `find`, `ls`, `cat`, and `sed`**. `SUBSUMPTION` models that
433
+ explicitly, which removes the false positives *and* makes the uncomfortable part visible: a grant
434
+ containing `bash` is not a narrow grant. `result.subsumedBy` lists what the parent covers only indirectly,
435
+ so a reviewer can see what a grant really means. Pass `subsumption: false` for a strict name-equality check.
436
+
437
+ ## Live capability catalog
438
+
439
+ Grants are validated against what actually exists in the session, not just against definition files:
440
+
441
+ | Source | Gives | Why it's trusted |
442
+ | :--- | :--- | :--- |
443
+ | provider request `tools` array | `tool:` capabilities, **including extension-provided ones** | authoritative — it is exactly what pi sent the model, and reflects any `--tools` allowlist already in force |
444
+ | skill roots (`.pi/skills`, `~/.pi/agent/skills`) | `skill:` capabilities | `SKILL.md` directories and top-level `.md` files, per pi's convention |
445
+ | the same roots | `agent:` capabilities | one per definition, from the same discovery `delegate` spawns from — so a definition can never be grantable but unspawnable, or listed but unknown |
446
+
447
+ This closes the **skills** half of "skills and tools" — previously ungovernable — and makes extension tools
448
+ visible, which is the only way `ext:`/`tool:` grants can be validated at all.
449
+
450
+ **Unknown is reported separately from denied**, because the causes and fixes differ: *denied* means the
451
+ delegator lacks authority; *unknown* means the capability does not exist here — a typo, or an uninstalled
452
+ package. Collapsing them would hide both. Verified live: `tools:["reed"]` →
453
+ `unknown capability: tool:reed — not present in this session's catalog (typo, or an uninstalled package?)`,
454
+ with no mention of escalation.
455
+
456
+ Provenance caveat: a provider payload gives tool *names*, not owning packages, so extension tools are
457
+ catalogued as `tool:<name>` (which is also how pi's `--tools` matches) and marked `kind: "extension"` for
458
+ display rather than qualified as `ext:<pkg>/<tool>`.
459
+
460
+ **`PI_BUILTIN_TOOLS` is a pinned observation** of pi 0.84.1. Drift misfiles a capability in the catalog; it
461
+ cannot grant one, because `--tools` is the authority.
462
+
463
+ ## Use as a library
464
+
465
+ The resolver, ledger, spawn planner and the whole approval model are pure functions, exported and usable
466
+ without pi:
467
+
468
+ ```ts
469
+ import { resolve, assertNarrowing, planSpawn, buildRecord, appendRecord } from "pi-daddy";
470
+
471
+ const result = resolve({
472
+ requested: ["tool:read", "tool:grep"],
473
+ parentGrant: ["tool:read", "tool:grep", "tool:write"], // what the delegator holds
474
+ ceiling: ["tool:read", "tool:grep"], // the definition's declared maximum
475
+ gated: ["tool:write"], // needs human approval, ever
476
+ });
477
+
478
+ assertNarrowing(result); // throws on a smuggled universal capability
479
+ const plan = planSpawn({ effective: result.effective, prompt: task });
480
+ // -> ["--print","--no-session","--no-extensions","--no-skills","--no-context-files",
481
+ // "--no-prompt-templates","--tools","grep,read"," summarise src/"]
482
+
483
+ await appendRecord({ path: ".pi/grants.jsonl" }, buildRecord({ /* … */ result, blocked: false, now: new Date() }));
484
+ ```
485
+
486
+ Subpaths are exported individually (`pi-daddy/resolve`, `/ledger`, `/spawn`, `/delegate`, `/catalog`,
487
+ `/propagation`, `/definitions`, `/fanout`, `/pi-tools`, `/approval`, `/approval-store`, `/approval-prompt`,
488
+ `/run-child`, `/run-herdr`).
489
+
490
+ ## Design decisions worth knowing
491
+
492
+ - **A zero grant is `--no-tools`, never "no flag".** pi rejects an empty `--tools`, and omitting the flag
493
+ silently falls back to pi's defaults — the opposite of a zero grant.
494
+ - **`--no-extensions` is always passed**, so ambient user extensions cannot widen a governed child. An
495
+ explicit `-e` still loads, which is exactly why the extension is re-added by hand for a child that holds
496
+ `tool:delegate`, and never otherwise.
497
+ - **Each resource class needs its own switch.** `--no-extensions` does not disable skills, context files or
498
+ prompt templates; all three are passed explicitly, and `--skill` *adds* to the discovered set unless
499
+ `--no-skills` goes with it. A granted skill is passed by path and refused if it cannot be located —
500
+ granting a capability the child would silently lack is a lie in the ledger.
501
+ - **The ledger fails closed by default.** An unrecorded grant is a hole; `strict: false` only where the
502
+ ledger is advisory.
503
+ - **Skills and definitions are capabilities too** (`skill:`, `agent:`), governed by the same machinery — but
504
+ they are not `--tools` entries, so `toPiToolsAllowlist()` filters them out.
505
+ - **Rejection reasons never mask one another** — `denied` (escalation), `clipped` (ceiling), and
506
+ `gatedBlocked` (needs approval) are computed independently and reported together, so a request with two
507
+ problems does not report one and hide the other.
508
+
509
+ ## Install
510
+
511
+ ```bash
512
+ pi install npm:pi-daddy # as a pi extension
513
+ npm i pi-daddy # as a library (the resolver, ledger and spawn planner are pure)
514
+ ```
515
+
516
+ The package is both. pi loads `extensions/grants.ts` through its own transpiling loader, which reads
517
+ TypeScript from `node_modules` quite happily; **Node does not** — it refuses to strip types under
518
+ `node_modules` — so the library entry points are compiled to `dist/`. Until 0.6.0 `exports` pointed at
519
+ `./src/*.ts`, and every consumer import failed with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` while
520
+ every in-repo test passed. `npm run test:smoke` packs a tarball, installs it into a scratch project and
521
+ *uses* it, so that gap cannot reopen silently.
522
+
523
+ ## Testing
524
+
525
+ ```bash
526
+ npm test # 292 unit tests. Fast, pure, no pi, no network.
527
+ npm run typecheck # src + extensions + tests + integration tests
528
+ npm run test:integration # 23 tests against a REAL pi process. ~32s, no model tokens.
529
+ npm run test:smoke # pack, install into a scratch project, import and use it
530
+ PI_GRANTS_IT_MODEL=1 npm run test:integration # + 4 end-to-end tests with a real model. ~60s, costs money.
531
+ ```
532
+
533
+ The resolver is a pure function, which is deliberate: it is the only place an escalation could be
534
+ introduced, so it is the only place needing exhaustive tests — and being pure, it can have them. Coverage
535
+ includes three-level transitive attenuation, approval-cannot-conjure-a-capability, empty vs. absent
536
+ ceilings, and the measured `fabric_exec` escalation.
537
+
538
+ **The integration suite exists because the extension is where the wiring bugs live** — every defect the live
539
+ probes ever found was there rather than in `src/`, and a pure test cannot see how configuration is *read*.
540
+ Its default tier drives slash commands, whose handlers run the real decision path over real `SKILL.md` files
541
+ in a real pi process **without a model deciding anything**, so it is deterministic and free. The opt-in tier
542
+ adds a model choosing to call tools, and asserts on structure (`isError`, the ledger JSON, whether a file
543
+ appeared on disk) rather than on model wording.
544
+
545
+ It earned itself immediately: its first run found that **every delegation refusal was being recorded by pi
546
+ as a success.** It is also checked against reintroduced defects, which is the point — restoring the G7 `NaN`
547
+ bug makes two of its tests fail, and the approval tests were verified by mutation (making an unpinned body
548
+ digest fail *open* fails exactly one test; deleting the body comparison fails three).
549
+
550
+ `test/file-size.test.ts` fails the build if any file in `src/` or `extensions/` exceeds 400 lines. It caught
551
+ its own author the day after it was added: rather than raise the cap, `delegation.ts` was split.
552
+
553
+ ## Status
554
+
555
+ **0.13.0 — usable, and honest about scope.** What exists and is verified against real pi: the resolver, the
556
+ ledger with an integrity reader, the spawn planner, `SKILL.md` definitions with `allowed-tools` as an
557
+ enforced ceiling, `delegate` and `delegate_all` with a subtree budget, two executors, and human approval for
558
+ gated capabilities (once / session / always, inheritable down the tree, persisted for `always` and pinned to
559
+ both the tools and the instructions).
560
+
561
+ Known gaps, stated because a gap nobody wrote down is the one that surprises somebody:
562
+
563
+ - **`bash` escapes governance.** Out of scope by decision (ADR-0012).
564
+ - **`subagents:rpc:spawn` bypasses the tripwire.** Unfixable from here.
565
+ - **The ledger is verified at session start** when one is configured: a damaged trail announces itself, an intact one stays quiet.
566
+ - **Pane cleanup covers everything except being killed outright.** A run closes its pane in a `finally`,
567
+ and anything still open is closed on process `exit` — which does **not** cover SIGKILL, nor a SIGTERM
568
+ nothing else in the process is listening for, because Node runs no `exit` handlers there. `herdr tab
569
+ close <id>` is the remedy. No signal handler is installed, deliberately: one here would suppress Node's
570
+ default termination and turn pi's *"interrupt this turn"* into *"exit pi"* (R-62).
571
+ - **A definition's *instructions* are governed only by identity.** `agent:<name>` says which file may be
572
+ spawned and the digest says which version ran, but nothing reads a body and judges what it says — the
573
+ operator authorises a file, and its contents are their responsibility.
574
+ - **No background delegation.** `delegate` runs to completion and returns the child's output (ADR-0015).
575
+ If one is ever built, ADR-0026 fixes the rule that blocked it twice: a background spawn whose gates are
576
+ unresolved when its tool call returns is **refused**, and an approval arriving later starts nothing —
577
+ otherwise a child's capability set would depend on when a human reached the dialog.
578
+ - **Whether persisted approvals earn their keep is still unmeasured.** `/grants ledger` now counts where
579
+ every approval came from, so the question ADR-0020 left open has a command; what it does not have yet is
580
+ a few weeks of real use to answer it.
581
+
582
+ `docs/SPEC.md` in the repository is the authoritative current-state document; the ADRs hold the reasoning.
583
+
584
+ Requires pi ≥ 0.83.0, Node ≥ 22.19. MIT.