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
@@ -0,0 +1,139 @@
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.js";
27
+ export { ENV_WORKSPACE_PIN } from "./env-names.js";
28
+ /**
29
+ * Half a SHA-256, hex. The comparison is equality against a value the operator's own root computed, not a
30
+ * signature, so this is sized to make accidental collision impossible rather than to resist an adversary who
31
+ * can already choose both sides.
32
+ */
33
+ const DIGEST_HEX = 32;
34
+ /** A canonical destination, reduced to something an environment variable can carry. */
35
+ export function destinationDigest(canonicalRoot) {
36
+ return createHash("sha256").update(canonicalRoot, "utf8").digest("hex").slice(0, DIGEST_HEX);
37
+ }
38
+ /** `id:digest` pairs. Ids cannot contain `:` or `,` — `isSafeWorkspaceId` is the reason this is unambiguous. */
39
+ export function formatWorkspacePin(pins) {
40
+ return [...pins.entries()]
41
+ .sort(([a], [b]) => a.localeCompare(b))
42
+ .map(([id, digest]) => `${id}:${digest}`)
43
+ .join(",");
44
+ }
45
+ /**
46
+ * Read a pin, refusing anything that is not exactly the shape this module writes.
47
+ *
48
+ * An absent variable is NOT the same as an empty one and both are distinguished by the caller: absent means no
49
+ * root ever established a pin, empty means a parent established one and this child inherited no entries. Both
50
+ * refuse at routing time; they need different messages to be diagnosable.
51
+ */
52
+ export function parseWorkspacePin(raw) {
53
+ if (raw === undefined)
54
+ return { refusal: "no destination pin was inherited" };
55
+ const trimmed = raw.trim();
56
+ if (trimmed === "")
57
+ return { pins: new Map() };
58
+ const pins = new Map();
59
+ for (const entry of trimmed.split(",")) {
60
+ const at = entry.lastIndexOf(":");
61
+ if (at <= 0)
62
+ return { refusal: `destination pin entry ${JSON.stringify(entry)} is not id:digest` };
63
+ const id = entry.slice(0, at);
64
+ const digest = entry.slice(at + 1);
65
+ if (!isSafeWorkspaceId(id))
66
+ return { refusal: `destination pin names a malformed workspace id ${JSON.stringify(id)}` };
67
+ if (!/^[0-9a-f]{32}$/.test(digest))
68
+ return { refusal: `destination pin for ${id} is not a ${DIGEST_HEX}-character digest` };
69
+ // A duplicated id with two digests is ambiguous, and picking either would let a tamperer supply both.
70
+ if (pins.has(id) && pins.get(id) !== digest)
71
+ return { refusal: `destination pin names ${id} twice with different destinations` };
72
+ pins.set(id, digest);
73
+ }
74
+ return { pins };
75
+ }
76
+ /**
77
+ * The pin a child inherits: the parent's entries, narrowed to the workspaces the child's grant actually names.
78
+ *
79
+ * Narrowing here rather than passing the parent's whole map is the difference between a pin and a hint. A child
80
+ * that cannot see an entry cannot route to it even if it rewrites the registry to make the id resolve, because
81
+ * routing needs a pin entry and it has none to offer.
82
+ */
83
+ export function attenuateWorkspacePin(pins, inheritable) {
84
+ const held = new Set(inheritable);
85
+ return new Map([...pins.entries()].filter(([id]) => held.has(workspaceCapability(id))));
86
+ }
87
+ /**
88
+ * Does the registry still resolve this id to what the grant meant?
89
+ *
90
+ * Returns a refusal reason, or `undefined` when the destination matches. A string rather than a thrown error
91
+ * because the one caller already owns the refusal code and the `details` this belongs in.
92
+ */
93
+ export function checkPinnedDestination(input) {
94
+ if ("refusal" in input.pin)
95
+ return input.pin.refusal;
96
+ const expected = input.pin.pins.get(input.workspaceId);
97
+ if (expected === undefined)
98
+ return (`no destination pin was inherited for workspace ${input.workspaceId}` +
99
+ (input.pin.pins.size > 0 ? ` — pinned: ${[...input.pin.pins.keys()].sort().join(", ")}` : ""));
100
+ const actual = destinationDigest(input.canonicalRoot);
101
+ if (actual !== expected)
102
+ return (`workspace ${input.workspaceId} now resolves to ${input.canonicalRoot}, which is not the destination this ` +
103
+ `session was granted. The registry has been rewritten since the grant was established, so the id no longer ` +
104
+ `means what it meant when it was authorised (ADR-0042)`);
105
+ return undefined;
106
+ }
107
+ /**
108
+ * Establish a root's pin by resolving every registered id to its canonical destination.
109
+ *
110
+ * **Only a session that inherited NO pin may call this**, and that rule is the mechanism. If a descendant could
111
+ * mint its own pin it would simply rewrite the registry and then re-establish, and the whole thing would be a
112
+ * comment. The caller enforces it because only the caller knows whether a pin was inherited; this function is
113
+ * the resolution, not the policy.
114
+ *
115
+ * An id whose destination cannot be canonicalised is LEFT OUT rather than pinned to a guess. Routing then
116
+ * refuses it for having no pin, which is the same direction as every other failure here.
117
+ */
118
+ export async function establishWorkspacePin(registry, canonicalise, onSkipped) {
119
+ const pins = new Map();
120
+ for (const [id, entry] of Object.entries(registry.workspaces)) {
121
+ if (!isSafeWorkspaceId(id)) {
122
+ onSkipped?.(id, "its id is malformed");
123
+ continue;
124
+ }
125
+ try {
126
+ pins.set(id, destinationDigest(await canonicalise(entry.path)));
127
+ }
128
+ catch (error) {
129
+ // Rule 8, and `registeredWorkspaceIds` one file over carries the long version of why: this was a bare
130
+ // `catch {}`, so an unmounted worktree or a directory not yet created dropped a workspace silently and
131
+ // the operator met it later as "no destination pin was inherited", a message that names neither the
132
+ // directory nor the reason. Unresolvable still means no pin — routing must not invent one — but the
133
+ // caller is told which id and why.
134
+ onSkipped?.(id, `its destination could not be canonicalised (${String(error)})`);
135
+ }
136
+ }
137
+ return pins;
138
+ }
139
+ //# sourceMappingURL=workspace-pin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-pin.js","sourceRoot":"","sources":["../../src/kernel/workspace-pin.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import { type ParsedPin } from "./workspace-pin.ts";
1
2
  export { ENV_WORKSPACE_REGISTRY } from "./env-names.ts";
2
3
  export type WorkspaceAccess = "read" | "write";
3
4
  export interface WorkspaceRegistryFile {
@@ -44,11 +45,29 @@ export declare function loadWorkspaceRegistry(path: string): Promise<WorkspaceRe
44
45
  * the old behaviour, which is why this is an optional parameter and not a changed return type.
45
46
  */
46
47
  export declare function registeredWorkspaceIds(registryPath?: string | undefined, onRefusal?: (reason: string) => void): Promise<string[]>;
47
- export declare function resolveWorkspace(registry: WorkspaceRegistryFile, workspaceId: string): Promise<ValidatedWorkspace>;
48
+ /**
49
+ * Resolve an authorised id to a worktree, refusing if the id no longer means what the grant meant.
50
+ *
51
+ * **`pin` is REQUIRED, and it used to default to reading the environment.** That default was the last
52
+ * environment fallback in the routing path, and it was the wrong reassurance: after `publishChildEnv` the
53
+ * variable holds a session's CHILD's pin, so a two-argument call checked a session against its child's
54
+ * authority. It is dead in-tree — the one production caller always passes one — but this is a public export,
55
+ * so an embedder could reach it. Requiring the argument closes it permanently and makes "a session's own pin
56
+ * lives in memory" unbreakable rather than merely currently-true.
57
+ */
58
+ export declare function resolveWorkspace(registry: WorkspaceRegistryFile, workspaceId: string, pin: ParsedPin): Promise<ValidatedWorkspace>;
48
59
  /**
49
60
  * Canonicalize and validate the initial workspace against Git's registered worktree list.
50
61
  * This prevents accidental misrouting. It does not constrain any path a child accesses after spawn.
51
62
  */
63
+ /**
64
+ * Canonicalise and verify a registered root as a git worktree.
65
+ *
66
+ * **This performs NO destination-pin check (ADR-0042), deliberately and dangerously.** It answers "is this
67
+ * path the worktree it claims to be", not "may this session route here" — `resolveWorkspace` is the one that
68
+ * asks the second question, and it is the only path production takes. It is a public export, so it is said
69
+ * here rather than left to be discovered: an embedder calling this directly routes unpinned.
70
+ */
52
71
  export declare function validateRegisteredWorkspace(input: {
53
72
  workspaceId: string;
54
73
  registeredRoot: string;
@@ -1 +1 @@
1
- {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../../src/kernel/workspace.ts"],"names":[],"mappings":"AAWA,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAIxD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,OAAO,CAAC;AAE/C,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,CAAC,CAAC;IACX,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7C;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,wFAAwF;IACxF,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACtB;AAsBD,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyFxF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,sBAAsB,CAC1C,YAAY,qBAAsC,EAClD,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GACnC,OAAO,CAAC,MAAM,EAAE,CAAC,CAQnB;AAED,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,qBAAqB,EAC/B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,kBAAkB,CAAC,CAe7B;AAED;;;GAGG;AACH,wBAAsB,2BAA2B,CAAC,KAAK,EAAE;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA0D9B"}
1
+ {"version":3,"file":"workspace.d.ts","sourceRoot":"","sources":["../../src/kernel/workspace.ts"],"names":[],"mappings":"AAWA,OAAO,EAA0B,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAIxD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,OAAO,CAAC;AAE/C,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,CAAC,CAAC;IACX,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC7C;;;;;;;;;OASG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,EAAE,MAAM,CAAC;IACpB,wFAAwF;IACxF,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACtB;AAsBD,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAyFxF;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,sBAAsB,CAC1C,YAAY,qBAAsC,EAClD,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GACnC,OAAO,CAAC,MAAM,EAAE,CAAC,CAQnB;AAED;;;;;;;;;GASG;AACH,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,qBAAqB,EAC/B,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,SAAS,GACb,OAAO,CAAC,kBAAkB,CAAC,CA4B7B;AAED;;;GAGG;AACH;;;;;;;GAOG;AACH,wBAAsB,2BAA2B,CAAC,KAAK,EAAE;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CA0D9B"}
@@ -9,6 +9,7 @@ import { promisify } from "node:util";
9
9
  import { GovernanceRefusal, refusal } from "./refusals.js";
10
10
  import { isSafeWorkspaceId, workspaceCapability } from "./capabilities.js";
11
11
  import { ENV_WORKSPACE_REGISTRY } from "./env-names.js";
12
+ import { checkPinnedDestination } from "./workspace-pin.js";
12
13
  export { ENV_WORKSPACE_REGISTRY } from "./env-names.js";
13
14
  const execFileAsync = promisify(execFile);
14
15
  /**
@@ -127,7 +128,17 @@ export async function registeredWorkspaceIds(registryPath = process.env[ENV_WORK
127
128
  return [];
128
129
  }
129
130
  }
130
- export async function resolveWorkspace(registry, workspaceId) {
131
+ /**
132
+ * Resolve an authorised id to a worktree, refusing if the id no longer means what the grant meant.
133
+ *
134
+ * **`pin` is REQUIRED, and it used to default to reading the environment.** That default was the last
135
+ * environment fallback in the routing path, and it was the wrong reassurance: after `publishChildEnv` the
136
+ * variable holds a session's CHILD's pin, so a two-argument call checked a session against its child's
137
+ * authority. It is dead in-tree — the one production caller always passes one — but this is a public export,
138
+ * so an embedder could reach it. Requiring the argument closes it permanently and makes "a session's own pin
139
+ * lives in memory" unbreakable rather than merely currently-true.
140
+ */
141
+ export async function resolveWorkspace(registry, workspaceId, pin) {
131
142
  const registered = Object.hasOwn(registry.workspaces, workspaceId) ? registry.workspaces[workspaceId] : undefined;
132
143
  const known = Object.keys(registry.workspaces).sort();
133
144
  if (!registered) {
@@ -135,12 +146,31 @@ export async function resolveWorkspace(registry, workspaceId) {
135
146
  (registry.source ? ` ${registry.source}` : "") +
136
147
  (known.length > 0 ? ` — it lists: ${known.join(", ")}` : " — it lists nothing"), { workspace_id: workspaceId, ...(registry.source ? { registry_path: registry.source } : {}) }));
137
148
  }
138
- return validateRegisteredWorkspace({ workspaceId, registeredRoot: registered.path });
149
+ const validated = await validateRegisteredWorkspace({ workspaceId, registeredRoot: registered.path });
150
+ // **ADR-0042, and it is checked HERE on purpose.** The capability check upstream asks whether this session may
151
+ // route to this NAME. This asks whether the name still points where it pointed when the name was granted —
152
+ // the question `g37-registry-tamper` showed nobody was asking. It runs after canonicalisation because the
153
+ // digest is of the canonical root; comparing the registry's raw string would be defeated by a symlink.
154
+ const mismatch = checkPinnedDestination({ workspaceId, canonicalRoot: validated.root, pin });
155
+ if (mismatch)
156
+ throw new GovernanceRefusal(refusal("WORKSPACE_NOT_AUTHORIZED", `workspace ${workspaceId} is not routable: ${mismatch}`, {
157
+ workspace_id: workspaceId,
158
+ resolved_root: validated.root,
159
+ }));
160
+ return validated;
139
161
  }
140
162
  /**
141
163
  * Canonicalize and validate the initial workspace against Git's registered worktree list.
142
164
  * This prevents accidental misrouting. It does not constrain any path a child accesses after spawn.
143
165
  */
166
+ /**
167
+ * Canonicalise and verify a registered root as a git worktree.
168
+ *
169
+ * **This performs NO destination-pin check (ADR-0042), deliberately and dangerously.** It answers "is this
170
+ * path the worktree it claims to be", not "may this session route here" — `resolveWorkspace` is the one that
171
+ * asks the second question, and it is the only path production takes. It is a public export, so it is said
172
+ * here rather than left to be discovered: an embedder calling this directly routes unpinned.
173
+ */
144
174
  export async function validateRegisteredWorkspace(input) {
145
175
  let registered;
146
176
  let supplied;
@@ -1 +1 @@
1
- {"version":3,"file":"workspace.js","sourceRoot":"","sources":["../../src/kernel/workspace.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"workspace.js","sourceRoot":"","sources":["../../src/kernel/workspace.ts"],"names":[],"mappings":"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"}
@@ -34,6 +34,8 @@ export interface GrantsCommandContext {
34
34
  executor: ExecutorChoice;
35
35
  /** ADR-0077: which advisor is in force, or why none is. Reported because an advisor sends task text out. */
36
36
  advisor: { decider: string; refusal?: string };
37
+ /** ADR-0042: which ids this session pinned, so a routing refusal is discoverable before it happens. */
38
+ workspacePin?: ReadonlyMap<string, string>;
37
39
  observed: boolean;
38
40
  depth: number;
39
41
  maxDepth: number;
@@ -92,6 +94,7 @@ export const grantsCommand = {
92
94
  ownGrant,
93
95
  executor,
94
96
  advisor,
97
+ workspacePin,
95
98
  observed,
96
99
  depth,
97
100
  maxDepth,
@@ -379,7 +382,19 @@ export const grantsCommand = {
379
382
  `${catalog.byKind("skill").length} skill, ${catalog.byKind("agentType").length} agent-type, ` +
380
383
  `${catalog.byKind("workspace").length} workspace`,
381
384
  ...(catalog.byKind("workspace").length > 0
382
- ? [` routable ${catalog.byKind("workspace").join(", ")} — held ones only are usable (ADR-0035)`]
385
+ ? [
386
+ ` routable ${catalog.byKind("workspace").join(", ")} — held ones only are usable (ADR-0035)`,
387
+ // ADR-0042 made a destination pin a PRECONDITION for routing, and it had no operator surface at
388
+ // all: not here, not at session start, not in the README. An operator refused for want of a pin
389
+ // could not discover that the mechanism existed, let alone which ids it covered.
390
+ ` pinned ${
391
+ workspacePin === undefined
392
+ ? "(none — no workspace is routable this session)"
393
+ : workspacePin.size === 0
394
+ ? "(none inherited — this session may route nowhere)"
395
+ : `${[...workspacePin.keys()].sort().join(", ")} — routing refuses if a registry entry moves`
396
+ }`,
397
+ ]
383
398
  : []),
384
399
  // Rule 8's loud half again. The catalog fails soft on an unreadable registry, which is right, and used
385
400
  // to discard the reason with it, which was not: one malformed entry removed every workspace from this
@@ -378,6 +378,7 @@ export default function (pi: ExtensionAPI) {
378
378
  decider: session.advisorSession.deciderName,
379
379
  ...(session.advisorSession.settings.refusal ? { refusal: session.advisorSession.settings.refusal } : {}),
380
380
  },
381
+ ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
381
382
  catalog: session.catalog,
382
383
  definitions: session.definitions,
383
384
  sessionApprovals: session.sessionApprovals,
@@ -5,6 +5,20 @@ export interface ReloadLifecycle {
5
5
  root: Record<string, string | undefined>;
6
6
  published?: Record<string, string | undefined>;
7
7
  activityRootId?: string;
8
+ /**
9
+ * The destination pin this owner settled on (ADR-0042), which must survive an extension reload.
10
+ *
11
+ * **Here rather than on the session, because a reload builds a NEW session.** `session.pinSettled` closed
12
+ * the `/grants init` re-mint, which reuses one session object, and left the reload open: a fresh object has
13
+ * no flag, a root legitimately inherited nothing and sits at depth 0, so it minted a second time — against
14
+ * whatever the registry said by then, which a child with `tool:write` had had the whole session to rewrite.
15
+ * A security review reproduced it. That is the same rule-on-the-object, second-path-builds-another-object
16
+ * shape this package keeps finding, three times in this feature alone.
17
+ *
18
+ * The lifecycle is keyed by owner in a `WeakMap` and is already the thing that "recovers its root rather
19
+ * than its child publication", so it is where settled-ness belongs.
20
+ */
21
+ workspacePin?: ReadonlyMap<string, string>;
8
22
  }
9
23
  type SessionOwner = object;
10
24
 
@@ -57,6 +71,17 @@ export function bindReloadLifecycle(
57
71
  if (!holder.latestChildPublication || !same(current, holder.latestChildPublication.environment)) {
58
72
  // No pi-daddy lifecycle published what is currently in process.env, so this is an explicit change to
59
73
  // this owner's root rather than another bound session's child state.
74
+ // **An explicit root replacement is a NEW grant, so the settled pin must go with the old one — but only
75
+ // when the root ACTUALLY changed.** The condition above is broader than this comment used to claim: its
76
+ // first disjunct fires when nothing has published yet in the process, where nothing has been replaced at
77
+ // all. Deleting there re-opened the mint window on the next reload, which review reproduced. Comparing
78
+ // against the stored root is what makes "replacement" mean replacement.
79
+ //
80
+ // The widening this closes: a replacement was honoured for the grant, the depth and the approvals and
81
+ // silently ignored for the pin, so a session reconciled to depth 1 — believing itself a descendant —
82
+ // kept a root-minted pin naming a workspace the replacement root never granted. Deleting it makes the
83
+ // next `establishRootPin` re-settle from the new root, which for a descendant means minting nothing.
84
+ if (!same(current, existing.root)) delete existing.workspacePin;
60
85
  existing.root = current;
61
86
  }
62
87
  return { lifecycle: existing, environment: withRoot(existing.root) };
@@ -328,6 +328,7 @@ export async function runOneDelegation(
328
328
  if (plan.ok || shouldSeekApproval(plan.result)) {
329
329
  try {
330
330
  preparedWorkspace = await prepareDelegationWorkspace({
331
+ ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
331
332
  spec: { ...spec.workspace, access: governedWorkspaceAccess(spec.workspace.access, plan.requested) },
332
333
  correlation: spec.correlation,
333
334
  childId: ids.childId,
@@ -67,6 +67,13 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
67
67
  // the moment somebody tries to delegate to it.
68
68
  for (const reason of session.definitionSkips)
69
69
  ctx.ui.notify(`grants: a definition was not loaded — ${reason}`, "warning");
70
+ // A registered workspace that could not be pinned is not routable this session, and the routing refusal an
71
+ // operator would otherwise meet names neither the directory nor the reason.
72
+ for (const skipped of session.workspaceSkips)
73
+ ctx.ui.notify(
74
+ `grants: workspace ${skipped} — it is registered but not routable this session (ADR-0042 destination pin)`,
75
+ "warning",
76
+ );
70
77
  if (session.catalog.registryRefusal)
71
78
  ctx.ui.notify(
72
79
  `grants: workspace registry unreadable, no workspace is routable this session — ${session.catalog.registryRefusal}`,
@@ -56,6 +56,16 @@ import { agentDir, projectSettingsPath } from "../src/kernel/project-paths.ts";
56
56
  import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/kernel/model-preflight.ts";
57
57
  import { beginExtensionLifecycle, rememberChildPublication, type ReloadLifecycle } from "./reload-environment.ts";
58
58
  import { reconcileSessionEnvironment } from "./session-environment.ts";
59
+ import { realpath } from "node:fs/promises";
60
+ import {
61
+ establishWorkspacePin,
62
+ formatWorkspacePin,
63
+ parseWorkspacePin,
64
+ type WorkspacePins,
65
+ ENV_WORKSPACE_PIN,
66
+ } from "../src/kernel/workspace-pin.ts";
67
+ import { loadWorkspaceRegistry } from "../src/kernel/workspace.ts";
68
+
59
69
  /**
60
70
  * Run governed children in herdr panes instead of captured child processes.
61
71
  *
@@ -196,6 +206,19 @@ export interface GrantsSession extends NativeSessionHost {
196
206
  * capability.
197
207
  */
198
208
  definitionSkips: string[];
209
+ /**
210
+ * This session's OWN destination pin (ADR-0042), captured once at start.
211
+ *
212
+ * **In memory, not re-read from the environment, and that is not a style choice.** `publishChildEnv` writes
213
+ * the CHILD's narrowed pin into `process.env` — that is how a child inherits anything here — so a session
214
+ * that re-read the variable at routing time would read its child's pin and lose its own. `ownGrant` has
215
+ * lived in memory for exactly this reason; the pin reached the same trap, and a test caught it.
216
+ */
217
+ workspacePin?: WorkspacePins;
218
+ /** Whether this session has already settled its pin. Minting twice is how a reload laundered a tamper. */
219
+ pinSettled: boolean;
220
+ /** Registered workspaces this session could not pin, and why — reported at session start (rule 8). */
221
+ workspaceSkips: string[];
199
222
  catalog: Catalog;
200
223
  /**
201
224
  * The in-flight catalog build, so `delegate` can wait for it instead of racing it.
@@ -264,7 +287,85 @@ export interface GrantsSession extends NativeSessionHost {
264
287
  * selling point is "no restart"). Two copies of these three steps is how the two callers come to disagree
265
288
  * about what loading means, so there is one.
266
289
  */
290
+ /**
291
+ * ADR-0042: a root records what each registered workspace id MEANT, once, before anything can rewrite it.
292
+ *
293
+ * **Only a session that inherited no pin establishes one.** A descendant that could mint its own would rewrite
294
+ * the registry, re-establish, and route anywhere — the mechanism would be a comment. So an inherited value is
295
+ * left exactly as it arrived, including an empty one, which says "your parent established a pin and gave you
296
+ * nothing from it" and refuses at routing with its own message.
297
+ *
298
+ * Failing to establish is not an error: a machine with no registry has no workspaces to route to, and the
299
+ * absent pin refuses anything that tries. That is the same direction as every other failure in this mechanism.
300
+ */
301
+ async function establishRootPin(session: GrantsSession): Promise<void> {
302
+ if (session.pinSettled) return;
303
+ // **One assignment, at the end, on every path.** Review found the previous shape — assign at each `return`
304
+ // — missing two of five exits: the `catch` around an unreadable registry, which is precisely the state a
305
+ // child can create by truncating the file, and the no-registry return. A root that took either reached the
306
+ // RELOAD with the lifecycle still empty and minted over whatever the registry said by then, routing into
307
+ // prod. That is the checklist failing, so the checklist is gone: the body computes a value and the caller
308
+ // assigns both fields once.
309
+ // **Settled AFTER the value exists.** Setting the flag first meant a throw would leave the session marked
310
+ // settled with nothing settled — routing nowhere, which is safe, but with the LIFECYCLE unset, so the next
311
+ // reload would mint again. Review could construct no throw today; "currently unreachable" is exactly the
312
+ // property this feature has now been wrong about four times, and the ordering costs nothing.
313
+ const settled = await settleWorkspacePin(session);
314
+ session.pinSettled = true;
315
+ session.workspacePin = settled;
316
+ session.reloadLifecycle.workspacePin = settled;
317
+ }
318
+
319
+ /**
320
+ * What this session's destination pin IS (ADR-0042). Every path returns a map; none writes anything.
321
+ *
322
+ * An empty map means "settled, and you may route nowhere", which is different from never having settled and
323
+ * is what every failure resolves to. The distinction that matters is not empty-versus-absent but
324
+ * settled-versus-not, and settling happens exactly once per owner.
325
+ */
326
+ async function settleWorkspacePin(session: GrantsSession): Promise<WorkspacePins> {
327
+ // Settled by an EARLIER SESSION OBJECT for this same owner — an extension reload. Adopting rather than
328
+ // re-deriving is the point: a root may mint, but only once, and only from the registry as it stood before
329
+ // any child had a chance to rewrite it.
330
+ if (session.reloadLifecycle.workspacePin) return session.reloadLifecycle.workspacePin;
331
+
332
+ const raw = session.reloadLifecycle.root[ENV_WORKSPACE_PIN];
333
+ const inherited = parseWorkspacePin(raw);
334
+ // Inherited, so it is authority and is kept exactly as it arrived — including an empty one, which says
335
+ // "your parent established a pin and gave you none of it".
336
+ if ("pins" in inherited) return inherited.pins;
337
+
338
+ // **A DESCENDANT NEVER MINTS.** Review reproduced the escalation end to end across a real process boundary:
339
+ // `workspacePinEnv` OMITS the variable when a parent has no pin of its own — which happens whenever that
340
+ // parent's registry was unreadable at its start, a state any child with `tool:write` can arrange — and the
341
+ // child then read the absence as "I am a root", minted from the registry it had just rewritten, and routed
342
+ // to the prod worktree holding only `workspace:staging`. Depth already rides in the environment and already
343
+ // attenuates downward, so it is enough on its own.
344
+ //
345
+ // A MALFORMED value refuses for the same reason even at the root. The module header says missing, empty,
346
+ // malformed and mismatched all refuse; that was true of the routing check and false here, where a refusal
347
+ // fell through to minting. A tamperer who can corrupt one byte must not thereby earn a promotion.
348
+ if (session.depth > 0 || raw !== undefined) return new Map();
349
+
350
+ const registryPath = process.env[ENV_WORKSPACE_REGISTRY];
351
+ if (!registryPath) return new Map();
352
+ try {
353
+ const registry = await loadWorkspaceRegistry(registryPath);
354
+ return await establishWorkspacePin(registry, realpath, (id, reason) =>
355
+ session.workspaceSkips.push(`${id} — ${reason}`),
356
+ );
357
+ } catch {
358
+ // An unreadable registry is already reported by the catalog and at session start. It does NOT follow that
359
+ // nothing is blocked — an earlier draft of this comment claimed that and review measured it false, because
360
+ // a grant supplied through `PI_DADDY_GRANT` never passes the catalog and `workspace:` is exempt from the
361
+ // unknown check anyway. A session can genuinely hold `workspace:w1` and be refused for want of a pin.
362
+ // Returning an empty map SETTLES it, so a later reload cannot mint over a registry that has since changed.
363
+ return new Map();
364
+ }
365
+ }
366
+
267
367
  export async function loadProjectDefinitions(session: GrantsSession, cwd: string): Promise<void> {
368
+ await establishRootPin(session);
268
369
  const skips: string[] = [];
269
370
  session.definitions = await loadDefinitions(cwd, (_path, reason) => skips.push(reason));
270
371
  session.definitionSkips = skips;
@@ -331,6 +432,9 @@ export function createGrantsSession(
331
432
  maxDepth,
332
433
  malformedBounds: bounds.malformed,
333
434
  definitionSkips: [],
435
+ workspacePin: undefined,
436
+ pinSettled: false,
437
+ workspaceSkips: [],
334
438
  // ADR-0012: `bash` is gated by DEFAULT — but only in a governed session. An ungoverned one
335
439
  // (no PI_DADDY_GRANT) still blocks nothing, so "governance is opt-in" holds exactly where it always
336
440
  // did. Inside a session the operator already chose to govern, handing a child `bash` hands it an
@@ -388,6 +492,10 @@ export function createGrantsSession(
388
492
  extensionPath: session.extensionPath,
389
493
  observerExtensionPath: session.observerExtensionPath,
390
494
  childEnv: activityChildEnv(session.activity),
495
+ // ADR-0042: the pin this session holds, handed to the kernel so a delegated child inherits a narrowed
496
+ // one through the same builder `publishChildEnv` uses. Read here rather than in the kernel so there is
497
+ // one place that knows the environment is where a session's own pin lives.
498
+ ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
391
499
  // ADR-0078: composition reads, the kernel decides. Called only for a mode that survived the gate.
392
500
  // `options` is forwarded, and its absence is why the second decision point was dead in production: a
393
501
  // one-parameter arrow is assignable to a two-parameter type, so the ids reached here and were discarded while
@@ -440,6 +548,9 @@ export function createGrantsSession(
440
548
  gated: session.gated,
441
549
  ledgerPath: session.ledgerPath,
442
550
  approved: republishable(session),
551
+ // ADR-0042. The pin this session holds, which `childEnv` narrows to what the child's grant names. A
552
+ // session with no pin passes none, and its children can route nowhere — the fail-closed direction.
553
+ ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
443
554
  // G7 / B-I8: an ungoverned session publishes nothing, so "governance is opt-in" holds for
444
555
  // descendants too. Previously it exported its own observed tool surface as their grant.
445
556
  governed: session.governed,
@@ -1,4 +1,5 @@
1
1
  import type { CorrelationMetadata } from "../src/kernel/correlation.ts";
2
+ import type { WorkspacePins } from "../src/kernel/workspace-pin.ts";
2
3
  import type { Capability } from "../src/kernel/resolve.ts";
3
4
  import { appendLedgerEvent, buildWorkspaceLeaseEvent } from "../src/governance/ledger.ts";
4
5
  import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/kernel/refusals.ts";
@@ -72,6 +73,8 @@ export async function prepareDelegationWorkspace(input: {
72
73
  parentExecutionId: string | null;
73
74
  signal?: AbortSignal;
74
75
  ledgerPath?: string;
76
+ /** The CALLING session's own destination pin (ADR-0042); the environment holds its child's, not its own. */
77
+ workspacePin?: WorkspacePins;
75
78
  }): Promise<PreparedWorkspace> {
76
79
  if (input.correlation?.workspace_id && input.correlation.workspace_id !== input.spec.workspace_id) {
77
80
  throw new GovernanceRefusal(
@@ -90,7 +93,19 @@ export async function prepareDelegationWorkspace(input: {
90
93
  }),
91
94
  );
92
95
  }
93
- const workspace = await resolveWorkspace(await loadWorkspaceRegistry(registryPath), input.spec.workspace_id);
96
+ // ADR-0042. The pin comes from the caller's session, NOT from `process.env`: `publishChildEnv` writes the
97
+ // child's narrowed pin into the environment, so reading it back here would check this session's routing
98
+ // against its child's authority. `ownGrant` has always lived in memory for the same reason.
99
+ const workspace = await resolveWorkspace(
100
+ await loadWorkspaceRegistry(registryPath),
101
+ input.spec.workspace_id,
102
+ // **No environment fallback.** Review called the old `?? parseWorkspacePin(process.env[...])` a live read
103
+ // of a variable the session no longer owns — `publishChildEnv` writes its CHILD's pin there. No state
104
+ // could be constructed where it read a usable value, but "currently unreachable" is a weaker property
105
+ // than "cannot happen", and the whole rule here is that a session's own pin lives in memory. A caller
106
+ // with no pin routes nowhere, which is what a caller with no pin should do.
107
+ input.workspacePin ? { pins: input.workspacePin } : { pins: new Map() },
108
+ );
94
109
  let lease: WorkspaceLease | undefined;
95
110
  try {
96
111
  lease = await acquireWorkspaceLease({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -178,3 +178,16 @@ export * from "./products/dashboard-handshake.ts";
178
178
  export * from "./products/dashboard-herdr.ts";
179
179
  export * from "./products/activity-timeline.ts";
180
180
  export { runDashboard, dashboardFrame, DASHBOARD_PROTOCOL_VERSION } from "./products/dashboard-cli.ts";
181
+
182
+ // ADR-0042. Exported because `resolveWorkspace` gained a destination-pin precondition in 0.38.0, and without
183
+ // these a public consumer has no supported way to satisfy it — the parameter defaults to reading an
184
+ // environment variable whose name and format were internal.
185
+ export {
186
+ destinationDigest,
187
+ establishWorkspacePin,
188
+ formatWorkspacePin,
189
+ parseWorkspacePin,
190
+ ENV_WORKSPACE_PIN,
191
+ type WorkspacePins,
192
+ type ParsedPin,
193
+ } from "./kernel/workspace-pin.ts";