pi-microsandbox 0.1.1 → 0.3.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/README.md +72 -57
- package/SECURITY.md +12 -8
- package/docs/commands.md +18 -14
- package/docs/configuration.md +49 -11
- package/docs/development.md +52 -10
- package/docs/getting-started.md +25 -8
- package/docs/images.md +18 -9
- package/docs/safety.md +43 -14
- package/docs/storage.md +63 -50
- package/docs/troubleshooting.md +54 -9
- package/extensions/pi-msb/command.ts +17 -189
- package/extensions/pi-msb/config.ts +191 -44
- package/extensions/pi-msb/control.ts +134 -302
- package/extensions/pi-msb/index.ts +2 -2
- package/extensions/pi-msb/labels.ts +46 -231
- package/extensions/pi-msb/locks.ts +4 -6
- package/extensions/pi-msb/prune.ts +16 -12
- package/extensions/pi-msb/sandbox-manager.ts +93 -143
- package/extensions/pi-msb/tools.ts +13 -26
- package/extensions/pi-msb/transport.ts +0 -18
- package/extensions/pi-msb/types.ts +42 -108
- package/extensions/pi-msb/workspace.ts +126 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
- package/extensions/pi-msb/git.ts +0 -256
- package/extensions/pi-msb/storage.ts +0 -332
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { homedir as osHomedir } from "node:os";
|
|
3
|
-
import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
4
4
|
import type {
|
|
5
5
|
Config,
|
|
6
6
|
ConfigLayerInput,
|
|
@@ -19,6 +19,39 @@ const CONTROL_KEYS = new Set(["removeSecrets", "removeMounts", "removeRouteTools
|
|
|
19
19
|
const SECRET_FIELDS = new Set(["env", "value", "allowHosts"]);
|
|
20
20
|
const MOUNT_FIELDS = new Set(["type", "hostPath", "guestPath", "readonly", "options"]);
|
|
21
21
|
const FORBIDDEN_CONFIG_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
22
|
+
const REMOVED_WORKSPACE_KEYS = new Set(["mode", "cloneBranch", "cloneDepth", "shallowArchive", "volumeQuotaMiB"]);
|
|
23
|
+
const REMOVED_WORKSPACE_MESSAGE = "was removed; pi-microsandbox now bind-mounts the discovered workspace root read/write";
|
|
24
|
+
const PROTECTED_GUEST_ENV = new Set([
|
|
25
|
+
"BASH_ENV",
|
|
26
|
+
"CDPATH",
|
|
27
|
+
"DOCKER_CERT_PATH",
|
|
28
|
+
"DOCKER_CONFIG",
|
|
29
|
+
"DOCKER_CONTEXT",
|
|
30
|
+
"DOCKER_HOST",
|
|
31
|
+
"DOCKER_TLS",
|
|
32
|
+
"DOCKER_TLS_VERIFY",
|
|
33
|
+
"ENV",
|
|
34
|
+
"GLOBIGNORE",
|
|
35
|
+
"LD_LIBRARY_PATH",
|
|
36
|
+
"LD_PRELOAD",
|
|
37
|
+
"PATH",
|
|
38
|
+
"SHELLOPTS",
|
|
39
|
+
]);
|
|
40
|
+
const PROTECTED_GUEST_PATHS = [
|
|
41
|
+
"/bin",
|
|
42
|
+
"/dev",
|
|
43
|
+
"/etc/ld.so.cache",
|
|
44
|
+
"/etc/ld.so.preload",
|
|
45
|
+
"/lib",
|
|
46
|
+
"/lib64",
|
|
47
|
+
"/proc",
|
|
48
|
+
"/sbin",
|
|
49
|
+
"/sys",
|
|
50
|
+
"/usr",
|
|
51
|
+
"/run",
|
|
52
|
+
"/var/run",
|
|
53
|
+
"/var/lib/docker",
|
|
54
|
+
] as const;
|
|
22
55
|
|
|
23
56
|
function deepFreeze<T>(value: T): DeepReadonly<T> {
|
|
24
57
|
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
@@ -30,23 +63,19 @@ function deepFreeze<T>(value: T): DeepReadonly<T> {
|
|
|
30
63
|
|
|
31
64
|
/** Defaults from PLAN §11.2. Values containing credentials are deliberately absent. */
|
|
32
65
|
export const DEFAULT_CONFIG = deepFreeze<Config>({
|
|
33
|
-
image: "ghcr.io/hcohe/pi-microsandbox:1.
|
|
66
|
+
image: "ghcr.io/hcohe/pi-microsandbox:1.1.0@sha256:ab4e99d4232f827b3f295ff3210437e01446dbb672ef0d0c78358566170ac86c",
|
|
34
67
|
pullPolicy: "if-missing",
|
|
35
68
|
bootstrapTools: "auto",
|
|
36
|
-
cpus:
|
|
37
|
-
memoryMiB:
|
|
69
|
+
cpus: 4,
|
|
70
|
+
memoryMiB: 8_192,
|
|
38
71
|
idleTimeoutSec: 600,
|
|
39
72
|
stopTimeoutMs: 10_000,
|
|
40
73
|
detached: true,
|
|
41
74
|
replace: false,
|
|
42
75
|
replaceTimeoutMs: 10_000,
|
|
43
76
|
sandboxName: null,
|
|
44
|
-
mode: "direct",
|
|
45
|
-
cloneBranch: "current",
|
|
46
|
-
cloneDepth: "unlimited",
|
|
47
|
-
shallowArchive: false,
|
|
48
|
-
volumeQuotaMiB: 2_048,
|
|
49
77
|
network: { mode: "default", allowHosts: [], allowDns: true, publishPorts: [] },
|
|
78
|
+
docker: { mode: "auto", startupTimeoutMs: 15_000 },
|
|
50
79
|
secrets: [],
|
|
51
80
|
mounts: [],
|
|
52
81
|
blockThirdParty: true,
|
|
@@ -76,6 +105,7 @@ export interface ResolveConfigInput {
|
|
|
76
105
|
readFile?: (path: string) => Promise<string | null>;
|
|
77
106
|
exists?: (path: string) => Promise<boolean>;
|
|
78
107
|
realpath?: (path: string) => Promise<string>;
|
|
108
|
+
stat?: (path: string) => Promise<{ isSocket(): boolean }>;
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
export class ConfigError extends Error {
|
|
@@ -145,6 +175,16 @@ function camel(key: string): string {
|
|
|
145
175
|
function snake(key: string): string {
|
|
146
176
|
return key.replace(/MiB/g, "mib").replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
147
177
|
}
|
|
178
|
+
function removedWorkspaceIssues(value: Record<string, unknown>): string[] {
|
|
179
|
+
return Object.keys(value)
|
|
180
|
+
.map(camel)
|
|
181
|
+
.filter((key) => REMOVED_WORKSPACE_KEYS.has(key))
|
|
182
|
+
.map((key) => `${snake(key)} ${REMOVED_WORKSPACE_MESSAGE}`);
|
|
183
|
+
}
|
|
184
|
+
function assertNoRemovedWorkspaceKeys(value: Record<string, unknown>): void {
|
|
185
|
+
const issues = removedWorkspaceIssues(value);
|
|
186
|
+
if (issues.length) throw new ConfigError(issues);
|
|
187
|
+
}
|
|
148
188
|
function pathKey(parts: string[]): string { return parts.join("."); }
|
|
149
189
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
150
190
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
@@ -196,6 +236,64 @@ function findEquals(text: string): number {
|
|
|
196
236
|
}
|
|
197
237
|
return -1;
|
|
198
238
|
}
|
|
239
|
+
function decodeBasicTomlKey(text: string): string {
|
|
240
|
+
let result = "";
|
|
241
|
+
for (let index = 1; index < text.length - 1; index++) {
|
|
242
|
+
const character = text[index]!;
|
|
243
|
+
if (character !== "\\") {
|
|
244
|
+
result += character;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const escape = text[++index];
|
|
248
|
+
if (escape === undefined) throw new Error("invalid quoted TOML key");
|
|
249
|
+
const simple: Record<string, string> = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", "\"": "\"", "\\": "\\" };
|
|
250
|
+
if (Object.hasOwn(simple, escape)) {
|
|
251
|
+
result += simple[escape];
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (escape !== "u" && escape !== "U") throw new Error("invalid quoted TOML key");
|
|
255
|
+
const length = escape === "u" ? 4 : 8;
|
|
256
|
+
const hex = text.slice(index + 1, index + 1 + length);
|
|
257
|
+
if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(hex)) throw new Error("invalid quoted TOML key");
|
|
258
|
+
const codePoint = Number.parseInt(hex, 16);
|
|
259
|
+
if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) throw new Error("invalid quoted TOML key");
|
|
260
|
+
result += String.fromCodePoint(codePoint);
|
|
261
|
+
index += length;
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
function parseTomlKeySegment(text: string): string {
|
|
266
|
+
const key = text.trim();
|
|
267
|
+
if (key.startsWith("\"") && key.endsWith("\"")) return decodeBasicTomlKey(key);
|
|
268
|
+
if (key.startsWith("'") && key.endsWith("'") && !key.slice(1, -1).includes("'")) return key.slice(1, -1);
|
|
269
|
+
if (/^[A-Za-z0-9_-]+$/.test(key)) return key;
|
|
270
|
+
throw new Error("invalid TOML key");
|
|
271
|
+
}
|
|
272
|
+
function parseTomlKey(text: string): string[] {
|
|
273
|
+
const parts: string[] = [];
|
|
274
|
+
let start = 0;
|
|
275
|
+
let quote = "";
|
|
276
|
+
for (let index = 0; index < text.length; index++) {
|
|
277
|
+
const character = text[index]!;
|
|
278
|
+
if (quote === "\"") {
|
|
279
|
+
if (character === "\\") index++;
|
|
280
|
+
else if (character === quote) quote = "";
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (quote === "'") {
|
|
284
|
+
if (character === quote) quote = "";
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (character === "\"" || character === "'") quote = character;
|
|
288
|
+
else if (character === ".") {
|
|
289
|
+
parts.push(parseTomlKeySegment(text.slice(start, index)));
|
|
290
|
+
start = index + 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (quote) throw new Error("invalid TOML key");
|
|
294
|
+
parts.push(parseTomlKeySegment(text.slice(start)));
|
|
295
|
+
return parts;
|
|
296
|
+
}
|
|
199
297
|
function parseTomlValue(text: string): unknown {
|
|
200
298
|
const value = text.trim();
|
|
201
299
|
if (value.startsWith("[") && value.endsWith("]")) return splitTopLevel(value.slice(1, -1)).map(parseTomlValue);
|
|
@@ -204,9 +302,8 @@ function parseTomlValue(text: string): unknown {
|
|
|
204
302
|
for (const part of splitTopLevel(value.slice(1, -1))) {
|
|
205
303
|
const at = findEquals(part);
|
|
206
304
|
if (at < 0) throw new Error("invalid inline table");
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
object[key] = parseTomlValue(part.slice(at + 1));
|
|
305
|
+
const keys = parseTomlKey(part.slice(0, at));
|
|
306
|
+
assign(object, keys, parseTomlValue(part.slice(at + 1)));
|
|
210
307
|
}
|
|
211
308
|
return object;
|
|
212
309
|
}
|
|
@@ -237,7 +334,7 @@ function readToml(text: string): Record<string, unknown> {
|
|
|
237
334
|
const line = stripTomlComment(raw);
|
|
238
335
|
if (!line) continue;
|
|
239
336
|
if (line.startsWith("[[") && line.endsWith("]]")) {
|
|
240
|
-
const keys = line.slice(2, -2).trim()
|
|
337
|
+
const keys = parseTomlKey(line.slice(2, -2).trim());
|
|
241
338
|
assertSafeConfigPath(keys);
|
|
242
339
|
let parent: Record<string, unknown> = root;
|
|
243
340
|
for (const key of keys.slice(0, -1)) {
|
|
@@ -255,14 +352,14 @@ function readToml(text: string): Record<string, unknown> {
|
|
|
255
352
|
continue;
|
|
256
353
|
}
|
|
257
354
|
if (line.startsWith("[") && line.endsWith("]")) {
|
|
258
|
-
section = line.slice(1, -1).trim()
|
|
355
|
+
section = parseTomlKey(line.slice(1, -1).trim());
|
|
259
356
|
assertSafeConfigPath(section);
|
|
260
357
|
arraySection = null;
|
|
261
358
|
continue;
|
|
262
359
|
}
|
|
263
360
|
const at = findEquals(line);
|
|
264
361
|
if (at < 0) throw new Error("invalid assignment");
|
|
265
|
-
const
|
|
362
|
+
const keys = parseTomlKey(line.slice(0, at));
|
|
266
363
|
const value = parseTomlValue(line.slice(at + 1));
|
|
267
364
|
if (arraySection) {
|
|
268
365
|
let parent: Record<string, unknown> = root;
|
|
@@ -271,9 +368,8 @@ function readToml(text: string): Record<string, unknown> {
|
|
|
271
368
|
if (!Array.isArray(list) || !isPlainObject(list[list.length - 1])) throw new Error("invalid array table");
|
|
272
369
|
parent = list[list.length - 1] as Record<string, unknown>;
|
|
273
370
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
} else assign(root, [...section, ...key.split(".").map((x) => x.trim())], value);
|
|
371
|
+
assign(parent, keys, value);
|
|
372
|
+
} else assign(root, [...section, ...keys], value);
|
|
277
373
|
}
|
|
278
374
|
return root;
|
|
279
375
|
}
|
|
@@ -299,9 +395,10 @@ function knownPath(path: string): boolean {
|
|
|
299
395
|
return parts.length === 1 && CONTROL_KEYS.has(parts[0]) && !nestedNetworkRemoval;
|
|
300
396
|
}
|
|
301
397
|
if (parts[0] === "network") return parts.length === 1 || (parts.length === 2 && ["mode", "allowHosts", "allowDns", "publishPorts", "removeAllowHosts", "removePublishPorts"].includes(parts[1]));
|
|
398
|
+
if (parts[0] === "docker") return parts.length === 1 || (parts.length === 2 && ["mode", "startupTimeoutMs"].includes(parts[1]));
|
|
302
399
|
if (parts[0] === "secrets") return parts.length === 1 || (parts.length === 2 && SECRET_FIELDS.has(parts[1]));
|
|
303
400
|
if (parts[0] === "mounts") return parts.length === 1 || (parts.length === 2 && MOUNT_FIELDS.has(parts[1]));
|
|
304
|
-
return ["image", "pullPolicy", "bootstrapTools", "cpus", "memoryMiB", "idleTimeoutSec", "stopTimeoutMs", "detached", "replace", "replaceTimeoutMs", "sandboxName", "
|
|
401
|
+
return ["image", "pullPolicy", "bootstrapTools", "cpus", "memoryMiB", "idleTimeoutSec", "stopTimeoutMs", "detached", "replace", "replaceTimeoutMs", "sandboxName", "blockThirdParty", "routeTools", "passThroughTools", "allowHostExecution", "allowSkillReads", "fallbackMode", "exposeSessionEnvironment", "hostEnv", "autoStart", "pruneOnStart", "showFooter", "lockDir", "hostRoAllowlist"].includes(parts[0]);
|
|
305
402
|
}
|
|
306
403
|
function collectUnknown(value: unknown, base: string[], warnings: string[]): void {
|
|
307
404
|
if (!isPlainObject(value)) return;
|
|
@@ -334,7 +431,9 @@ function withMountDefaults(value: LayerValue): LayerValue {
|
|
|
334
431
|
}
|
|
335
432
|
function normalizeLayer(name: ConfigLayerName, raw: Record<string, unknown>, source?: string): ParsedConfigLayer {
|
|
336
433
|
assertSafeConfigValue(raw);
|
|
337
|
-
const
|
|
434
|
+
const normalized = normalizeValue(raw) as Record<string, unknown>;
|
|
435
|
+
assertNoRemovedWorkspaceKeys(normalized);
|
|
436
|
+
const value = withMountDefaults(normalized as LayerValue);
|
|
338
437
|
const warnings: string[] = [];
|
|
339
438
|
collectUnknown(value, [], warnings);
|
|
340
439
|
if (name === "project" && Array.isArray(value.secrets)) {
|
|
@@ -349,7 +448,10 @@ function normalizeLayer(name: ConfigLayerName, raw: Record<string, unknown>, sou
|
|
|
349
448
|
|
|
350
449
|
export function parseTomlConfig(text: string, source: string): ParsedConfigLayer {
|
|
351
450
|
try { return normalizeLayer(source === "cli" ? "cli" : source === "env" ? "env" : source === "project" ? "project" : "global", readToml(text), source); }
|
|
352
|
-
catch
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error instanceof ConfigError) throw error;
|
|
453
|
+
throw new ConfigError([`invalid TOML in ${source}`]);
|
|
454
|
+
}
|
|
353
455
|
}
|
|
354
456
|
|
|
355
457
|
function envScalar(text: string, key: string): unknown {
|
|
@@ -454,6 +556,7 @@ export function mergeConfigLayers(layers: readonly ConfigLayerInput[]): MergeRes
|
|
|
454
556
|
const warnings: string[] = [];
|
|
455
557
|
for (const layer of layers) {
|
|
456
558
|
assertSafeConfigValue(layer.value);
|
|
559
|
+
assertNoRemovedWorkspaceKeys(layer.value as Record<string, unknown>);
|
|
457
560
|
warnings.push(...layer.warnings.map(warning));
|
|
458
561
|
const source = withMountDefaults(clone(layer.value) as LayerValue);
|
|
459
562
|
applyRemovals(result, source, layer.name, provenance);
|
|
@@ -483,6 +586,7 @@ function validatePort(port: unknown): boolean {
|
|
|
483
586
|
}
|
|
484
587
|
|
|
485
588
|
export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
589
|
+
assertNoRemovedWorkspaceKeys(raw as Record<string, unknown>);
|
|
486
590
|
const config = mergeWithDefaults(raw);
|
|
487
591
|
const issues: string[] = [];
|
|
488
592
|
const n = (value: unknown) => typeof value === "number" && Number.isFinite(value);
|
|
@@ -493,15 +597,14 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
493
597
|
if (!n(config.idleTimeoutSec) || config.idleTimeoutSec < 0) issues.push(issueForPath("idleTimeoutSec", "must be non-negative"));
|
|
494
598
|
if (!n(config.stopTimeoutMs) || config.stopTimeoutMs < 1) issues.push(issueForPath("stopTimeoutMs", "must be positive"));
|
|
495
599
|
if (!n(config.replaceTimeoutMs) || config.replaceTimeoutMs < 1) issues.push(issueForPath("replaceTimeoutMs", "must be positive"));
|
|
496
|
-
|
|
497
|
-
if (!["auto", "
|
|
600
|
+
const docker = isPlainObject(config.docker) ? config.docker : null;
|
|
601
|
+
if (!docker || !["auto", "require", "disabled"].includes(docker.mode as string)) issues.push(issueForPath("docker.mode", "must be auto, require, or disabled"));
|
|
602
|
+
if (!docker || !n(docker.startupTimeoutMs) || !Number.isInteger(docker.startupTimeoutMs) || (docker.startupTimeoutMs as number) < 1 || (docker.startupTimeoutMs as number) > 300_000) issues.push(issueForPath("docker.startupTimeoutMs", "must be an integer between 1 and 300000"));
|
|
498
603
|
if (!["auto", true, false].includes(config.bootstrapTools)) issues.push(issueForPath("bootstrapTools", "must be auto, true, or false"));
|
|
499
604
|
if (!["block", "host"].includes(config.fallbackMode)) issues.push(issueForPath("fallbackMode", "must be block or host"));
|
|
500
|
-
const booleanFields = ["detached", "replace", "
|
|
605
|
+
const booleanFields = ["detached", "replace", "blockThirdParty", "allowHostExecution", "allowSkillReads", "exposeSessionEnvironment", "autoStart", "pruneOnStart", "showFooter"] as const;
|
|
501
606
|
for (const field of booleanFields) if (typeof config[field] !== "boolean") issues.push(issueForPath(field, "must be boolean"));
|
|
502
607
|
if (config.sandboxName !== null && typeof config.sandboxName !== "string") issues.push(issueForPath("sandboxName", "must be a string or null"));
|
|
503
|
-
if (typeof config.cloneBranch !== "string" || !config.cloneBranch) issues.push(issueForPath("cloneBranch", "must be a non-empty string"));
|
|
504
|
-
if (config.cloneDepth !== "unlimited" && (!n(config.cloneDepth) || !Number.isInteger(config.cloneDepth) || config.cloneDepth < 1)) issues.push(issueForPath("cloneDepth", "must be a positive integer or unlimited"));
|
|
505
608
|
const stringArray = (field: string, value: unknown) => {
|
|
506
609
|
if (!Array.isArray(value)) { issues.push(issueForPath(field, "must be an array")); return false; }
|
|
507
610
|
for (const item of value) if (typeof item !== "string" || !item) issues.push(issueForPath(field, "must contain non-empty strings"));
|
|
@@ -509,7 +612,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
509
612
|
};
|
|
510
613
|
if (stringArray("routeTools", config.routeTools)) for (const tool of config.routeTools) if (!ROUTED_TOOLS.includes(tool)) issues.push(issueForPath("routeTools", `unknown routed tool ${tool}`));
|
|
511
614
|
stringArray("passThroughTools", config.passThroughTools);
|
|
512
|
-
stringArray("hostEnv", config.hostEnv);
|
|
615
|
+
if (stringArray("hostEnv", config.hostEnv)) for (const name of config.hostEnv) if (PROTECTED_GUEST_ENV.has(name)) issues.push(issueForPath("hostEnv", `must not forward protected process environment variable ${name}`));
|
|
513
616
|
stringArray("hostRoAllowlist", config.hostRoAllowlist);
|
|
514
617
|
const network = isPlainObject(config.network) ? config.network : null;
|
|
515
618
|
if (!network || !["default", "open", "allowlist", "deny"].includes(network.mode as string)) issues.push(issueForPath("network.mode", "unknown network mode"));
|
|
@@ -521,6 +624,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
521
624
|
if (!Array.isArray(config.secrets)) issues.push(issueForPath("secrets", "must be an array"));
|
|
522
625
|
for (const [index, secret] of (Array.isArray(config.secrets) ? config.secrets : []).entries()) {
|
|
523
626
|
if (!secret || typeof secret.env !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(secret.env)) issues.push(issueForPath(`secrets[${index}]`, "env must be a valid host environment name"));
|
|
627
|
+
else if (PROTECTED_GUEST_ENV.has(secret.env)) issues.push(issueForPath(`secrets[${index}].env`, `must not set protected process environment variable ${secret.env}`));
|
|
524
628
|
if (typeof secret?.value !== "string") issues.push(issueForPath(`secrets[${index}].value`, "must be a string"));
|
|
525
629
|
if (!Array.isArray(secret?.allowHosts) || !secret.allowHosts.length || secret.allowHosts.some((host: unknown) => typeof host !== "string" || !host)) issues.push(issueForPath(`secrets[${index}]`, "allowHosts must be a non-empty string array"));
|
|
526
630
|
}
|
|
@@ -542,6 +646,7 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
542
646
|
if (typeof guest === "string" && isAbsolute(guest)) {
|
|
543
647
|
const canonical = String(canonicalGuestPath(guest));
|
|
544
648
|
if (isInside(canonical, "/tmp") || isInside("/tmp", canonical)) issues.push(issueForPath(`mounts[${index}].guestPath`, "must not shadow reserved /tmp paths"));
|
|
649
|
+
if (PROTECTED_GUEST_PATHS.some((reserved) => isInside(canonical, reserved) || isInside(reserved, canonical))) issues.push(issueForPath(`mounts[${index}].guestPath`, "must not shadow protected guest system or Docker runtime paths"));
|
|
545
650
|
}
|
|
546
651
|
}
|
|
547
652
|
if (issues.length) throw new ConfigError(issues);
|
|
@@ -580,7 +685,10 @@ function configDir(env: NodeJS.ProcessEnv, input: ResolveConfigInput): string {
|
|
|
580
685
|
}
|
|
581
686
|
function layerFromText(name: ConfigLayerName, text: string, source: string): ParsedConfigLayer {
|
|
582
687
|
try { return normalizeLayer(name, readToml(text), source); }
|
|
583
|
-
catch
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (error instanceof ConfigError) throw error;
|
|
690
|
+
throw new ConfigError([`invalid TOML in ${source}`]);
|
|
691
|
+
}
|
|
584
692
|
}
|
|
585
693
|
|
|
586
694
|
export async function resolveConfig(input: ResolveConfigInput): Promise<ResolvedConfig> {
|
|
@@ -599,28 +707,40 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
|
|
|
599
707
|
const canonicalPath = async (path: string, failClosed = false): Promise<string> => {
|
|
600
708
|
try { return await realpath(path); }
|
|
601
709
|
catch {
|
|
602
|
-
if (failClosed) throw new ConfigError(["trusted project path could not be canonicalized"]);
|
|
710
|
+
if (failClosed) throw new ConfigError(["mount or trusted project path could not be canonicalized"]);
|
|
603
711
|
return resolve(path);
|
|
604
712
|
}
|
|
605
713
|
};
|
|
606
714
|
const project = await projectFile(input.cwd, input.repoRoot, input.configDirName, exists);
|
|
607
|
-
|
|
608
|
-
if (project && input.projectTrusted) {
|
|
609
|
-
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
715
|
+
const projectText = project ? await readFile(project) : null;
|
|
716
|
+
if (project && !input.projectTrusted) {
|
|
717
|
+
warnings.push("project config ignored because this project is not trusted");
|
|
718
|
+
if (projectText !== null) {
|
|
719
|
+
// Ignored project configuration cannot influence policy, but a removed
|
|
720
|
+
// workspace selector must still stop startup before direct host writes.
|
|
721
|
+
try {
|
|
722
|
+
const normalized = normalizeValue(readToml(projectText)) as Record<string, unknown>;
|
|
723
|
+
assertNoRemovedWorkspaceKeys(normalized);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error instanceof ConfigError && error.issues.some((issue) => issue.includes(REMOVED_WORKSPACE_MESSAGE))) throw error;
|
|
726
|
+
// Preserve the existing trust rule for every other malformed/unsafe key:
|
|
727
|
+
// untrusted project configuration is not interpreted.
|
|
620
728
|
}
|
|
621
|
-
layers.push({ ...parsed, value: projectValue });
|
|
622
729
|
}
|
|
623
730
|
}
|
|
731
|
+
if (project && input.projectTrusted && projectText !== null) {
|
|
732
|
+
const parsed = layerFromText("project", projectText, project);
|
|
733
|
+
const projectValue = withMountDefaults(clone(parsed.value) as LayerValue);
|
|
734
|
+
if (Array.isArray(projectValue.mounts)) {
|
|
735
|
+
projectValue.mounts = await Promise.all(projectValue.mounts.map(async (mount: any) => {
|
|
736
|
+
if (mount && (mount.type === "dir" || mount.type === "file") && typeof mount.hostPath === "string" && isAbsolute(mount.hostPath)) {
|
|
737
|
+
return { ...mount, hostPath: await canonicalPath(mount.hostPath, true) };
|
|
738
|
+
}
|
|
739
|
+
return mount;
|
|
740
|
+
})) as any;
|
|
741
|
+
}
|
|
742
|
+
layers.push({ ...parsed, value: projectValue });
|
|
743
|
+
}
|
|
624
744
|
layers.push(parseEnvConfig(env));
|
|
625
745
|
if (input.cliOverridesToml !== undefined) layers.push(parseTomlConfig(input.cliOverridesToml, "cli"));
|
|
626
746
|
const merged = mergeConfigLayers(layers);
|
|
@@ -629,6 +749,12 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
|
|
|
629
749
|
config = normalizeLegacy(config, warnings);
|
|
630
750
|
// Legacy mounts participate in the same overlap/type checks as native mounts.
|
|
631
751
|
config = validateConfig(config);
|
|
752
|
+
config = {
|
|
753
|
+
...config,
|
|
754
|
+
mounts: await Promise.all(config.mounts.map(async (mount) => mount.type === "named" || mount.type === "tmpfs"
|
|
755
|
+
? mount
|
|
756
|
+
: { ...mount, hostPath: await canonicalPath(mount.hostPath!, true) })),
|
|
757
|
+
};
|
|
632
758
|
|
|
633
759
|
const projectGuestPath = String(canonicalGuestPath(resolve(input.repoRoot ?? input.cwd)));
|
|
634
760
|
const projectShadowIssues = config.mounts.flatMap((mount, index) => {
|
|
@@ -649,6 +775,20 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
|
|
|
649
775
|
.filter((mount) => mount && mount.readonly === false)
|
|
650
776
|
.map((mount) => String(canonicalGuestPath(mount.guestPath ?? "")));
|
|
651
777
|
const policyIssues: string[] = [];
|
|
778
|
+
const mountStat = input.stat ?? fs.stat;
|
|
779
|
+
const knownHostDockerSockets = ["/var/run/docker.sock", "/run/docker.sock"];
|
|
780
|
+
if (input.homedir ?? env.HOME) knownHostDockerSockets.push(join(String(input.homedir ?? env.HOME), ".docker", "run", "docker.sock"));
|
|
781
|
+
if (env.XDG_RUNTIME_DIR) knownHostDockerSockets.push(join(env.XDG_RUNTIME_DIR, "docker.sock"));
|
|
782
|
+
for (const [index, mount] of config.mounts.entries()) {
|
|
783
|
+
if (mount.type === "named" || mount.type === "tmpfs" || typeof mount.hostPath !== "string") continue;
|
|
784
|
+
const host = resolve(mount.hostPath);
|
|
785
|
+
let unsafe = basename(host) === "docker.sock" || knownHostDockerSockets.some((socket) => isInside(socket, host));
|
|
786
|
+
if (!unsafe) {
|
|
787
|
+
try { unsafe = (await mountStat(host)).isSocket(); }
|
|
788
|
+
catch { policyIssues.push(issueForPath(`mounts[${index}].hostPath`, "could not be safely inspected")); continue; }
|
|
789
|
+
}
|
|
790
|
+
if (unsafe) policyIssues.push(issueForPath(`mounts[${index}].hostPath`, "must not mount a host socket or Docker socket path"));
|
|
791
|
+
}
|
|
652
792
|
const projectSecretFiles = new Map<string, { reference: string; canonical: string }>();
|
|
653
793
|
if (Array.isArray(projectMounts)) for (const mount of projectMounts as any[]) {
|
|
654
794
|
if (!mount || typeof mount.hostPath !== "string" || mount.type === "named" || mount.type === "tmpfs") continue;
|
|
@@ -708,7 +848,11 @@ function setAt(base: any, parts: string[], value: unknown): any {
|
|
|
708
848
|
export function applyOverride(base: DeepPartial<Config>, dottedSnakeKey: string, value: unknown): DeepPartial<Config> {
|
|
709
849
|
const rawParts = dottedSnakeKey.split(".").filter(Boolean);
|
|
710
850
|
assertSafeConfigPath(rawParts);
|
|
711
|
-
|
|
851
|
+
const parts = rawParts.map(camel);
|
|
852
|
+
if (REMOVED_WORKSPACE_KEYS.has(parts[0] ?? "")) {
|
|
853
|
+
throw new ConfigError([`${dottedSnakeKey} ${REMOVED_WORKSPACE_MESSAGE}`]);
|
|
854
|
+
}
|
|
855
|
+
return setAt(base, parts, value) as DeepPartial<Config>;
|
|
712
856
|
}
|
|
713
857
|
export function removeOverride(base: DeepPartial<Config>, dottedSnakeKey: string): DeepPartial<Config> {
|
|
714
858
|
assertSafeConfigValue(base);
|
|
@@ -717,6 +861,9 @@ export function removeOverride(base: DeepPartial<Config>, dottedSnakeKey: string
|
|
|
717
861
|
assertSafeConfigPath(rawParts);
|
|
718
862
|
const parts = rawParts.map(camel);
|
|
719
863
|
assertSafeConfigPath(parts);
|
|
864
|
+
if (REMOVED_WORKSPACE_KEYS.has(parts[0] ?? "")) {
|
|
865
|
+
throw new ConfigError([`${dottedSnakeKey} ${REMOVED_WORKSPACE_MESSAGE}`]);
|
|
866
|
+
}
|
|
720
867
|
let current = out;
|
|
721
868
|
for (const part of parts.slice(0, -1)) { if (!isPlainObject(current[part])) return out; current = current[part]; }
|
|
722
869
|
delete current[parts[parts.length - 1]];
|