@fungi.computer/truffle 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/private-files-patch.js +39 -36
- package/dist/private-invocation-computer.d.ts +224 -5
- 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 +17 -7
- package/dist/private-local-bwrap.js +73 -33
- 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 +18 -8
- package/dist/private-local-containment.js +70 -45
- 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 -3
- package/dist/private-local-linux-sandbox.js +71 -25
- 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 +2 -0
- 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 +16 -0
- package/dist/private-local-native-staging.js +138 -0
- 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 +391 -71
- package/dist/private-local-sandbox-policy.d.ts +46 -6
- package/dist/private-local-sandbox-policy.js +350 -71
- package/dist/private-local-sandbox.d.ts +34 -18
- package/dist/private-local-sandbox.js +225 -60
- 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 +18 -6
- 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 +43 -9
- package/dist/private-local-session-discovery.d.ts +3 -2
- 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 +3 -1
- 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 +25 -19
|
@@ -8,14 +8,54 @@ 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;
|
|
13
|
+
/** Whole Git administration and Agent state, read-only in Workspace. */
|
|
12
14
|
readOnly: readonly string[];
|
|
13
|
-
immovable: readonly string[];
|
|
14
15
|
}>;
|
|
15
|
-
/**
|
|
16
|
-
export
|
|
16
|
+
/** The host, never project configuration, chooses every writable cache grant. */
|
|
17
|
+
export type LinuxSandboxProfile = LocalSandboxPolicy & Readonly<{
|
|
18
|
+
mode: "workspace" | "read-only";
|
|
19
|
+
writable: readonly string[];
|
|
20
|
+
hidden: readonly string[];
|
|
21
|
+
temporaryDirectory: string;
|
|
22
|
+
/** Host-selected cache carve-out to prepare before rendering bwrap mounts. */
|
|
23
|
+
fungiCache: string | undefined;
|
|
24
|
+
sshAgentSocket: string | undefined;
|
|
25
|
+
sshAgentMask: string | undefined;
|
|
26
|
+
}>;
|
|
27
|
+
export declare function linuxSandboxProfile(policy: LocalSandboxPolicy, mode: "workspace" | "read-only", temporaryDirectory: string, host: NodeJS.ProcessEnv,
|
|
28
|
+
/** Pass the real passwd home in production; fixtures supply their own. */
|
|
29
|
+
osHome: string, platform?: "linux" | "darwin"): LinuxSandboxProfile;
|
|
30
|
+
/** Private persistent Agent temp, not the staging directory or host /tmp. */
|
|
31
|
+
export declare function agentSandboxTemporaryDirectory(agentId: string, project: string): string;
|
|
32
|
+
export declare class UnsafeNativePayload extends Error {
|
|
33
|
+
readonly name = "UnsafeNativePayload";
|
|
34
|
+
}
|
|
35
|
+
export declare class UnsafeHostCode extends Error {
|
|
36
|
+
readonly name = "UnsafeHostCode";
|
|
37
|
+
}
|
|
38
|
+
/** A symlinked Git marker cannot be protected by a bind on its logical name. */
|
|
39
|
+
export declare class UnsafeGitMarker extends Error {
|
|
40
|
+
readonly name = "UnsafeGitMarker";
|
|
41
|
+
}
|
|
17
42
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
43
|
+
* Packed CLI identity is supplied by the Node bin wrapper, which loaded both
|
|
44
|
+
* paths before spawning Bun. This is an admission check, not an integrity
|
|
45
|
+
* check on JS that might already have been rewritten.
|
|
20
46
|
*/
|
|
21
|
-
export declare function
|
|
47
|
+
export declare function assertHostCodeOutsideAgent(project: string, cli: string, launcher: string): void;
|
|
48
|
+
/** Refuse both logical and resolved native source locations in the whole
|
|
49
|
+
* Agent project or Git administration before verifying source bytes. */
|
|
50
|
+
export declare function assertHostOwnedNativeFile(project: string, path: string): void;
|
|
51
|
+
/** One logical/resolved authority for Files, bwrap and Seatbelt. */
|
|
52
|
+
export declare function protectedSandboxPaths(paths: readonly string[]): string[];
|
|
53
|
+
/** Git owns the representation of the selected folder's worktree and common dir. */
|
|
54
|
+
export declare function gitDirectories(project: string, root: string, path?: string): {
|
|
55
|
+
common: string;
|
|
56
|
+
worktree: string;
|
|
57
|
+
} | undefined;
|
|
58
|
+
/** Remote names from the protected common config only, never a racy worktree path. */
|
|
59
|
+
export declare function protectedGitRemoteNames(project: string, common: string, path: string): ReadonlySet<string> | undefined;
|
|
60
|
+
/** Recompute for each command: the prior command may have initialized Git. */
|
|
61
|
+
export declare function sandboxPolicy(project: string, folder: string): LocalSandboxPolicy;
|
|
@@ -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, realpathSync, statSync, } 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. */
|
|
@@ -17,10 +20,198 @@ export function resolveAgentFolder(project, folder) {
|
|
|
17
20
|
const within = relative(root, absolute);
|
|
18
21
|
if (isAbsolute(within) || within === ".." || within.startsWith(`..${sep}`))
|
|
19
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");
|
|
20
25
|
if (!lstatSync(absolute).isDirectory())
|
|
21
26
|
throw new Error("Agent folder must be a directory");
|
|
22
27
|
return { folder: within.split(sep).join("/") || ".", absolute };
|
|
23
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
|
+
}
|
|
132
|
+
export class UnsafeNativePayload extends Error {
|
|
133
|
+
name = "UnsafeNativePayload";
|
|
134
|
+
}
|
|
135
|
+
export class UnsafeHostCode extends Error {
|
|
136
|
+
name = "UnsafeHostCode";
|
|
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
|
+
}
|
|
142
|
+
function within(root, path) {
|
|
143
|
+
const remainder = relative(root, path);
|
|
144
|
+
return (!isAbsolute(remainder) &&
|
|
145
|
+
remainder !== ".." &&
|
|
146
|
+
!remainder.startsWith(`..${sep}`));
|
|
147
|
+
}
|
|
148
|
+
function inGitAdministration(path) {
|
|
149
|
+
for (let directory = dirname(path);; directory = dirname(directory)) {
|
|
150
|
+
if (basename(directory) === ".git" ||
|
|
151
|
+
(lstatSync(join(directory, "HEAD"), {
|
|
152
|
+
throwIfNoEntry: false,
|
|
153
|
+
})?.isFile() &&
|
|
154
|
+
(lstatSync(join(directory, "objects"), {
|
|
155
|
+
throwIfNoEntry: false,
|
|
156
|
+
})?.isDirectory() ||
|
|
157
|
+
lstatSync(join(directory, "commondir"), {
|
|
158
|
+
throwIfNoEntry: false,
|
|
159
|
+
})?.isFile())))
|
|
160
|
+
return true;
|
|
161
|
+
if (directory === dirname(directory))
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Packed CLI identity is supplied by the Node bin wrapper, which loaded both
|
|
167
|
+
* paths before spawning Bun. This is an admission check, not an integrity
|
|
168
|
+
* check on JS that might already have been rewritten.
|
|
169
|
+
*/
|
|
170
|
+
export function assertHostCodeOutsideAgent(project, cli, launcher) {
|
|
171
|
+
const root = realpathSync(project);
|
|
172
|
+
const paths = [
|
|
173
|
+
cli,
|
|
174
|
+
launcher,
|
|
175
|
+
dirname(cli),
|
|
176
|
+
dirname(dirname(cli)),
|
|
177
|
+
dirname(launcher),
|
|
178
|
+
dirname(dirname(launcher)),
|
|
179
|
+
];
|
|
180
|
+
for (const path of paths) {
|
|
181
|
+
try {
|
|
182
|
+
const logical = resolve(path);
|
|
183
|
+
const actual = realpathSync(logical);
|
|
184
|
+
if ([logical, actual].some((candidate) => within(root, candidate) || inGitAdministration(candidate)))
|
|
185
|
+
throw new UnsafeHostCode("Fungi host JavaScript is inside the Agent project or Git administration; install Fungi outside the project.");
|
|
186
|
+
}
|
|
187
|
+
catch (cause) {
|
|
188
|
+
if (cause instanceof UnsafeHostCode)
|
|
189
|
+
throw cause;
|
|
190
|
+
throw new UnsafeHostCode("Fungi host JavaScript location cannot be verified; reinstall Fungi outside the project.", { cause });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const path of [cli, launcher]) {
|
|
194
|
+
try {
|
|
195
|
+
const file = statSync(path);
|
|
196
|
+
if (!file.isFile() || file.nlink > 1)
|
|
197
|
+
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).");
|
|
198
|
+
}
|
|
199
|
+
catch (cause) {
|
|
200
|
+
if (cause instanceof UnsafeHostCode)
|
|
201
|
+
throw cause;
|
|
202
|
+
throw new UnsafeHostCode("Fungi host JavaScript cannot be inspected; reinstall Fungi using a copying install outside the project.", { cause });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/** Refuse both logical and resolved native source locations in the whole
|
|
207
|
+
* Agent project or Git administration before verifying source bytes. */
|
|
208
|
+
export function assertHostOwnedNativeFile(project, path) {
|
|
209
|
+
const logical = resolve(path);
|
|
210
|
+
const actual = realpathSync(logical);
|
|
211
|
+
const gitAdmin = (candidate) => inGitAdministration(candidate);
|
|
212
|
+
if ([logical, actual].some((candidate) => within(project, candidate) || gitAdmin(candidate)))
|
|
213
|
+
throw new UnsafeNativePayload("Packaged native payload is inside the Agent project or a Git-admin folder; reinstall Fungi outside the project.");
|
|
214
|
+
}
|
|
24
215
|
/** Missing reserved names resolve through their nearest existing ancestor. */
|
|
25
216
|
function canonicalPath(path) {
|
|
26
217
|
if (lstatSync(path, { throwIfNoEntry: false }) !== undefined)
|
|
@@ -38,84 +229,172 @@ export function protectedSandboxPaths(paths) {
|
|
|
38
229
|
})),
|
|
39
230
|
];
|
|
40
231
|
}
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
.min(1)
|
|
49
|
-
.max(maximumDiscoveryDirectories);
|
|
50
|
-
function gitProtection(root, directoryLimit) {
|
|
51
|
-
const pending = [
|
|
52
|
-
{ path: root, kind: "tree" },
|
|
53
|
-
];
|
|
54
|
-
const readOnly = [];
|
|
55
|
-
const immovable = [];
|
|
56
|
-
let discoveredDirectories = 1; // The writable root itself.
|
|
57
|
-
for (let directory = pending.pop(); directory !== undefined; directory = pending.pop()) {
|
|
58
|
-
const entries = readdirSync(directory.path, { withFileTypes: true });
|
|
59
|
-
const has = (name) => entries.some((entry) => entry.name === name);
|
|
60
|
-
const git = directory.kind === "git" ||
|
|
61
|
-
basename(directory.path) === ".git" ||
|
|
62
|
-
(has("HEAD") && (has("objects") || has("commondir"))) ||
|
|
63
|
-
(directory.kind === "modules" &&
|
|
64
|
-
(has("HEAD") || has("config") || has("hooks")));
|
|
65
|
-
// A nested Git-file checkout is a repository boundary, not another copy of
|
|
66
|
-
// the selected project's payload to walk. The normal root scan owns common
|
|
67
|
-
// administration; never follow the pointer. Never prune the selected root or
|
|
68
|
-
// directories already recognized as Git administration.
|
|
69
|
-
if (directory.path !== root &&
|
|
70
|
-
!git &&
|
|
71
|
-
entries.some((entry) => entry.name === ".git" && entry.isFile())) {
|
|
72
|
-
readOnly.push(join(directory.path, ".git"));
|
|
232
|
+
// One bounded host Git query per writable folder, regardless of repository size.
|
|
233
|
+
const maximumGitOutputBytes = 16 * 1024;
|
|
234
|
+
const gitTimeoutMilliseconds = 30_000;
|
|
235
|
+
/** Host Git from absolute PATH entries; never a binary the Agent can write. */
|
|
236
|
+
function hostGit(project, path, strictPath) {
|
|
237
|
+
for (const directory of path.split(":")) {
|
|
238
|
+
if (!isAbsolute(directory))
|
|
73
239
|
continue;
|
|
240
|
+
const candidate = join(directory, "git");
|
|
241
|
+
try {
|
|
242
|
+
accessSync(candidate, constants.X_OK);
|
|
243
|
+
const actual = realpathSync(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;
|
|
74
254
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
for (const entry of entries) {
|
|
80
|
-
const path = join(directory.path, entry.name);
|
|
81
|
-
if (entry.name === ".git" && !entry.isDirectory())
|
|
82
|
-
readOnly.push(path); // Never parse a Git pointer or grant an external dir.
|
|
83
|
-
if (!entry.isDirectory())
|
|
84
|
-
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
|
-
let kind = "tree";
|
|
89
|
-
if (git && entry.name === "modules")
|
|
90
|
-
kind = "modules";
|
|
91
|
-
else if (git && entry.name === "worktrees")
|
|
92
|
-
kind = "worktrees";
|
|
93
|
-
else if (!git && directory.kind === "modules")
|
|
94
|
-
kind = "modules";
|
|
95
|
-
else if (!git && directory.kind === "worktrees")
|
|
96
|
-
kind = "git";
|
|
97
|
-
pending.push({ path, kind });
|
|
255
|
+
catch (cause) {
|
|
256
|
+
// No usable Git on this PATH entry; any other fault refuses discovery.
|
|
257
|
+
if (!unreadableEntry(cause))
|
|
258
|
+
throw cause;
|
|
98
259
|
}
|
|
99
260
|
}
|
|
100
|
-
return
|
|
261
|
+
return undefined;
|
|
101
262
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
263
|
+
function gitEnvironment() {
|
|
264
|
+
const environment = {};
|
|
265
|
+
for (const [name, value] of Object.entries(process.env))
|
|
266
|
+
if (!name.startsWith("GIT_"))
|
|
267
|
+
environment[name] = value;
|
|
268
|
+
return { ...environment, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
|
|
269
|
+
}
|
|
270
|
+
/** Missing, denied or looping PATH entry; never a reason to grant a Git path. */
|
|
271
|
+
function unreadableEntry(cause) {
|
|
272
|
+
if (!(cause instanceof Error) || !("code" in cause))
|
|
273
|
+
return false;
|
|
274
|
+
switch (cause.code) {
|
|
275
|
+
case "ENOENT":
|
|
276
|
+
case "ENOTDIR":
|
|
277
|
+
case "ELOOP":
|
|
278
|
+
case "EACCES":
|
|
279
|
+
case "EISDIR":
|
|
280
|
+
return true;
|
|
281
|
+
default:
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
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,
|
|
300
|
+
"rev-parse",
|
|
301
|
+
"--absolute-git-dir",
|
|
302
|
+
"--git-common-dir",
|
|
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))
|
|
313
|
+
return undefined;
|
|
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] !== "")
|
|
318
|
+
return undefined;
|
|
319
|
+
const [gitDir, commonDir] = locations;
|
|
320
|
+
if (gitDir === undefined || !isAbsolute(gitDir) || !commonDir)
|
|
321
|
+
return undefined;
|
|
322
|
+
try {
|
|
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 };
|
|
328
|
+
}
|
|
329
|
+
catch (cause) {
|
|
330
|
+
if (unreadableEntry(cause))
|
|
331
|
+
return undefined;
|
|
332
|
+
throw cause;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
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);
|
|
373
|
+
}
|
|
374
|
+
return names;
|
|
375
|
+
}
|
|
376
|
+
/** Recompute for each command: the prior command may have initialized Git. */
|
|
377
|
+
export function sandboxPolicy(project, folder) {
|
|
108
378
|
const root = realpathSync(project);
|
|
109
379
|
const writableRoot = realpathSync(folder);
|
|
110
|
-
const
|
|
111
|
-
if (isAbsolute(
|
|
380
|
+
const remainder = relative(root, writableRoot);
|
|
381
|
+
if (isAbsolute(remainder) ||
|
|
382
|
+
remainder === ".." ||
|
|
383
|
+
remainder.startsWith(`..${sep}`))
|
|
112
384
|
throw new Error("Sandbox folder resolves outside the project.");
|
|
113
385
|
if (!lstatSync(writableRoot).isDirectory())
|
|
114
386
|
throw new Error("Sandbox folder must be a directory.");
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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)] };
|
|
121
400
|
}
|
|
@@ -1,37 +1,53 @@
|
|
|
1
|
+
import type { NativeSourceReader } from "./private-local-bwrap.js";
|
|
1
2
|
import { z } from "zod";
|
|
3
|
+
import { sandboxUnavailableReasonSchema, type SandboxUnavailableReason } from "./private-local-containment.js";
|
|
4
|
+
import type { LocalPermissionMode } from "./private-local-permission-mode.js";
|
|
2
5
|
import type { LocalSandboxWrites } from "./private-invocation-files.js";
|
|
6
|
+
import type { NativePayloadStaging } from "./private-local-native-staging.js";
|
|
3
7
|
/** Availability is not a hostile-program security or an approval status. */
|
|
4
|
-
export declare const sandboxStatusSchema: z.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
export declare const sandboxStatusSchema: z.ZodObject<{
|
|
9
|
+
mode: z.ZodEnum<{
|
|
10
|
+
"read-only": "read-only";
|
|
11
|
+
workspace: "workspace";
|
|
12
|
+
"full-access": "full-access";
|
|
13
|
+
}>;
|
|
14
|
+
available: z.ZodBoolean;
|
|
15
|
+
reason: z.ZodOptional<z.ZodObject<{
|
|
11
16
|
kind: z.ZodEnum<{
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"unsupported-platform": "unsupported-platform";
|
|
17
|
-
"invalid-folder": "invalid-folder";
|
|
18
|
-
"backend-refused": "backend-refused";
|
|
17
|
+
install: "install";
|
|
18
|
+
unsupported: "unsupported";
|
|
19
|
+
folder: "folder";
|
|
20
|
+
"startup-refused": "startup-refused";
|
|
19
21
|
}>;
|
|
20
22
|
message: z.ZodString;
|
|
21
|
-
}, z.core.$strict
|
|
22
|
-
}, z.core.$strict
|
|
23
|
+
}, z.core.$strict>>;
|
|
24
|
+
}, z.core.$strict>;
|
|
23
25
|
export type LocalSandboxStatus = z.output<typeof sandboxStatusSchema>;
|
|
24
26
|
export type LocalSandboxControls = LocalSandboxWrites & Readonly<{
|
|
25
27
|
assertReady(): void;
|
|
28
|
+
assertModeReady(mode: LocalPermissionMode): void;
|
|
26
29
|
status(): LocalSandboxStatus;
|
|
27
30
|
close(): void;
|
|
28
31
|
}>;
|
|
32
|
+
/** Refusal before an Agent Files write can be staged or dispatched. */
|
|
33
|
+
export declare class ProtectedLocalFilesPath extends Error {
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly name = "ProtectedLocalFilesPath";
|
|
36
|
+
constructor(path: string);
|
|
37
|
+
}
|
|
29
38
|
/**
|
|
30
|
-
* Native Local owns the live
|
|
39
|
+
* Native Local owns the live mode; the adapter owns only preparation.
|
|
31
40
|
* Every consumer calls this authority immediately before launching or writing.
|
|
32
41
|
*/
|
|
33
42
|
export declare function createLocalSandboxControls(input: Readonly<{
|
|
34
43
|
project: string;
|
|
35
44
|
folder: string;
|
|
36
|
-
|
|
45
|
+
agentId?: string;
|
|
46
|
+
mode(): LocalPermissionMode;
|
|
47
|
+
defaultReason?: z.output<typeof sandboxUnavailableReasonSchema> | undefined;
|
|
48
|
+
platform?: string;
|
|
49
|
+
readNativePatchSource?: NativeSourceReader;
|
|
50
|
+
native?: NativePayloadStaging | undefined;
|
|
37
51
|
}>): LocalSandboxControls;
|
|
52
|
+
/** Probe a fresh Agent before deciding its mode; never treat an unavailable backend as a sandboxed run. */
|
|
53
|
+
export declare function probeLocalSandbox(project: string, folder: string, native?: NativePayloadStaging): SandboxUnavailableReason | undefined;
|