@fungi.computer/truffle 0.2.2 → 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.
Files changed (68) hide show
  1. package/dist/private-files-patch.js +3 -17
  2. package/dist/private-invocation-computer.d.ts +224 -12
  3. package/dist/private-invocation-computer.js +162 -22
  4. package/dist/private-invocation-files.d.ts +3 -2
  5. package/dist/private-invocation-files.js +22 -3
  6. package/dist/private-local-agent-control.d.ts +2 -2
  7. package/dist/private-local-bwrap.d.ts +9 -7
  8. package/dist/private-local-bwrap.js +26 -21
  9. package/dist/private-local-computer-processes.d.ts +15 -0
  10. package/dist/private-local-computer-processes.js +13 -0
  11. package/dist/private-local-containment.d.ts +15 -13
  12. package/dist/private-local-containment.js +49 -65
  13. package/dist/private-local-delegation.d.ts +3 -3
  14. package/dist/private-local-delegation.js +32 -19
  15. package/dist/private-local-exec-review.d.ts +10 -0
  16. package/dist/private-local-exec-review.js +50 -0
  17. package/dist/private-local-exec-rules.d.ts +14 -0
  18. package/dist/private-local-exec-rules.js +420 -0
  19. package/dist/private-local-git-exclude.js +25 -15
  20. package/dist/private-local-linux-sandbox.d.ts +5 -5
  21. package/dist/private-local-linux-sandbox.js +63 -30
  22. package/dist/private-local-mail.d.ts +2 -2
  23. package/dist/private-local-mesh-links.d.ts +4 -1
  24. package/dist/private-local-mesh-links.js +50 -3
  25. package/dist/private-local-native-digests.js +1 -1
  26. package/dist/private-local-native-install-error.d.ts +2 -0
  27. package/dist/private-local-native-install-error.js +10 -0
  28. package/dist/private-local-native-staging.d.ts +7 -8
  29. package/dist/private-local-native-staging.js +132 -43
  30. package/dist/private-local-permission-mode.d.ts +10 -0
  31. package/dist/private-local-permission-mode.js +18 -0
  32. package/dist/private-local-runtime-host.d.ts +19 -5
  33. package/dist/private-local-runtime-host.js +378 -65
  34. package/dist/private-local-sandbox-policy.d.ts +30 -11
  35. package/dist/private-local-sandbox-policy.js +223 -373
  36. package/dist/private-local-sandbox.d.ts +30 -25
  37. package/dist/private-local-sandbox.js +190 -100
  38. package/dist/private-local-seatbelt.d.ts +7 -13
  39. package/dist/private-local-seatbelt.js +55 -32
  40. package/dist/private-local-service-contract.d.ts +5 -4
  41. package/dist/private-local-service-discovery.d.ts +19 -7
  42. package/dist/private-local-service-discovery.js +4 -3
  43. package/dist/private-local-service-listener.js +40 -2
  44. package/dist/private-local-service-log.d.ts +8 -0
  45. package/dist/private-local-service-log.js +3 -0
  46. package/dist/private-local-service-owner.d.ts +18 -10
  47. package/dist/private-local-service-owner.js +117 -25
  48. package/dist/private-local-service.js +30 -3
  49. package/dist/private-local-session-discovery.d.ts +2 -1
  50. package/dist/private-local-session-discovery.js +18 -6
  51. package/dist/private-mcp-form-contract.d.ts +8 -1
  52. package/dist/private-mcp-form-contract.js +8 -1
  53. package/dist/private-mcp-form-session.d.ts +33 -3
  54. package/dist/private-mcp-form-session.js +28 -5
  55. package/dist/private-oyster-mcp-auth.d.ts +1 -1
  56. package/dist/private-oyster-mcp-auth.js +139 -7
  57. package/dist/private-provider-rejection.js +1 -1
  58. package/dist/seatbelt/safe-base.sbpl +2 -2
  59. package/native/bin/bwrap-build-source/Dockerfile +2 -0
  60. package/native/bin/bwrap-build-source/README.md +6 -6
  61. package/native/bin/bwrap-build-source/pin.mjs +2 -2
  62. package/native/bin/bwrap-build-source/record.mjs +12 -1
  63. package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +10 -7
  64. package/native/bin/bwrap-build.json +3 -2
  65. package/native/bin/fungi-apply-patch +0 -0
  66. package/native/bin/fungi-seccomp-workspace.bpf +0 -0
  67. package/native/bin/fungi-seccomp-workspace.bpf.sha256 +1 -0
  68. package/package.json +23 -17
@@ -1,6 +1,6 @@
1
1
  import { isUtf8 } from "node:buffer";
2
2
  import { spawnSync } from "node:child_process";
3
- import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { accessSync, constants, lstatSync, mkdirSync, realpathSync, statSync, } from "node:fs";
4
4
  import { userInfo } from "node:os";
5
5
  import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep, } from "node:path";
6
6
  import { z } from "zod";
@@ -20,16 +20,125 @@ export function resolveAgentFolder(project, folder) {
20
20
  const within = relative(root, absolute);
21
21
  if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`))
22
22
  throw new Error("Agent folder resolves outside the project");
23
+ if (within === ".fungi" || within.startsWith(`.fungi${sep}`))
24
+ throw new Error("Agent folder cannot be inside protected project .fungi");
23
25
  if (!lstatSync(absolute).isDirectory())
24
26
  throw new Error("Agent folder must be a directory");
25
27
  return { folder: within.split(sep).join("/") || ".", absolute };
26
28
  }
29
+ function existingDirectory(path) {
30
+ if (!isAbsolute(path))
31
+ return [];
32
+ try {
33
+ return statSync(path).isDirectory() ? [realpathSync(path)] : [];
34
+ }
35
+ catch (cause) {
36
+ if (unreadableEntry(cause))
37
+ return [];
38
+ throw cause;
39
+ }
40
+ }
41
+ function covers(root, path) {
42
+ return within(root, path);
43
+ }
44
+ export function linuxSandboxProfile(policy, mode, temporaryDirectory, host,
45
+ /** Pass the real passwd home in production; fixtures supply their own. */
46
+ osHome, platform = "linux") {
47
+ const home = host.HOME ?? osHome;
48
+ if (!isAbsolute(home))
49
+ throw new Error("Sandbox HOME must be absolute");
50
+ if (!isAbsolute(osHome))
51
+ throw new Error("Sandbox OS home must be absolute");
52
+ const cache = host.XDG_CACHE_HOME ?? join(home, ".cache");
53
+ const data = host.XDG_DATA_HOME ?? join(home, ".local", "share");
54
+ const cargo = host.CARGO_HOME ?? join(home, ".cargo");
55
+ const caches = [
56
+ cache,
57
+ join(home, ".npm"),
58
+ join(host.PNPM_HOME ?? join(data, "pnpm"), "store"),
59
+ join(home, ".bun", "install", "cache"),
60
+ join(cargo, "registry"),
61
+ join(cargo, "git"),
62
+ host.GOMODCACHE ?? join(home, "go", "pkg", "mod"),
63
+ host.GOCACHE ?? join(cache, "go-build"),
64
+ ];
65
+ const socket = host.SSH_AUTH_SOCK;
66
+ const selectedSocket = socket &&
67
+ isAbsolute(socket) &&
68
+ statSync(socket, { throwIfNoEntry: false })?.isSocket()
69
+ ? socket
70
+ : undefined;
71
+ const writableCaches = [...new Set(caches.flatMap(existingDirectory))].filter((path) => !covers(path, join(home, ".cargo", "bin")) &&
72
+ !covers(path, join(home, ".local", "bin")));
73
+ const fungiCache = canonicalPath(join(cache, "fungi"));
74
+ const protectedFungiCache = mode === "workspace" &&
75
+ writableCaches.some((path) => covers(path, fungiCache))
76
+ ? fungiCache
77
+ : undefined;
78
+ // A previous service can leave fallback Agent temp behind even when this
79
+ // Agent has moved to XDG_RUNTIME_DIR. Never expose either host candidate.
80
+ const fallbackTemporaryBases = [
81
+ ...new Set([home, osHome]
82
+ .map((root) => join(root, ".cache", "fungi", "tmp"))
83
+ .flatMap(existingDirectory)),
84
+ ];
85
+ return {
86
+ ...policy,
87
+ readOnly: protectedFungiCache === undefined
88
+ ? policy.readOnly
89
+ : [...policy.readOnly, protectedFungiCache],
90
+ mode,
91
+ temporaryDirectory,
92
+ fungiCache: protectedFungiCache,
93
+ writable: mode === "workspace"
94
+ ? [...new Set([policy.writableRoot, ...writableCaches])]
95
+ : [],
96
+ hidden: [
97
+ join(home, ".config", "fungi"),
98
+ join(home, ".fungi", "service"),
99
+ // Linux mounts a private tmpfs over siblings after re-binding our temp.
100
+ // Seatbelt's path denial would also hide the Agent's own fallback temp.
101
+ ...(platform === "linux" ? fallbackTemporaryBases : []),
102
+ ],
103
+ sshAgentSocket: mode === "workspace" ? selectedSocket : undefined,
104
+ sshAgentMask: mode === "read-only" ? selectedSocket : undefined,
105
+ };
106
+ }
107
+ /** Private persistent Agent temp, not the staging directory or host /tmp. */
108
+ export function agentSandboxTemporaryDirectory(agentId, project) {
109
+ if (!/^[a-zA-Z0-9_-]+$/.test(agentId))
110
+ throw new Error("Invalid Local Agent ID for temporary directory");
111
+ const home = userInfo().homedir;
112
+ const runtime = process.env.XDG_RUNTIME_DIR;
113
+ const base = runtime && isAbsolute(runtime)
114
+ ? join(runtime, "fungi", "tmp")
115
+ : join(home, ".cache", "fungi", "tmp");
116
+ const projectRoot = realpathSync(project);
117
+ if ([resolve(base), canonicalPath(base)].some((path) => within(projectRoot, path) || inGitAdministration(path)))
118
+ throw new Error("Agent temporary directory cannot live in the Agent project or Git administration");
119
+ mkdirSync(base, { recursive: true, mode: 0o700 });
120
+ const directory = join(base, agentId);
121
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
122
+ if (within(projectRoot, realpathSync(directory)))
123
+ throw new Error("Agent temporary directory resolved into the Agent project");
124
+ const metadata = lstatSync(directory);
125
+ if (!metadata.isDirectory() ||
126
+ metadata.isSymbolicLink() ||
127
+ metadata.uid !== process.getuid?.() ||
128
+ (metadata.mode & 0o077) !== 0)
129
+ throw new Error("Local Agent temporary directory must be private");
130
+ return realpathSync(directory);
131
+ }
27
132
  export class UnsafeNativePayload extends Error {
28
133
  name = "UnsafeNativePayload";
29
134
  }
30
135
  export class UnsafeHostCode extends Error {
31
136
  name = "UnsafeHostCode";
32
137
  }
138
+ /** A symlinked Git marker cannot be protected by a bind on its logical name. */
139
+ export class UnsafeGitMarker extends Error {
140
+ name = "UnsafeGitMarker";
141
+ }
33
142
  function within(root, path) {
34
143
  const remainder = relative(root, path);
35
144
  return (!isAbsolute(remainder) &&
@@ -103,93 +212,6 @@ export function assertHostOwnedNativeFile(project, path) {
103
212
  if ([logical, actual].some((candidate) => within(project, candidate) || gitAdmin(candidate)))
104
213
  throw new UnsafeNativePayload("Packaged native payload is inside the Agent project or a Git-admin folder; reinstall Fungi outside the project.");
105
214
  }
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
- }
193
215
  /** Missing reserved names resolve through their nearest existing ancestor. */
194
216
  function canonicalPath(path) {
195
217
  if (lstatSync(path, { throwIfNoEntry: false }) !== undefined)
@@ -207,31 +229,28 @@ export function protectedSandboxPaths(paths) {
207
229
  })),
208
230
  ];
209
231
  }
210
- // A project tree is untrusted input. Refuse incomplete discovery rather than
211
- // silently protecting only a prefix or allowing a bare command to bypass it.
212
- // Bound directory traversal, not ordinary dependency-file payloads.
213
- const maximumDiscoveryDirectories = 100_000;
214
- const discoveryDirectoryLimit = z
215
- .number()
216
- .int()
217
- .min(1)
218
- .max(maximumDiscoveryDirectories);
219
- // Each work tree costs four short Git processes; bound the process count too.
220
- const maximumGitWorkTrees = 256;
221
- const maximumGitOutputBytes = 256 * 1024 * 1024;
232
+ // One bounded host Git query per writable folder, regardless of repository size.
233
+ const maximumGitOutputBytes = 16 * 1024;
222
234
  const gitTimeoutMilliseconds = 30_000;
223
235
  /** 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(":")) {
236
+ function hostGit(project, path, strictPath) {
237
+ for (const directory of path.split(":")) {
226
238
  if (!isAbsolute(directory))
227
239
  continue;
228
240
  const candidate = join(directory, "git");
229
241
  try {
230
242
  accessSync(candidate, constants.X_OK);
231
243
  const actual = realpathSync(candidate);
232
- if (statSync(actual).isFile() &&
233
- ![candidate, actual].some((path) => within(project, path) || inGitAdministration(path)))
234
- return candidate;
244
+ if (!statSync(actual).isFile())
245
+ continue;
246
+ // A grant must inspect the first executable on its command PATH. For
247
+ // sandbox discovery, keep looking for a trusted Git instead.
248
+ if ([candidate, actual].some((location) => within(project, location) || inGitAdministration(location))) {
249
+ if (strictPath)
250
+ return undefined;
251
+ continue;
252
+ }
253
+ return candidate;
235
254
  }
236
255
  catch (cause) {
237
256
  // No usable Git on this PATH entry; any other fault refuses discovery.
@@ -248,303 +267,134 @@ function gitEnvironment() {
248
267
  environment[name] = value;
249
268
  return { ...environment, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
250
269
  }
251
- /** Missing, non-directory or looping entries; never other faults. */
252
- function absentEntry(cause) {
270
+ /** Missing, denied or looping PATH entry; never a reason to grant a Git path. */
271
+ function unreadableEntry(cause) {
253
272
  if (!(cause instanceof Error) || !("code" in cause))
254
273
  return false;
255
274
  switch (cause.code) {
256
275
  case "ENOENT":
257
276
  case "ENOTDIR":
258
277
  case "ELOOP":
278
+ case "EACCES":
279
+ case "EISDIR":
259
280
  return true;
260
281
  default:
261
282
  return false;
262
283
  }
263
284
  }
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([
285
+ /** Git owns the representation of the selected folder's worktree and common dir. */
286
+ export function gitDirectories(project, root, path) {
287
+ const git = hostGit(project, path ?? process.env.PATH ?? "", path !== undefined);
288
+ if (git === undefined)
289
+ return undefined;
290
+ // Configuration may be Agent-authored: no fsmonitor process, untracked
291
+ // cache, pager, inherited GIT_* override, or index write on the host.
292
+ const result = spawnSync(git, [
293
+ "--no-pager",
294
+ "-c",
295
+ "core.fsmonitor=false",
296
+ "-c",
297
+ "core.untrackedCache=false",
298
+ "-C",
299
+ root,
318
300
  "rev-parse",
319
- "--is-inside-work-tree",
320
301
  "--absolute-git-dir",
321
302
  "--git-common-dir",
322
- ], 10);
323
- if (location?.length !== 3)
303
+ ], {
304
+ cwd: root,
305
+ env: gitEnvironment(),
306
+ maxBuffer: maximumGitOutputBytes,
307
+ timeout: gitTimeoutMilliseconds,
308
+ stdio: ["ignore", "pipe", "ignore"],
309
+ });
310
+ if (result.error !== undefined ||
311
+ result.status !== 0 ||
312
+ !isUtf8(result.stdout))
324
313
  return undefined;
325
- const [inside, gitDirectory = "", commonDirectory = ""] = location;
326
- if (inside !== "true")
314
+ // A newline in a reported path is ambiguous in rev-parse's line protocol;
315
+ // do not turn any partial answer into an external writable grant.
316
+ const locations = result.stdout.toString("utf8").split("\n");
317
+ if (locations.length !== 3 || locations[2] !== "")
327
318
  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)
319
+ const [gitDir, commonDir] = locations;
320
+ if (gitDir === undefined || !isAbsolute(gitDir) || !commonDir)
332
321
  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
322
  try {
372
- const target = readFileSync(join(gitDirectory, "commondir"), "utf8").replace(/[\r\n]+$/u, "");
373
- return (target !== "" &&
374
- realpathSync(resolve(gitDirectory, target)) !== realpathSync(gitDirectory));
323
+ const worktree = realpathSync(gitDir);
324
+ const common = realpathSync(resolve(root, commonDir));
325
+ if (!statSync(worktree).isDirectory() || !statSync(common).isDirectory())
326
+ return undefined;
327
+ return { common, worktree };
375
328
  }
376
329
  catch (cause) {
377
- // Unreadable or dangling: protect it as its own common directory.
378
330
  if (unreadableEntry(cause))
379
- return false;
331
+ return undefined;
380
332
  throw cause;
381
333
  }
382
334
  }
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) {
391
- const pending = [
392
- { path: root, kind: "work-tree" },
393
- ];
394
- const readOnly = [];
395
- const immovable = [];
396
- const named = new Set();
397
- const executable = hostGit(project);
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
- };
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
- }
471
- const entries = readdirSync(directory.path, { withFileTypes: true });
472
- const has = (name) => entries.some((entry) => entry.name === name);
473
- const git = directory.kind === "git" ||
474
- basename(directory.path) === ".git" ||
475
- (has("HEAD") && (has("objects") || has("commondir"))) ||
476
- (directory.kind === "modules" &&
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;
486
- // A nested Git-file checkout is a repository boundary, not another copy of
487
- // the selected project's payload to walk. The normal root scan owns common
488
- // administration; never follow the pointer. Never prune the selected root or
489
- // directories already recognized as Git administration.
490
- if (directory.path !== root &&
491
- !git &&
492
- entries.some((entry) => entry.name === ".git" && entry.isFile())) {
493
- readOnly.push(join(directory.path, ".git"));
494
- continue;
495
- }
496
- if (git) {
497
- immovable.push(directory.path);
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"));
506
- }
507
- for (const entry of entries) {
508
- const path = join(directory.path, entry.name);
509
- if (entry.name === ".git" && !entry.isDirectory())
510
- readOnly.push(path); // Never parse a Git pointer or grant an external dir.
511
- if (!entry.isDirectory())
512
- continue; // No symlink cycles or outside walks.
513
- let kind = "tree";
514
- if (git && entry.name === "modules")
515
- kind = "modules";
516
- else if (git && entry.name === "worktrees")
517
- kind = "worktrees";
518
- else if (!git && directory.kind === "modules")
519
- kind = "modules";
520
- else if (!git && directory.kind === "worktrees")
521
- kind = "git";
522
- queue(path, kind);
523
- }
335
+ /** Remote names from the protected common config only, never a racy worktree path. */
336
+ export function protectedGitRemoteNames(project, common, path) {
337
+ const git = hostGit(project, path, true);
338
+ if (git === undefined)
339
+ return undefined;
340
+ const config = join(common, "config");
341
+ const entry = lstatSync(config, { throwIfNoEntry: false });
342
+ if (entry === undefined || !entry.isFile() || entry.nlink !== 1)
343
+ return undefined;
344
+ // --file plus --no-includes excludes global and Agent-writable included
345
+ // config. Git, not a handwritten config reader, decodes its own format.
346
+ const result = spawnSync(git, [
347
+ "config",
348
+ "--no-includes",
349
+ "--file",
350
+ config,
351
+ "--null",
352
+ "--name-only",
353
+ "--get-regexp",
354
+ "^remote\\..*\\.url$",
355
+ ], {
356
+ cwd: project,
357
+ env: gitEnvironment(),
358
+ maxBuffer: maximumGitOutputBytes,
359
+ timeout: gitTimeoutMilliseconds,
360
+ stdio: ["ignore", "pipe", "ignore"],
361
+ });
362
+ if (result.error !== undefined || !isUtf8(result.stdout))
363
+ return undefined;
364
+ if (result.status === 1 && result.stdout.length === 0)
365
+ return new Set();
366
+ if (result.status !== 0 || result.stdout.at(-1) !== 0)
367
+ return undefined;
368
+ const names = new Set();
369
+ for (const key of result.stdout.toString("utf8").slice(0, -1).split("\0")) {
370
+ const name = /^remote\.([A-Za-z][A-Za-z0-9_.-]*)\.url$/u.exec(key)?.[1];
371
+ if (name !== undefined)
372
+ names.add(name);
524
373
  }
525
- return {
526
- readOnly: [...new Set(readOnly)],
527
- immovable: [...new Set(immovable)],
528
- };
374
+ return names;
529
375
  }
530
- /**
531
- * Recompute for every command: the previous command may have initialized Git.
532
- * An internal caller may tighten the directory budget, never widen the ceiling.
533
- */
534
- export function sandboxPolicy(project, folder, directoryLimit = maximumDiscoveryDirectories) {
535
- const limit = discoveryDirectoryLimit.parse(directoryLimit);
376
+ /** Recompute for each command: the prior command may have initialized Git. */
377
+ export function sandboxPolicy(project, folder) {
536
378
  const root = realpathSync(project);
537
379
  const writableRoot = realpathSync(folder);
538
- const within = relative(root, writableRoot);
539
- if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`))
380
+ const remainder = relative(root, writableRoot);
381
+ if (isAbsolute(remainder) ||
382
+ remainder === ".." ||
383
+ remainder.startsWith(`..${sep}`))
540
384
  throw new Error("Sandbox folder resolves outside the project.");
541
385
  if (!lstatSync(writableRoot).isDirectory())
542
386
  throw new Error("Sandbox folder must be a directory.");
543
- const git = gitProtection(root, writableRoot, limit);
544
- return {
545
- projectRoot: root,
546
- writableRoot,
547
- readOnly: [join(writableRoot, ".fungi"), ...git.readOnly],
548
- immovable: git.immovable,
549
- };
387
+ // Do not create a marker on the host for an absent .git.
388
+ const marker = join(writableRoot, ".git");
389
+ const readOnly = [join(writableRoot, ".fungi")];
390
+ const markerEntry = lstatSync(marker, { throwIfNoEntry: false });
391
+ if (markerEntry === undefined)
392
+ return { projectRoot: root, writableRoot, readOnly };
393
+ if (markerEntry.isSymbolicLink())
394
+ throw new UnsafeGitMarker(`Agent folder ${marker} is a symlink. Remove the .git symlink or select a different Agent folder before running sandboxed commands.`);
395
+ readOnly.push(marker);
396
+ const git = gitDirectories(root, writableRoot);
397
+ if (git !== undefined)
398
+ readOnly.push(git.worktree, git.common);
399
+ return { projectRoot: root, writableRoot, readOnly: [...new Set(readOnly)] };
550
400
  }