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.
- package/CHANGELOG.md +79 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel/delegate-types.d.ts +9 -0
- package/dist/kernel/delegate-types.d.ts.map +1 -1
- package/dist/kernel/delegate-types.js.map +1 -1
- package/dist/kernel/delegate.d.ts.map +1 -1
- package/dist/kernel/delegate.js +6 -1
- package/dist/kernel/delegate.js.map +1 -1
- package/dist/kernel/env-names.d.ts +2 -0
- package/dist/kernel/env-names.d.ts.map +1 -1
- package/dist/kernel/env-names.js +3 -0
- package/dist/kernel/env-names.js.map +1 -1
- package/dist/kernel/propagation.d.ts +24 -1
- package/dist/kernel/propagation.d.ts.map +1 -1
- package/dist/kernel/propagation.js +28 -1
- package/dist/kernel/propagation.js.map +1 -1
- package/dist/kernel/workspace-pin.d.ts +56 -0
- package/dist/kernel/workspace-pin.d.ts.map +1 -0
- package/dist/kernel/workspace-pin.js +139 -0
- package/dist/kernel/workspace-pin.js.map +1 -0
- package/dist/kernel/workspace.d.ts +20 -1
- package/dist/kernel/workspace.d.ts.map +1 -1
- package/dist/kernel/workspace.js +32 -2
- package/dist/kernel/workspace.js.map +1 -1
- package/extensions/grants-command.ts +16 -1
- package/extensions/grants.ts +1 -0
- package/extensions/reload-environment.ts +25 -0
- package/extensions/run-delegation.ts +1 -0
- package/extensions/session-report.ts +7 -0
- package/extensions/session.ts +111 -0
- package/extensions/workspace-runtime.ts +16 -1
- package/package.json +1 -1
- package/src/index.ts +13 -0
- package/src/kernel/delegate-types.ts +9 -0
- package/src/kernel/delegate.ts +6 -0
- package/src/kernel/env-names.ts +3 -0
- package/src/kernel/propagation.ts +40 -0
- package/src/kernel/workspace-pin.ts +158 -0
- 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.
|
package/src/kernel/delegate.ts
CHANGED
|
@@ -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.
|
package/src/kernel/env-names.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/kernel/workspace.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|