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.
@@ -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.0.0@sha256:00ea1e0911189815614e8a8eee36d1fd64f0f1edb39492e0bda9f273c834e59f",
66
+ image: "ghcr.io/hcohe/pi-microsandbox:1.1.0@sha256:ab4e99d4232f827b3f295ff3210437e01446dbb672ef0d0c78358566170ac86c",
34
67
  pullPolicy: "if-missing",
35
68
  bootstrapTools: "auto",
36
- cpus: 1,
37
- memoryMiB: 512,
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 key = part.slice(0, at).trim();
208
- assertSafeConfigKey(key, [key]);
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().split(".").map((x) => x.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().split(".").map((x) => x.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 key = line.slice(0, at).trim();
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
- assertSafeConfigKey(key, [...arraySection, key]);
275
- parent[key] = value;
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", "mode", "cloneBranch", "cloneDepth", "shallowArchive", "volumeQuotaMiB", "blockThirdParty", "routeTools", "passThroughTools", "allowHostExecution", "allowSkillReads", "fallbackMode", "exposeSessionEnvironment", "hostEnv", "autoStart", "pruneOnStart", "showFooter", "lockDir", "hostRoAllowlist"].includes(parts[0]);
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 value = withMountDefaults(normalizeValue(raw) as LayerValue);
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 { throw new ConfigError([`invalid TOML in ${source}`]); }
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
- if (!n(config.volumeQuotaMiB) || config.volumeQuotaMiB < 1) issues.push(issueForPath("volumeQuotaMiB", "must be positive"));
497
- if (!["auto", "git", "direct", "none"].includes(config.mode)) issues.push(issueForPath("mode", "unknown storage mode"));
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", "shallowArchive", "blockThirdParty", "allowHostExecution", "allowSkillReads", "exposeSessionEnvironment", "autoStart", "pruneOnStart", "showFooter"] as const;
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 { throw new ConfigError([`invalid TOML in ${source}`]); }
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
- if (project && !input.projectTrusted) warnings.push("project config ignored because this project is not trusted");
608
- if (project && input.projectTrusted) {
609
- const text = await readFile(project);
610
- if (text !== null) {
611
- const parsed = layerFromText("project", text, project);
612
- const projectValue = withMountDefaults(clone(parsed.value) as LayerValue);
613
- if (Array.isArray(projectValue.mounts)) {
614
- projectValue.mounts = await Promise.all(projectValue.mounts.map(async (mount: any) => {
615
- if (mount && (mount.type === "dir" || mount.type === "file") && typeof mount.hostPath === "string" && isAbsolute(mount.hostPath)) {
616
- return { ...mount, hostPath: await canonicalPath(mount.hostPath, true) };
617
- }
618
- return mount;
619
- })) as any;
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
- return setAt(base, rawParts.map(camel), value) as DeepPartial<Config>;
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]];