@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.
- package/dist/private-files-patch.js +38 -21
- package/dist/private-invocation-computer.d.ts +9 -2
- package/dist/private-local-bwrap.d.ts +13 -5
- package/dist/private-local-bwrap.js +65 -30
- package/dist/private-local-containment.d.ts +10 -2
- package/dist/private-local-containment.js +64 -23
- package/dist/private-local-linux-sandbox.d.ts +4 -2
- package/dist/private-local-linux-sandbox.js +16 -3
- package/dist/private-local-native-digests.js +2 -0
- package/dist/private-local-native-staging.d.ts +17 -0
- package/dist/private-local-native-staging.js +49 -0
- package/dist/private-local-runtime-host.js +13 -6
- package/dist/private-local-sandbox-policy.d.ts +21 -0
- package/dist/private-local-sandbox-policy.js +439 -10
- package/dist/private-local-sandbox.d.ts +11 -0
- package/dist/private-local-sandbox.js +93 -18
- package/dist/private-local-service-discovery.d.ts +1 -1
- package/dist/private-local-service-owner.d.ts +1 -1
- package/dist/private-local-service.js +13 -6
- package/dist/private-local-session-discovery.d.ts +1 -1
- package/native/bin/bwrap-build-source/Dockerfile +1 -1
- package/package.json +10 -10
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
+
queue(path, kind);
|
|
98
523
|
}
|
|
99
524
|
}
|
|
100
|
-
return {
|
|
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;
|