@fungi.computer/truffle 0.2.1 → 0.2.2

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.
@@ -8,10 +8,31 @@ export declare function resolveAgentFolder(project: string, folder: string): {
8
8
  };
9
9
  /** Containment authority, not an approval setting or an execution mode. */
10
10
  export type LocalSandboxPolicy = Readonly<{
11
+ projectRoot: string;
11
12
  writableRoot: string;
12
13
  readOnly: readonly string[];
13
14
  immovable: readonly string[];
14
15
  }>;
16
+ export declare class UnsafeNativePayload extends Error {
17
+ readonly name = "UnsafeNativePayload";
18
+ }
19
+ export declare class UnsafeHostCode extends Error {
20
+ readonly name = "UnsafeHostCode";
21
+ }
22
+ /**
23
+ * Packed CLI identity is supplied by the Node bin wrapper, which loaded both
24
+ * paths before spawning Bun. This is an admission check, not an integrity
25
+ * check on JS that might already have been rewritten.
26
+ */
27
+ export declare function assertHostCodeOutsideAgent(project: string, cli: string, launcher: string): void;
28
+ /** Refuse both logical and resolved native source locations in the whole
29
+ * Agent project or Git administration before verifying source bytes. */
30
+ export declare function assertHostOwnedNativeFile(project: string, path: string): void;
31
+ /** Host liveness check for private PID-named run reclamation. */
32
+ export type NativeRunLivenessProbe = (pid: number) => void;
33
+ /** New private inode, never a link or bind of an installed payload. */
34
+ export declare function privateNativeDirectory(project: string, probe?: NativeRunLivenessProbe): string;
35
+ export declare function stageNativeBytes(directory: string, label: string, bytes: Uint8Array): string;
15
36
  /** One logical/resolved authority for Files, bwrap and Seatbelt. */
16
37
  export declare function protectedSandboxPaths(paths: readonly string[]): string[];
17
38
  /**
@@ -1,4 +1,7 @@
1
- import { lstatSync, readdirSync, realpathSync } from "node:fs";
1
+ import { isUtf8 } from "node:buffer";
2
+ import { spawnSync } from "node:child_process";
3
+ import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
+ import { userInfo } from "node:os";
2
5
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, } from "node:path";
3
6
  import { z } from "zod";
4
7
  /** Retained POSIX folder grammar; no separate safe/YOLO representation. */
@@ -21,6 +24,172 @@ export function resolveAgentFolder(project, folder) {
21
24
  throw new Error("Agent folder must be a directory");
22
25
  return { folder: within.split(sep).join("/") || ".", absolute };
23
26
  }
27
+ export class UnsafeNativePayload extends Error {
28
+ name = "UnsafeNativePayload";
29
+ }
30
+ export class UnsafeHostCode extends Error {
31
+ name = "UnsafeHostCode";
32
+ }
33
+ function within(root, path) {
34
+ const remainder = relative(root, path);
35
+ return (!isAbsolute(remainder) &&
36
+ remainder !== ".." &&
37
+ !remainder.startsWith(`..${sep}`));
38
+ }
39
+ function inGitAdministration(path) {
40
+ for (let directory = dirname(path);; directory = dirname(directory)) {
41
+ if (basename(directory) === ".git" ||
42
+ (lstatSync(join(directory, "HEAD"), {
43
+ throwIfNoEntry: false,
44
+ })?.isFile() &&
45
+ (lstatSync(join(directory, "objects"), {
46
+ throwIfNoEntry: false,
47
+ })?.isDirectory() ||
48
+ lstatSync(join(directory, "commondir"), {
49
+ throwIfNoEntry: false,
50
+ })?.isFile())))
51
+ return true;
52
+ if (directory === dirname(directory))
53
+ return false;
54
+ }
55
+ }
56
+ /**
57
+ * Packed CLI identity is supplied by the Node bin wrapper, which loaded both
58
+ * paths before spawning Bun. This is an admission check, not an integrity
59
+ * check on JS that might already have been rewritten.
60
+ */
61
+ export function assertHostCodeOutsideAgent(project, cli, launcher) {
62
+ const root = realpathSync(project);
63
+ const paths = [
64
+ cli,
65
+ launcher,
66
+ dirname(cli),
67
+ dirname(dirname(cli)),
68
+ dirname(launcher),
69
+ dirname(dirname(launcher)),
70
+ ];
71
+ for (const path of paths) {
72
+ try {
73
+ const logical = resolve(path);
74
+ const actual = realpathSync(logical);
75
+ if ([logical, actual].some((candidate) => within(root, candidate) || inGitAdministration(candidate)))
76
+ throw new UnsafeHostCode("Fungi host JavaScript is inside the Agent project or Git administration; install Fungi outside the project.");
77
+ }
78
+ catch (cause) {
79
+ if (cause instanceof UnsafeHostCode)
80
+ throw cause;
81
+ throw new UnsafeHostCode("Fungi host JavaScript location cannot be verified; reinstall Fungi outside the project.", { cause });
82
+ }
83
+ }
84
+ for (const path of [cli, launcher]) {
85
+ try {
86
+ const file = statSync(path);
87
+ if (!file.isFile() || file.nlink > 1)
88
+ throw new UnsafeHostCode("Fungi host JavaScript is not an independent file (hardlinked or not regular); use a copying install outside the project (pnpm package-import-method=copy or npm global).");
89
+ }
90
+ catch (cause) {
91
+ if (cause instanceof UnsafeHostCode)
92
+ throw cause;
93
+ throw new UnsafeHostCode("Fungi host JavaScript cannot be inspected; reinstall Fungi using a copying install outside the project.", { cause });
94
+ }
95
+ }
96
+ }
97
+ /** Refuse both logical and resolved native source locations in the whole
98
+ * Agent project or Git administration before verifying source bytes. */
99
+ export function assertHostOwnedNativeFile(project, path) {
100
+ const logical = resolve(path);
101
+ const actual = realpathSync(logical);
102
+ const gitAdmin = (candidate) => inGitAdministration(candidate);
103
+ if ([logical, actual].some((candidate) => within(project, candidate) || gitAdmin(candidate)))
104
+ throw new UnsafeNativePayload("Packaged native payload is inside the Agent project or a Git-admin folder; reinstall Fungi outside the project.");
105
+ }
106
+ function sweepErrorCode(cause) {
107
+ if (!(cause instanceof Error) || !("code" in cause))
108
+ return undefined;
109
+ switch (cause.code) {
110
+ case "ENOENT":
111
+ case "ESRCH":
112
+ case "EPERM":
113
+ return cause.code;
114
+ default:
115
+ return undefined;
116
+ }
117
+ }
118
+ const probeNativeRunLiveness = (pid) => {
119
+ process.kill(pid, 0);
120
+ };
121
+ /** Reclamation may fail independently of private-run admission. */
122
+ function sweepPrivateNativeRuns(root, uid, probe) {
123
+ try {
124
+ for (const name of readdirSync(root)) {
125
+ const match = /^run-([1-9]\d*)-([A-Za-z0-9]{6})$/.exec(name);
126
+ if (match === null)
127
+ continue;
128
+ const stale = join(root, name);
129
+ try {
130
+ const entry = lstatSync(stale);
131
+ if (!entry.isDirectory() ||
132
+ entry.uid !== uid ||
133
+ (entry.mode & 0o077) !== 0)
134
+ continue;
135
+ try {
136
+ probe(Number(match[1]));
137
+ }
138
+ catch (cause) {
139
+ if (sweepErrorCode(cause) === "ESRCH")
140
+ rmSync(stale, { recursive: true, force: true });
141
+ else if (sweepErrorCode(cause) !== "EPERM")
142
+ throw cause;
143
+ }
144
+ }
145
+ catch (cause) {
146
+ if (sweepErrorCode(cause) !== "ENOENT")
147
+ console.warn("Native stale-run sweep could not inspect entry", cause);
148
+ }
149
+ }
150
+ }
151
+ catch (cause) {
152
+ console.warn("Native stale-run sweep could not list runs", cause);
153
+ }
154
+ }
155
+ /** New private inode, never a link or bind of an installed payload. */
156
+ export function privateNativeDirectory(project, probe = probeNativeRunLiveness) {
157
+ const uid = process.getuid?.();
158
+ if (uid === undefined)
159
+ throw new UnsafeNativePayload("Host native staging requires a POSIX user identity.");
160
+ const runtime = process.env.XDG_RUNTIME_DIR;
161
+ const base = runtime ?? join(userInfo().homedir, ".local", "state");
162
+ if (runtime !== undefined) {
163
+ if (!isAbsolute(runtime))
164
+ throw new UnsafeNativePayload("XDG_RUNTIME_DIR must be absolute.");
165
+ const directory = lstatSync(runtime, { throwIfNoEntry: false });
166
+ if (directory === undefined ||
167
+ !directory.isDirectory() ||
168
+ directory.uid !== uid ||
169
+ (directory.mode & 0o077) !== 0)
170
+ throw new UnsafeNativePayload("XDG_RUNTIME_DIR must be a private directory owned by this user.");
171
+ }
172
+ const root = join(base, "fungi", "native");
173
+ const resolvedProject = realpathSync(project);
174
+ if (within(resolvedProject, resolve(root)) || inGitAdministration(root))
175
+ throw new UnsafeNativePayload("Host native staging cannot live inside the Agent project or Git administration.");
176
+ mkdirSync(root, { recursive: true, mode: 0o700 });
177
+ const actual = realpathSync(root);
178
+ if (within(resolvedProject, actual) || inGitAdministration(actual))
179
+ throw new UnsafeNativePayload("Host native staging resolves inside the Agent project or Git administration.");
180
+ const metadata = lstatSync(actual);
181
+ if (!metadata.isDirectory() ||
182
+ metadata.uid !== uid ||
183
+ (metadata.mode & 0o077) !== 0)
184
+ throw new UnsafeNativePayload("Host native staging root must be private and owned by this user.");
185
+ sweepPrivateNativeRuns(actual, uid, probe);
186
+ return mkdtempSync(join(actual, `run-${String(process.pid)}-`));
187
+ }
188
+ export function stageNativeBytes(directory, label, bytes) {
189
+ const target = join(mkdtempSync(join(directory, "payload-")), label);
190
+ writeFileSync(target, bytes, { flag: "wx", mode: 0o500 });
191
+ return target;
192
+ }
24
193
  /** Missing reserved names resolve through their nearest existing ancestor. */
25
194
  function canonicalPath(path) {
26
195
  if (lstatSync(path, { throwIfNoEntry: false }) !== undefined)
@@ -47,14 +216,258 @@ const discoveryDirectoryLimit = z
47
216
  .int()
48
217
  .min(1)
49
218
  .max(maximumDiscoveryDirectories);
50
- function gitProtection(root, directoryLimit) {
219
+ // Each work tree costs four short Git processes; bound the process count too.
220
+ const maximumGitWorkTrees = 256;
221
+ const maximumGitOutputBytes = 256 * 1024 * 1024;
222
+ const gitTimeoutMilliseconds = 30_000;
223
+ /** Host Git from absolute PATH entries; never a binary the Agent can write. */
224
+ function hostGit(project) {
225
+ for (const directory of (process.env.PATH ?? "").split(":")) {
226
+ if (!isAbsolute(directory))
227
+ continue;
228
+ const candidate = join(directory, "git");
229
+ try {
230
+ accessSync(candidate, constants.X_OK);
231
+ const actual = realpathSync(candidate);
232
+ if (statSync(actual).isFile() &&
233
+ ![candidate, actual].some((path) => within(project, path) || inGitAdministration(path)))
234
+ return candidate;
235
+ }
236
+ catch (cause) {
237
+ // No usable Git on this PATH entry; any other fault refuses discovery.
238
+ if (!unreadableEntry(cause))
239
+ throw cause;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+ function gitEnvironment() {
245
+ const environment = {};
246
+ for (const [name, value] of Object.entries(process.env))
247
+ if (!name.startsWith("GIT_"))
248
+ environment[name] = value;
249
+ return { ...environment, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
250
+ }
251
+ /** Missing, non-directory or looping entries; never other faults. */
252
+ function absentEntry(cause) {
253
+ if (!(cause instanceof Error) || !("code" in cause))
254
+ return false;
255
+ switch (cause.code) {
256
+ case "ENOENT":
257
+ case "ENOTDIR":
258
+ case "ELOOP":
259
+ return true;
260
+ default:
261
+ return false;
262
+ }
263
+ }
264
+ /**
265
+ * Absent or permission-denied. Only for choices where denial is safe (skip a
266
+ * PATH entry, protect more); never where it would drop protection, since an
267
+ * Agent can `chmod 000` a directory inside its folder.
268
+ */
269
+ function unreadableEntry(cause) {
270
+ return (absentEntry(cause) ||
271
+ (cause instanceof Error && "code" in cause && cause.code === "EACCES"));
272
+ }
273
+ // `git ls-files --stage`: <mode> SP <object> SP <stage> TAB <path>.
274
+ const stagedEntry = /^([0-7]{6}) [0-9a-f]{40,64} [0-3]\t(.+)$/su;
275
+ /**
276
+ * Git's own names for one work tree: its administration directories, the
277
+ * nested repositories it reports (gitlinks, untracked repositories, registered
278
+ * worktrees) and bare-repository candidates among its non-ignored files.
279
+ * Ignored payload is never listed. Undefined when Git cannot answer, so the
280
+ * caller falls back to the complete budgeted walk.
281
+ */
282
+ function gitWorkTreeView(git, workTree) {
283
+ const run = (args, separator) => {
284
+ // Repository configuration may be Agent-authored: no fsmonitor command,
285
+ // no Agent-forged untracked cache, no pager, no index writes.
286
+ const result = spawnSync(git, [
287
+ "--no-pager",
288
+ "-c",
289
+ "core.fsmonitor=false",
290
+ "-c",
291
+ "core.untrackedCache=false",
292
+ ...args,
293
+ ], {
294
+ cwd: workTree,
295
+ env: gitEnvironment(),
296
+ maxBuffer: maximumGitOutputBytes,
297
+ timeout: gitTimeoutMilliseconds,
298
+ stdio: ["ignore", "pipe", "ignore"],
299
+ });
300
+ if (result.error !== undefined || result.status !== 0)
301
+ return undefined;
302
+ // Raw bytes: a name that is not UTF-8 cannot be protected by a string
303
+ // path, so the whole view is refused instead of silently dropping it.
304
+ const records = [];
305
+ for (let start = 0; start < result.stdout.length;) {
306
+ const end = result.stdout.indexOf(separator, start);
307
+ const stop = end === -1 ? result.stdout.length : end;
308
+ const record = result.stdout.subarray(start, stop);
309
+ if (!isUtf8(record))
310
+ return undefined;
311
+ records.push(record.toString("utf8"));
312
+ start = stop + 1;
313
+ }
314
+ return records;
315
+ };
316
+ // Newline-separated; a path containing a newline fails the count check.
317
+ const location = run([
318
+ "rev-parse",
319
+ "--is-inside-work-tree",
320
+ "--absolute-git-dir",
321
+ "--git-common-dir",
322
+ ], 10);
323
+ if (location?.length !== 3)
324
+ return undefined;
325
+ const [inside, gitDirectory = "", commonDirectory = ""] = location;
326
+ if (inside !== "true")
327
+ return undefined;
328
+ const staged = run(["ls-files", "-z", "--stage"], 0);
329
+ const others = run(["ls-files", "-z", "--others", "--exclude-standard"], 0);
330
+ const worktrees = run(["worktree", "list", "--porcelain", "-z"], 0);
331
+ if (staged === undefined || others === undefined || worktrees === undefined)
332
+ return undefined;
333
+ const repositories = [];
334
+ const bare = [];
335
+ const files = (path) => {
336
+ if (basename(path) === "HEAD")
337
+ bare.push(dirname(resolve(workTree, path)));
338
+ };
339
+ for (const record of staged) {
340
+ if (record === "")
341
+ continue;
342
+ const entry = stagedEntry.exec(record);
343
+ if (entry === null)
344
+ return undefined;
345
+ const [, mode = "", path = ""] = entry;
346
+ if (mode === "160000")
347
+ repositories.push(resolve(workTree, path));
348
+ else
349
+ files(path);
350
+ }
351
+ for (const path of others) {
352
+ if (path === "")
353
+ continue;
354
+ // Git reports a nested repository as its directory with a trailing slash.
355
+ if (path.endsWith("/"))
356
+ repositories.push(resolve(workTree, path));
357
+ else
358
+ files(path);
359
+ }
360
+ for (const field of worktrees)
361
+ if (field.startsWith("worktree "))
362
+ repositories.push(field.slice(9));
363
+ return {
364
+ administration: [gitDirectory, resolve(workTree, commonDirectory)],
365
+ repositories,
366
+ bare,
367
+ };
368
+ }
369
+ /** Git's commondir rule, compared only; never a grant or a walk. */
370
+ function sharesCommonDirectory(gitDirectory) {
371
+ try {
372
+ const target = readFileSync(join(gitDirectory, "commondir"), "utf8").replace(/[\r\n]+$/u, "");
373
+ return (target !== "" &&
374
+ realpathSync(resolve(gitDirectory, target)) !== realpathSync(gitDirectory));
375
+ }
376
+ catch (cause) {
377
+ // Unreadable or dangling: protect it as its own common directory.
378
+ if (unreadableEntry(cause))
379
+ return false;
380
+ throw cause;
381
+ }
382
+ }
383
+ /**
384
+ * Discovery follows Git. Inside a work tree, Git names its administration and
385
+ * nested repositories; ignored payload (dependency stores, tool output) is
386
+ * not searched. Outside any work tree, or when Git cannot answer, a complete
387
+ * walk with the directory budget finds repositories. Git administration is
388
+ * always walked completely and never pruned.
389
+ */
390
+ function gitProtection(project, root, directoryLimit) {
51
391
  const pending = [
52
- { path: root, kind: "tree" },
392
+ { path: root, kind: "work-tree" },
53
393
  ];
54
394
  const readOnly = [];
55
395
  const immovable = [];
396
+ const named = new Set();
397
+ const executable = hostGit(project);
56
398
  let discoveredDirectories = 1; // The writable root itself.
399
+ let workTrees = 0;
400
+ const queue = (path, kind) => {
401
+ discoveredDirectories += 1;
402
+ if (discoveredDirectories > directoryLimit)
403
+ throw new Error(`Sandbox Git discovery exceeds ${String(directoryLimit)} directories`);
404
+ pending.push({ path, kind });
405
+ };
406
+ // Git-reported names are data: accept only real directories reached without
407
+ // a symlink, inside the writable root, once. Never bind or prepare outside.
408
+ const contained = (path) => {
409
+ if (path === root || !within(root, path))
410
+ return false;
411
+ try {
412
+ return realpathSync(path) === path && lstatSync(path).isDirectory();
413
+ }
414
+ catch (cause) {
415
+ // A reported name that no longer exists names nothing to protect. A
416
+ // denied one may hide a repository: rethrow EACCES so discovery refuses.
417
+ if (absentEntry(cause))
418
+ return false;
419
+ throw cause;
420
+ }
421
+ };
422
+ const name = (path, kind) => {
423
+ const key = `${kind}\0${path}`;
424
+ if (named.has(key) || !contained(path))
425
+ return;
426
+ named.add(key);
427
+ queue(path, kind);
428
+ };
429
+ const inspect = (path) => lstatSync(path, { throwIfNoEntry: false });
430
+ /** False when Git cannot answer; the caller walks the directory instead. */
431
+ const askGit = (workTree) => {
432
+ workTrees += 1;
433
+ if (workTrees > maximumGitWorkTrees)
434
+ throw new Error(`Sandbox Git discovery exceeds ${String(maximumGitWorkTrees)} work trees`);
435
+ const view = executable === undefined
436
+ ? undefined
437
+ : gitWorkTreeView(executable, workTree);
438
+ if (view === undefined)
439
+ return false;
440
+ const marker = inspect(join(workTree, ".git"));
441
+ if (marker?.isDirectory())
442
+ name(join(workTree, ".git"), "git");
443
+ else if (marker !== undefined)
444
+ readOnly.push(join(workTree, ".git")); // Never parse a Git pointer.
445
+ for (const path of view.administration)
446
+ name(path, "git");
447
+ // Validate each Git-reported name before inspecting anything under it.
448
+ for (const path of view.repositories) {
449
+ if (!contained(path))
450
+ continue;
451
+ const nested = inspect(join(path, ".git"));
452
+ if (nested?.isDirectory())
453
+ name(path, "work-tree");
454
+ else if (nested !== undefined)
455
+ readOnly.push(join(path, ".git"));
456
+ }
457
+ for (const path of view.bare)
458
+ if (contained(path) &&
459
+ inspect(join(path, "HEAD"))?.isFile() &&
460
+ (inspect(join(path, "objects"))?.isDirectory() ||
461
+ inspect(join(path, "commondir"))?.isFile()))
462
+ name(path, "git");
463
+ return true;
464
+ };
57
465
  for (let directory = pending.pop(); directory !== undefined; directory = pending.pop()) {
466
+ if (directory.kind === "work-tree") {
467
+ if (!askGit(directory.path))
468
+ pending.push({ path: directory.path, kind: "walk" });
469
+ continue;
470
+ }
58
471
  const entries = readdirSync(directory.path, { withFileTypes: true });
59
472
  const has = (name) => entries.some((entry) => entry.name === name);
60
473
  const git = directory.kind === "git" ||
@@ -62,6 +475,14 @@ function gitProtection(root, directoryLimit) {
62
475
  (has("HEAD") && (has("objects") || has("commondir"))) ||
63
476
  (directory.kind === "modules" &&
64
477
  (has("HEAD") || has("config") || has("hooks")));
478
+ // Outside administration, a directory holding a `.git` directory is a
479
+ // work tree whose contents Git names. A `walk` is the selected root
480
+ // outside any work tree, or a work tree Git could not answer for.
481
+ if (directory.kind === "tree" &&
482
+ !git &&
483
+ entries.some((entry) => entry.name === ".git" && entry.isDirectory()) &&
484
+ askGit(directory.path))
485
+ continue;
65
486
  // A nested Git-file checkout is a repository boundary, not another copy of
66
487
  // the selected project's payload to walk. The normal root scan owns common
67
488
  // administration; never follow the pointer. Never prune the selected root or
@@ -74,7 +495,14 @@ function gitProtection(root, directoryLimit) {
74
495
  }
75
496
  if (git) {
76
497
  immovable.push(directory.path);
77
- readOnly.push(join(directory.path, "hooks"), join(directory.path, "config"), join(directory.path, "config.worktree"));
498
+ // A read-only commondir naming another directory makes hooks and config
499
+ // common paths that Git never reads here; protect them only where this
500
+ // directory is its own common directory (bwrap caps its argv at 9,000).
501
+ if (!has("commondir") || !sharesCommonDirectory(directory.path))
502
+ readOnly.push(join(directory.path, "hooks"), join(directory.path, "config"));
503
+ readOnly.push(join(directory.path, "config.worktree"),
504
+ // Redirects config and hooks to another common directory.
505
+ join(directory.path, "commondir"));
78
506
  }
79
507
  for (const entry of entries) {
80
508
  const path = join(directory.path, entry.name);
@@ -82,9 +510,6 @@ function gitProtection(root, directoryLimit) {
82
510
  readOnly.push(path); // Never parse a Git pointer or grant an external dir.
83
511
  if (!entry.isDirectory())
84
512
  continue; // No symlink cycles or outside walks.
85
- discoveredDirectories += 1;
86
- if (discoveredDirectories > directoryLimit)
87
- throw new Error(`Sandbox Git discovery exceeds ${String(directoryLimit)} directories`);
88
513
  let kind = "tree";
89
514
  if (git && entry.name === "modules")
90
515
  kind = "modules";
@@ -94,10 +519,13 @@ function gitProtection(root, directoryLimit) {
94
519
  kind = "modules";
95
520
  else if (!git && directory.kind === "worktrees")
96
521
  kind = "git";
97
- pending.push({ path, kind });
522
+ queue(path, kind);
98
523
  }
99
524
  }
100
- return { readOnly, immovable };
525
+ return {
526
+ readOnly: [...new Set(readOnly)],
527
+ immovable: [...new Set(immovable)],
528
+ };
101
529
  }
102
530
  /**
103
531
  * Recompute for every command: the previous command may have initialized Git.
@@ -112,8 +540,9 @@ export function sandboxPolicy(project, folder, directoryLimit = maximumDiscovery
112
540
  throw new Error("Sandbox folder resolves outside the project.");
113
541
  if (!lstatSync(writableRoot).isDirectory())
114
542
  throw new Error("Sandbox folder must be a directory.");
115
- const git = gitProtection(writableRoot, limit);
543
+ const git = gitProtection(root, writableRoot, limit);
116
544
  return {
545
+ projectRoot: root,
117
546
  writableRoot,
118
547
  readOnly: [join(writableRoot, ".fungi"), ...git.readOnly],
119
548
  immovable: git.immovable,
@@ -1,5 +1,7 @@
1
+ import type { NativeSourceReader } from "./private-local-bwrap.js";
1
2
  import { z } from "zod";
2
3
  import type { LocalSandboxWrites } from "./private-invocation-files.js";
4
+ import { type NativeRunLivenessProbe } from "./private-local-sandbox-policy.js";
3
5
  /** Availability is not a hostile-program security or an approval status. */
4
6
  export declare const sandboxStatusSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
5
7
  state: z.ZodLiteral<"available">;
@@ -10,11 +12,17 @@ export declare const sandboxStatusSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
10
12
  reason: z.ZodObject<{
11
13
  kind: z.ZodEnum<{
12
14
  missing: "missing";
15
+ "missing-artifact": "missing-artifact";
13
16
  "invalid-digest": "invalid-digest";
14
17
  "digest-mismatch": "digest-mismatch";
18
+ unreadable: "unreadable";
15
19
  "not-executable": "not-executable";
20
+ "staging-failed": "staging-failed";
16
21
  "unsupported-platform": "unsupported-platform";
17
22
  "invalid-folder": "invalid-folder";
23
+ "unsafe-native-payload": "unsafe-native-payload";
24
+ "unsafe-host-code": "unsafe-host-code";
25
+ "startup-refused": "startup-refused";
18
26
  "backend-refused": "backend-refused";
19
27
  }>;
20
28
  message: z.ZodString;
@@ -34,4 +42,7 @@ export declare function createLocalSandboxControls(input: Readonly<{
34
42
  project: string;
35
43
  folder: string;
36
44
  required(): boolean;
45
+ platform?: string;
46
+ readNativePatchSource?: NativeSourceReader;
47
+ probeNativeRunLiveness?: NativeRunLivenessProbe;
37
48
  }>): LocalSandboxControls;