pi-daddy 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +187 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/dist/approval-prompt.d.ts +116 -0
- package/dist/approval-prompt.d.ts.map +1 -0
- package/dist/approval-prompt.js +124 -0
- package/dist/approval-prompt.js.map +1 -0
- package/dist/approval-store.d.ts +149 -0
- package/dist/approval-store.d.ts.map +1 -0
- package/dist/approval-store.js +311 -0
- package/dist/approval-store.js.map +1 -0
- package/dist/approval.d.ts +221 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +244 -0
- package/dist/approval.js.map +1 -0
- package/dist/capabilities.d.ts +31 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +45 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/catalog.d.ts +81 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +164 -0
- package/dist/catalog.js.map +1 -0
- package/dist/definitions.d.ts +101 -0
- package/dist/definitions.d.ts.map +1 -0
- package/dist/definitions.js +182 -0
- package/dist/definitions.js.map +1 -0
- package/dist/delegate.d.ts +133 -0
- package/dist/delegate.d.ts.map +1 -0
- package/dist/delegate.js +261 -0
- package/dist/delegate.js.map +1 -0
- package/dist/fanout.d.ts +62 -0
- package/dist/fanout.d.ts.map +1 -0
- package/dist/fanout.js +84 -0
- package/dist/fanout.js.map +1 -0
- package/dist/file-lock.d.ts +54 -0
- package/dist/file-lock.d.ts.map +1 -0
- package/dist/file-lock.js +142 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger-report.d.ts +106 -0
- package/dist/ledger-report.d.ts.map +1 -0
- package/dist/ledger-report.js +157 -0
- package/dist/ledger-report.js.map +1 -0
- package/dist/ledger.d.ts +139 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +94 -0
- package/dist/ledger.js.map +1 -0
- package/dist/pane-reaper.d.ts +49 -0
- package/dist/pane-reaper.d.ts.map +1 -0
- package/dist/pane-reaper.js +120 -0
- package/dist/pane-reaper.js.map +1 -0
- package/dist/pi-tools.d.ts +20 -0
- package/dist/pi-tools.d.ts.map +1 -0
- package/dist/pi-tools.js +21 -0
- package/dist/pi-tools.js.map +1 -0
- package/dist/propagation.d.ts +185 -0
- package/dist/propagation.d.ts.map +1 -0
- package/dist/propagation.js +255 -0
- package/dist/propagation.js.map +1 -0
- package/dist/resolve.d.ts +110 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +181 -0
- package/dist/resolve.js.map +1 -0
- package/dist/run-child.d.ts +50 -0
- package/dist/run-child.d.ts.map +1 -0
- package/dist/run-child.js +109 -0
- package/dist/run-child.js.map +1 -0
- package/dist/run-herdr.d.ts +91 -0
- package/dist/run-herdr.d.ts.map +1 -0
- package/dist/run-herdr.js +306 -0
- package/dist/run-herdr.js.map +1 -0
- package/dist/spawn.d.ts +60 -0
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +116 -0
- package/dist/spawn.js.map +1 -0
- package/extensions/approvals.ts +285 -0
- package/extensions/delegation.ts +237 -0
- package/extensions/grants-command.ts +310 -0
- package/extensions/grants.ts +346 -0
- package/extensions/run-delegation.ts +270 -0
- package/extensions/session.ts +234 -0
- package/package.json +122 -0
- package/src/approval-prompt.ts +222 -0
- package/src/approval-store.ts +383 -0
- package/src/approval.ts +364 -0
- package/src/capabilities.ts +51 -0
- package/src/catalog.ts +186 -0
- package/src/definitions.ts +233 -0
- package/src/delegate.ts +383 -0
- package/src/fanout.ts +96 -0
- package/src/file-lock.ts +142 -0
- package/src/index.ts +59 -0
- package/src/ledger-report.ts +227 -0
- package/src/ledger.ts +214 -0
- package/src/pane-reaper.ts +132 -0
- package/src/pi-tools.ts +25 -0
- package/src/propagation.ts +308 -0
- package/src/resolve.ts +235 -0
- package/src/run-child.ts +149 -0
- package/src/run-herdr.ts +357 -0
- package/src/spawn.ts +156 -0
package/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.
|