@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.
- package/dist/private-files-patch.js +3 -17
- package/dist/private-invocation-computer.d.ts +224 -12
- package/dist/private-invocation-computer.js +162 -22
- package/dist/private-invocation-files.d.ts +3 -2
- package/dist/private-invocation-files.js +22 -3
- package/dist/private-local-agent-control.d.ts +2 -2
- package/dist/private-local-bwrap.d.ts +9 -7
- package/dist/private-local-bwrap.js +26 -21
- package/dist/private-local-computer-processes.d.ts +15 -0
- package/dist/private-local-computer-processes.js +13 -0
- package/dist/private-local-containment.d.ts +15 -13
- package/dist/private-local-containment.js +49 -65
- package/dist/private-local-delegation.d.ts +3 -3
- package/dist/private-local-delegation.js +32 -19
- package/dist/private-local-exec-review.d.ts +10 -0
- package/dist/private-local-exec-review.js +50 -0
- package/dist/private-local-exec-rules.d.ts +14 -0
- package/dist/private-local-exec-rules.js +420 -0
- package/dist/private-local-git-exclude.js +25 -15
- package/dist/private-local-linux-sandbox.d.ts +5 -5
- package/dist/private-local-linux-sandbox.js +63 -30
- package/dist/private-local-mail.d.ts +2 -2
- package/dist/private-local-mesh-links.d.ts +4 -1
- package/dist/private-local-mesh-links.js +50 -3
- package/dist/private-local-native-digests.js +1 -1
- package/dist/private-local-native-install-error.d.ts +2 -0
- package/dist/private-local-native-install-error.js +10 -0
- package/dist/private-local-native-staging.d.ts +7 -8
- package/dist/private-local-native-staging.js +132 -43
- package/dist/private-local-permission-mode.d.ts +10 -0
- package/dist/private-local-permission-mode.js +18 -0
- package/dist/private-local-runtime-host.d.ts +19 -5
- package/dist/private-local-runtime-host.js +378 -65
- package/dist/private-local-sandbox-policy.d.ts +30 -11
- package/dist/private-local-sandbox-policy.js +223 -373
- package/dist/private-local-sandbox.d.ts +30 -25
- package/dist/private-local-sandbox.js +190 -100
- package/dist/private-local-seatbelt.d.ts +7 -13
- package/dist/private-local-seatbelt.js +55 -32
- package/dist/private-local-service-contract.d.ts +5 -4
- package/dist/private-local-service-discovery.d.ts +19 -7
- package/dist/private-local-service-discovery.js +4 -3
- package/dist/private-local-service-listener.js +40 -2
- package/dist/private-local-service-log.d.ts +8 -0
- package/dist/private-local-service-log.js +3 -0
- package/dist/private-local-service-owner.d.ts +18 -10
- package/dist/private-local-service-owner.js +117 -25
- package/dist/private-local-service.js +30 -3
- package/dist/private-local-session-discovery.d.ts +2 -1
- package/dist/private-local-session-discovery.js +18 -6
- package/dist/private-mcp-form-contract.d.ts +8 -1
- package/dist/private-mcp-form-contract.js +8 -1
- package/dist/private-mcp-form-session.d.ts +33 -3
- package/dist/private-mcp-form-session.js +28 -5
- package/dist/private-oyster-mcp-auth.d.ts +1 -1
- package/dist/private-oyster-mcp-auth.js +139 -7
- package/dist/private-provider-rejection.js +1 -1
- package/dist/seatbelt/safe-base.sbpl +2 -2
- package/native/bin/bwrap-build-source/Dockerfile +2 -0
- package/native/bin/bwrap-build-source/README.md +6 -6
- package/native/bin/bwrap-build-source/pin.mjs +2 -2
- package/native/bin/bwrap-build-source/record.mjs +12 -1
- package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +10 -7
- package/native/bin/bwrap-build.json +3 -2
- package/native/bin/fungi-apply-patch +0 -0
- package/native/bin/fungi-seccomp-workspace.bpf +0 -0
- package/native/bin/fungi-seccomp-workspace.bpf.sha256 +1 -0
- 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,
|
|
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
|
-
//
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
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,
|
|
252
|
-
function
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
],
|
|
323
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
|
329
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
331
|
+
return undefined;
|
|
380
332
|
throw cause;
|
|
381
333
|
}
|
|
382
334
|
}
|
|
383
|
-
/**
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
|
539
|
-
if (isAbsolute(
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
}
|