pi-microsandbox 0.1.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.
@@ -0,0 +1,164 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ import type { DiscoveredSkillPath, HostReadAccess } from "./types.ts";
5
+
6
+ function isWithin(root: string, candidate: string): boolean {
7
+ const relative = path.relative(root, candidate);
8
+ return (
9
+ relative === "" ||
10
+ (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
11
+ );
12
+ }
13
+
14
+ function denied(requestedPath: string, reason: string, cause?: unknown): Error {
15
+ const error = new Error(`Host read denied for ${requestedPath}: ${reason}`);
16
+ if (cause !== undefined) (error as Error & { cause?: unknown }).cause = cause;
17
+ return error;
18
+ }
19
+
20
+ async function canonicalRegularFile(requestedPath: string): Promise<string> {
21
+ let canonicalPath: string;
22
+ try {
23
+ canonicalPath = await realpath(requestedPath);
24
+ } catch (error) {
25
+ throw denied(requestedPath, "the file is missing or cannot be resolved", error);
26
+ }
27
+
28
+ let stats: Awaited<ReturnType<typeof lstat>>;
29
+ try {
30
+ stats = await lstat(canonicalPath);
31
+ } catch (error) {
32
+ throw denied(requestedPath, "the file cannot be inspected", error);
33
+ }
34
+ if (!stats.isFile()) throw denied(requestedPath, "the target is not a regular file");
35
+ return canonicalPath;
36
+ }
37
+
38
+ /**
39
+ * Resolve a read against the host copy of a Pi-discovered skill.
40
+ *
41
+ * A discovered SKILL.md grants its directory only after canonical containment
42
+ * has been checked. Other discovered files are standalone skills and grant no
43
+ * supporting files. This host exception is intentionally read-only and narrow.
44
+ */
45
+ export async function resolveDiscoveredSkillRead(
46
+ requestedPath: string,
47
+ cwd: string,
48
+ skills: readonly DiscoveredSkillPath[],
49
+ ): Promise<string | undefined> {
50
+ const requestedAbsolute = path.resolve(cwd, requestedPath);
51
+ const exactSkills: string[] = [];
52
+ const directorySkills: string[] = [];
53
+
54
+ for (const skill of skills) {
55
+ const skillFile = path.resolve(skill.filePath);
56
+ if (requestedAbsolute === skillFile) exactSkills.push(skillFile);
57
+
58
+ if (path.basename(skillFile) === "SKILL.md") {
59
+ const baseDir = path.resolve(skill.baseDir);
60
+ if (isWithin(baseDir, requestedAbsolute)) directorySkills.push(baseDir);
61
+ }
62
+ }
63
+
64
+ // Do not touch unrelated host paths. An undefined result lets the normal
65
+ // sandbox read path handle them.
66
+ if (exactSkills.length === 0 && directorySkills.length === 0) return undefined;
67
+
68
+ if (exactSkills.length > 0) {
69
+ for (const skillFile of exactSkills) {
70
+ let skillCanonical: string;
71
+ try {
72
+ skillCanonical = await realpath(skillFile);
73
+ } catch (error) {
74
+ throw denied(requestedPath, "the discovered skill is missing or cannot be resolved", error);
75
+ }
76
+
77
+ let requestedCanonical: string;
78
+ try {
79
+ requestedCanonical = await realpath(requestedAbsolute);
80
+ } catch (error) {
81
+ throw denied(requestedPath, "the discovered skill is missing or cannot be resolved", error);
82
+ }
83
+ if (requestedCanonical !== skillCanonical) {
84
+ throw denied(requestedPath, "it no longer resolves to the discovered skill");
85
+ }
86
+ return canonicalRegularFile(requestedAbsolute);
87
+ }
88
+ }
89
+
90
+ let requestedCanonical: string;
91
+ try {
92
+ requestedCanonical = await realpath(requestedAbsolute);
93
+ } catch (error) {
94
+ throw denied(requestedPath, "the file is missing or cannot be resolved", error);
95
+ }
96
+
97
+ for (const baseDir of directorySkills) {
98
+ let canonicalBaseDir: string;
99
+ try {
100
+ canonicalBaseDir = await realpath(baseDir);
101
+ } catch (error) {
102
+ throw denied(requestedPath, "the discovered skill directory is missing or cannot be resolved", error);
103
+ }
104
+
105
+ if (!isWithin(canonicalBaseDir, requestedCanonical)) {
106
+ throw denied(requestedPath, "it resolves outside the discovered skill directory");
107
+ }
108
+ return canonicalRegularFile(requestedAbsolute);
109
+ }
110
+
111
+ // The path was lexically under a discovered directory, but its canonical
112
+ // target was outside it (usually a supporting symlink).
113
+ throw denied(requestedPath, "it resolves outside the discovered skill directory");
114
+ }
115
+
116
+ export function createHostReadAccess(): HostReadAccess {
117
+ let skills: readonly DiscoveredSkillPath[] = [];
118
+ const generatedFiles = new Set<string>();
119
+
120
+ return {
121
+ updateSkills(nextSkills) {
122
+ // The list is replaced on every before_agent_start; retaining an old list
123
+ // would make a later turn inherit a host-read grant it did not discover.
124
+ skills = nextSkills.map((skill) => ({
125
+ filePath: skill.filePath,
126
+ baseDir: skill.baseDir,
127
+ }));
128
+ },
129
+
130
+ async allowGeneratedFile(filePath) {
131
+ const absolutePath = path.resolve(filePath);
132
+ const canonicalPath = await canonicalRegularFile(absolutePath);
133
+ generatedFiles.add(canonicalPath);
134
+ },
135
+
136
+ async resolve(requestedPath, cwd) {
137
+ const skillPath = await resolveDiscoveredSkillRead(requestedPath, cwd, skills);
138
+ if (skillPath !== undefined) return skillPath;
139
+
140
+ if (generatedFiles.size === 0) return undefined;
141
+
142
+ const requestedAbsolute = path.resolve(cwd, requestedPath);
143
+ let requestedCanonical: string;
144
+ try {
145
+ requestedCanonical = await realpath(requestedAbsolute);
146
+ } catch (error) {
147
+ if (generatedFiles.has(requestedAbsolute)) {
148
+ throw denied(requestedPath, "the recorded output is missing or cannot be resolved", error);
149
+ }
150
+ return undefined;
151
+ }
152
+ // Compare canonical paths before validating the file. This prevents a
153
+ // recorded output that was replaced by a symlink to a different host
154
+ // file from becoming a new host-read grant.
155
+ if (!generatedFiles.has(requestedCanonical)) return undefined;
156
+ return canonicalRegularFile(requestedCanonical);
157
+ },
158
+
159
+ clear() {
160
+ skills = [];
161
+ generatedFiles.clear();
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,332 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isAbsolute } from "node:path";
3
+
4
+ import {
5
+ LABEL_KEYS,
6
+ STATE_SCHEMA_VERSION,
7
+ volumeNameFor,
8
+ } from "./types.ts";
9
+ import type {
10
+ Config,
11
+ GitRepoInfo,
12
+ GitSeedBundle,
13
+ GitVolumePlan,
14
+ PersistedSandboxState,
15
+ SandboxTransport,
16
+ StoragePlan,
17
+ VolumeRecord,
18
+ } from "./types.ts";
19
+
20
+ const BUNDLE_SEED_REF = "refs/pi-msb/seed";
21
+
22
+ /**
23
+ * Build the path-preserving storage description used by the sandbox builder.
24
+ * The guest path is deliberately the same absolute path as the host path; in
25
+ * in particular, a git volume is mounted at the repository root rather than at
26
+ * a synthetic guest-only source path.
27
+ */
28
+ export function buildStoragePlan(input: {
29
+ cwd: string;
30
+ sessionId: string;
31
+ config: Config;
32
+ git: GitRepoInfo;
33
+ restored?: PersistedSandboxState | null;
34
+ }): StoragePlan {
35
+ assertAbsolute(input.cwd, "cwd");
36
+
37
+ const selectedMode = selectMode(input.config.mode);
38
+ if (selectedMode === "direct") {
39
+ return {
40
+ kind: "direct-mount",
41
+ hostPath: input.git.hostCwd ?? input.cwd,
42
+ guestPath: input.cwd,
43
+ workdir: input.cwd,
44
+ };
45
+ }
46
+
47
+ if (selectedMode === "none") {
48
+ return {
49
+ kind: "none",
50
+ guestPath: input.cwd,
51
+ workdir: input.cwd,
52
+ };
53
+ }
54
+
55
+ if (!input.git.isGitRepo || !input.git.repoRoot) {
56
+ throw new Error("git storage requires a Git repository");
57
+ }
58
+ assertAbsolute(input.git.repoRoot, "git repository root");
59
+ if (input.git.guestRepoRoot === null) {
60
+ throw new Error("git storage cannot preserve the requested cwd namespace for this symlink topology");
61
+ }
62
+ const mountGuestPath = input.git.guestRepoRoot ?? input.git.repoRoot;
63
+ assertAbsolute(mountGuestPath, "git guest repository root");
64
+
65
+ const volumeName = volumeNameFor(input.sessionId);
66
+ const restoredForThisPlan = isRestoredGitState(input.restored, {
67
+ sessionId: input.sessionId,
68
+ cwd: input.cwd,
69
+ volumeName,
70
+ });
71
+
72
+ return {
73
+ kind: "git-volume",
74
+ sessionId: input.sessionId,
75
+ volumeName,
76
+ volumeQuotaMiB: input.config.volumeQuotaMiB,
77
+ repoRoot: input.git.repoRoot,
78
+ mountGuestPath,
79
+ workdir: input.cwd,
80
+ branch: input.git.branch,
81
+ headSha: input.git.headSha,
82
+ unborn: input.git.unborn,
83
+ depth: input.config.cloneDepth,
84
+ // A matching persisted entry means the manager is recovering a retained
85
+ // volume. It must still validate the volume's labels before mounting it.
86
+ seedRequired: !restoredForThisPlan,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Validate the identity labels before a named volume is mounted. A name alone
92
+ * is not an ownership proof: names can collide after a copied/forked state
93
+ * file or an operator-created resource.
94
+ */
95
+ export function validateReusableVolume(plan: GitVolumePlan, volume: VolumeRecord): boolean {
96
+ if (plan.kind !== "git-volume" || volume.name !== plan.volumeName) return false;
97
+
98
+ const labels = volume.labels;
99
+ if (!labels || typeof labels !== "object") return false;
100
+ if (labels[LABEL_KEYS.managed] !== "true") return false;
101
+ if (labels[LABEL_KEYS.schema] !== String(STATE_SCHEMA_VERSION)) return false;
102
+ if (labels[LABEL_KEYS.session] !== plan.sessionId) return false;
103
+ if (labels[LABEL_KEYS.cwd] !== plan.workdir) return false;
104
+ if (labels[LABEL_KEYS.mode] !== "git") return false;
105
+ if (labels[LABEL_KEYS.keep] !== "true") return false;
106
+
107
+ // A retained volume is trusted only after its complete managed identity
108
+ // matches the current git plan. The mode label is required even though the
109
+ // shared VolumeLabelInput contract predates that label.
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * Seed a newly-created git volume. This function owns cleanup of both the
115
+ * temporary guest copy and the host bundle. Callers must only invoke it for a
116
+ * plan with seedRequired=true.
117
+ */
118
+ export async function seedGitVolume(
119
+ transport: SandboxTransport,
120
+ plan: GitVolumePlan,
121
+ bundle: GitSeedBundle | null,
122
+ ): Promise<{ headSha: string | null }> {
123
+ if (plan.kind !== "git-volume") {
124
+ throw new Error("cannot seed non-git storage");
125
+ }
126
+ const guestBundlePath = `/tmp/pi-msb-seed-${randomUUID()}.bundle`;
127
+ let operationError: unknown;
128
+ let cleanupError: unknown;
129
+ let headSha: string | null = null;
130
+
131
+ try {
132
+ if (!plan.unborn && (!bundle || !plan.headSha)) {
133
+ throw new Error("a committed Git volume requires a seed bundle and HEAD SHA");
134
+ }
135
+ if (plan.unborn && bundle) {
136
+ // An unborn repository has no committed object to bundle. Treating a
137
+ // supplied bundle as authoritative would make the source state ambiguous.
138
+ throw new Error("an unborn Git repository cannot be seeded from a bundle");
139
+ }
140
+ if (bundle && plan.headSha && bundle.headSha !== plan.headSha) {
141
+ throw new Error(`seed bundle SHA mismatch: expected ${plan.headSha}, got ${bundle.headSha}`);
142
+ }
143
+ if (bundle && bundle.branch !== plan.branch) {
144
+ throw new Error(
145
+ `seed bundle branch mismatch: expected ${plan.branch ?? "detached"}, got ${bundle.branch ?? "detached"}`,
146
+ );
147
+ }
148
+
149
+ if (bundle) {
150
+ await copyBundle(transport, bundle.hostPath, guestBundlePath);
151
+
152
+ // `git clone` only imports the bundle's branch refs. The seed is kept in
153
+ // a private namespace, so initialize first and fetch that ref explicitly.
154
+ // This also avoids configuring the temporary bundle as a remote.
155
+ await runChecked(
156
+ transport,
157
+ "git",
158
+ ["init", plan.mountGuestPath],
159
+ plan.mountGuestPath,
160
+ );
161
+ await runChecked(
162
+ transport,
163
+ "git",
164
+ ["fetch", "--no-tags", guestBundlePath, BUNDLE_SEED_REF],
165
+ plan.mountGuestPath,
166
+ );
167
+
168
+ // Keep an immutable local ref for later diff/reference operations after
169
+ // the temporary guest bundle is removed.
170
+ await runChecked(
171
+ transport,
172
+ "git",
173
+ ["update-ref", `refs/pi-msb/seed/${plan.headSha}`, plan.headSha!],
174
+ plan.mountGuestPath,
175
+ );
176
+
177
+ if (plan.branch !== null) {
178
+ await runChecked(
179
+ transport,
180
+ "git",
181
+ ["checkout", "-B", plan.branch ?? "", plan.headSha!],
182
+ plan.mountGuestPath,
183
+ );
184
+ } else {
185
+ await runChecked(
186
+ transport,
187
+ "git",
188
+ ["checkout", "--detach", plan.headSha!],
189
+ plan.mountGuestPath,
190
+ );
191
+ }
192
+
193
+ await verifySeed(transport, plan);
194
+ headSha = plan.headSha;
195
+ } else {
196
+ const initArgs = plan.branch
197
+ ? ["init", "-b", plan.branch, plan.mountGuestPath]
198
+ : ["init", plan.mountGuestPath];
199
+ await runChecked(transport, "git", initArgs, plan.mountGuestPath);
200
+ await verifySeed(transport, plan);
201
+ headSha = null;
202
+ }
203
+ } catch (error) {
204
+ operationError = error;
205
+ } finally {
206
+ // Both bundle copies must be deleted. These cleanup operations are best
207
+ // effort after an operation failure, but are still attempted independently
208
+ // so a failed guest command cannot leak the host temporary bundle.
209
+ try {
210
+ await removeGuestBundle(transport, guestBundlePath);
211
+ } catch (error) {
212
+ cleanupError ??= error;
213
+ }
214
+ if (bundle) {
215
+ try {
216
+ await bundle.cleanup();
217
+ } catch (error) {
218
+ cleanupError ??= error;
219
+ }
220
+ }
221
+ }
222
+
223
+ if (operationError) throw operationError;
224
+ if (cleanupError) throw cleanupError;
225
+ return { headSha };
226
+ }
227
+
228
+ function selectMode(mode: Config["mode"]): "git" | "direct" | "none" {
229
+ // Keep `auto` as a compatibility alias for the direct default. It must not
230
+ // implicitly switch to Git isolation based on the current directory.
231
+ if (mode === "auto") return "direct";
232
+ return mode;
233
+ }
234
+
235
+ function assertAbsolute(value: string, label: string): void {
236
+ if (!isAbsolute(value)) throw new Error(`${label} must be an absolute path`);
237
+ }
238
+
239
+ function isRestoredGitState(
240
+ restored: PersistedSandboxState | null | undefined,
241
+ expected: { sessionId: string; cwd: string; volumeName: string },
242
+ ): boolean {
243
+ return Boolean(
244
+ restored &&
245
+ restored.version === STATE_SCHEMA_VERSION &&
246
+ restored.sessionId === expected.sessionId &&
247
+ restored.mode === "git" &&
248
+ restored.cwd === expected.cwd &&
249
+ restored.volumeName === expected.volumeName,
250
+ );
251
+ }
252
+
253
+ async function copyBundle(
254
+ transport: SandboxTransport,
255
+ hostPath: string,
256
+ guestPath: string,
257
+ ): Promise<void> {
258
+ try {
259
+ await transport.copyFromHost(hostPath, guestPath);
260
+ } catch (error) {
261
+ throw new Error(`copying Git seed bundle failed: ${errorMessage(error)}`, { cause: error });
262
+ }
263
+ }
264
+
265
+ async function runChecked(
266
+ transport: SandboxTransport,
267
+ command: string,
268
+ args: string[],
269
+ cwd: string,
270
+ ): Promise<{ stdout: Buffer; stderr: Buffer }> {
271
+ const result = await transport.exec(command, args, { cwd });
272
+ if (result.exitCode !== 0) {
273
+ const stderr = result.stderr.toString("utf8").trim();
274
+ const suffix = stderr ? `: ${stderr}` : "";
275
+ throw new Error(`${command} ${args[0] ?? "command"} failed (exit ${result.exitCode})${suffix}`);
276
+ }
277
+ return { stdout: result.stdout, stderr: result.stderr };
278
+ }
279
+
280
+ async function verifySeed(transport: SandboxTransport, plan: GitVolumePlan): Promise<void> {
281
+ const head = await transport.exec("git", ["rev-parse", "HEAD"], { cwd: plan.mountGuestPath });
282
+ const actualHead = head.stdout.toString("utf8").trim();
283
+ if (plan.unborn) {
284
+ // An unborn repository has no object named HEAD yet. Git reports that
285
+ // normal state with a nonzero rev-parse status.
286
+ if (head.exitCode === 0) {
287
+ throw new Error(`unborn Git seed unexpectedly has HEAD ${actualHead || "<empty>"}`);
288
+ }
289
+ } else {
290
+ if (head.exitCode !== 0) {
291
+ const stderr = head.stderr.toString("utf8").trim();
292
+ throw new Error(`unable to read seed HEAD${stderr ? `: ${stderr}` : ""}`);
293
+ }
294
+ if (actualHead !== plan.headSha) {
295
+ throw new Error(`seed HEAD mismatch: expected ${plan.headSha}, got ${actualHead || "<empty>"}`);
296
+ }
297
+ }
298
+
299
+ const branch = await transport.exec(
300
+ "git",
301
+ ["symbolic-ref", "--quiet", "--short", "HEAD"],
302
+ { cwd: plan.mountGuestPath },
303
+ );
304
+ const actualBranch = branch.stdout.toString("utf8").trim();
305
+ if (plan.branch !== null) {
306
+ if (branch.exitCode !== 0 || actualBranch !== plan.branch) {
307
+ const stderr = branch.stderr.toString("utf8").trim();
308
+ throw new Error(
309
+ `seed branch mismatch: expected ${plan.branch}, got ${actualBranch || "detached"}${stderr ? `: ${stderr}` : ""}`,
310
+ );
311
+ }
312
+ } else if (branch.exitCode === 0) {
313
+ throw new Error(`seed branch mismatch: expected detached HEAD, got ${actualBranch}`);
314
+ }
315
+
316
+ const status = await runChecked(transport, "git", ["status", "--porcelain"], plan.mountGuestPath);
317
+ if (status.stdout.toString("utf8") !== "") {
318
+ throw new Error(`seed repository is not clean: ${status.stdout.toString("utf8").trim()}`);
319
+ }
320
+ }
321
+
322
+ async function removeGuestBundle(transport: SandboxTransport, guestPath: string): Promise<void> {
323
+ const result = await transport.exec("rm", ["-f", "--", guestPath]);
324
+ if (result.exitCode !== 0) {
325
+ const stderr = result.stderr.toString("utf8").trim();
326
+ throw new Error(`removing Git seed bundle failed${stderr ? `: ${stderr}` : ""}`);
327
+ }
328
+ }
329
+
330
+ function errorMessage(error: unknown): string {
331
+ return error instanceof Error ? error.message : String(error);
332
+ }