pi-daddy 0.37.0 → 0.38.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 (42) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/kernel/delegate-types.d.ts +9 -0
  7. package/dist/kernel/delegate-types.d.ts.map +1 -1
  8. package/dist/kernel/delegate-types.js.map +1 -1
  9. package/dist/kernel/delegate.d.ts.map +1 -1
  10. package/dist/kernel/delegate.js +6 -1
  11. package/dist/kernel/delegate.js.map +1 -1
  12. package/dist/kernel/env-names.d.ts +2 -0
  13. package/dist/kernel/env-names.d.ts.map +1 -1
  14. package/dist/kernel/env-names.js +3 -0
  15. package/dist/kernel/env-names.js.map +1 -1
  16. package/dist/kernel/propagation.d.ts +24 -1
  17. package/dist/kernel/propagation.d.ts.map +1 -1
  18. package/dist/kernel/propagation.js +28 -1
  19. package/dist/kernel/propagation.js.map +1 -1
  20. package/dist/kernel/workspace-pin.d.ts +56 -0
  21. package/dist/kernel/workspace-pin.d.ts.map +1 -0
  22. package/dist/kernel/workspace-pin.js +139 -0
  23. package/dist/kernel/workspace-pin.js.map +1 -0
  24. package/dist/kernel/workspace.d.ts +20 -1
  25. package/dist/kernel/workspace.d.ts.map +1 -1
  26. package/dist/kernel/workspace.js +32 -2
  27. package/dist/kernel/workspace.js.map +1 -1
  28. package/extensions/grants-command.ts +16 -1
  29. package/extensions/grants.ts +1 -0
  30. package/extensions/reload-environment.ts +25 -0
  31. package/extensions/run-delegation.ts +1 -0
  32. package/extensions/session-report.ts +7 -0
  33. package/extensions/session.ts +111 -0
  34. package/extensions/workspace-runtime.ts +16 -1
  35. package/package.json +1 -1
  36. package/src/index.ts +13 -0
  37. package/src/kernel/delegate-types.ts +9 -0
  38. package/src/kernel/delegate.ts +6 -0
  39. package/src/kernel/env-names.ts +3 -0
  40. package/src/kernel/propagation.ts +40 -0
  41. package/src/kernel/workspace-pin.ts +158 -0
  42. package/src/kernel/workspace.ts +34 -1
@@ -10,6 +10,7 @@ import type { InheritableApproval } from "./approval.ts";
10
10
  import type { Catalog } from "./catalog.ts";
11
11
  import type { ApprovalBinding, CorrelationMetadata } from "./correlation.ts";
12
12
  import type { StructuredRefusal } from "./refusals.ts";
13
+ import type { WorkspacePins } from "./workspace-pin.ts";
13
14
 
14
15
  /** The two ways a child can be started. Defined here, below the executors, so the ledger and the planner can name it without importing an executor (ADR-0076 layering). */
15
16
  export const EXECUTOR_KINDS = ["process", "herdr"] as const;
@@ -82,6 +83,14 @@ export interface DelegationContext {
82
83
  * (ADR-0076: the kernel imports no product; products contribute through this hook).
83
84
  */
84
85
  childEnv?: (child: { childExecutionId?: string }) => Readonly<Record<string, string>>;
86
+ /**
87
+ * This session's destination pins (ADR-0042), narrowed by `workspacePinEnv` to what the child holds.
88
+ *
89
+ * Supplied by the composition layer rather than read from the environment here, so the kernel keeps one
90
+ * source for it and a test can hand one in. Absent means this session established no pin: the child then
91
+ * inherits none and can route nowhere, which is the fail-closed direction.
92
+ */
93
+ workspacePin?: WorkspacePins;
85
94
  /**
86
95
  * Stage what crosses for a GRANTED handoff (ADR-0078), supplied by the composition layer for `childEnv`'s
87
96
  * reason: building it means reading files and the parent's session, and the kernel does no I/O.
@@ -24,6 +24,7 @@ import {
24
24
  ENV_MAX_DEPTH,
25
25
  ENV_PARENT_ID,
26
26
  inheritableGrant,
27
+ workspacePinEnv,
27
28
  } from "./propagation.ts";
28
29
  import { inheritApprovals, type InheritableApproval } from "./approval.ts";
29
30
  import { explainDoubledNamespace, suggestForUnknown, unknownCapabilities, type Catalog } from "./catalog.ts";
@@ -399,6 +400,11 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
399
400
  // reason on the other path.
400
401
  env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(",");
401
402
  if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
403
+ // ADR-0042, through the SAME builder `childEnv` uses. This is the fork the comment above is about: a rule
404
+ // spelled once in `childEnv` and not here is a rule that does not hold on the path a delegated child
405
+ // actually takes. Without this a routed grandchild inherits no pin and can route nowhere — fail-closed,
406
+ // but wrong, and silently so.
407
+ Object.assign(env, workspacePinEnv(ctx.workspacePin, inheritable));
402
408
  // Composition-supplied per-child environment (the activity timeline's observation identity today).
403
409
  // Never process-global grant state: a key in the governance namespace is a programming error in the
404
410
  // caller, refused loudly rather than letting a product widen what the child inherits.
@@ -34,6 +34,8 @@ export const ENV_APPROVAL_TIMEOUT = "PI_DADDY_APPROVAL_TIMEOUT";
34
34
  export const ENV_ALLOW_UNRESOLVED_MODELS = "PI_DADDY_ALLOW_UNRESOLVED_MODELS";
35
35
  export const ENV_WORKSPACE_REGISTRY = "PI_DADDY_WORKSPACE_REGISTRY";
36
36
  export const ENV_WORKSPACE_LEASE_DIR = "PI_DADDY_WORKSPACE_LEASE_DIR";
37
+ /** ADR-0042: what each authorised workspace id MEANT when the grant was established. Authority, not metadata. */
38
+ export const ENV_WORKSPACE_PIN = "PI_DADDY_WORKSPACE_PIN";
37
39
  export const ENV_EXECUTION_ARCHIVE = "PI_DADDY_EXECUTION_ARCHIVE";
38
40
  export const ENV_NATIVE_SESSION_ROOT = "PI_DADDY_NATIVE_SESSION_ROOT";
39
41
  export const ENV_RETAIN_NATIVE_SESSIONS = "PI_DADDY_RETAIN_NATIVE_SESSIONS";
@@ -83,6 +85,7 @@ export const GOVERNANCE_ENV_KEYS: readonly string[] = Object.freeze([
83
85
  ENV_ALLOW_UNRESOLVED_MODELS,
84
86
  ENV_WORKSPACE_REGISTRY,
85
87
  ENV_WORKSPACE_LEASE_DIR,
88
+ ENV_WORKSPACE_PIN,
86
89
  ENV_EXECUTION_ARCHIVE,
87
90
  ENV_NATIVE_SESSION_ROOT,
88
91
  ENV_RETAIN_NATIVE_SESSIONS,
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import type { Capability } from "./resolve.ts";
29
+ import { attenuateWorkspacePin, formatWorkspacePin, type WorkspacePins } from "./workspace-pin.ts";
29
30
  import { WILDCARD } from "./pi-tools.ts";
30
31
  import { WORKSPACE_WILDCARD } from "./resolve.ts";
31
32
  import { inheritApprovals, type InheritableApproval } from "./approval.ts";
@@ -43,6 +44,7 @@ import {
43
44
  ENV_GATED,
44
45
  ENV_LEDGER,
45
46
  ENV_APPROVED,
47
+ ENV_WORKSPACE_PIN,
46
48
  } from "./env-names.ts";
47
49
  export {
48
50
  ENV_GRANT,
@@ -95,6 +97,9 @@ export const GRANT_ENV_KEYS = [
95
97
  ENV_FANOUT,
96
98
  ENV_PARENT_ID,
97
99
  ENV_EXECUTION_ID,
100
+ // ADR-0042: a child must never keep its parent's unnarrowed pin, so it is stripped like every other
101
+ // governance value and re-supplied only by the spawn plan.
102
+ ENV_WORKSPACE_PIN,
98
103
  ] as const;
99
104
 
100
105
  export const parseList = (raw: string | undefined): Capability[] =>
@@ -233,6 +238,14 @@ export function gatedFromEnv(raw: string | undefined): Capability[] {
233
238
  export interface ChildEnvInput {
234
239
  /** This session's own grant — becomes the child's inherited parent grant. */
235
240
  ownGrant: Capability[];
241
+ /**
242
+ * This session's destination pins (ADR-0042), narrowed here to what the child's grant names.
243
+ *
244
+ * Passed in rather than read from the environment for the reason `inheritableGrant`'s comment records: a rule
245
+ * with two spellings gets the guard on the quieter one. Absent means this session established none, and the
246
+ * child then inherits no pin and can route nowhere — which is the fail-closed direction.
247
+ */
248
+ workspacePin?: WorkspacePins;
236
249
  /** This session's depth; children are one deeper. */
237
250
  depth: number;
238
251
  maxDepth: number;
@@ -313,9 +326,36 @@ export function childEnv(input: ChildEnvInput): Record<string, string> {
313
326
  env[ENV_APPROVED] = inheritApprovals(input.approved ?? [], inheritable).join(",");
314
327
  // Empty is an explicit one-run ledger opt-out and must overwrite a prior publication too.
315
328
  if (input.ledgerPath !== undefined) env[ENV_LEDGER] = input.ledgerPath;
329
+ // ADR-0042. ALWAYS written when this session has any pin at all, empty string included, for the same reason
330
+ // `ENV_APPROVED` is: an omitted key does not overwrite, so a child would inherit the PARENT's unnarrowed pin
331
+ // through the process-global publication path. An empty value parses back as "a pin was established and you
332
+ // got nothing from it", which refuses at routing with a different message from "no pin exists".
333
+ Object.assign(env, workspacePinEnv(input.workspacePin, inheritable));
316
334
  return env;
317
335
  }
318
336
 
337
+ /**
338
+ * The destination-pin half of a child's environment (ADR-0042), spelled ONCE for both spawn paths.
339
+ *
340
+ * **There are two places a child's environment is built**, and this file already carries the scar: the
341
+ * "held but never inherited" rule for `workspace:*` lived only in `childEnv`, so `delegate.ts` — the path a
342
+ * delegated child's grant actually travels — handed the wildcard straight down, and the test written beside
343
+ * that fix exercised the wrong path. The pin reached the same fork. The ADR is explicit that "the process and
344
+ * Herdr paths must share one propagation builder", so this is that builder and both callers use it.
345
+ *
346
+ * Written even when it narrows to nothing, and omitted only when this session has no pin at all: an omitted
347
+ * key does not overwrite, so the child would inherit the parent's UNNARROWED pin through the process-global
348
+ * publication path. Empty parses back as "a pin exists and you got none of it", which refuses at routing.
349
+ */
350
+ export function workspacePinEnv(
351
+ pins: WorkspacePins | undefined,
352
+ inheritable: readonly Capability[],
353
+ ): Record<string, string> {
354
+ return pins === undefined
355
+ ? {}
356
+ : { [ENV_WORKSPACE_PIN]: formatWorkspacePin(attenuateWorkspacePin(pins, inheritable)) };
357
+ }
358
+
319
359
  /**
320
360
  * The environment for a child this process spawns itself: the parent's environment with every governance
321
361
  * variable stripped, then the per-child plan applied.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The inherited destination pin (ADR-0042): what a workspace id MEANT when the authority was granted.
3
+ *
4
+ * **The escalation this closes, measured in `g37-registry-tamper`.** `workspace:<id>` attenuates the NAME, not
5
+ * the mutable id-to-path meaning. A child holding `workspace:staging` and `tool:write` — no `bash` — can rewrite
6
+ * the operator's registry so `staging` points at the `prod` worktree, route a grandchild there, and take an
7
+ * exclusive write lease on it. Every capability check passes, because the id it was granted is the id it used.
8
+ * File ownership cannot help: a governed child runs as the parent's uid, so the registry is as writable to it as
9
+ * to the operator.
10
+ *
11
+ * **The mechanism is authority, not metadata.** A root resolves each registered id to its canonical destination
12
+ * and records a digest of it. Descendants inherit only the entries their effective grant authorises, cannot mint
13
+ * an entry, and routing requires an exact match against the inherited digest. A rewritten registry therefore
14
+ * changes where an id points and NOT what an inherited id is allowed to mean, which is the whole property.
15
+ *
16
+ * **Why a digest rather than the path.** The path is already visible to the child through the registry it can
17
+ * read; pinning the digest keeps the wire small for a realistic registry and makes an equality check the only
18
+ * operation, so there is no path-comparison subtlety to get wrong. `validateRegisteredWorkspace` still does the
19
+ * canonicalisation; this only asks whether the answer changed.
20
+ *
21
+ * **Every failure refuses.** Missing, empty, malformed and mismatched all refuse, because the alternative is a
22
+ * mechanism a child can disable by corrupting one byte. ADR-0042 records that an earlier attempt was reverted
23
+ * after four failures, and that it failed "specifically because it was treated as a small patch".
24
+ */
25
+ import { createHash } from "node:crypto";
26
+ import { isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
27
+ import type { Capability } from "./resolve.ts";
28
+
29
+ export { ENV_WORKSPACE_PIN } from "./env-names.ts";
30
+
31
+ /**
32
+ * Half a SHA-256, hex. The comparison is equality against a value the operator's own root computed, not a
33
+ * signature, so this is sized to make accidental collision impossible rather than to resist an adversary who
34
+ * can already choose both sides.
35
+ */
36
+ const DIGEST_HEX = 32;
37
+
38
+ /** A canonical destination, reduced to something an environment variable can carry. */
39
+ export function destinationDigest(canonicalRoot: string): string {
40
+ return createHash("sha256").update(canonicalRoot, "utf8").digest("hex").slice(0, DIGEST_HEX);
41
+ }
42
+
43
+ export type WorkspacePins = ReadonlyMap<string, string>;
44
+
45
+ /** `id:digest` pairs. Ids cannot contain `:` or `,` — `isSafeWorkspaceId` is the reason this is unambiguous. */
46
+ export function formatWorkspacePin(pins: WorkspacePins): string {
47
+ return [...pins.entries()]
48
+ .sort(([a], [b]) => a.localeCompare(b))
49
+ .map(([id, digest]) => `${id}:${digest}`)
50
+ .join(",");
51
+ }
52
+
53
+ export type ParsedPin = { pins: WorkspacePins } | { refusal: string };
54
+
55
+ /**
56
+ * Read a pin, refusing anything that is not exactly the shape this module writes.
57
+ *
58
+ * An absent variable is NOT the same as an empty one and both are distinguished by the caller: absent means no
59
+ * root ever established a pin, empty means a parent established one and this child inherited no entries. Both
60
+ * refuse at routing time; they need different messages to be diagnosable.
61
+ */
62
+ export function parseWorkspacePin(raw: string | undefined): ParsedPin {
63
+ if (raw === undefined) return { refusal: "no destination pin was inherited" };
64
+ const trimmed = raw.trim();
65
+ if (trimmed === "") return { pins: new Map() };
66
+ const pins = new Map<string, string>();
67
+ for (const entry of trimmed.split(",")) {
68
+ const at = entry.lastIndexOf(":");
69
+ if (at <= 0) return { refusal: `destination pin entry ${JSON.stringify(entry)} is not id:digest` };
70
+ const id = entry.slice(0, at);
71
+ const digest = entry.slice(at + 1);
72
+ if (!isSafeWorkspaceId(id))
73
+ return { refusal: `destination pin names a malformed workspace id ${JSON.stringify(id)}` };
74
+ if (!/^[0-9a-f]{32}$/.test(digest))
75
+ return { refusal: `destination pin for ${id} is not a ${DIGEST_HEX}-character digest` };
76
+ // A duplicated id with two digests is ambiguous, and picking either would let a tamperer supply both.
77
+ if (pins.has(id) && pins.get(id) !== digest)
78
+ return { refusal: `destination pin names ${id} twice with different destinations` };
79
+ pins.set(id, digest);
80
+ }
81
+ return { pins };
82
+ }
83
+
84
+ /**
85
+ * The pin a child inherits: the parent's entries, narrowed to the workspaces the child's grant actually names.
86
+ *
87
+ * Narrowing here rather than passing the parent's whole map is the difference between a pin and a hint. A child
88
+ * that cannot see an entry cannot route to it even if it rewrites the registry to make the id resolve, because
89
+ * routing needs a pin entry and it has none to offer.
90
+ */
91
+ export function attenuateWorkspacePin(pins: WorkspacePins, inheritable: readonly Capability[]): WorkspacePins {
92
+ const held = new Set<Capability>(inheritable);
93
+ return new Map([...pins.entries()].filter(([id]) => held.has(workspaceCapability(id))));
94
+ }
95
+
96
+ /**
97
+ * Does the registry still resolve this id to what the grant meant?
98
+ *
99
+ * Returns a refusal reason, or `undefined` when the destination matches. A string rather than a thrown error
100
+ * because the one caller already owns the refusal code and the `details` this belongs in.
101
+ */
102
+ export function checkPinnedDestination(input: {
103
+ workspaceId: string;
104
+ canonicalRoot: string;
105
+ pin: ParsedPin;
106
+ }): string | undefined {
107
+ if ("refusal" in input.pin) return input.pin.refusal;
108
+ const expected = input.pin.pins.get(input.workspaceId);
109
+ if (expected === undefined)
110
+ return (
111
+ `no destination pin was inherited for workspace ${input.workspaceId}` +
112
+ (input.pin.pins.size > 0 ? ` — pinned: ${[...input.pin.pins.keys()].sort().join(", ")}` : "")
113
+ );
114
+ const actual = destinationDigest(input.canonicalRoot);
115
+ if (actual !== expected)
116
+ return (
117
+ `workspace ${input.workspaceId} now resolves to ${input.canonicalRoot}, which is not the destination this ` +
118
+ `session was granted. The registry has been rewritten since the grant was established, so the id no longer ` +
119
+ `means what it meant when it was authorised (ADR-0042)`
120
+ );
121
+ return undefined;
122
+ }
123
+
124
+ /**
125
+ * Establish a root's pin by resolving every registered id to its canonical destination.
126
+ *
127
+ * **Only a session that inherited NO pin may call this**, and that rule is the mechanism. If a descendant could
128
+ * mint its own pin it would simply rewrite the registry and then re-establish, and the whole thing would be a
129
+ * comment. The caller enforces it because only the caller knows whether a pin was inherited; this function is
130
+ * the resolution, not the policy.
131
+ *
132
+ * An id whose destination cannot be canonicalised is LEFT OUT rather than pinned to a guess. Routing then
133
+ * refuses it for having no pin, which is the same direction as every other failure here.
134
+ */
135
+ export async function establishWorkspacePin(
136
+ registry: { workspaces: Record<string, { path: string }> },
137
+ canonicalise: (path: string) => Promise<string>,
138
+ onSkipped?: (id: string, reason: string) => void,
139
+ ): Promise<WorkspacePins> {
140
+ const pins = new Map<string, string>();
141
+ for (const [id, entry] of Object.entries(registry.workspaces)) {
142
+ if (!isSafeWorkspaceId(id)) {
143
+ onSkipped?.(id, "its id is malformed");
144
+ continue;
145
+ }
146
+ try {
147
+ pins.set(id, destinationDigest(await canonicalise(entry.path)));
148
+ } catch (error) {
149
+ // Rule 8, and `registeredWorkspaceIds` one file over carries the long version of why: this was a bare
150
+ // `catch {}`, so an unmounted worktree or a directory not yet created dropped a workspace silently and
151
+ // the operator met it later as "no destination pin was inherited", a message that names neither the
152
+ // directory nor the reason. Unresolvable still means no pin — routing must not invent one — but the
153
+ // caller is told which id and why.
154
+ onSkipped?.(id, `its destination could not be canonicalised (${String(error)})`);
155
+ }
156
+ }
157
+ return pins;
158
+ }
@@ -9,6 +9,7 @@ import { promisify } from "node:util";
9
9
  import { GovernanceRefusal, refusal } from "./refusals.ts";
10
10
  import { isSafeWorkspaceId, workspaceCapability } from "./capabilities.ts";
11
11
  import { ENV_WORKSPACE_REGISTRY } from "./env-names.ts";
12
+ import { checkPinnedDestination, type ParsedPin } from "./workspace-pin.ts";
12
13
  export { ENV_WORKSPACE_REGISTRY } from "./env-names.ts";
13
14
 
14
15
  const execFileAsync = promisify(execFile);
@@ -181,9 +182,20 @@ export async function registeredWorkspaceIds(
181
182
  }
182
183
  }
183
184
 
185
+ /**
186
+ * Resolve an authorised id to a worktree, refusing if the id no longer means what the grant meant.
187
+ *
188
+ * **`pin` is REQUIRED, and it used to default to reading the environment.** That default was the last
189
+ * environment fallback in the routing path, and it was the wrong reassurance: after `publishChildEnv` the
190
+ * variable holds a session's CHILD's pin, so a two-argument call checked a session against its child's
191
+ * authority. It is dead in-tree — the one production caller always passes one — but this is a public export,
192
+ * so an embedder could reach it. Requiring the argument closes it permanently and makes "a session's own pin
193
+ * lives in memory" unbreakable rather than merely currently-true.
194
+ */
184
195
  export async function resolveWorkspace(
185
196
  registry: WorkspaceRegistryFile,
186
197
  workspaceId: string,
198
+ pin: ParsedPin,
187
199
  ): Promise<ValidatedWorkspace> {
188
200
  const registered = Object.hasOwn(registry.workspaces, workspaceId) ? registry.workspaces[workspaceId] : undefined;
189
201
  const known = Object.keys(registry.workspaces).sort();
@@ -198,13 +210,34 @@ export async function resolveWorkspace(
198
210
  ),
199
211
  );
200
212
  }
201
- return validateRegisteredWorkspace({ workspaceId, registeredRoot: registered.path });
213
+ const validated = await validateRegisteredWorkspace({ workspaceId, registeredRoot: registered.path });
214
+ // **ADR-0042, and it is checked HERE on purpose.** The capability check upstream asks whether this session may
215
+ // route to this NAME. This asks whether the name still points where it pointed when the name was granted —
216
+ // the question `g37-registry-tamper` showed nobody was asking. It runs after canonicalisation because the
217
+ // digest is of the canonical root; comparing the registry's raw string would be defeated by a symlink.
218
+ const mismatch = checkPinnedDestination({ workspaceId, canonicalRoot: validated.root, pin });
219
+ if (mismatch)
220
+ throw new GovernanceRefusal(
221
+ refusal("WORKSPACE_NOT_AUTHORIZED", `workspace ${workspaceId} is not routable: ${mismatch}`, {
222
+ workspace_id: workspaceId,
223
+ resolved_root: validated.root,
224
+ }),
225
+ );
226
+ return validated;
202
227
  }
203
228
 
204
229
  /**
205
230
  * Canonicalize and validate the initial workspace against Git's registered worktree list.
206
231
  * This prevents accidental misrouting. It does not constrain any path a child accesses after spawn.
207
232
  */
233
+ /**
234
+ * Canonicalise and verify a registered root as a git worktree.
235
+ *
236
+ * **This performs NO destination-pin check (ADR-0042), deliberately and dangerously.** It answers "is this
237
+ * path the worktree it claims to be", not "may this session route here" — `resolveWorkspace` is the one that
238
+ * asks the second question, and it is the only path production takes. It is a public export, so it is said
239
+ * here rather than left to be discovered: an embedder calling this directly routes unpinned.
240
+ */
208
241
  export async function validateRegisteredWorkspace(input: {
209
242
  workspaceId: string;
210
243
  registeredRoot: string;