pi-daddy 0.17.0 → 0.18.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 +154 -80
- package/README.md +54 -25
- package/dist/approval-prompt.d.ts +3 -1
- package/dist/approval-prompt.d.ts.map +1 -1
- package/dist/approval-prompt.js +1 -1
- package/dist/approval-prompt.js.map +1 -1
- package/dist/approval-store.d.ts.map +1 -1
- package/dist/approval-store.js +4 -1
- package/dist/approval-store.js.map +1 -1
- package/dist/approval.d.ts +20 -2
- package/dist/approval.d.ts.map +1 -1
- package/dist/approval.js +26 -6
- package/dist/approval.js.map +1 -1
- package/dist/chain.d.ts +6 -1
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js +1 -1
- package/dist/chain.js.map +1 -1
- package/dist/check-runner.d.ts +61 -0
- package/dist/check-runner.d.ts.map +1 -0
- package/dist/check-runner.js +237 -0
- package/dist/check-runner.js.map +1 -0
- package/dist/correlation.d.ts +90 -0
- package/dist/correlation.d.ts.map +1 -0
- package/dist/correlation.js +183 -0
- package/dist/correlation.js.map +1 -0
- package/dist/delegate-types.d.ts +140 -0
- package/dist/delegate-types.d.ts.map +1 -0
- package/dist/delegate-types.js +8 -0
- package/dist/delegate-types.js.map +1 -0
- package/dist/delegate.d.ts +4 -128
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +68 -72
- package/dist/delegate.js.map +1 -1
- package/dist/delegation-approval.d.ts +36 -0
- package/dist/delegation-approval.d.ts.map +1 -0
- package/dist/delegation-approval.js +67 -0
- package/dist/delegation-approval.js.map +1 -0
- package/dist/git-identity.d.ts +13 -0
- package/dist/git-identity.d.ts.map +1 -0
- package/dist/git-identity.js +44 -0
- package/dist/git-identity.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lease-record.d.ts +78 -0
- package/dist/lease-record.d.ts.map +1 -0
- package/dist/lease-record.js +52 -0
- package/dist/lease-record.js.map +1 -0
- package/dist/ledger-events.d.ts +87 -0
- package/dist/ledger-events.d.ts.map +1 -0
- package/dist/ledger-events.js +45 -0
- package/dist/ledger-events.js.map +1 -0
- package/dist/ledger-report.d.ts +19 -12
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +81 -1
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +44 -1
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +20 -2
- package/dist/ledger.js.map +1 -1
- package/dist/refusals.d.ts +16 -0
- package/dist/refusals.d.ts.map +1 -0
- package/dist/refusals.js +51 -0
- package/dist/refusals.js.map +1 -0
- package/dist/run-child.d.ts +4 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +21 -1
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +7 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +41 -12
- package/dist/run-herdr.js.map +1 -1
- package/dist/workspace-lease.d.ts +28 -0
- package/dist/workspace-lease.d.ts.map +1 -0
- package/dist/workspace-lease.js +276 -0
- package/dist/workspace-lease.js.map +1 -0
- package/dist/workspace.d.ts +32 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +79 -0
- package/dist/workspace.js.map +1 -0
- package/extensions/approval-banking.ts +66 -0
- package/extensions/approvals.ts +95 -7
- package/extensions/chain-approval-facts.ts +51 -0
- package/extensions/chain-ledger.ts +48 -0
- package/extensions/delegate-chain.ts +115 -81
- package/extensions/delegation.ts +81 -23
- package/extensions/execute-child.ts +289 -0
- package/extensions/fanout-outcome.ts +97 -0
- package/extensions/run-delegation.ts +130 -120
- package/extensions/session.ts +4 -0
- package/extensions/workspace-runtime.ts +165 -0
- package/package.json +17 -1
- package/src/approval-prompt.ts +4 -2
- package/src/approval-store.ts +4 -1
- package/src/approval.ts +47 -14
- package/src/chain.ts +3 -1
- package/src/check-runner.ts +341 -0
- package/src/correlation.ts +260 -0
- package/src/delegate-types.ts +144 -0
- package/src/delegate.ts +80 -179
- package/src/delegation-approval.ts +99 -0
- package/src/git-identity.ts +52 -0
- package/src/index.ts +50 -0
- package/src/lease-record.ts +119 -0
- package/src/ledger-events.ts +138 -0
- package/src/ledger-report.ts +90 -2
- package/src/ledger.ts +68 -3
- package/src/refusals.ts +66 -0
- package/src/run-child.ts +23 -1
- package/src/run-herdr.ts +41 -11
- package/src/workspace-lease.ts +303 -0
- package/src/workspace.ts +135 -0
|
@@ -11,20 +11,29 @@
|
|
|
11
11
|
* tool surface is observed.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { Type } from "typebox";
|
|
16
14
|
import { DELEGATE_SUBJECT, shouldSeekApproval } from "../src/approval.ts";
|
|
17
|
-
import {
|
|
18
|
-
import { MAX_CHILDREN_PER_CALL, childSpawnId, splitBudget } from "../src/fanout.ts";
|
|
15
|
+
import { planDelegation } from "../src/delegate.ts";
|
|
19
16
|
import { appendRecord, buildRecord } from "../src/ledger.ts";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
import {
|
|
18
|
+
obtainApprovals,
|
|
19
|
+
republishable,
|
|
20
|
+
snapshotOf,
|
|
21
|
+
unbankApprovals,
|
|
22
|
+
type ApprovalOutcome,
|
|
23
|
+
type ApprovalUIContext,
|
|
24
|
+
} from "./approvals.ts";
|
|
25
25
|
import type { InheritableApproval } from "../src/approval.ts";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
26
|
+
import type { GrantsSession } from "./session.ts";
|
|
27
|
+
import type { CorrelationMetadata } from "../src/correlation.ts";
|
|
28
|
+
import { GovernanceRefusal, refusal as structuredRefusal } from "../src/refusals.ts";
|
|
29
|
+
import { executePlannedChild, type DelegationOutcome } from "./execute-child.ts";
|
|
30
|
+
import {
|
|
31
|
+
governedWorkspaceAccess,
|
|
32
|
+
prepareDelegationWorkspace,
|
|
33
|
+
releaseDelegationWorkspace,
|
|
34
|
+
type DelegationWorkspaceSpec,
|
|
35
|
+
type PreparedWorkspace,
|
|
36
|
+
} from "./workspace-runtime.ts";
|
|
28
37
|
|
|
29
38
|
/** What one child was asked to do. The shape both tools accept, per child. */
|
|
30
39
|
interface ChildSpec {
|
|
@@ -32,6 +41,8 @@ interface ChildSpec {
|
|
|
32
41
|
agent?: string;
|
|
33
42
|
tools?: string[];
|
|
34
43
|
model?: string;
|
|
44
|
+
correlation?: CorrelationMetadata;
|
|
45
|
+
workspace?: DelegationWorkspaceSpec;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
/**
|
|
@@ -106,6 +117,7 @@ export async function planWithApprovals(
|
|
|
106
117
|
ctx,
|
|
107
118
|
request.task,
|
|
108
119
|
signal,
|
|
120
|
+
plan.approvalBinding,
|
|
109
121
|
);
|
|
110
122
|
const outcome = approval;
|
|
111
123
|
if (outcome.approved.length > 0) {
|
|
@@ -128,28 +140,29 @@ export async function planWithApprovals(
|
|
|
128
140
|
// headline property was false on the hot path, for the approvals it was written to cover.
|
|
129
141
|
// Taken from this session's snapshot, the same source `republishable` uses.
|
|
130
142
|
bodySha256: snapshotOf(session, approvalSubject)?.bodySha256,
|
|
143
|
+
...(outcome.bindings[capability] ? { binding: outcome.bindings[capability] } : {}),
|
|
131
144
|
})),
|
|
132
145
|
])),
|
|
133
146
|
...extra,
|
|
134
147
|
});
|
|
135
148
|
}
|
|
136
|
-
if (!plan.ok && approval.reason)
|
|
149
|
+
if (!plan.ok && approval.reason) {
|
|
150
|
+
plan = {
|
|
151
|
+
...plan,
|
|
152
|
+
reason: approval.reason,
|
|
153
|
+
...(approval.refusalCode
|
|
154
|
+
? { refusal: structuredRefusal(approval.refusalCode, approval.reason) }
|
|
155
|
+
: {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
137
158
|
} catch (error) {
|
|
138
|
-
|
|
159
|
+
const message = `grants: approval flow failed, denying (${String(error)})`;
|
|
160
|
+
plan = { ...plan, reason: message, refusal: structuredRefusal("APPROVAL_FLOW_FAILED", message) };
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
return { plan, approval };
|
|
142
164
|
}
|
|
143
165
|
|
|
144
|
-
interface DelegationOutcome {
|
|
145
|
-
ok: boolean;
|
|
146
|
-
text: string;
|
|
147
|
-
reason?: string;
|
|
148
|
-
granted: Capability[];
|
|
149
|
-
depth: number;
|
|
150
|
-
exitCode: number | null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
166
|
/**
|
|
154
167
|
* Plan, gate, audit and run ONE governed child. Shared by `delegate` and `delegate_all`.
|
|
155
168
|
*
|
|
@@ -215,14 +228,27 @@ export async function runOneDelegation(
|
|
|
215
228
|
* where nothing was ever gated — `/grants ledger` counted it in neither `bySource` nor `unattributed`, so it did
|
|
216
229
|
* not even show up as a gap, and ADR-0010's compensating control was blind to every chain step.
|
|
217
230
|
*/
|
|
218
|
-
approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "humanDenied">;
|
|
231
|
+
approvalFacts?: Pick<ApprovalOutcome, "approved" | "sources" | "scopes" | "expiresAt" | "uses" | "humanDenied">;
|
|
219
232
|
} = {},
|
|
220
233
|
): Promise<DelegationOutcome> {
|
|
221
234
|
const { onProgress, preApproved, taskFrom, approvalFacts } = options;
|
|
222
235
|
// pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
|
|
223
236
|
// alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
|
|
224
237
|
const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
225
|
-
const request = {
|
|
238
|
+
const request = {
|
|
239
|
+
task: spec.task,
|
|
240
|
+
agent: spec.agent,
|
|
241
|
+
tools: spec.tools,
|
|
242
|
+
model: spec.model ?? defaultModel,
|
|
243
|
+
correlation: spec.workspace
|
|
244
|
+
? { ...(spec.correlation ?? {}), workspace_id: spec.workspace.workspace_id }
|
|
245
|
+
: spec.correlation,
|
|
246
|
+
// The binding's workspace comes from the ROUTING SPEC, which is resolved against the operator
|
|
247
|
+
// registry and leased before any human is asked — never from `correlation`, which is a model-supplied
|
|
248
|
+
// claim that nothing validates when no spec accompanies it (R-110).
|
|
249
|
+
boundWorkspaceId: spec.workspace?.workspace_id,
|
|
250
|
+
boundContextId: spec.correlation?.context_id,
|
|
251
|
+
};
|
|
226
252
|
const extra = { fanoutBudget: budget, spawnId: ids.parentId, childSpawnId: ids.childId };
|
|
227
253
|
|
|
228
254
|
// ADR-0031: herdr was DEMANDED (`PI_GRANTS_HERDR=1`) and is not answering. Refused rather than relocated —
|
|
@@ -239,24 +265,47 @@ export async function runOneDelegation(
|
|
|
239
265
|
// `ctx: null` rather than skipping the plan entirely: the ledger still gets a full, honest record of what was
|
|
240
266
|
// requested and refused, and stored approvals still count toward it — nothing is *hidden*, only nobody is
|
|
241
267
|
// *asked*. It is the same argument `/grants` uses for its preview.
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
let
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
preApproved
|
|
253
|
-
|
|
268
|
+
const executorRefusal = session.executor.refusal;
|
|
269
|
+
let preparedWorkspace: PreparedWorkspace | undefined;
|
|
270
|
+
let approvalOutcome: ApprovalOutcome | undefined;
|
|
271
|
+
let plan: ReturnType<typeof planDelegation>;
|
|
272
|
+
let ledgerDenied = false;
|
|
273
|
+
|
|
274
|
+
if (spec.workspace && !executorRefusal) {
|
|
275
|
+
// Check non-liftable refusals before taking a lease, and take the lease before asking a human. This
|
|
276
|
+
// preserves both anti-race rules: a doomed spawn cannot bank approval, and a conflicting writer starts
|
|
277
|
+
// no child process.
|
|
278
|
+
const preview = await planWithApprovals(session, request, extra, null, signal, preApproved);
|
|
279
|
+
plan = preview.plan;
|
|
280
|
+
if (plan.ok || shouldSeekApproval(plan.result)) {
|
|
281
|
+
try {
|
|
282
|
+
preparedWorkspace = await prepareDelegationWorkspace({
|
|
283
|
+
spec: { ...spec.workspace, access: governedWorkspaceAccess(spec.workspace.access, plan.requested) },
|
|
284
|
+
correlation: spec.correlation,
|
|
285
|
+
childId: ids.childId,
|
|
286
|
+
signal,
|
|
287
|
+
ledgerPath: session.ledgerPath,
|
|
288
|
+
});
|
|
289
|
+
request.correlation = preparedWorkspace.correlation;
|
|
290
|
+
const gated = await planWithApprovals(session, request, extra, ctx, signal, preApproved);
|
|
291
|
+
plan = gated.plan;
|
|
292
|
+
approvalOutcome = gated.approval;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const value = error instanceof GovernanceRefusal
|
|
295
|
+
? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
|
|
296
|
+
: structuredRefusal("WORKSPACE_LEASE_STALE", `workspace setup failed (${String(error)})`);
|
|
297
|
+
plan = { ...plan, ok: false, reason: value.message, refusal: value };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
const gated = await planWithApprovals(session, request, extra, executorRefusal ? null : ctx, signal, preApproved);
|
|
302
|
+
plan = gated.plan;
|
|
303
|
+
approvalOutcome = gated.approval;
|
|
304
|
+
}
|
|
254
305
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (refusal) {
|
|
259
|
-
plan = { ...plan, ok: false, reason: `grants: ${refusal}` };
|
|
306
|
+
if (executorRefusal) {
|
|
307
|
+
const message = `grants: ${executorRefusal}`;
|
|
308
|
+
plan = { ...plan, ok: false, reason: message, refusal: structuredRefusal("EXECUTOR_UNAVAILABLE", message) };
|
|
260
309
|
}
|
|
261
310
|
|
|
262
311
|
// G6 / B-I3: no `&& plan.result` guard — `planDelegation` always carries one now.
|
|
@@ -284,104 +333,65 @@ export async function runOneDelegation(
|
|
|
284
333
|
// (a chain). Without the second, an approved chain step recorded nothing about the human who authorised it.
|
|
285
334
|
approved: approvalOutcome?.approved ?? approvalFacts?.approved,
|
|
286
335
|
approvalSources: approvalOutcome?.sources ?? approvalFacts?.sources,
|
|
287
|
-
approvalScopes: approvalOutcome?.
|
|
336
|
+
approvalScopes: approvalOutcome?.recordedScopes ?? approvalFacts?.scopes,
|
|
337
|
+
approvalExpiresAt: approvalOutcome?.expiresAt ?? approvalFacts?.expiresAt,
|
|
338
|
+
approvalUses: approvalOutcome?.uses ?? approvalFacts?.uses,
|
|
288
339
|
humanDenied: approvalOutcome?.humanDenied ?? approvalFacts?.humanDenied,
|
|
289
340
|
gateOutcome: approvalOutcome?.gateOutcome,
|
|
290
341
|
// ADR-0018: taken from the PLAN, never re-derived here. The B-I3 lesson — a call site that
|
|
291
342
|
// recomputed the digest could record one the planner never used.
|
|
292
343
|
definitionDigest: plan.definitionDigest,
|
|
344
|
+
taskDigest: plan.taskDigest,
|
|
345
|
+
correlation: plan.correlation,
|
|
346
|
+
refusal: plan.refusal,
|
|
293
347
|
now: new Date(),
|
|
294
348
|
}),
|
|
295
349
|
).catch((error) => {
|
|
296
350
|
// G6 / A-R4 + B-I2: fail closed. This path PROVISIONS, so an unrecorded delegation would be a
|
|
297
351
|
// child running with granted capabilities and no audit line.
|
|
298
|
-
plan = {
|
|
352
|
+
plan = {
|
|
353
|
+
...plan,
|
|
354
|
+
ok: false,
|
|
355
|
+
reason: `grants: ledger write failed, denying — ${String(error)}`,
|
|
356
|
+
refusal: structuredRefusal("LEDGER_WRITE_FAILED", `grants: ledger write failed, denying — ${String(error)}`),
|
|
357
|
+
};
|
|
358
|
+
ledgerDenied = true;
|
|
299
359
|
});
|
|
300
360
|
}
|
|
301
361
|
|
|
302
362
|
if (!plan.ok) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
// **Which one is chosen was reversed by ADR-0031**: the session probes for a reachable herdr server at
|
|
313
|
-
// startup rather than waiting to be told. Still never detected from a binary on `PATH` — only from a server
|
|
314
|
-
// that answered — and the choice is disclosed at session start, in `/grants`, and per child in the ledger.
|
|
315
|
-
// Read live off `session.executor`, because the probe finishes after this module is loaded.
|
|
316
|
-
const output = session.executor.kind === "herdr"
|
|
317
|
-
? await runHerdrPane({
|
|
318
|
-
args: plan.args.slice(0, -1),
|
|
319
|
-
// The task is delivered as a prompt, so it never reaches argv at all. `plan.args` still ends
|
|
320
|
-
// with the neutralised task (planSpawn is executor-agnostic), hence the slice — and the leading
|
|
321
|
-
// space `neutralisePrompt` added is stripped because there is no parser to defend against here.
|
|
322
|
-
prompt: plan.args[plan.args.length - 1].trimStart(),
|
|
323
|
-
// Grant/depth/ledger go on the PANE: `herdr agent start` has no --env, but a pane's environment
|
|
324
|
-
// reaches the shell that launches the agent (docs/probes/g16-herdr).
|
|
325
|
-
env: plan.env,
|
|
326
|
-
cwd: ctx.cwd,
|
|
327
|
-
name: `${spec.agent ?? "delegate"}-${ids.childId}`,
|
|
328
|
-
// Was `process.env[ENV_HERDR_WORKSPACE]`, i.e. "omitted lets herdr choose" — which put children in a
|
|
329
|
-
// different workspace from the pi session that spawned them, so switching to one meant hopping
|
|
330
|
-
// workspaces rather than tabs. `resolveWorkspace` prefers the operator's explicit answer and otherwise
|
|
331
|
-
// inherits the parent's own `HERDR_WORKSPACE_ID` (measured; herdr sets it in every pane it creates).
|
|
332
|
-
workspace: resolveWorkspace(process.env),
|
|
333
|
-
signal,
|
|
334
|
-
timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
|
|
335
|
-
keepPane: process.env[ENV_HERDR_KEEP_PANE] === "1",
|
|
336
|
-
// ADR-0032. The pane id arrives first and is what a human switches to; the pane's tail follows as the
|
|
337
|
-
// child works. Both are display only.
|
|
338
|
-
//
|
|
339
|
-
// `snapshot`, not `chunk`: `agent read` returns a snapshot of a bounded terminal, and the sink must
|
|
340
|
-
// REPLACE what it holds. Treating it as a stream produced an 89,000× amplification and fabricated lines
|
|
341
|
-
// the child never printed — see `tailLines` in `src/herdr-poll.ts`.
|
|
342
|
-
onPane: onProgress ? (paneId, agentName) => onProgress({ paneId, agentName, state: "running" }) : undefined,
|
|
343
|
-
onSnapshot: onProgress ? (snapshot) => onProgress({ snapshot }) : undefined,
|
|
344
|
-
})
|
|
345
|
-
: await runChild({
|
|
346
|
-
command: "pi",
|
|
347
|
-
args: plan.args,
|
|
348
|
-
// Explicit per-child env — the parent's own grant vars must not leak in. A plain spread would
|
|
349
|
-
// not achieve that: a key `plan.env` does not set is a key the parent's value survives into, so
|
|
350
|
-
// `mergeChildEnv` strips every governance variable first and lets only the plan put them back.
|
|
351
|
-
env: mergeChildEnv(process.env, plan.env),
|
|
352
|
-
cwd: ctx.cwd,
|
|
353
|
-
signal,
|
|
354
|
-
timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
|
|
355
|
-
// No pane on this path, so streaming is the ONLY observability a subprocess child can have — which is
|
|
356
|
-
// why ADR-0032 chose the streaming option over status lines alone.
|
|
357
|
-
onOutput: onProgress ? (chunk) => onProgress({ chunk }) : undefined,
|
|
363
|
+
// EVERY refusal reached after the gate ran. Gating on `ledgerDenied` left three post-gate refusals
|
|
364
|
+
// stranding a 30-day approval — most reachably a human declining the SECOND of two gated capabilities,
|
|
365
|
+
// which needs no fault at all. The predicate is now the rule itself, so it cannot drift from it again.
|
|
366
|
+
if (ctx) await unbankApprovals(session, ctx, approvalOutcome?.banked);
|
|
367
|
+
// Guarded: this contains a `strict: true` append, and on the path where the ledger is already known
|
|
368
|
+
// unwritable an unguarded call replaced the governance refusal, and its code, with a ledger error.
|
|
369
|
+
try {
|
|
370
|
+
await releaseDelegationWorkspace({
|
|
371
|
+
prepared: preparedWorkspace, childId: ids.childId, ledgerPath: session.ledgerPath, reason: "refused",
|
|
358
372
|
});
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (output.spawnError || output.aborted || output.timedOut || output.code !== 0) {
|
|
363
|
-
const why = output.spawnError
|
|
364
|
-
? `could not be started: ${output.spawnError}`
|
|
365
|
-
: output.aborted
|
|
366
|
-
? "was cancelled"
|
|
367
|
-
: output.timedOut
|
|
368
|
-
? "exceeded its time limit and was killed"
|
|
369
|
-
: `exited with code ${output.code}`;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
plan = { ...plan, reason: `${plan.reason ?? "refused"}; workspace release record failed: ${String(error)}` };
|
|
375
|
+
}
|
|
370
376
|
return {
|
|
371
377
|
ok: false,
|
|
372
|
-
text:
|
|
373
|
-
reason:
|
|
374
|
-
granted:
|
|
378
|
+
text: "",
|
|
379
|
+
reason: plan.reason,
|
|
380
|
+
granted: [],
|
|
375
381
|
depth: plan.childDepth,
|
|
376
|
-
exitCode:
|
|
382
|
+
exitCode: null,
|
|
383
|
+
...(plan.refusal ? { refusal: plan.refusal } : {}),
|
|
377
384
|
};
|
|
378
385
|
}
|
|
379
386
|
|
|
380
|
-
return {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
+
return executePlannedChild({
|
|
388
|
+
session,
|
|
389
|
+
plan,
|
|
390
|
+
agent: spec.agent,
|
|
391
|
+
childId: ids.childId,
|
|
392
|
+
cwd: ctx.cwd,
|
|
393
|
+
preparedWorkspace,
|
|
394
|
+
signal,
|
|
395
|
+
onProgress,
|
|
396
|
+
});
|
|
387
397
|
}
|
package/extensions/session.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { parseInherited, type InheritableApproval } from "../src/approval.ts";
|
|
18
|
+
import type { ApprovalBinding } from "../src/correlation.ts";
|
|
18
19
|
import { createApprovalGateProvider } from "../src/approval-prompt.ts";
|
|
19
20
|
import { makeCatalog, skillPathsFromCatalog, type Catalog } from "../src/catalog.ts";
|
|
20
21
|
import type { SkillDefinition } from "../src/definitions.ts";
|
|
@@ -117,6 +118,8 @@ export interface GrantsSession {
|
|
|
117
118
|
|
|
118
119
|
/** Approval keys approved for this session. In memory only — this dies with the process. */
|
|
119
120
|
readonly sessionApprovals: Set<string>;
|
|
121
|
+
/** Exact bindings for correlated approvals; these never inherit across a delegation boundary. */
|
|
122
|
+
readonly sessionApprovalBindings: Map<string, ApprovalBinding>;
|
|
120
123
|
/**
|
|
121
124
|
* Approvals inherited from the delegator, already clamped to this session's grant upstream.
|
|
122
125
|
*
|
|
@@ -288,6 +291,7 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
|
|
|
288
291
|
extensionPath,
|
|
289
292
|
|
|
290
293
|
sessionApprovals: new Set<string>(),
|
|
294
|
+
sessionApprovalBindings: new Map<string, ApprovalBinding>(),
|
|
291
295
|
inheritedApprovals: parseInherited(process.env[ENV_APPROVED]),
|
|
292
296
|
approvalGateFor: createApprovalGateProvider(),
|
|
293
297
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { CorrelationMetadata } from "../src/correlation.ts";
|
|
2
|
+
import type { Capability } from "../src/resolve.ts";
|
|
3
|
+
import {
|
|
4
|
+
appendLedgerEvent,
|
|
5
|
+
buildWorkspaceLeaseEvent,
|
|
6
|
+
} from "../src/ledger.ts";
|
|
7
|
+
import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refusals.ts";
|
|
8
|
+
import {
|
|
9
|
+
acquireWorkspaceLease,
|
|
10
|
+
defaultWorkspaceLeaseDir,
|
|
11
|
+
ENV_WORKSPACE_REGISTRY,
|
|
12
|
+
loadWorkspaceRegistry,
|
|
13
|
+
resolveWorkspace,
|
|
14
|
+
type ValidatedWorkspace,
|
|
15
|
+
type WorkspaceAccess,
|
|
16
|
+
type WorkspaceLease,
|
|
17
|
+
leaseAcquisitionOutcome,
|
|
18
|
+
type LeaseReleaseOutcome,
|
|
19
|
+
} from "../src/workspace.ts";
|
|
20
|
+
|
|
21
|
+
export interface DelegationWorkspaceSpec {
|
|
22
|
+
workspace_id: string;
|
|
23
|
+
access: WorkspaceAccess;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const KNOWN_READ_ONLY_TOOLS = new Set(["tool:read", "tool:grep", "tool:find", "tool:ls"]);
|
|
27
|
+
|
|
28
|
+
/** A model may ask for stricter coordination but cannot label a write-capable grant read-only. */
|
|
29
|
+
export function governedWorkspaceAccess(declared: WorkspaceAccess, requested: readonly Capability[]): WorkspaceAccess {
|
|
30
|
+
if (declared === "write") return "write";
|
|
31
|
+
return requested.every((capability) => KNOWN_READ_ONLY_TOOLS.has(capability)) ? "read" : "write";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PreparedWorkspace {
|
|
35
|
+
workspace: ValidatedWorkspace;
|
|
36
|
+
lease: WorkspaceLease;
|
|
37
|
+
correlation: CorrelationMetadata;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolve an operator-registered root and acquire its governed-writer lease before any child starts. */
|
|
41
|
+
export async function prepareDelegationWorkspace(input: {
|
|
42
|
+
spec: DelegationWorkspaceSpec;
|
|
43
|
+
correlation?: CorrelationMetadata;
|
|
44
|
+
childId: string;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
ledgerPath?: string;
|
|
47
|
+
}): Promise<PreparedWorkspace> {
|
|
48
|
+
if (input.correlation?.workspace_id && input.correlation.workspace_id !== input.spec.workspace_id) {
|
|
49
|
+
throw new GovernanceRefusal(refusal(
|
|
50
|
+
"APPROVAL_SCOPE_MISMATCH",
|
|
51
|
+
`correlation workspace ${input.correlation.workspace_id} does not match requested workspace ${input.spec.workspace_id}`,
|
|
52
|
+
{ workspace_id: input.spec.workspace_id },
|
|
53
|
+
));
|
|
54
|
+
}
|
|
55
|
+
const registryPath = process.env[ENV_WORKSPACE_REGISTRY];
|
|
56
|
+
if (!registryPath) {
|
|
57
|
+
throw new GovernanceRefusal(refusal(
|
|
58
|
+
"WORKSPACE_NOT_REGISTERED",
|
|
59
|
+
`${ENV_WORKSPACE_REGISTRY} is required when a delegation names a workspace`,
|
|
60
|
+
{ workspace_id: input.spec.workspace_id },
|
|
61
|
+
));
|
|
62
|
+
}
|
|
63
|
+
const workspace = await resolveWorkspace(await loadWorkspaceRegistry(registryPath), input.spec.workspace_id);
|
|
64
|
+
let lease: WorkspaceLease | undefined;
|
|
65
|
+
try {
|
|
66
|
+
lease = await acquireWorkspaceLease({
|
|
67
|
+
workspace,
|
|
68
|
+
access: input.spec.access,
|
|
69
|
+
leaseDir: defaultWorkspaceLeaseDir(),
|
|
70
|
+
ownerId: input.childId,
|
|
71
|
+
signal: input.signal,
|
|
72
|
+
});
|
|
73
|
+
const correlation = { ...(input.correlation ?? {}), workspace_id: input.spec.workspace_id };
|
|
74
|
+
if (input.ledgerPath) {
|
|
75
|
+
await appendLedgerEvent(
|
|
76
|
+
{ path: input.ledgerPath, strict: true },
|
|
77
|
+
buildWorkspaceLeaseEvent({
|
|
78
|
+
childId: input.childId,
|
|
79
|
+
workspaceId: workspace.workspaceId,
|
|
80
|
+
root: workspace.root,
|
|
81
|
+
access: input.spec.access,
|
|
82
|
+
outcome: leaseAcquisitionOutcome(input.spec.access, lease.recovered),
|
|
83
|
+
recovered: lease.recovered,
|
|
84
|
+
correlation,
|
|
85
|
+
now: new Date(),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return { workspace, lease, correlation };
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// A load-bearing ledger failure can happen after the kernel lock was acquired. Release before trying
|
|
92
|
+
// to record the refusal, or this live parent would strand its own writer lease until process exit.
|
|
93
|
+
await lease?.release("setup-failed");
|
|
94
|
+
const structured: StructuredRefusal = error instanceof GovernanceRefusal
|
|
95
|
+
? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
|
|
96
|
+
: refusal("WORKSPACE_LEASE_STALE", `workspace lease failed (${String(error)})`);
|
|
97
|
+
if (input.ledgerPath) {
|
|
98
|
+
await appendLedgerEvent(
|
|
99
|
+
{ path: input.ledgerPath, strict: true },
|
|
100
|
+
buildWorkspaceLeaseEvent({
|
|
101
|
+
childId: input.childId,
|
|
102
|
+
workspaceId: workspace.workspaceId,
|
|
103
|
+
root: workspace.root,
|
|
104
|
+
access: input.spec.access,
|
|
105
|
+
outcome: "refused",
|
|
106
|
+
refusal: structured,
|
|
107
|
+
correlation: { ...(input.correlation ?? {}), workspace_id: input.spec.workspace_id },
|
|
108
|
+
now: new Date(),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Records what release actually DID, rather than asserting a handover. `release()` cannot throw
|
|
118
|
+
* (R-99) — but THIS function still can, through its own strict append, so callers wrap it rather than
|
|
119
|
+
* calling it bare from a `finally`. An earlier version of this comment claimed the opposite, and the
|
|
120
|
+
* refusal path took it at its word. Returning a value rather than throwing is
|
|
121
|
+
* the whole reason it returns a value. `retained` is a deliberate non-release and is reported as such
|
|
122
|
+
* so the next owner's `recovered: true` does not blame a healthy path (R-104).
|
|
123
|
+
*/
|
|
124
|
+
export async function releaseDelegationWorkspace(input: {
|
|
125
|
+
prepared: PreparedWorkspace | undefined;
|
|
126
|
+
childId: string;
|
|
127
|
+
ledgerPath?: string;
|
|
128
|
+
reason: string;
|
|
129
|
+
/** Deliberately keep the lease: a herdr writer tab would not close, so the pane may still be live. */
|
|
130
|
+
retain?: boolean;
|
|
131
|
+
}): Promise<LeaseReleaseOutcome | "retained" | undefined> {
|
|
132
|
+
if (!input.prepared) return undefined;
|
|
133
|
+
// A retained lease writes no `state: "released"`, so the record stays `active` and the NEXT owner reads
|
|
134
|
+
// it as a crash — the exact blame `retained` was added to remove. Marking it keeps the successor honest;
|
|
135
|
+
// R-104 was fixed in the release event's wording only.
|
|
136
|
+
const outcome: LeaseReleaseOutcome | "retained" = input.retain
|
|
137
|
+
? (await input.prepared.lease.markRetained(input.reason), "retained")
|
|
138
|
+
: await input.prepared.lease.release(input.reason);
|
|
139
|
+
if (input.ledgerPath) {
|
|
140
|
+
await appendLedgerEvent(
|
|
141
|
+
{ path: input.ledgerPath, strict: true },
|
|
142
|
+
buildWorkspaceLeaseEvent({
|
|
143
|
+
childId: input.childId,
|
|
144
|
+
workspaceId: input.prepared.workspace.workspaceId,
|
|
145
|
+
root: input.prepared.workspace.root,
|
|
146
|
+
access: input.prepared.lease.access,
|
|
147
|
+
outcome: leaseReleaseLedgerOutcome(outcome, input.reason),
|
|
148
|
+
releaseReason: input.reason,
|
|
149
|
+
correlation: input.prepared.correlation,
|
|
150
|
+
now: new Date(),
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return outcome;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function leaseReleaseLedgerOutcome(
|
|
158
|
+
outcome: LeaseReleaseOutcome | "retained",
|
|
159
|
+
reason: string,
|
|
160
|
+
): "timeout" | "released" | "released-unrecorded" | "lost" | "retained" {
|
|
161
|
+
if (outcome === "retained") return "retained";
|
|
162
|
+
if (outcome === "lost") return "lost";
|
|
163
|
+
if (outcome === "released-unrecorded") return "released-unrecorded";
|
|
164
|
+
return reason === "timeout" ? "timeout" : "released";
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -112,6 +112,22 @@
|
|
|
112
112
|
"./skill-packages": {
|
|
113
113
|
"types": "./dist/skill-packages.d.ts",
|
|
114
114
|
"default": "./dist/skill-packages.js"
|
|
115
|
+
},
|
|
116
|
+
"./correlation": {
|
|
117
|
+
"types": "./dist/correlation.d.ts",
|
|
118
|
+
"default": "./dist/correlation.js"
|
|
119
|
+
},
|
|
120
|
+
"./refusals": {
|
|
121
|
+
"types": "./dist/refusals.d.ts",
|
|
122
|
+
"default": "./dist/refusals.js"
|
|
123
|
+
},
|
|
124
|
+
"./workspace": {
|
|
125
|
+
"types": "./dist/workspace.d.ts",
|
|
126
|
+
"default": "./dist/workspace.js"
|
|
127
|
+
},
|
|
128
|
+
"./check-runner": {
|
|
129
|
+
"types": "./dist/check-runner.d.ts",
|
|
130
|
+
"default": "./dist/check-runner.js"
|
|
115
131
|
}
|
|
116
132
|
},
|
|
117
133
|
"bin": {
|
package/src/approval-prompt.ts
CHANGED
|
@@ -36,8 +36,10 @@ export interface PromptRequest {
|
|
|
36
36
|
/** Agent type name, or `DELEGATE_SUBJECT` on the delegate path. */
|
|
37
37
|
subject: string;
|
|
38
38
|
path: ApprovalPath;
|
|
39
|
-
/** Shown to the human for context. Never part of a key. */
|
|
39
|
+
/** Shown to the human for context. Never itself part of a key. */
|
|
40
40
|
task?: string;
|
|
41
|
+
/** Trusted exact-scope identity; bound requests single-flight only with an identical binding. */
|
|
42
|
+
bindingKey?: string;
|
|
41
43
|
signal?: AbortSignal;
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -194,7 +196,7 @@ export function createApprovalGate(
|
|
|
194
196
|
};
|
|
195
197
|
}
|
|
196
198
|
|
|
197
|
-
const key = approvalKey(request.capability, request.subject)
|
|
199
|
+
const key = `${approvalKey(request.capability, request.subject)}${request.bindingKey ? `#${request.bindingKey}` : ""}`;
|
|
198
200
|
|
|
199
201
|
// R-29. Join an in-flight dialog, but only *keep* its answer if that answer was about more than one
|
|
200
202
|
// spawn. A `once` belongs to whichever caller the human was actually looking at — the title shows
|
package/src/approval-store.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { withFileLock } from "./file-lock.ts";
|
|
|
36
36
|
import { homedir } from "node:os";
|
|
37
37
|
import { basename, dirname, join } from "node:path";
|
|
38
38
|
import { entryVerdict, type ApprovalEntry, type EntryVerdict, type SubjectSnapshot } from "./approval.ts";
|
|
39
|
+
import { isApprovalBinding } from "./correlation.ts";
|
|
39
40
|
|
|
40
41
|
interface ApprovalFile {
|
|
41
42
|
version: 1;
|
|
@@ -148,7 +149,8 @@ function isValidEntryShape(entry: unknown): entry is ApprovalEntry {
|
|
|
148
149
|
typeof obj.approvedAt === "string" &&
|
|
149
150
|
typeof obj.expiresAt === "string" &&
|
|
150
151
|
typeof obj.cwd === "string" &&
|
|
151
|
-
Array.isArray(obj.grantAtApproval)
|
|
152
|
+
Array.isArray(obj.grantAtApproval) &&
|
|
153
|
+
(obj.binding === undefined || isApprovalBinding(obj.binding))
|
|
152
154
|
);
|
|
153
155
|
}
|
|
154
156
|
|
|
@@ -173,6 +175,7 @@ function sanitise(valid: Map<string, ApprovalEntry>): Record<string, ApprovalEnt
|
|
|
173
175
|
cwd: e.cwd,
|
|
174
176
|
grantAtApproval: e.grantAtApproval,
|
|
175
177
|
...(e.bodyAtApproval !== undefined ? { bodyAtApproval: e.bodyAtApproval } : {}),
|
|
178
|
+
...(e.binding !== undefined ? { binding: structuredClone(e.binding) } : {}),
|
|
176
179
|
},
|
|
177
180
|
]),
|
|
178
181
|
);
|