pi-daddy 0.18.1 → 0.19.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 (130) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +46 -2
  3. package/contracts/ledger/v2/README.md +59 -0
  4. package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
  5. package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
  6. package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
  7. package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
  8. package/contracts/ledger/v2/ledger-event.schema.json +633 -0
  9. package/dist/approval-prompt.d.ts +2 -1
  10. package/dist/approval-prompt.d.ts.map +1 -1
  11. package/dist/approval-prompt.js +9 -0
  12. package/dist/approval-prompt.js.map +1 -1
  13. package/dist/approval.d.ts +4 -2
  14. package/dist/approval.d.ts.map +1 -1
  15. package/dist/approval.js +4 -0
  16. package/dist/approval.js.map +1 -1
  17. package/dist/capabilities.d.ts +90 -0
  18. package/dist/capabilities.d.ts.map +1 -1
  19. package/dist/capabilities.js +114 -3
  20. package/dist/capabilities.js.map +1 -1
  21. package/dist/catalog.d.ts +15 -1
  22. package/dist/catalog.d.ts.map +1 -1
  23. package/dist/catalog.js +54 -3
  24. package/dist/catalog.js.map +1 -1
  25. package/dist/check-runner.d.ts.map +1 -1
  26. package/dist/check-runner.js +5 -7
  27. package/dist/check-runner.js.map +1 -1
  28. package/dist/cli.d.ts.map +1 -1
  29. package/dist/cli.js +21 -1
  30. package/dist/cli.js.map +1 -1
  31. package/dist/definitions.d.ts.map +1 -1
  32. package/dist/definitions.js +7 -1
  33. package/dist/definitions.js.map +1 -1
  34. package/dist/delegate.d.ts.map +1 -1
  35. package/dist/delegate.js +31 -4
  36. package/dist/delegate.js.map +1 -1
  37. package/dist/delegation-approval.d.ts.map +1 -1
  38. package/dist/delegation-approval.js +37 -12
  39. package/dist/delegation-approval.js.map +1 -1
  40. package/dist/executor.d.ts +2 -1
  41. package/dist/executor.d.ts.map +1 -1
  42. package/dist/executor.js +1 -0
  43. package/dist/executor.js.map +1 -1
  44. package/dist/grant-env.d.ts +2 -0
  45. package/dist/grant-env.d.ts.map +1 -1
  46. package/dist/grant-env.js +26 -3
  47. package/dist/grant-env.js.map +1 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/init.d.ts +13 -1
  53. package/dist/init.d.ts.map +1 -1
  54. package/dist/init.js +35 -2
  55. package/dist/init.js.map +1 -1
  56. package/dist/lease-helper.d.ts +58 -0
  57. package/dist/lease-helper.d.ts.map +1 -0
  58. package/dist/lease-helper.js +94 -0
  59. package/dist/lease-helper.js.map +1 -0
  60. package/dist/lease-record.d.ts +15 -3
  61. package/dist/lease-record.d.ts.map +1 -1
  62. package/dist/lease-record.js.map +1 -1
  63. package/dist/ledger-events.d.ts +35 -14
  64. package/dist/ledger-events.d.ts.map +1 -1
  65. package/dist/ledger-events.js +41 -0
  66. package/dist/ledger-events.js.map +1 -1
  67. package/dist/ledger.d.ts +10 -5
  68. package/dist/ledger.d.ts.map +1 -1
  69. package/dist/ledger.js +13 -3
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/propagation.d.ts +16 -0
  72. package/dist/propagation.d.ts.map +1 -1
  73. package/dist/propagation.js +20 -1
  74. package/dist/propagation.js.map +1 -1
  75. package/dist/refusals.d.ts +1 -1
  76. package/dist/refusals.d.ts.map +1 -1
  77. package/dist/refusals.js +1 -0
  78. package/dist/refusals.js.map +1 -1
  79. package/dist/resolve.d.ts +10 -0
  80. package/dist/resolve.d.ts.map +1 -1
  81. package/dist/resolve.js +27 -3
  82. package/dist/resolve.js.map +1 -1
  83. package/dist/routing-authority.d.ts +71 -0
  84. package/dist/routing-authority.d.ts.map +1 -0
  85. package/dist/routing-authority.js +100 -0
  86. package/dist/routing-authority.js.map +1 -0
  87. package/dist/skill-packages.d.ts +11 -5
  88. package/dist/skill-packages.d.ts.map +1 -1
  89. package/dist/skill-packages.js +20 -11
  90. package/dist/skill-packages.js.map +1 -1
  91. package/dist/workspace-lease.d.ts +15 -3
  92. package/dist/workspace-lease.d.ts.map +1 -1
  93. package/dist/workspace-lease.js +81 -24
  94. package/dist/workspace-lease.js.map +1 -1
  95. package/dist/workspace.d.ts +25 -0
  96. package/dist/workspace.d.ts.map +1 -1
  97. package/dist/workspace.js +142 -5
  98. package/dist/workspace.js.map +1 -1
  99. package/extensions/delegation.ts +7 -1
  100. package/extensions/grants-command.ts +11 -1
  101. package/extensions/grants.ts +6 -1
  102. package/extensions/init-command.ts +33 -2
  103. package/extensions/session-report.ts +13 -20
  104. package/extensions/session.ts +9 -1
  105. package/extensions/workspace-runtime.ts +34 -4
  106. package/package.json +8 -3
  107. package/src/approval-prompt.ts +2 -1
  108. package/src/approval.ts +4 -2
  109. package/src/capabilities.ts +120 -4
  110. package/src/catalog.ts +62 -4
  111. package/src/check-runner.ts +8 -8
  112. package/src/cli.ts +24 -1
  113. package/src/definitions.ts +7 -1
  114. package/src/delegate.ts +41 -4
  115. package/src/delegation-approval.ts +37 -12
  116. package/src/executor.ts +2 -1
  117. package/src/grant-env.ts +39 -7
  118. package/src/index.ts +1 -0
  119. package/src/init.ts +40 -2
  120. package/src/lease-helper.ts +97 -0
  121. package/src/lease-record.ts +15 -3
  122. package/src/ledger-events.ts +66 -22
  123. package/src/ledger.ts +30 -6
  124. package/src/propagation.ts +21 -1
  125. package/src/refusals.ts +1 -0
  126. package/src/resolve.ts +29 -3
  127. package/src/routing-authority.ts +121 -0
  128. package/src/skill-packages.ts +20 -13
  129. package/src/workspace-lease.ts +84 -24
  130. package/src/workspace.ts +179 -6
package/src/ledger.ts CHANGED
@@ -34,11 +34,17 @@ import type { StructuredRefusal } from "./refusals.ts";
34
34
  import type { RuntimeLedgerEvent } from "./ledger-events.ts";
35
35
 
36
36
  export const LEDGER_VERSION = 2 as const;
37
+ export const LEDGER_EVENT_KINDS = [
38
+ "capability_decision", "workspace_lease", "child_lifecycle", "check_receipt",
39
+ ] as const;
40
+ export type LedgerEventKind = typeof LEDGER_EVENT_KINDS[number];
41
+ export const LEDGER_GATE_OUTCOMES = ["declined", "dismissed", "no-ui", "error"] as const;
42
+ export type LedgerGateOutcome = typeof LEDGER_GATE_OUTCOMES[number];
37
43
 
38
44
  export interface LedgerEventBase {
39
45
  /** Optional only on the legacy-compatible `GrantRecord` public type; every v2 event builder writes it. */
40
46
  ledgerVersion?: typeof LEDGER_VERSION;
41
- event?: "capability_decision" | "workspace_lease" | "child_lifecycle" | "check_receipt";
47
+ event?: LedgerEventKind;
42
48
  ts: string;
43
49
  childId?: string;
44
50
  correlation?: CorrelationMetadata;
@@ -104,7 +110,7 @@ export interface GrantRecord extends LedgerEventBase {
104
110
  /** Present only when the source was a live prompt, and only when one scope covers the whole set. */
105
111
  approvalScope?: ApprovalScope;
106
112
  /** A human was asked and declined. Distinct from `denied`, which is an escalation attempt. */
107
- humanDenied?: boolean;
113
+ humanDenied?: true;
108
114
  /**
109
115
  * WHY a gate went unsatisfied, when the answer was not a yes.
110
116
  *
@@ -120,9 +126,10 @@ export interface GrantRecord extends LedgerEventBase {
120
126
  * say *"nobody was there to ask"* and be believed, so it is recorded rather than inferred.
121
127
  *
122
128
  * **Privacy is unchanged**: this is a fixed five-member enum, not text — nothing model-authored, nothing
123
- * a task could carry.
129
+ * a task could carry. The prompt has five outcomes, but `granted` is deliberately omitted from the
130
+ * four-member ledger enum because the approval source/scope fields already record a yes.
124
131
  */
125
- gateOutcome?: PromptOutcomeKind;
132
+ gateOutcome?: LedgerGateOutcome;
126
133
  /**
127
134
  * WHICH operator-authored instructions this child was given (ADR-0018).
128
135
  *
@@ -208,6 +215,12 @@ export function buildRecord(args: {
208
215
  refusal?: StructuredRefusal;
209
216
  now: Date;
210
217
  }): GrantRecord {
218
+ if (args.taskDigest !== undefined && !/^[a-f0-9]{64}$/i.test(args.taskDigest)) {
219
+ throw new TypeError("taskDigest must be a SHA-256 hex digest");
220
+ }
221
+ if (args.definitionDigest && !/^[a-f0-9]{64}$/i.test(args.definitionDigest.sha256)) {
222
+ throw new TypeError("definitionDigest.sha256 must be a SHA-256 hex digest");
223
+ }
211
224
  // R-46: the scalar is a SUMMARY, emitted only when it cannot mislead. `buildRecord` derives it rather
212
225
  // than accepting it, so a call site cannot supply one that disagrees with the map beside it.
213
226
  const sources = args.approvalSources ?? {};
@@ -217,7 +230,7 @@ export function buildRecord(args: {
217
230
  return {
218
231
  // A trusted task digest is what distinguishes a v2 capability event from the legacy construction API.
219
232
  // Production delegation always supplies it; old TypeScript callers remain able to append legacy lines.
220
- ...(args.taskDigest ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
233
+ ...(args.taskDigest !== undefined ? { ledgerVersion: LEDGER_VERSION, event: "capability_decision" as const } : {}),
221
234
  ts: args.now.toISOString(),
222
235
  parentId: args.parentId,
223
236
  childId: args.childId,
@@ -225,7 +238,7 @@ export function buildRecord(args: {
225
238
  agentType: args.agentType,
226
239
  executor: args.executor,
227
240
  ...(args.taskFrom ? { taskFrom: args.taskFrom } : {}),
228
- ...(args.taskDigest ? { taskDigest: args.taskDigest } : {}),
241
+ ...(args.taskDigest !== undefined ? { taskDigest: args.taskDigest } : {}),
229
242
  ...(args.correlation ? { correlation: structuredClone(args.correlation) } : {}),
230
243
  ...(args.refusal ? { refusal: structuredClone(args.refusal) } : {}),
231
244
  requested: args.requested,
@@ -302,11 +315,22 @@ export function isEscalationAttempt(record: GrantRecord): boolean {
302
315
  }
303
316
 
304
317
  export {
318
+ CHILD_LIFECYCLE_STATES,
319
+ CHILD_PROCESS_SIGNALS,
320
+ WORKSPACE_ACCESSES,
321
+ WORKSPACE_LEASE_OUTCOMES,
322
+ WORKSPACE_RECOVERY_VALUES,
323
+ buildCheckReceiptLedgerEvent,
305
324
  buildChildLifecycleEvent,
306
325
  buildWorkspaceLeaseEvent,
326
+ type CapabilityDecisionEvent,
307
327
  type ChildLifecycleEvent,
328
+ type ChildLifecycleState,
329
+ type ChildProcessSignal,
308
330
  type CheckReceiptLedgerEvent,
309
331
  type RuntimeLedgerEvent,
332
+ type WorkspaceAccess,
310
333
  type WorkspaceLeaseEvent,
311
334
  type WorkspaceLeaseOutcome,
335
+ type WorkspaceRecovery,
312
336
  } from "./ledger-events.ts";
@@ -27,6 +27,7 @@
27
27
 
28
28
  import type { Capability } from "./resolve.ts";
29
29
  import { WILDCARD } from "./pi-tools.ts";
30
+ import { WORKSPACE_WILDCARD } from "./resolve.ts";
30
31
  import { inheritApprovals, type InheritableApproval } from "./approval.ts";
31
32
  import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
32
33
 
@@ -247,9 +248,28 @@ export interface ChildEnvInput {
247
248
  * empty grant, so they can spawn nothing. That fails closed. It is also unreachable in normal flow,
248
249
  * because a session's first provider request always precedes its first tool call.
249
250
  */
251
+ /**
252
+ * The part of a grant a child may inherit: everything but the wildcards that are HELD, never handed down.
253
+ *
254
+ * `tool:*` for R-26's reason — a root that handed it down let every descendant reacquire the full catalog.
255
+ * `workspace:*` for the same reason one namespace over (R-131): a descendant holding it could route anywhere
256
+ * the registry lists, which is the attenuation failure ADR-0035 exists to close. `agent:*` is deliberately
257
+ * NOT stripped — ADR-0023 decided that a session authorised for any definition passes that on, because
258
+ * definitions are ceilings rather than roots.
259
+ *
260
+ * **Exported, and there is exactly one spelling of this rule on purpose.** It shipped as a filter inline in
261
+ * `childEnv` and nowhere else, so a grant travelling the OTHER path — `delegate.ts` building a child's
262
+ * `PI_GRANTS_GRANT` from `result.effective` — carried `workspace:*` straight down. The test written beside
263
+ * that fix exercised `childEnv`, which is not the path a delegated child's grant travels, so it could not
264
+ * catch it. R-28's shape: two routes for one rule, with the guard on the quieter one. Both call this now.
265
+ */
266
+ export function inheritableGrant(grant: readonly Capability[]): Capability[] {
267
+ return grant.filter((c) => c !== WILDCARD && c !== WORKSPACE_WILDCARD);
268
+ }
269
+
250
270
  export function childEnv(input: ChildEnvInput): Record<string, string> {
251
271
  if (input.governed === false) return {};
252
- const inheritable = input.ownGrant.filter((c) => c !== WILDCARD);
272
+ const inheritable = inheritableGrant(input.ownGrant);
253
273
  const env: Record<string, string> = {
254
274
  [ENV_GRANT]: (assertCapabilitiesArePropagatable(inheritable), inheritable.join(",")),
255
275
  [ENV_DEPTH]: String(input.depth + 1),
package/src/refusals.ts CHANGED
@@ -28,6 +28,7 @@ export const REFUSAL_CODES = [
28
28
  "LEDGER_WRITE_FAILED",
29
29
  "FANOUT_FAILED",
30
30
  "WORKSPACE_NOT_REGISTERED",
31
+ "WORKSPACE_NOT_AUTHORIZED",
31
32
  "WORKSPACE_WRITE_CONFLICT",
32
33
  "WORKSPACE_LEASE_STALE",
33
34
  "CHECK_NOT_CONFIGURED",
package/src/resolve.ts CHANGED
@@ -76,6 +76,16 @@ import { isWellFormedCapability } from "./capabilities.ts";
76
76
  * `tool:*` — authority to grant every tool — which made the safe configuration the laborious one.
77
77
  */
78
78
  export const AGENT_WILDCARD: Capability = "agent:*";
79
+ /**
80
+ * `workspace:*` covers any `workspace:<id>` — ADR-0035, and the second namespace wildcard.
81
+ *
82
+ * Added as a deliberate edit, which is what the comment in `resolve()` below asks for: there is no
83
+ * generalised `<ns>:*` rule, so a namespace does not acquire a wildcard by existing. Unlike `agent:*` this
84
+ * one is **not inheritable** (`childEnv` strips it) — R-26's rule, because a root that handed
85
+ * `workspace:*` down would make routing attenuation meaningless below the root, which is the exact defect
86
+ * R-131 records.
87
+ */
88
+ export const WORKSPACE_WILDCARD: Capability = "workspace:*";
79
89
 
80
90
  export interface ResolveInput {
81
91
  /** What the delegating agent asked to give the child. */
@@ -125,7 +135,10 @@ export function resolve(input: ResolveInput): ResolveResult {
125
135
  const parent =
126
136
  input.subsumption === false ? held : new Set(expandSubsumed(input.parentGrant));
127
137
  /**
128
- * `agent:*` covers any `agent:<name>` — ADR-0023, and the ONLY wildcard rule in this function.
138
+ * `agent:*` covers any `agent:<name>` — ADR-0023. It was the only wildcard rule in this function when
139
+ * that was written; ADR-0035 added a third, so `covered()` now has `tool:*`, `agent:*` and `workspace:*`.
140
+ * There is still no GENERALISED `<ns>:*` rule: each is a deliberate edit, which is the property worth
141
+ * keeping. Corrected because the sentence sat eighty lines above "all THREE wildcards are excluded".
129
142
  *
130
143
  * `resolve` is otherwise exact-match plus subsumption, deliberately: `tool:*` works not because anything
131
144
  * here understands it, but because `deriveOwnGrant` *enumerates* a session's observed tool names beside
@@ -150,13 +163,17 @@ export function resolve(input: ResolveInput): ResolveResult {
150
163
  * spellings of one rule, and the enforcing one was wrong.
151
164
  */
152
165
  const anyCapability = held.has(WILDCARD);
166
+ const anyWorkspace = held.has(WORKSPACE_WILDCARD);
153
167
  // A malformed id is never covered — not by an exact hold, and above all not by a wildcard's PREFIX rule,
154
168
  // which is how `agent:x,tool:bash` got admitted and then split into two capabilities in the child.
155
169
  // Landing in `denied` is the right outcome rather than a throw: it fails closed AND records an
156
170
  // escalation attempt, so the ledger shows the attempt instead of a clean line.
157
171
  const covered = (c: Capability): boolean =>
158
172
  isWellFormedCapability(c)
159
- && (parent.has(c) || anyCapability || (anyDefinition && c.startsWith("agent:")));
173
+ && (parent.has(c) || anyCapability
174
+ || (anyDefinition && c.startsWith("agent:"))
175
+ || (anyWorkspace && c.startsWith("workspace:")));
176
+
160
177
  const ceiling = input.ceiling === undefined ? null : new Set(input.ceiling);
161
178
  const gated = new Set(input.gated ?? []);
162
179
  const approved = new Set(input.approved ?? []);
@@ -199,7 +216,16 @@ export function resolve(input: ResolveInput): ResolveResult {
199
216
  // F9: capabilities covered by a WILDCARD are not "subsumed" — this field means "the grant is broader
200
217
  // than its list suggests", which is the `bash`-covers-`grep` warning. A wildcard holder already knows
201
218
  // its grant is broad; listing every id under it would bury the signal the field exists to carry.
202
- subsumedBy: effective.filter((c) => !held.has(c) && !anyCapability && !(anyDefinition && c.startsWith("agent:"))),
219
+ //
220
+ // All THREE wildcards are excluded. `anyWorkspace` was missing, so a caller holding `workspace:*` saw
221
+ // its own `workspace:prod` reported as subsumed while the `agent:*` holder correctly saw nothing — the
222
+ // field contradicting its own rule, and a false "broader than it looks" flag in the ledger and in
223
+ // `/grants`. Every wildcard added to `covered()` above needs a line here; that is now three for three.
224
+ subsumedBy: effective.filter((c) =>
225
+ !held.has(c)
226
+ && !anyCapability
227
+ && !(anyDefinition && c.startsWith("agent:"))
228
+ && !(anyWorkspace && c.startsWith("workspace:"))),
203
229
  };
204
230
  }
205
231
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * ADR-0035's three routing guards, and why each one is shaped the way it is.
3
+ *
4
+ * Lifted out of `delegate.ts` when it crossed the 400-line ceiling `test/file-size.test.ts` enforces — the
5
+ * same move `grants.ts` made at 398 and `delegate.ts` itself made at 413. It is a real seam rather than a
6
+ * line-count dodge: all three answer one question, *may this caller route this child to this workspace, and
7
+ * is the id even usable as a capability?*, and none of them looks at anything else in a delegation. The
8
+ * alternative was trimming the rationale below to fit, which is how a codebase loses the reasons for its
9
+ * guards.
10
+ *
11
+ * Each returns a refusal DESCRIPTOR rather than a `Delegation`, so the planner keeps sole ownership of
12
+ * assembling records. `denied` is present exactly when the attempt should count as an escalation.
13
+ */
14
+
15
+ import { mayRouteToWorkspace, isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
16
+ import { WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
17
+ import { WILDCARD } from "./pi-tools.ts";
18
+ import type { RefusalCode } from "./refusals.ts";
19
+
20
+ export interface RoutingRefusal {
21
+ code: RefusalCode;
22
+ reason: string;
23
+ /**
24
+ * The capability to record in `denied`, when this refusal IS an escalation attempt.
25
+ *
26
+ * Absent for a malformed id, which is a bad request rather than a bid for authority — and seeding `denied`
27
+ * with an id that re-splits into several capabilities would make the one signal `isEscalationAttempt` reads
28
+ * unparseable. Present for an unauthorised route, which is exactly a bid for authority.
29
+ */
30
+ denied?: Capability[];
31
+ }
32
+
33
+ /**
34
+ * May this caller route a child to `boundWorkspaceId`, and is that id usable as a capability id at all?
35
+ *
36
+ * Checked before anything is said about the target, for the reason `maySpawnDefinition` is: it is a
37
+ * governance question about the SESSION. Before ADR-0035 nothing checked it — the registry inherited into
38
+ * every governed child and a child routed to `staging` could route its grandchild to `prod` (R-131, measured
39
+ * in `docs/probes/g36-workspace-attenuation`).
40
+ *
41
+ * **Well-formedness first, because `workspace_id` is a model-facing tool parameter** and the next step turns
42
+ * it into a capability id. `workspace_id: "prod,tool:bash"` produced a `WORKSPACE_NOT_AUTHORIZED` whose
43
+ * `denied` array held `workspace:prod,tool:bash`: no authority minted, since the refusal is terminal, but
44
+ * `denied` is the channel `isEscalationAttempt` and `/grants ledger` count and an id that re-splits makes it
45
+ * unreadable. 0.18.1 is what happens when a comma goes unremarked.
46
+ *
47
+ * **`!== undefined`, not truthiness.** `workspace_id: ""` is falsy, so it skipped BOTH checks and failed
48
+ * closed much later at `resolveWorkspace` with `denied: []` — an unauthorised routing attempt invisible to
49
+ * every audit query. An empty id is a malformed id, not an absent one.
50
+ */
51
+ export function checkRoutingAuthority(
52
+ boundWorkspaceId: string | undefined,
53
+ ownGrant: readonly Capability[],
54
+ ): RoutingRefusal | null {
55
+ if (boundWorkspaceId === undefined) return null;
56
+ const authorising = workspaceCapability(boundWorkspaceId);
57
+ if (!isSafeWorkspaceId(boundWorkspaceId)) {
58
+ return {
59
+ code: "GRANT_ID_MALFORMED",
60
+ reason:
61
+ `workspace id ${JSON.stringify(boundWorkspaceId)} is not usable as a capability id — it would ` +
62
+ `become ${JSON.stringify(authorising)}, and an id must match [A-Za-z0-9][A-Za-z0-9._/-]*. Slashes ` +
63
+ `and dots are fine; spaces, quotes, commas, wildcards, shell metacharacters and non-ASCII are not.`,
64
+ };
65
+ }
66
+ if (mayRouteToWorkspace(ownGrant, boundWorkspaceId)) return null;
67
+ // Filtered by the grammar too: an id that cannot pass `isSafeWorkspaceId` is refused on every
68
+ // attempt, so listing it here points the model at a destination it can never reach.
69
+ const held = ownGrant
70
+ .filter((c) => c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)))
71
+ .sort();
72
+ return {
73
+ code: "WORKSPACE_NOT_AUTHORIZED",
74
+ // Recorded as a denial rather than a bare refusal, exactly as DEFINITION_NOT_AUTHORIZED is: asking to
75
+ // route somewhere this session was not granted IS an attempt to exceed the grant.
76
+ denied: [authorising],
77
+ reason:
78
+ `cannot route a child to workspace "${boundWorkspaceId}" — this session does not hold ${authorising}. ` +
79
+ (held.length > 0
80
+ ? `It may route to: ${held.join(", ")}.`
81
+ : `It may route to no workspace at all; add ${authorising} to its grant to allow this one.`),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * May `workspace:*` be handed to a child? No — and only its HOLDER gets told so in those words.
87
+ *
88
+ * `workspace:*` is held and never inherited, so a child that "was granted" it would receive an env without
89
+ * it, and the ledger would record an authority the child does not have — the mirror image of R-131 and just
90
+ * as unreadable. `childEnv` strips it from a session's OWN grant because a root legitimately holds it; asking
91
+ * to hand it to a child is a different act and the honest answer is no. `NARROWING_VIOLATED` is the existing
92
+ * code for "this grant would not actually narrow", which is precisely what routing authority over every
93
+ * registered root does.
94
+ *
95
+ * **`tool:*` does NOT reach the same outcome, and an earlier comment claimed it did.** It said
96
+ * `assertNarrowing` produces the equivalent refusal; it does not. `UNIVERSAL_CAPABILITIES` is
97
+ * `["ext:pi-fabric/fabric_exec", "tool:fabric_exec"]`, so `result.universal` is empty for `tool:*` and
98
+ * nothing throws: requesting it for a child is allowed, recorded as granted, and silently stripped from the
99
+ * child env by `inheritableGrant` (R-135). The asymmetry is deliberate — an ungoverned session's own grant IS
100
+ * `tool:*`, so refusing to spawn from one would break governance-is-opt-in, while `workspace:*` only ever
101
+ * appears in `requested` because somebody asked — but it is an asymmetry, not one rule twice.
102
+ *
103
+ * **Only for a holder.** A caller without it is attempting an escalation, and this refusal would tell it the
104
+ * wildcard "is held, never inherited" — false about a session holding nothing of the sort — while leaving
105
+ * `denied` empty, so nothing counted a model probing `tools: ["workspace:*"]`. Returning `null` lets
106
+ * `resolve()` deny it as uncovered, which is what `agent:*` and `tool:*` already do.
107
+ */
108
+ export function checkWorkspaceWildcardRequest(
109
+ requested: readonly Capability[],
110
+ ownGrant: readonly Capability[],
111
+ ): RoutingRefusal | null {
112
+ if (!requested.includes(WORKSPACE_WILDCARD)) return null;
113
+ if (!ownGrant.includes(WORKSPACE_WILDCARD) && !ownGrant.includes(WILDCARD)) return null;
114
+ return {
115
+ code: "NARROWING_VIOLATED",
116
+ reason:
117
+ `cannot grant ${WORKSPACE_WILDCARD} to a child — it is held, never inherited, because a descendant ` +
118
+ `holding it could route anywhere the registry lists and routing would stop attenuating below here. ` +
119
+ `Name the workspaces this child may route to instead (workspace:<id>, one per id).`,
120
+ };
121
+ }
@@ -30,7 +30,8 @@ import { homedir } from "node:os";
30
30
  import { join, resolve, sep } from "node:path";
31
31
  import { ceilingForDefinition, parseSkillDefinition, type SkillDefinition } from "./definitions.ts";
32
32
  import { WILDCARD } from "./pi-tools.ts";
33
- import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
33
+ import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
34
+ import { isSafeCapability } from "./capabilities.ts";
34
35
 
35
36
  export interface DiscoveredSkill {
36
37
  definition: SkillDefinition;
@@ -107,21 +108,27 @@ export function isSafeName(name: string): boolean {
107
108
  * grants), which is right for the enforcement path — the catalog refuses what it does not know — and is
108
109
  * exactly why the check has to be here, at the boundary that *generates* rather than the one that enforces.
109
110
  *
110
- * The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`, and
111
- * `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused separately and
112
- * loudly, because "you tried to grant yourself everything" is a different fact from "that is not a name".
111
+ * The grammar is the one `docs/SPEC.md` documents: `tool:<name>`, `skill:<name>`, `agent:<name>`,
112
+ * `workspace:<id>`, and `ext:<pkg>/<tool>` where `<pkg>` may be npm-scoped. No wildcards — those are refused
113
+ * separately and loudly, because "you tried to grant yourself everything" is a different fact from "that is
114
+ * not a name".
115
+ *
116
+ * `workspace:` was absent until 2026-08-21, so the boundary that GENERATES grants could not emit the one
117
+ * capability ADR-0035's breaking change made mandatory: a package needing to route was reported as declaring
118
+ * something that "is not a name". A namespace has to be added here as well as to the enforcing path, and
119
+ * that is the whole lesson of the review this came out of.
113
120
  */
114
- export function isSafeCapability(id: Capability): boolean {
115
- const segment = "[A-Za-z0-9][A-Za-z0-9._-]*";
116
- return (
117
- new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
118
- new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id)
119
- );
120
- }
121
+ // Imported as well as re-exported: a bare `export … from` does not bind the name in this module's scope, and
122
+ // `refusalFor` below calls it. Re-exported because this has been the import site since 0.13.0.
123
+ export { isSafeCapability };
121
124
 
122
- /** The two ids that confer root authority. A package declaring one is claiming it, not describing a need. */
125
+ /**
126
+ * The ids that confer authority over a whole namespace. A package declaring one is claiming it, not
127
+ * describing a need — so it is reported as a *wildcard claim* rather than as a malformed name, which is a
128
+ * different sentence to show an operator. `workspace:*` belongs here for the same reason `agent:*` does.
129
+ */
123
130
  function wildcardsIn(capabilities: Capability[]): Capability[] {
124
- return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD);
131
+ return capabilities.filter((c) => c === WILDCARD || c === AGENT_WILDCARD || c === WORKSPACE_WILDCARD);
125
132
  }
126
133
 
127
134
  /**
@@ -14,6 +14,7 @@ import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { GovernanceRefusal, refusal } from "./refusals.ts";
16
16
  import type { ValidatedWorkspace, WorkspaceAccess } from "./workspace.ts";
17
+ import { assertCloseBounds, HELPER_SOURCE, LEASE_READY, unrefStream } from "./lease-helper.ts";
17
18
 
18
19
  export const ENV_WORKSPACE_LEASE_DIR = "PI_GRANTS_WORKSPACE_LEASE_DIR";
19
20
 
@@ -22,28 +23,17 @@ export function defaultWorkspaceLeaseDir(env: NodeJS.ProcessEnv = process.env):
22
23
  return env[ENV_WORKSPACE_LEASE_DIR] ?? join(agentDir, "pi-daddy", "workspace-leases");
23
24
  }
24
25
 
25
- const LEASE_READY = "PI_DADDY_LEASE_READY";
26
- // The lock holder also owns crash cleanup for the governed child. If the parent dies, stdin closes;
27
- // the helper signals the attached process, or closes the herdr tab, before releasing flock. A raw
28
- // descendant deliberately detached by bash remains ADR-0012's OS-containment boundary, not a lease
29
- // guarantee. The process branch SIGTERMs, escalates to SIGKILL at +500ms, and releases at +750ms
30
- // WITHOUT confirming death (R-101). The herdr branch retries `tab close` a BOUNDED number of times
31
- // and then releases anyway, leaving a marker file: an unreleasable lock strands a worktree forever
32
- // with no in-product recovery, which is strictly worse than a recorded failure to close (R-102).
33
- const HELPER_SOURCE = `
34
- import { execFile } from "node:child_process";
35
- import { writeFileSync } from "node:fs";
36
- let clean=false, target=null, buffered="";
37
- process.stdout.write(${JSON.stringify(`${LEASE_READY}:`)}+process.pid+"\\n");
38
- process.stdin.setEncoding("utf8");
39
- process.stdin.on("data",chunk=>{buffered+=chunk;for(;;){const i=buffered.indexOf("\\n");if(i<0)break;const line=buffered.slice(0,i);buffered=buffered.slice(i+1);try{const value=JSON.parse(line);if(value.release)clean=true;else if(value.process_pid)target={process_pid:value.process_pid};else if(value.herdr_tab)target={herdr_tab:value.herdr_tab};}catch{}}});
40
- process.stdin.on("end",()=>{if(clean||!target)return process.exit(0);if(target.process_pid){try{process.kill(target.process_pid,"SIGTERM")}catch{return process.exit(0)}setTimeout(()=>{try{process.kill(target.process_pid,"SIGKILL")}catch{}},500);return setTimeout(()=>process.exit(0),750);}let left=Number(process.env.PI_DADDY_LEASE_CLOSE_ATTEMPTS||10);const giveUp=()=>{try{if(process.env.PI_DADDY_LEASE_MARKER)writeFileSync(process.env.PI_DADDY_LEASE_MARKER,JSON.stringify({reason:"herdr-close-failed",herdr_tab:target.herdr_tab})+"\\n")}catch{}process.exit(0)};const close=()=>execFile("herdr",["tab","close",target.herdr_tab],error=>{if(!error)return process.exit(0);if(--left<=0)return giveUp();setTimeout(close,1000)});close();});
41
- process.stdin.resume();`;
42
26
 
43
27
  /**
44
- * The ledger outcome for a release. ONE definition, exported, because two call sites had their own copies
45
- * and the check runner's copy asserted `released` unconditionally — reproducing in the evidence path the
46
- * exact defect this union was added to expose (R-100/R-103).
28
+ * The ledger outcome for a release. Exported because call sites had their own copies and the check runner's
29
+ * asserted `released` unconditionally — reproducing in the evidence path the exact defect this union was
30
+ * added to expose (R-100/R-103).
31
+ *
32
+ * **It is NOT the only definition, and saying so here was wrong.** `extensions/workspace-runtime.ts` keeps a
33
+ * private copy with no `not-held` arm, and it is the one the whole delegation path uses — so releasing a
34
+ * *read* lease on a delegated child is still recorded `released`, a handover the kernel never performed.
35
+ * Measured; open as R-141; the sentence claiming a single definition survived two review passes because
36
+ * `test/workspace.test.ts` pins the `not-held` arm on THIS function, which no delegation path calls.
47
37
  */
48
38
  export function leaseReleaseLedgerOutcome(
49
39
  outcome: LeaseReleaseOutcome | "retained",
@@ -78,9 +68,17 @@ export async function acquireWorkspaceLease(input: {
78
68
  signal?: AbortSignal;
79
69
  flockCommand?: string;
80
70
  acquisitionTimeoutMs?: number;
71
+ /**
72
+ * Bound on how long ONE `herdr tab close` attempt may take before it counts as failed (R-146). A hung
73
+ * herdr is not a failed herdr: without this the attempt never returns and the retry bound below is
74
+ * unreachable.
75
+ */
76
+ herdrCloseTimeoutMs?: number;
81
77
  /** Bound on the helper's `herdr tab close` retries before it releases the lock anyway (R-102). */
82
78
  herdrCloseAttempts?: number;
83
79
  }): Promise<WorkspaceLease> {
80
+ // Before the read-lease return, so both paths are validated (R-152).
81
+ assertCloseBounds(input);
84
82
  if (input.access === "read") {
85
83
  return {
86
84
  workspace: input.workspace,
@@ -89,7 +87,9 @@ export async function acquireWorkspaceLease(input: {
89
87
  recovered: false,
90
88
  attachProcess: () => {},
91
89
  attachHerdrTab: () => {},
92
- markRetained: async () => {},
90
+ // A read lease took no kernel lock, so there is nothing to keep: `not-held` is the same answer
91
+ // `release()` gives, and it is the truth rather than a retention that never happened (R-152).
92
+ markRetained: async () => "not-held" as const,
93
93
  readCloseFailure: async () => null,
94
94
  lost: new Promise(() => {}),
95
95
  // No kernel lock was ever taken, so there is no handover to claim (R-105, release side).
@@ -108,7 +108,12 @@ export async function acquireWorkspaceLease(input: {
108
108
  process.execPath, "--input-type=module", "-e", HELPER_SOURCE,
109
109
  ], {
110
110
  stdio: ["pipe", "pipe", "pipe"],
111
- env: { ...process.env, PI_DADDY_LEASE_MARKER: paths.marker, PI_DADDY_LEASE_CLOSE_ATTEMPTS: String(input.herdrCloseAttempts ?? 10) },
111
+ env: {
112
+ ...process.env,
113
+ PI_DADDY_LEASE_MARKER: paths.marker,
114
+ PI_DADDY_LEASE_CLOSE_ATTEMPTS: String(input.herdrCloseAttempts ?? 10),
115
+ PI_DADDY_LEASE_CLOSE_TIMEOUT_MS: String(input.herdrCloseTimeoutMs ?? 15_000),
116
+ },
112
117
  // Own process group, for two reasons. It lets teardown kill `flock` AND the helper holding the lock
113
118
  // file descriptor as one unit even before the helper has reported its pid (R-99), and it stops a
114
119
  // stray group signal — a terminal Ctrl-C, a closed window — from releasing a live writer's lock as
@@ -240,12 +245,67 @@ export async function acquireWorkspaceLease(input: {
240
245
  async markRetained(reason = "retained") {
241
246
  // Deliberately does NOT touch the kernel lock or the helper: the pane may still be live. It only
242
247
  // stops the record from looking like a crash.
248
+ //
249
+ // **Both guards below exist because the CALLER ledgers whatever this returns (R-152).**
250
+ //
251
+ // Already settled: `release()` checked `settled` and this did not, so a completed handover could be
252
+ // rewritten into `retained:…` and the memoized answer flipped with it — the mirror of the defect R-146
253
+ // fixed, reachable by the `try { … } finally { await lease.release() }` shape this API invites.
254
+ if (settled) return settled;
255
+ // Already dead: the helper is gone and the kernel lock with it, so the fact is `lost`. Recording a
256
+ // retention here tells an operator a pane may still be live and tells the next owner nothing crashed —
257
+ // precisely the two facts R-103's outcome union was added to keep apart.
258
+ if (holder.exitCode !== null || holder.signalCode !== null) return (settled = "lost");
243
259
  const current = await readMetadata(paths.metadata);
260
+ let recorded = false;
244
261
  if (current !== "malformed" && current?.token === token) {
245
- await atomicMetadata(paths.metadata, {
262
+ recorded = await atomicMetadata(paths.metadata, {
246
263
  ...metadata, state: "released", released_at: new Date().toISOString(), release_reason: `retained:${reason}`,
247
- }).catch(() => undefined);
264
+ }).then(() => true, () => false);
248
265
  }
266
+ /**
267
+ * **It must, however, let THIS process exit (R-146).** Leaving the helper alone is the decision;
268
+ * leaving the parent's three pipes and the child handle referenced was an accident, and it meant a
269
+ * retained lease wedged its own process forever — measured `exit=124` against `exit=0` for the same
270
+ * sequence ending in `release()`.
271
+ *
272
+ * **Which hosts it actually wedged, corrected after review.** `process.exit()` runs `exit` handlers and
273
+ * ignores pending handles, so this only bites a host that lets the loop drain: pi's **print** mode sets
274
+ * `process.exitCode` and returns (`dist/main.js`), and a library consumer — an ADR-0034 external
275
+ * controller calling this package directly — does the same. pi's interactive and rpc modes call
276
+ * `process.exit()` explicitly (`dist/modes/interactive/interactive-mode.js:3148`), so there the process
277
+ * still leaves and `pane-reaper`'s `process.once("exit")` sweep still runs. An earlier version of this
278
+ * comment claimed the sweep was lost generally and cited `src/cli.ts`, which has one subcommand
279
+ * (`init`) and can neither hold a lease nor open a pane.
280
+ *
281
+ * Unref rather than close, because the lock must outlive this call: when the parent does exit, the
282
+ * helper sees EOF and runs the path it was written for — bounded `herdr tab close` attempts, a marker
283
+ * file, then release anyway (R-102: an unreleasable lock strands a worktree with no in-product
284
+ * recovery, which is strictly worse than a recorded failure to close). Narrowed to this path on
285
+ * purpose: at spawn it would remove the accidental guarantee that a process cannot exit while a lease
286
+ * is still ACTIVE, which is a different decision and not this fix.
287
+ */
288
+ /**
289
+ * **Terminal (R-146).** `releasing` stops a late `holder.close` being reported as a lost lease, and
290
+ * `settled` stops a later `release()` running the clean handshake — which it did: measured, it returned
291
+ * `released`, overwrote `retained:herdr-close-failed` with `completed`, and sent `{release:true}` so the
292
+ * helper exited `clean` and never attempted the close. The pane retention exists to protect was
293
+ * abandoned silently, and the ledger asserted a clean handover for a lease kept *because* a pane would
294
+ * not close. No in-tree caller does this — but `WorkspaceLease` is a public export and
295
+ * `try { … } finally { await lease.release() }` is the obvious shape for the external controllers this
296
+ * whole fix is for.
297
+ */
298
+ releasing = true;
299
+ // **Settle only if the record was actually written.** An unwritable lease directory used to leave the
300
+ // record `state: "active"` while the ledger said `retained`, so the next owner reported a phantom
301
+ // `recovered: true` — and making retention terminal removed the later `release()` that would still have
302
+ // written the handover. Unsettled, that repair is available again; the residue is stated in R-152
303
+ // rather than implied, because nothing here can write a record to a directory that refuses writes.
304
+ if (recorded) settled = "retained";
305
+ holder.unref();
306
+ unrefStream(holder.stdout);
307
+ unrefStream(holder.stderr);
308
+ return "retained";
249
309
  },
250
310
  async readCloseFailure() {
251
311
  try {