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.
Files changed (113) hide show
  1. package/CHANGELOG.md +154 -80
  2. package/README.md +54 -25
  3. package/dist/approval-prompt.d.ts +3 -1
  4. package/dist/approval-prompt.d.ts.map +1 -1
  5. package/dist/approval-prompt.js +1 -1
  6. package/dist/approval-prompt.js.map +1 -1
  7. package/dist/approval-store.d.ts.map +1 -1
  8. package/dist/approval-store.js +4 -1
  9. package/dist/approval-store.js.map +1 -1
  10. package/dist/approval.d.ts +20 -2
  11. package/dist/approval.d.ts.map +1 -1
  12. package/dist/approval.js +26 -6
  13. package/dist/approval.js.map +1 -1
  14. package/dist/chain.d.ts +6 -1
  15. package/dist/chain.d.ts.map +1 -1
  16. package/dist/chain.js +1 -1
  17. package/dist/chain.js.map +1 -1
  18. package/dist/check-runner.d.ts +61 -0
  19. package/dist/check-runner.d.ts.map +1 -0
  20. package/dist/check-runner.js +237 -0
  21. package/dist/check-runner.js.map +1 -0
  22. package/dist/correlation.d.ts +90 -0
  23. package/dist/correlation.d.ts.map +1 -0
  24. package/dist/correlation.js +183 -0
  25. package/dist/correlation.js.map +1 -0
  26. package/dist/delegate-types.d.ts +140 -0
  27. package/dist/delegate-types.d.ts.map +1 -0
  28. package/dist/delegate-types.js +8 -0
  29. package/dist/delegate-types.js.map +1 -0
  30. package/dist/delegate.d.ts +4 -128
  31. package/dist/delegate.d.ts.map +1 -1
  32. package/dist/delegate.js +68 -72
  33. package/dist/delegate.js.map +1 -1
  34. package/dist/delegation-approval.d.ts +36 -0
  35. package/dist/delegation-approval.d.ts.map +1 -0
  36. package/dist/delegation-approval.js +67 -0
  37. package/dist/delegation-approval.js.map +1 -0
  38. package/dist/git-identity.d.ts +13 -0
  39. package/dist/git-identity.d.ts.map +1 -0
  40. package/dist/git-identity.js +44 -0
  41. package/dist/git-identity.js.map +1 -0
  42. package/dist/index.d.ts +5 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +5 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/lease-record.d.ts +78 -0
  47. package/dist/lease-record.d.ts.map +1 -0
  48. package/dist/lease-record.js +52 -0
  49. package/dist/lease-record.js.map +1 -0
  50. package/dist/ledger-events.d.ts +87 -0
  51. package/dist/ledger-events.d.ts.map +1 -0
  52. package/dist/ledger-events.js +45 -0
  53. package/dist/ledger-events.js.map +1 -0
  54. package/dist/ledger-report.d.ts +19 -12
  55. package/dist/ledger-report.d.ts.map +1 -1
  56. package/dist/ledger-report.js +81 -1
  57. package/dist/ledger-report.js.map +1 -1
  58. package/dist/ledger.d.ts +44 -1
  59. package/dist/ledger.d.ts.map +1 -1
  60. package/dist/ledger.js +20 -2
  61. package/dist/ledger.js.map +1 -1
  62. package/dist/refusals.d.ts +16 -0
  63. package/dist/refusals.d.ts.map +1 -0
  64. package/dist/refusals.js +51 -0
  65. package/dist/refusals.js.map +1 -0
  66. package/dist/run-child.d.ts +4 -0
  67. package/dist/run-child.d.ts.map +1 -1
  68. package/dist/run-child.js +21 -1
  69. package/dist/run-child.js.map +1 -1
  70. package/dist/run-herdr.d.ts +7 -0
  71. package/dist/run-herdr.d.ts.map +1 -1
  72. package/dist/run-herdr.js +41 -12
  73. package/dist/run-herdr.js.map +1 -1
  74. package/dist/workspace-lease.d.ts +28 -0
  75. package/dist/workspace-lease.d.ts.map +1 -0
  76. package/dist/workspace-lease.js +276 -0
  77. package/dist/workspace-lease.js.map +1 -0
  78. package/dist/workspace.d.ts +32 -0
  79. package/dist/workspace.d.ts.map +1 -0
  80. package/dist/workspace.js +79 -0
  81. package/dist/workspace.js.map +1 -0
  82. package/extensions/approval-banking.ts +66 -0
  83. package/extensions/approvals.ts +95 -7
  84. package/extensions/chain-approval-facts.ts +51 -0
  85. package/extensions/chain-ledger.ts +48 -0
  86. package/extensions/delegate-chain.ts +115 -81
  87. package/extensions/delegation.ts +81 -23
  88. package/extensions/execute-child.ts +289 -0
  89. package/extensions/fanout-outcome.ts +97 -0
  90. package/extensions/run-delegation.ts +130 -120
  91. package/extensions/session.ts +4 -0
  92. package/extensions/workspace-runtime.ts +165 -0
  93. package/package.json +17 -1
  94. package/src/approval-prompt.ts +4 -2
  95. package/src/approval-store.ts +4 -1
  96. package/src/approval.ts +47 -14
  97. package/src/chain.ts +3 -1
  98. package/src/check-runner.ts +341 -0
  99. package/src/correlation.ts +260 -0
  100. package/src/delegate-types.ts +144 -0
  101. package/src/delegate.ts +80 -179
  102. package/src/delegation-approval.ts +99 -0
  103. package/src/git-identity.ts +52 -0
  104. package/src/index.ts +50 -0
  105. package/src/lease-record.ts +119 -0
  106. package/src/ledger-events.ts +138 -0
  107. package/src/ledger-report.ts +90 -2
  108. package/src/ledger.ts +68 -3
  109. package/src/refusals.ts +66 -0
  110. package/src/run-child.ts +23 -1
  111. package/src/run-herdr.ts +41 -11
  112. package/src/workspace-lease.ts +303 -0
  113. package/src/workspace.ts +135 -0
package/src/delegate.ts CHANGED
@@ -1,24 +1,11 @@
1
1
  /**
2
- * Governed delegation — provisioning, not merely enforcement.
3
- *
4
- * The `tool_call` interceptor can only *permit or refuse* a `pi-subagents` spawn, because that package's
5
- * `Agent` tool has no `tools` parameter. When we do the spawning ourselves the grant becomes an argument,
6
- * which is what "give them some tools but not others" actually requires.
7
- *
8
- * Two properties fall out of owning the spawn:
9
- *
10
- * 1. **No propagation race at all.** Each child receives its own explicit `env` object, so nothing is
11
- * written to the shared `process.env`. The interceptor's constraint (only parent-level facts may be
12
- * pushed, because the channel is global) does not apply here.
13
- * 2. **Depth control by capability.** `tool:delegate` is itself a capability. Grant it and the child can
14
- * sub-delegate; withhold it and the child is a leaf. No separate depth mechanism is required, though
15
- * `maxDepth` remains as a cheap backstop.
2
+ * Governed delegation planning. Owning the spawn makes the grant an argument; each child receives its own
3
+ * environment, and `tool:delegate` determines whether it is a delegator or a leaf.
16
4
  */
17
5
 
18
6
  import { planSpawn } from "./spawn.ts";
19
7
  import { ceilingForDefinition, digestDefinition, type DefinitionDigest, type SkillDefinition } from "./definitions.ts";
20
- import { resolve, assertNarrowing, type Capability, type ResolveResult } from "./resolve.ts";
21
- import { AGENT_WILDCARD } from "./resolve.ts";
8
+ import { assertNarrowing, type Capability, type ResolveResult } from "./resolve.ts";
22
9
  import { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
23
10
 
24
11
  // Re-exported so the split stays internal: `delegate.ts` has been the import site for these since 0.6.0 and
@@ -26,128 +13,41 @@ import { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapa
26
13
  // charged to every caller for a line count they did not cause.
27
14
  export { DELEGATE_CAPABILITY, agentCapability, maySpawnDefinition, normaliseCapability } from "./capabilities.ts";
28
15
  import { ENV_APPROVED, ENV_DEPTH, ENV_FANOUT, ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID } from "./propagation.ts";
29
- import { DELEGATE_SUBJECT, inheritApprovals, type InheritableApproval } from "./approval.ts";
16
+ import { inheritApprovals, type InheritableApproval } from "./approval.ts";
30
17
  import { suggestForUnknown, unknownCapabilities, type Catalog } from "./catalog.ts";
18
+ import { GovernanceRefusal, refusal, type RefusalCode, type StructuredRefusal } from "./refusals.ts";
19
+ import {
20
+ digestTask,
21
+ normaliseCorrelation,
22
+ type ApprovalBinding,
23
+ type CorrelationMetadata,
24
+ } from "./correlation.ts";
25
+ import { resolveDelegationApproval } from "./delegation-approval.ts";
26
+ import type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
27
+ export type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
31
28
 
32
- export interface DelegationRequest {
33
- task: string;
34
- /**
35
- * Capabilities the delegator wants the child to hold.
36
- *
37
- * Optional since ADR-0016: prefer `agent`, which names an operator-authored definition. This form
38
- * lets the MODEL choose the capability set, which is the weaker arrangement — it is still bounded by
39
- * the session grant (ADR-0008), so it cannot escalate, but nothing about it was reviewed by a human.
40
- */
41
- tools?: string[];
42
- /**
43
- * Name of a `SKILL.md` definition to spawn (ADR-0016).
44
- *
45
- * When given, the definition's `allowed-tools` is the ceiling and its body is the child's system
46
- * prompt. The model chooses only *which* definition and *what* task; the capability set is the
47
- * operator's, written down in a file.
48
- */
49
- agent?: string;
50
- model?: string;
51
- provider?: string;
52
- thinking?: string;
53
- }
54
-
55
- export interface DelegationContext {
56
- ownGrant: Capability[];
57
- depth: number;
58
- maxDepth: number;
59
- gated: Capability[];
60
- /**
61
- * Approvals in force for this delegation, with subject and scope (ADR-0014).
62
- *
63
- * One source of truth for two different questions. The **gate check here** honours every entry,
64
- * including `once` — that approval applies to *this* spawn, which is exactly what the human said yes
65
- * to. What crosses to the CHILD is `inheritApprovals`, which drops `once` and keeps the subject, so
66
- * the same list cannot silently authorise a subtree.
67
- */
68
- approved?: InheritableApproval[];
69
- ledgerPath?: string;
70
- /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
71
- extensionPath?: string;
72
- /** Live capability catalog. When supplied, capabilities absent from it are refused as unknown. */
73
- catalog?: Catalog;
74
- /**
75
- * Absolute path per skill NAME, from the catalog's `source` field (R-32).
76
- *
77
- * Without it every granted `skill:` capability is unresolvable and the delegation is refused, which
78
- * is the correct direction: a caller that cannot say where a skill lives cannot honestly grant it.
79
- */
80
- skillPaths?: Record<string, string>;
81
- /** Let the child load `AGENTS.md` / `CLAUDE.md`. Default false — see `planSpawn`. */
82
- contextFiles?: boolean;
83
- /** Known `SKILL.md` definitions by name, for `DelegationRequest.agent` (ADR-0016). */
84
- definitions?: Map<string, SkillDefinition>;
85
- /**
86
- * Build an INTERACTIVE plan — no `--print` — for an executor that drives the child after starting it.
87
- *
88
- * `runHerdrPane` requires this: `--print` makes pi process the prompt and exit, so it never reaches the
89
- * interactive readiness `herdr agent start` waits for and the agent is never detected. Default is the
90
- * non-interactive plan, because a governed child should not sit waiting for a human by accident.
91
- */
92
- interactive?: boolean;
93
- /**
94
- * Total descendants this session may still create (`src/fanout.ts`). Split among children by the caller.
95
- *
96
- * Omitted means unbounded, which is the pre-fan-out behaviour and correct for a single blocking
97
- * delegation — the accident that used to bound cardinality to one.
98
- */
99
- fanoutBudget?: number;
100
- /** This session's ledger id, so a child's `parentId` names its real parent (F8). */
101
- spawnId?: string;
102
- /** Ledger id assigned to THIS child, distinguishing it from its siblings (F8). */
103
- childSpawnId?: string;
104
- }
105
-
106
- export interface Delegation {
107
- ok: boolean;
108
- reason?: string;
109
- args: string[];
110
- /** Per-child environment — never merged into the parent's process.env. */
111
- env: Record<string, string>;
112
- effective: Capability[];
113
- /**
114
- * The result this plan was made from. **Required** (B-I3): while it was optional the extension
115
- * guarded its ledger write with `if (ledgerPath && plan.result)`, silently dropping every refusal
116
- * that returned before `resolve()` ran. The type is what keeps a new early exit auditable.
117
- */
118
- result: ResolveResult;
119
- childDepth: number;
120
- /**
121
- * The capabilities this delegation asked for, whatever route named them.
122
- *
123
- * Carried on the plan rather than re-derived by the caller (the B-I3 lesson): with `agent`, the
124
- * request names a DEFINITION and the capabilities come from its `allowed-tools`, so a ledger that
125
- * read the tool parameters would record an empty request for every definition spawn.
126
- */
127
- requested: Capability[];
128
- /** Ledger id for this child, if the caller assigned one (F8). */
129
- childId?: string;
130
- /**
131
- * Which operator-authored instructions this spawn used (ADR-0018).
132
- *
133
- * Absent for a `tools:`-style delegation, which has no definition and therefore no instructions to
134
- * identify — and absent on an ADR-0017 authorisation refusal, which is decided before the file is read.
135
- */
136
- definitionDigest?: DefinitionDigest;
137
- }
138
-
139
- /**
140
- * Plan a governed delegation. Pure: returns argv and env, spawns nothing.
141
- *
142
- * Fails closed on depth, on any requested capability the delegator does not hold, on gated capabilities
143
- * without approval, and on a grant that cannot narrow (a universal capability slipping through).
144
- */
145
29
  export function planDelegation(request: DelegationRequest, ctx: DelegationContext): Delegation {
146
30
  const childDepth = ctx.depth + 1;
31
+ const denied = (plan: Delegation, code: RefusalCode): Delegation =>
32
+ plan.reason ? { ...plan, refusal: refusal(code, plan.reason) } : plan;
147
33
  // G6 / B-I3: every refusal carries a result, including the four below that return before `resolve()`
148
34
  // is ever called. The extension guarded its ledger write with `if (ledgerPath && plan.result)`, so
149
35
  // those four governance decisions — disabled, too deep, no task, unknown capability — were never
150
36
  // audited at all. An empty result is the honest record: nothing was resolved, and that is the fact.
37
+ // Bounded/whitelisted correlation is a REFUSAL, not an exception escaping the planner. It is reachable
38
+ // from a model-facing tool parameter on all three delegation tools, and throwing from here produced a
39
+ // governed refusal with no code and no ledger line at all — the ledger file was never even created
40
+ // (R-112). Caught here so it becomes an ordinary recorded decision.
41
+ let correlation: CorrelationMetadata | undefined;
42
+ let correlationRefused: StructuredRefusal | undefined;
43
+ try {
44
+ correlation = normaliseCorrelation(request.correlation);
45
+ } catch (error) {
46
+ correlationRefused = error instanceof GovernanceRefusal
47
+ ? { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) }
48
+ : refusal("CORRELATION_INVALID", String(error instanceof Error ? error.message : error));
49
+ }
50
+ const taskDigest = digestTask(request.task ?? "");
151
51
  const empty: Delegation = {
152
52
  ok: false,
153
53
  args: [],
@@ -155,14 +55,21 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
155
55
  effective: [],
156
56
  childDepth,
157
57
  requested: [],
58
+ taskDigest,
59
+ ...(correlation ? { correlation } : {}),
158
60
  result: { effective: [], denied: [], clipped: [], gatedBlocked: [], universal: [], subsumedBy: [] },
159
61
  };
160
62
 
161
- if (ctx.maxDepth <= 0) return { ...empty, reason: "delegation is disabled (maxDepth 0)" };
63
+ if (correlationRefused) {
64
+ return { ...empty, reason: correlationRefused.message, refusal: correlationRefused };
65
+ }
66
+ if (ctx.maxDepth <= 0) {
67
+ return denied({ ...empty, reason: "delegation is disabled (maxDepth 0)" }, "DEPTH_EXCEEDED");
68
+ }
162
69
  if (childDepth > ctx.maxDepth) {
163
- return { ...empty, reason: `delegation depth limit reached (${ctx.maxDepth})` };
70
+ return denied({ ...empty, reason: `delegation depth limit reached (${ctx.maxDepth})` }, "DEPTH_EXCEEDED");
164
71
  }
165
- if (!request.task?.trim()) return { ...empty, reason: "a delegation needs a task" };
72
+ if (!request.task?.trim()) return denied({ ...empty, reason: "a delegation needs a task" }, "TASK_MISSING");
166
73
 
167
74
  // ADR-0016. A named definition replaces the model's tool list with an operator-authored ceiling.
168
75
  let requested: Capability[];
@@ -179,12 +86,12 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
179
86
  // here is simply an error.
180
87
  if (!definition) {
181
88
  const known = [...(ctx.definitions?.keys() ?? [])].sort();
182
- return {
89
+ return denied({
183
90
  ...empty,
184
91
  reason:
185
92
  `unknown agent "${request.agent}"` +
186
93
  (known.length > 0 ? ` — known definitions: ${known.join(", ")}` : " — no definitions were found"),
187
- };
94
+ }, "UNKNOWN_DEFINITION");
188
95
  }
189
96
 
190
97
  // ADR-0017: authorisation comes BEFORE anything is said about the file. Which definitions this
@@ -198,7 +105,7 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
198
105
  if (!maySpawnDefinition(ctx.ownGrant, definition.name)) {
199
106
  const authorising = agentCapability(definition.name);
200
107
  const held = ctx.ownGrant.filter((c) => c.startsWith("agent:")).sort();
201
- return {
108
+ return denied({
202
109
  ...empty,
203
110
  requested: [authorising],
204
111
  result: { ...empty.result, denied: [authorising] },
@@ -208,7 +115,7 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
208
115
  (held.length > 0
209
116
  ? `It may spawn: ${held.join(", ")}.`
210
117
  : `It may spawn no definitions at all; add ${authorising} to its grant to allow this one.`),
211
- };
118
+ }, "DEFINITION_NOT_AUTHORIZED");
212
119
  }
213
120
 
214
121
  // ADR-0018. Recorded from here on — after authorisation, because the digest is a fact about a file
@@ -224,21 +131,21 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
224
131
 
225
132
  const ceiling = ceilingForDefinition(definition);
226
133
  if (ceiling.undeclared) {
227
- return {
134
+ return denied({
228
135
  ...empty,
229
136
  reason:
230
137
  `agent "${definition.name}" declares no \`allowed-tools\`, so it cannot be spawned — add one ` +
231
138
  `to ${definition.source}. An undeclared capability set is treated as NONE, never as everything.`,
232
- };
139
+ }, "UNDECLARED_TOOLS");
233
140
  }
234
141
  if (ceiling.patterns.length > 0) {
235
- return {
142
+ return denied({
236
143
  ...empty,
237
144
  reason:
238
145
  `agent "${definition.name}" restricts a tool with a pattern (${ceiling.patterns.join(", ")}), ` +
239
146
  `which pi's --tools cannot express — it matches whole tool names only. Granting the bare tool ` +
240
147
  `would widen the declaration and dropping it would silently narrow, so neither is done.`,
241
- };
148
+ }, "CEILING_PATTERNS_UNRESOLVED");
242
149
  }
243
150
  requested = ceiling.capabilities;
244
151
  systemPrompt = definition.body;
@@ -262,59 +169,40 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
262
169
  return s === null ? null : `${c} → did you mean ${s}?`;
263
170
  })
264
171
  .filter((h): h is string => h !== null);
265
- return {
172
+ return denied({
266
173
  ...empty,
267
174
  requested,
268
175
  reason:
269
176
  `unknown capabilit${unknown.length === 1 ? "y" : "ies"}: ${unknown.join(", ")} — not present in ` +
270
177
  `this session's catalog (typo, or an uninstalled package?)` +
271
178
  (hints.length > 0 ? ` — ${hints.join("; ")}` : ""),
272
- };
179
+ }, "UNKNOWN_TOOL");
273
180
  }
274
181
  }
275
182
 
276
- // ADR-0014's A-S6 — an approval for one subject cannot satisfy another — enforced HERE, not only in
277
- // `resolveApprovals`. **Not a no-op elsewhere**: the re-plan passes `republishable(session)` with every subject
278
- // unfiltered, so a human's "no" for one definition was overridden by a yes for another. Measured; R-83.
279
- const subject = request.agent ?? DELEGATE_SUBJECT;
280
- const approvedCapabilities = (ctx.approved ?? []).filter((a) => a.subject === subject).map((a) => a.capability);
281
- const result = resolve({
183
+ const { result, approvalBinding, bindingMismatch } = resolveDelegationApproval({
184
+ task: request.task,
185
+ agent: request.agent,
186
+ boundWorkspaceId: request.boundWorkspaceId,
187
+ boundContextId: request.boundContextId,
282
188
  requested,
283
189
  parentGrant: ctx.ownGrant,
284
190
  gated: ctx.gated,
285
- approved: approvedCapabilities,
191
+ approved: ctx.approved,
192
+ spawned,
193
+ definitionDigest,
194
+ correlation,
195
+ parentId: ctx.spawnId ?? `d${ctx.depth}`,
286
196
  });
287
-
288
- /**
289
- * ADR-0024: gating `agent:<name>` asks a human before that definition runs.
290
- *
291
- * `gatedBlocked` is a filter over `requested`, and for a definition spawn `requested` is the definition's
292
- * CEILING — so the id that authorises it was never a candidate, and `PI_GRANTS_GATED=agent:deploy` did
293
- * nothing at all on the path an operator writing it means. It half-worked when some *other* definition
294
- * passed the id down in its own `allowed-tools`, which is worse than not working (R-47, R-25's shape).
295
- *
296
- * Evaluated here rather than by adding the id to `requested`, and that is the load-bearing part: a
297
- * capability in `requested` flows to `effective`, which becomes the CHILD's grant — so the child would
298
- * hold `agent:deploy` and could spawn `deploy` itself without anyone being asked. This is the parent's
299
- * authority to run the definition *now*, not something the child receives.
300
- *
301
- * `agent:*` in the gate covers every definition, so "ask me before any definition runs" is one variable.
302
- */
303
- if (spawned) {
304
- const authorising = agentCapability(spawned.name);
305
- const gatedHere = ctx.gated.includes(authorising) || ctx.gated.includes(AGENT_WILDCARD);
306
- if (gatedHere && !approvedCapabilities.includes(authorising)) {
307
- result.gatedBlocked = [...result.gatedBlocked, authorising];
308
- }
309
- }
197
+ if (approvalBinding) Object.assign(empty, { approvalBinding });
310
198
 
311
199
  if (result.denied.length > 0) {
312
- return {
200
+ return denied({
313
201
  ...empty,
314
202
  requested,
315
203
  result,
316
204
  reason: `cannot grant ${result.denied.join(", ")} — this session does not hold it (capability escalation blocked)`,
317
- };
205
+ }, "CAPABILITY_ESCALATION");
318
206
  }
319
207
  // ADR-0011: narrowing is checked BEFORE the gate, and the order is load-bearing rather than
320
208
  // stylistic. `assertNarrowing` refuses regardless of approval, so with the old order this returned
@@ -324,10 +212,20 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
324
212
  try {
325
213
  assertNarrowing(result);
326
214
  } catch (error) {
327
- return { ...empty, requested, result, reason: String(error instanceof Error ? error.message : error) };
215
+ // ADR-0011's narrowing invariant is the hardest rule this package enforces, and it was the one
216
+ // refusal an external controller could not identify by code (R-109).
217
+ return denied(
218
+ { ...empty, requested, result, reason: String(error instanceof Error ? error.message : error) },
219
+ "NARROWING_VIOLATED",
220
+ );
328
221
  }
329
222
  if (result.gatedBlocked.length > 0) {
330
- return { ...empty, requested, result, reason: `${result.gatedBlocked.join(", ")} requires explicit approval` };
223
+ const code = bindingMismatch ? "APPROVAL_SCOPE_MISMATCH" : "GATED_UNAPPROVED";
224
+ const suffix = bindingMismatch ? " (an approval exists, but its task/workspace/context scope does not match)" : "";
225
+ return denied(
226
+ { ...empty, requested, result, reason: `${result.gatedBlocked.join(", ")} requires explicit approval${suffix}` },
227
+ code,
228
+ );
331
229
  }
332
230
 
333
231
  const canSubDelegate = result.effective.includes(DELEGATE_CAPABILITY);
@@ -349,14 +247,14 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
349
247
  // not contain. `unknownCapabilities` above catches names absent from the catalog entirely; this
350
248
  // catches one that is known but whose path we could not resolve, which is a different fault.
351
249
  if (plan.unresolvedSkills.length > 0) {
352
- return {
250
+ return denied({
353
251
  ...empty,
354
252
  requested,
355
253
  result,
356
254
  reason:
357
255
  `cannot locate ${plan.unresolvedSkills.join(", ")} on disk — granted but unresolvable, so the ` +
358
256
  `child would silently lack it`,
359
- };
257
+ }, "DEFINITION_UNREADABLE");
360
258
  }
361
259
 
362
260
  // A child may only delegate further if it was granted the capability AND has the extension to do it.
@@ -394,6 +292,9 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
394
292
  childDepth,
395
293
  requested,
396
294
  childId: ctx.childSpawnId,
295
+ taskDigest,
296
+ ...(correlation ? { correlation } : {}),
297
+ ...(approvalBinding ? { approvalBinding } : {}),
397
298
  ...(definitionDigest ? { definitionDigest } : {}),
398
299
  };
399
300
  }
@@ -0,0 +1,99 @@
1
+ import { DELEGATE_SUBJECT, type InheritableApproval } from "./approval.ts";
2
+ import { agentCapability } from "./capabilities.ts";
3
+ import {
4
+ approvalBindingsEqual,
5
+ buildApprovalBinding,
6
+ type ApprovalBinding,
7
+ type CorrelationMetadata,
8
+ } from "./correlation.ts";
9
+ import type { DefinitionDigest, SkillDefinition } from "./definitions.ts";
10
+ import { AGENT_WILDCARD, resolve, type Capability, type ResolveResult } from "./resolve.ts";
11
+
12
+ /**
13
+ * Resolve the approval half of one delegation after its requested capability set is known.
14
+ *
15
+ * Kept as one function because the ordering is security-relevant: compute the unapproved and potential
16
+ * effective sets first, derive the exact binding from those trusted values, then decide which approvals
17
+ * match. An approval must never define the scope against which it is checked.
18
+ */
19
+ export function resolveDelegationApproval(input: {
20
+ task: string;
21
+ agent?: string;
22
+ requested: Capability[];
23
+ parentGrant: Capability[];
24
+ gated: Capability[];
25
+ approved?: InheritableApproval[];
26
+ spawned?: SkillDefinition;
27
+ definitionDigest?: DefinitionDigest;
28
+ /**
29
+ * Present iff this call is task-bound. Its VALUES never enter the binding — only its presence selects
30
+ * the exact-bound regime over the legacy subject-scoped one.
31
+ */
32
+ correlation?: CorrelationMetadata;
33
+ /** Trusted: an id that was resolved against the operator registry and leased. Never a caller claim. */
34
+ boundWorkspaceId?: string;
35
+ /** Caller-declared label. Narrows the binding only; asserts nothing about enforcement. */
36
+ boundContextId?: string;
37
+ parentId: string;
38
+ }): { result: ResolveResult; approvalBinding?: ApprovalBinding; bindingMismatch: boolean } {
39
+ const subject = input.agent ?? DELEGATE_SUBJECT;
40
+ const unapproved = resolve({
41
+ requested: input.requested,
42
+ parentGrant: input.parentGrant,
43
+ gated: input.gated,
44
+ approved: [],
45
+ });
46
+
47
+ // ADR-0024: a definition's authorising id is gated as the PARENT's authority to run it now. It never
48
+ // joins requested/effective, because that would hand the child authority to recursively spawn itself.
49
+ let authorisingCapability: Capability | undefined;
50
+ if (input.spawned) {
51
+ const authorising = agentCapability(input.spawned.name);
52
+ if (input.gated.includes(authorising) || input.gated.includes(AGENT_WILDCARD)) {
53
+ authorisingCapability = authorising;
54
+ unapproved.gatedBlocked = [...unapproved.gatedBlocked, authorising];
55
+ }
56
+ }
57
+
58
+ const potential = resolve({
59
+ requested: input.requested,
60
+ parentGrant: input.parentGrant,
61
+ gated: input.gated,
62
+ // If the definition also declares its own authorising `agent:<name>`, the same approval unblocks
63
+ // that requested child capability too; excluding it here would bind a smaller set than we provision.
64
+ approved: unapproved.gatedBlocked,
65
+ });
66
+ const approvalBinding = input.correlation
67
+ ? buildApprovalBinding({
68
+ task: input.task,
69
+ requested: input.requested,
70
+ effective: potential.effective,
71
+ definitionSha256: input.definitionDigest?.sha256,
72
+ parentId: input.parentId,
73
+ workspaceId: input.boundWorkspaceId,
74
+ contextId: input.boundContextId,
75
+ })
76
+ : undefined;
77
+
78
+ const forSubject = (input.approved ?? []).filter((approval) => approval.subject === subject);
79
+ const approvedCapabilities = forSubject
80
+ .filter((approval) =>
81
+ approvalBinding
82
+ ? approvalBindingsEqual(approval.binding, approvalBinding)
83
+ : approval.binding === undefined,
84
+ )
85
+ .map((approval) => approval.capability);
86
+ const bindingMismatch = approvalBinding !== undefined && forSubject.some(
87
+ (approval) => approval.binding !== undefined && !approvalBindingsEqual(approval.binding, approvalBinding),
88
+ );
89
+ const result = resolve({
90
+ requested: input.requested,
91
+ parentGrant: input.parentGrant,
92
+ gated: input.gated,
93
+ approved: approvedCapabilities,
94
+ });
95
+ if (authorisingCapability && !approvedCapabilities.includes(authorisingCapability)) {
96
+ result.gatedBlocked = [...result.gatedBlocked, authorisingCapability];
97
+ }
98
+ return { result, ...(approvalBinding ? { approvalBinding } : {}), bindingMismatch };
99
+ }
@@ -0,0 +1,52 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { GovernanceRefusal, refusal } from "./refusals.ts";
7
+ import type { ValidatedWorkspace } from "./workspace.ts";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export interface GitCandidateIdentity {
12
+ headSha: string;
13
+ treeSha: string;
14
+ }
15
+
16
+ /** Compute HEAD plus NON-IGNORED tracked/untracked candidate content without changing the real index.
17
+ *
18
+ * NOT the exact working tree: `git add -A` honours `.gitignore`, `core.excludesFile` and
19
+ * `$GIT_DIR/info/exclude`, and records only a gitlink for a submodule. And it is not read-only — it writes
20
+ * blob objects into the real `.git/objects`. `docs/SPEC.md` retracted the word "exact"; this comment said
21
+ * it for one more round. */
22
+ export async function computeGitCandidateIdentity(workspace: ValidatedWorkspace): Promise<GitCandidateIdentity> {
23
+ const dir = await mkdtemp(join(tmpdir(), "pi-daddy-index-"));
24
+ const index = join(dir, "index");
25
+ const env: NodeJS.ProcessEnv = {
26
+ PATH: process.env.PATH,
27
+ HOME: process.env.HOME,
28
+ LANG: process.env.LANG,
29
+ LC_ALL: process.env.LC_ALL,
30
+ TMPDIR: process.env.TMPDIR,
31
+ GIT_INDEX_FILE: index,
32
+ };
33
+ const git = async (args: string[]) => (await execFileAsync("git", ["-C", workspace.root, ...args], {
34
+ env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
35
+ })).stdout.trim();
36
+ try {
37
+ const headSha = await git(["rev-parse", "HEAD"]);
38
+ await git(["read-tree", "HEAD"]);
39
+ await git(["add", "-A"]);
40
+ const treeSha = await git(["write-tree"]);
41
+ if (!/^[a-f0-9]{40,64}$/i.test(headSha) || !/^[a-f0-9]{40,64}$/i.test(treeSha)) throw new Error("Git returned an invalid object id");
42
+ return { headSha, treeSha };
43
+ } catch (error) {
44
+ throw new GovernanceRefusal(refusal(
45
+ "CHECK_IDENTITY_UNAVAILABLE",
46
+ `could not compute exact Git head/candidate-tree identity for workspace ${workspace.workspaceId} (${String(error)})`,
47
+ { workspace_id: workspace.workspaceId },
48
+ ));
49
+ } finally {
50
+ await rm(dir, { recursive: true, force: true });
51
+ }
52
+ }
package/src/index.ts CHANGED
@@ -9,11 +9,20 @@ export {
9
9
  } from "./resolve.ts";
10
10
 
11
11
  export {
12
+ appendLedgerEvent,
12
13
  appendRecord,
14
+ buildChildLifecycleEvent,
13
15
  buildRecord,
16
+ buildWorkspaceLeaseEvent,
14
17
  isEscalationAttempt,
18
+ LEDGER_VERSION,
19
+ type CheckReceiptLedgerEvent,
20
+ type ChildLifecycleEvent,
15
21
  type GrantRecord,
16
22
  type LedgerOptions,
23
+ type RuntimeLedgerEvent,
24
+ type WorkspaceLeaseEvent,
25
+ type WorkspaceLeaseOutcome,
17
26
  } from "./ledger.ts";
18
27
 
19
28
  export { planSpawn, type SpawnPlan, type SpawnPlanInput } from "./spawn.ts";
@@ -46,6 +55,47 @@ export {
46
55
  type SubjectLookup,
47
56
  } from "./approval-store.ts";
48
57
 
58
+ export {
59
+ approvalBindingDigest,
60
+ approvalBindingsEqual,
61
+ buildApprovalBinding,
62
+ digestCapabilities,
63
+ digestTask,
64
+ isApprovalBinding,
65
+ normaliseCorrelation,
66
+ type ApprovalBinding,
67
+ type CorrelationMetadata,
68
+ type JsonValue,
69
+ } from "./correlation.ts";
70
+
71
+ export {
72
+ GovernanceRefusal,
73
+ REFUSAL_CODES,
74
+ refusal,
75
+ type RefusalCode,
76
+ type StructuredRefusal,
77
+ } from "./refusals.ts";
78
+
79
+ export {
80
+ acquireWorkspaceLease,
81
+ defaultWorkspaceLeaseDir,
82
+ loadWorkspaceRegistry,
83
+ resolveWorkspace,
84
+ validateRegisteredWorkspace,
85
+ type ValidatedWorkspace,
86
+ type WorkspaceAccess,
87
+ type WorkspaceLease,
88
+ type WorkspaceRegistryFile,
89
+ } from "./workspace.ts";
90
+
91
+ export {
92
+ buildCheckEnvironment,
93
+ runNamedCheck,
94
+ type CheckDefinition,
95
+ type CheckReceipt,
96
+ type CheckRegistry,
97
+ } from "./check-runner.ts";
98
+
49
99
  export {
50
100
  createApprovalGate,
51
101
  createApprovalGateProvider,