apex-code 0.0.1-alpha.2 → 0.0.1-alpha.4
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/CHANGELOG.md +47 -1
- package/README.md +7 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -1
- package/dist/core/sandbox/child-entry.d.ts.map +1 -1
- package/dist/core/sandbox/child-entry.js +4 -0
- package/dist/core/sandbox/child-entry.js.map +1 -1
- package/dist/core/sandbox/cli-launch.d.ts +10 -1
- package/dist/core/sandbox/cli-launch.d.ts.map +1 -1
- package/dist/core/sandbox/cli-launch.js +50 -4
- package/dist/core/sandbox/cli-launch.js.map +1 -1
- package/dist/core/sandbox/cli-supervisor.d.ts +2 -0
- package/dist/core/sandbox/cli-supervisor.d.ts.map +1 -1
- package/dist/core/sandbox/cli-supervisor.js +1 -0
- package/dist/core/sandbox/cli-supervisor.js.map +1 -1
- package/dist/core/sandbox/default-hosts.d.ts +28 -0
- package/dist/core/sandbox/default-hosts.d.ts.map +1 -0
- package/dist/core/sandbox/default-hosts.js +69 -0
- package/dist/core/sandbox/default-hosts.js.map +1 -0
- package/dist/core/sandbox/linux-backend.d.ts +16 -0
- package/dist/core/sandbox/linux-backend.d.ts.map +1 -1
- package/dist/core/sandbox/linux-backend.js +60 -11
- package/dist/core/sandbox/linux-backend.js.map +1 -1
- package/dist/core/sandbox/macos-backend.d.ts +7 -0
- package/dist/core/sandbox/macos-backend.d.ts.map +1 -1
- package/dist/core/sandbox/macos-backend.js +17 -9
- package/dist/core/sandbox/macos-backend.js.map +1 -1
- package/dist/core/sandbox/network-refusal.d.ts +24 -0
- package/dist/core/sandbox/network-refusal.d.ts.map +1 -0
- package/dist/core/sandbox/network-refusal.js +72 -0
- package/dist/core/sandbox/network-refusal.js.map +1 -0
- package/dist/core/sandbox/supervisor.d.ts +9 -0
- package/dist/core/sandbox/supervisor.d.ts.map +1 -1
- package/dist/core/sandbox/supervisor.js.map +1 -1
- package/dist/core/session-share.d.ts +15 -0
- package/dist/core/session-share.d.ts.map +1 -1
- package/dist/core/session-share.js +23 -0
- package/dist/core/session-share.js.map +1 -1
- package/dist/core/settings-manager.d.ts +5 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +3 -3
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/utils/tools-manager.d.ts +28 -1
- package/dist/utils/tools-manager.d.ts.map +1 -1
- package/dist/utils/tools-manager.js +68 -5
- package/dist/utils/tools-manager.js.map +1 -1
- package/dist/utils/version-check.d.ts +2 -0
- package/dist/utils/version-check.d.ts.map +1 -1
- package/dist/utils/version-check.js +2 -0
- package/dist/utils/version-check.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/npm-shrinkwrap.json +5 -5
- package/package.json +2 -2
|
@@ -9,6 +9,22 @@ export interface LinuxSandboxBackendOptions {
|
|
|
9
9
|
/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */
|
|
10
10
|
spawnChild?: typeof spawn;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Where the supervisor's proxy socket lives on each side of the boundary.
|
|
14
|
+
*
|
|
15
|
+
* AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:
|
|
16
|
+
* a deep-but-ordinary project directory exhausts that budget and the sandbox fails to
|
|
17
|
+
* start at all with `listen EINVAL`. The host side therefore gets a short, unique path
|
|
18
|
+
* under the system temp directory, and it is bind-mounted into the child.
|
|
19
|
+
*
|
|
20
|
+
* The child-side path has to sit under `/home`. That is the one writable mount at the
|
|
21
|
+
* point the mountpoint is created — the sandbox root is a read-only bind, so bwrap
|
|
22
|
+
* cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveProxySocketPaths(temporaryDirectory?: string): {
|
|
25
|
+
hostSocketPath: string;
|
|
26
|
+
childSocketPath: string;
|
|
27
|
+
};
|
|
12
28
|
/**
|
|
13
29
|
* Linux's deliberately small platform adapter. The sandbox starts from a read-only
|
|
14
30
|
* host root and bind-mounts only the canonical workspace as writable. `--unshare-net`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linux-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAItD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,2FAA2F;IAC3F,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AAmCD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA+J9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { closeSync, mkdirSync, openSync, writeFileSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn \"unknown\";\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst socketPath = join(stateDirectory, \"proxy.sock\");\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.js\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tsocketPath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tif (exitCode !== 0 && !proxyAlreadyRecordedAViolation) {\n\t\t\t\t\tconst kind = classifySandboxFailure(stderr);\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail:\n\t\t\t\t\t\t\tkind === \"unknown\"\n\t\t\t\t\t\t\t\t? \"Sandboxed process exited unsuccessfully; inspect its stderr for the OS refusal.\"\n\t\t\t\t\t\t\t\t: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"linux-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAK7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,2FAA2F;IAC3F,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA6BD;;;;;;;;;;;GAWG;AACH,wBAAgB,uBAAuB,CAAC,kBAAkB,GAAE,MAAiB,GAAG;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACxB,CAKA;AAmBD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA8K9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t// Immediately after the /home tmpfs: that is the only writable mount at\n\t\t\t\t\t\t// this point, so it is the only place bwrap can create the mountpoint.\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\thostSocketPath,\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t// After the workspace bind: these destinations sit inside it, and an\n\t\t\t\t\t\t// earlier mount would be masked when the workspace is bound over them.\n\t\t\t\t\t\t...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [\n\t\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\tsource,\n\t\t\t\t\t\t\tdestination,\n\t\t\t\t\t\t]),\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
3
5
|
import { dirname, join, resolve } from "node:path";
|
|
4
6
|
import { createSandboxNetworkProxy } from "./network-proxy.js";
|
|
7
|
+
/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */
|
|
8
|
+
const SUN_PATH_LIMIT = 108;
|
|
5
9
|
function commandExists(command) {
|
|
6
10
|
const result = spawnSync(command, ["--version"], { stdio: "ignore", timeout: 1_000 });
|
|
7
11
|
return result.status === 0;
|
|
@@ -26,12 +30,41 @@ function readOnlyMountArguments(path, kind = "directory", descriptor = 3) {
|
|
|
26
30
|
? [...parentArguments, "--tmpfs", directory, "--perms", "0400", "--file", String(descriptor), target]
|
|
27
31
|
: [...parentArguments, "--ro-bind", directory, directory];
|
|
28
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Where the supervisor's proxy socket lives on each side of the boundary.
|
|
35
|
+
*
|
|
36
|
+
* AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:
|
|
37
|
+
* a deep-but-ordinary project directory exhausts that budget and the sandbox fails to
|
|
38
|
+
* start at all with `listen EINVAL`. The host side therefore gets a short, unique path
|
|
39
|
+
* under the system temp directory, and it is bind-mounted into the child.
|
|
40
|
+
*
|
|
41
|
+
* The child-side path has to sit under `/home`. That is the one writable mount at the
|
|
42
|
+
* point the mountpoint is created — the sandbox root is a read-only bind, so bwrap
|
|
43
|
+
* cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveProxySocketPaths(temporaryDirectory = tmpdir()) {
|
|
46
|
+
const name = `apex-${process.pid}-${randomBytes(4).toString("hex")}.sock`;
|
|
47
|
+
// An unusually long TMPDIR would reintroduce the very limit this avoids.
|
|
48
|
+
const base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? "/tmp" : temporaryDirectory;
|
|
49
|
+
return { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Classify an unsuccessful child from what its stderr actually evidences, or return
|
|
53
|
+
* undefined when nothing suggests the boundary refused anything.
|
|
54
|
+
*
|
|
55
|
+
* A non-zero exit is not a violation. An invalid API key, a failing test command, or a
|
|
56
|
+
* script exiting 1 are the child's own business, and recording them as sandbox
|
|
57
|
+
* violations blames the boundary for failures it had no part in — which is worse than
|
|
58
|
+
* silence, because it sends whoever reads the output looking for a refusal that never
|
|
59
|
+
* happened. Only Linux's own refusal wording earns a violation here; the proxy records
|
|
60
|
+
* network refusals itself, before this fallback is consulted.
|
|
61
|
+
*/
|
|
29
62
|
function classifySandboxFailure(stderr) {
|
|
30
63
|
if (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr))
|
|
31
64
|
return "filesystem";
|
|
32
65
|
if (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr))
|
|
33
66
|
return "network";
|
|
34
|
-
return
|
|
67
|
+
return undefined;
|
|
35
68
|
}
|
|
36
69
|
/**
|
|
37
70
|
* Linux's deliberately small platform adapter. The sandbox starts from a read-only
|
|
@@ -67,13 +100,16 @@ export function createLinuxSandboxBackend(options) {
|
|
|
67
100
|
const stateDirectory = join(launch.policy.workspace, ".apex-code", "sandbox-state");
|
|
68
101
|
mkdirSync(stateDirectory, { recursive: true });
|
|
69
102
|
const violationCountBeforeLaunch = violationStore?.totalCount ?? 0;
|
|
70
|
-
const
|
|
103
|
+
const { hostSocketPath, childSocketPath } = resolveProxySocketPaths();
|
|
71
104
|
proxy = await createSandboxNetworkProxy({
|
|
72
|
-
socketPath,
|
|
105
|
+
socketPath: hostSocketPath,
|
|
73
106
|
allowedHosts: launch.policy.allowedHosts,
|
|
74
107
|
violationStore,
|
|
75
108
|
});
|
|
76
|
-
|
|
109
|
+
// .cjs forces CommonJS regardless of the target workspace's package.json "type"
|
|
110
|
+
// field -- a plain .js here would be parsed as ESM under "type": "module" and
|
|
111
|
+
// crash on `require`, since Node resolves module type from the nearest package.json.
|
|
112
|
+
const relayScriptPath = join(stateDirectory, "relay.cjs");
|
|
77
113
|
writeFileSync(relayScriptPath, `
|
|
78
114
|
const net = require("node:net");
|
|
79
115
|
const child_process = require("node:child_process");
|
|
@@ -125,11 +161,23 @@ server.on("error", (err) => {
|
|
|
125
161
|
"/",
|
|
126
162
|
"--tmpfs",
|
|
127
163
|
"/home",
|
|
164
|
+
// Immediately after the /home tmpfs: that is the only writable mount at
|
|
165
|
+
// this point, so it is the only place bwrap can create the mountpoint.
|
|
166
|
+
"--bind",
|
|
167
|
+
hostSocketPath,
|
|
168
|
+
childSocketPath,
|
|
128
169
|
...readOnlyMounts,
|
|
129
170
|
...readOnlyFileMounts,
|
|
130
171
|
"--bind",
|
|
131
172
|
launch.policy.workspace,
|
|
132
173
|
launch.policy.workspace,
|
|
174
|
+
// After the workspace bind: these destinations sit inside it, and an
|
|
175
|
+
// earlier mount would be masked when the workspace is bound over them.
|
|
176
|
+
...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [
|
|
177
|
+
"--ro-bind",
|
|
178
|
+
source,
|
|
179
|
+
destination,
|
|
180
|
+
]),
|
|
133
181
|
"--dev",
|
|
134
182
|
"/dev",
|
|
135
183
|
"--proc",
|
|
@@ -144,7 +192,7 @@ server.on("error", (err) => {
|
|
|
144
192
|
launch.environment?.TMPDIR ?? stateDirectory,
|
|
145
193
|
"--setenv",
|
|
146
194
|
"APEX_UDS_PATH",
|
|
147
|
-
|
|
195
|
+
childSocketPath,
|
|
148
196
|
"--",
|
|
149
197
|
process.execPath,
|
|
150
198
|
relayScriptPath,
|
|
@@ -160,14 +208,12 @@ server.on("error", (err) => {
|
|
|
160
208
|
const exitCode = await waitForExit(child);
|
|
161
209
|
await proxy?.close();
|
|
162
210
|
const proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;
|
|
163
|
-
|
|
164
|
-
|
|
211
|
+
const kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;
|
|
212
|
+
if (kind) {
|
|
165
213
|
violationStore?.add({
|
|
166
214
|
kind,
|
|
167
215
|
command: [launch.command, ...launch.args].join(" "),
|
|
168
|
-
detail:
|
|
169
|
-
? "Sandboxed process exited unsuccessfully; inspect its stderr for the OS refusal."
|
|
170
|
-
: stderr.trim(),
|
|
216
|
+
detail: stderr.trim(),
|
|
171
217
|
timestamp: new Date(),
|
|
172
218
|
});
|
|
173
219
|
}
|
|
@@ -176,6 +222,9 @@ server.on("error", (err) => {
|
|
|
176
222
|
finally {
|
|
177
223
|
for (const descriptor of readOnlyFileDescriptors)
|
|
178
224
|
closeSync(descriptor);
|
|
225
|
+
// The socket now lives outside the workspace, so nothing else will ever clean
|
|
226
|
+
// it up; leaving it would litter the temp directory once per launch.
|
|
227
|
+
rmSync(hostSocketPath, { force: true });
|
|
179
228
|
}
|
|
180
229
|
},
|
|
181
230
|
async close() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"linux-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { closeSync, mkdirSync, openSync, writeFileSync } from \"node:fs\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn \"unknown\";\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst socketPath = join(stateDirectory, \"proxy.sock\");\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.js\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tsocketPath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tif (exitCode !== 0 && !proxyAlreadyRecordedAViolation) {\n\t\t\t\t\tconst kind = classifySandboxFailure(stderr);\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail:\n\t\t\t\t\t\t\tkind === \"unknown\"\n\t\t\t\t\t\t\t\t? \"Sandboxed process exited unsuccessfully; inspect its stderr for the OS refusal.\"\n\t\t\t\t\t\t\t\t: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"linux-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/linux-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { randomBytes } from \"node:crypto\";\nimport { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\n/** AF_UNIX `sun_path` is 108 bytes on Linux, including the terminating NUL. */\nconst SUN_PATH_LIMIT = 108;\n\nexport interface LinuxSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/** Injectable only to prove crash cleanup in tests; production spawns `bwrap` directly. */\n\tspawnChild?: typeof spawn;\n}\n\nfunction commandExists(command: string): boolean {\n\tconst result = spawnSync(command, [\"--version\"], { stdio: \"ignore\", timeout: 1_000 });\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\nfunction readOnlyMountArguments(path: string, kind: \"directory\" | \"file\" = \"directory\", descriptor = 3): string[] {\n\tconst target = resolve(path);\n\tconst directory = dirname(target);\n\tconst ancestors: string[] = [];\n\tlet current = directory;\n\twhile (current !== \"/\" && current !== \"/home\") {\n\t\tancestors.push(current);\n\t\tcurrent = dirname(current);\n\t}\n\tconst parentArguments = ancestors.reverse().flatMap((ancestor) => [\"--dir\", ancestor]);\n\treturn kind === \"file\"\n\t\t? [...parentArguments, \"--tmpfs\", directory, \"--perms\", \"0400\", \"--file\", String(descriptor), target]\n\t\t: [...parentArguments, \"--ro-bind\", directory, directory];\n}\n\n/**\n * Where the supervisor's proxy socket lives on each side of the boundary.\n *\n * AF_UNIX caps `sun_path` at 108 bytes, so the socket cannot live under the workspace:\n * a deep-but-ordinary project directory exhausts that budget and the sandbox fails to\n * start at all with `listen EINVAL`. The host side therefore gets a short, unique path\n * under the system temp directory, and it is bind-mounted into the child.\n *\n * The child-side path has to sit under `/home`. That is the one writable mount at the\n * point the mountpoint is created — the sandbox root is a read-only bind, so bwrap\n * cannot create a mountpoint at `/run`, `/tmp`, or anywhere else on it.\n */\nexport function resolveProxySocketPaths(temporaryDirectory: string = tmpdir()): {\n\thostSocketPath: string;\n\tchildSocketPath: string;\n} {\n\tconst name = `apex-${process.pid}-${randomBytes(4).toString(\"hex\")}.sock`;\n\t// An unusually long TMPDIR would reintroduce the very limit this avoids.\n\tconst base = join(temporaryDirectory, name).length + 1 > SUN_PATH_LIMIT ? \"/tmp\" : temporaryDirectory;\n\treturn { hostSocketPath: join(base, name), childSocketPath: `/home/${name}` };\n}\n\n/**\n * Classify an unsuccessful child from what its stderr actually evidences, or return\n * undefined when nothing suggests the boundary refused anything.\n *\n * A non-zero exit is not a violation. An invalid API key, a failing test command, or a\n * script exiting 1 are the child's own business, and recording them as sandbox\n * violations blames the boundary for failures it had no part in — which is worse than\n * silence, because it sends whoever reads the output looking for a refusal that never\n * happened. Only Linux's own refusal wording earns a violation here; the proxy records\n * network refusals itself, before this fallback is consulted.\n */\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | undefined {\n\tif (/Read-only file system|Permission denied|Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (/Network is unreachable|ENETUNREACH|EHOSTUNREACH/i.test(stderr)) return \"network\";\n\treturn undefined;\n}\n\n/**\n * Linux's deliberately small platform adapter. The sandbox starts from a read-only\n * host root and bind-mounts only the canonical workspace as writable. `--unshare-net`\n * removes all direct network interfaces for the child and every descendant.\n */\nexport function createLinuxSandboxBackend(options?: LinuxSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"linux\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on Linux only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? commandExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"bwrap\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"Bubblewrap (bwrap) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"Linux sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\tconst stateDirectory = join(launch.policy.workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tconst { hostSocketPath, childSocketPath } = resolveProxySocketPaths();\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\tsocketPath: hostSocketPath,\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\n\t\t\t// .cjs forces CommonJS regardless of the target workspace's package.json \"type\"\n\t\t\t// field -- a plain .js here would be parsed as ESM under \"type\": \"module\" and\n\t\t\t// crash on `require`, since Node resolves module type from the nearest package.json.\n\t\t\tconst relayScriptPath = join(stateDirectory, \"relay.cjs\");\n\t\t\twriteFileSync(\n\t\t\t\trelayScriptPath,\n\t\t\t\t`\nconst net = require(\"node:net\");\nconst child_process = require(\"node:child_process\");\n\nconst socketPath = process.env.APEX_UDS_PATH;\nconst server = net.createServer((c) => {\n\tconst client = net.connect(socketPath);\n\tc.pipe(client).pipe(c);\n\tc.on(\"error\", () => {});\n\tclient.on(\"error\", () => {});\n});\n\nserver.listen(0, \"127.0.0.1\", () => {\n\tconst port = server.address().port;\n\tconst env = Object.assign({}, process.env, {\n\t\tHTTP_PROXY: \\`http://127.0.0.1:\\${port}\\`,\n\t\tHTTPS_PROXY: \\`http://127.0.0.1:\\${port}\\`\n\t});\n\tconst args = process.argv.slice(2);\n\tconst child = child_process.spawn(args[0], args.slice(1), {\n\t\tstdio: \"inherit\",\n\t\tenv: env,\n\t});\n\tchild.on(\"exit\", (code, signal) => {\n\t\tprocess.exit(code ?? (signal ? 128 : 1));\n\t});\n});\nserver.on(\"error\", (err) => {\n\tconsole.error(\"Relay error:\", err);\n\tprocess.exit(1);\n});\n`.trim(),\n\t\t\t);\n\n\t\t\tconst readOnlyMounts = [process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])].flatMap((path) =>\n\t\t\t\treadOnlyMountArguments(path),\n\t\t\t);\n\t\t\tconst readOnlyFileMounts = (launch.readOnlyFiles ?? []).flatMap((path, index) =>\n\t\t\t\treadOnlyMountArguments(path, \"file\", index + 3),\n\t\t\t);\n\t\t\tconst readOnlyFileDescriptors = (launch.readOnlyFiles ?? []).map((path) => openSync(path, \"r\"));\n\t\t\t// The descriptors above are opened before the child spawns and must be closed\n\t\t\t// on every exit path -- including a spawn/wait rejection -- or a crashed launch\n\t\t\t// leaks an open handle onto a host-owned credential file for the process's life.\n\t\t\ttry {\n\t\t\t\tconst spawnBwrap = options?.spawnChild ?? spawn;\n\t\t\t\tconst child = spawnBwrap(\n\t\t\t\t\t\"bwrap\",\n\t\t\t\t\t[\n\t\t\t\t\t\t\"--new-session\",\n\t\t\t\t\t\t\"--die-with-parent\",\n\t\t\t\t\t\t\"--unshare-user\",\n\t\t\t\t\t\t\"--unshare-pid\",\n\t\t\t\t\t\t\"--unshare-net\",\n\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"/\",\n\t\t\t\t\t\t\"--tmpfs\",\n\t\t\t\t\t\t\"/home\",\n\t\t\t\t\t\t// Immediately after the /home tmpfs: that is the only writable mount at\n\t\t\t\t\t\t// this point, so it is the only place bwrap can create the mountpoint.\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\thostSocketPath,\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t...readOnlyMounts,\n\t\t\t\t\t\t...readOnlyFileMounts,\n\t\t\t\t\t\t\"--bind\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t// After the workspace bind: these destinations sit inside it, and an\n\t\t\t\t\t\t// earlier mount would be masked when the workspace is bound over them.\n\t\t\t\t\t\t...(launch.readOnlyBinaries ?? []).flatMap(({ source, destination }) => [\n\t\t\t\t\t\t\t\"--ro-bind\",\n\t\t\t\t\t\t\tsource,\n\t\t\t\t\t\t\tdestination,\n\t\t\t\t\t\t]),\n\t\t\t\t\t\t\"--dev\",\n\t\t\t\t\t\t\"/dev\",\n\t\t\t\t\t\t\"--proc\",\n\t\t\t\t\t\t\"/proc\",\n\t\t\t\t\t\t\"--chdir\",\n\t\t\t\t\t\tlaunch.policy.workspace,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"HOME\",\n\t\t\t\t\t\tlaunch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"TMPDIR\",\n\t\t\t\t\t\tlaunch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\t\"--setenv\",\n\t\t\t\t\t\t\"APEX_UDS_PATH\",\n\t\t\t\t\t\tchildSocketPath,\n\t\t\t\t\t\t\"--\",\n\t\t\t\t\t\tprocess.execPath,\n\t\t\t\t\t\trelayScriptPath,\n\t\t\t\t\t\tlaunch.command,\n\t\t\t\t\t\t...launch.args,\n\t\t\t\t\t],\n\t\t\t\t\t{ env: launch.environment, stdio: [\"inherit\", \"inherit\", \"pipe\", ...readOnlyFileDescriptors] },\n\t\t\t\t);\n\t\t\t\tlet stderr = \"\";\n\t\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\t\tstderr += chunk;\n\t\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t\t});\n\t\t\t\tconst exitCode = await waitForExit(child);\n\t\t\t\tawait proxy?.close();\n\t\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\t\tif (kind) {\n\t\t\t\t\tviolationStore?.add({\n\t\t\t\t\t\tkind,\n\t\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\treturn exitCode;\n\t\t\t} finally {\n\t\t\t\tfor (const descriptor of readOnlyFileDescriptors) closeSync(descriptor);\n\t\t\t\t// The socket now lives outside the workspace, so nothing else will ever clean\n\t\t\t\t// it up; leaving it would litter the temp directory once per launch.\n\t\t\t\trmSync(hostSocketPath, { force: true });\n\t\t\t}\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import type { SandboxBackend } from "./supervisor.ts";
|
|
2
3
|
import type { SandboxViolationStore } from "./violations.ts";
|
|
3
4
|
export interface MacosSandboxBackendOptions {
|
|
@@ -5,6 +6,12 @@ export interface MacosSandboxBackendOptions {
|
|
|
5
6
|
platform?: NodeJS.Platform;
|
|
6
7
|
commandExists?: (command: string) => boolean;
|
|
7
8
|
violationStore?: SandboxViolationStore;
|
|
9
|
+
/**
|
|
10
|
+
* Injectable only so violation attribution can be tested off macOS; production
|
|
11
|
+
* spawns `sandbox-exec` directly. Without this the decision about what counts as a
|
|
12
|
+
* violation would be verifiable only on a macOS host.
|
|
13
|
+
*/
|
|
14
|
+
spawnChild?: typeof spawn;
|
|
8
15
|
}
|
|
9
16
|
/**
|
|
10
17
|
* macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"macos-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;CACvC;AAqDD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CAyI9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else stays \"unknown\"; network-proxy.ts's own precise per-request\n\t// recording is the reliable \"network\" signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\treturn \"unknown\";\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst child = spawn(\"sandbox-exec\", [\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args], {\n\t\t\t\tcwd: workspace,\n\t\t\t\tenv: {\n\t\t\t\t\t...launch.environment,\n\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t},\n\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t});\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tif (exitCode !== 0 && !proxyAlreadyRecordedAViolation) {\n\t\t\t\tconst kind = classifySandboxFailure(stderr);\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail:\n\t\t\t\t\t\tkind === \"unknown\"\n\t\t\t\t\t\t\t? \"Sandboxed process exited unsuccessfully; inspect its stderr for the OS refusal.\"\n\t\t\t\t\t\t\t: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"macos-backend.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAa,MAAM,oBAAoB,CAAC;AAKtD,OAAO,KAAK,EAAE,cAAc,EAAiB,MAAM,iBAAiB,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAE7D,MAAM,WAAW,0BAA0B;IAC1C,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;IAC3B,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC7C,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,KAAK,CAAC;CAC1B;AA8DD;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,CAAC,EAAE,0BAA0B,GAAG,cAAc,CA2I9F","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/**\n\t * Injectable only so violation attribution can be tested off macOS; production\n\t * spawns `sandbox-exec` directly. Without this the decision about what counts as a\n\t * violation would be verifiable only on a macOS host.\n\t */\n\tspawnChild?: typeof spawn;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\n/** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */\nconst DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" | undefined {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else that still shows a refusal stays \"unknown\";\n\t// network-proxy.ts's own precise per-request recording is the reliable \"network\"\n\t// signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (DENIAL_EVIDENCE.test(stderr)) return \"unknown\";\n\t// No refusal in evidence. The coarseness above is about telling filesystem from\n\t// network, not about telling a denial from an ordinary failure -- a non-zero exit\n\t// on its own says nothing about the boundary, and reporting it as a violation\n\t// sends the reader looking for a refusal that never happened.\n\treturn undefined;\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst spawnSandboxExec = options?.spawnChild ?? spawn;\n\t\t\tconst child = spawnSandboxExec(\n\t\t\t\t\"sandbox-exec\",\n\t\t\t\t[\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args],\n\t\t\t\t{\n\t\t\t\t\tcwd: workspace,\n\t\t\t\t\tenv: {\n\t\t\t\t\t\t...launch.environment,\n\t\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t},\n\t\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t\t},\n\t\t\t);\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\tif (kind) {\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
@@ -39,6 +39,8 @@ function readOnlyDirectories(paths) {
|
|
|
39
39
|
});
|
|
40
40
|
return [...new Set(canonical)];
|
|
41
41
|
}
|
|
42
|
+
/** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */
|
|
43
|
+
const DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;
|
|
42
44
|
function classifySandboxFailure(stderr) {
|
|
43
45
|
// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt
|
|
44
46
|
// denials surface through the launched command's own generic "Operation not
|
|
@@ -46,11 +48,18 @@ function classifySandboxFailure(stderr) {
|
|
|
46
48
|
// macOS-specific phrase equivalent to Linux's distinct "Network is unreachable".
|
|
47
49
|
// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile
|
|
48
50
|
// forgot to allow a binary, not a policy decision) is unambiguous from stderr
|
|
49
|
-
// text. Anything else stays "unknown";
|
|
50
|
-
// recording is the reliable "network"
|
|
51
|
+
// text. Anything else that still shows a refusal stays "unknown";
|
|
52
|
+
// network-proxy.ts's own precise per-request recording is the reliable "network"
|
|
53
|
+
// signal on this platform.
|
|
51
54
|
if (/execvp\(\) of .* failed: Operation not permitted/i.test(stderr))
|
|
52
55
|
return "filesystem";
|
|
53
|
-
|
|
56
|
+
if (DENIAL_EVIDENCE.test(stderr))
|
|
57
|
+
return "unknown";
|
|
58
|
+
// No refusal in evidence. The coarseness above is about telling filesystem from
|
|
59
|
+
// network, not about telling a denial from an ordinary failure -- a non-zero exit
|
|
60
|
+
// on its own says nothing about the boundary, and reporting it as a violation
|
|
61
|
+
// sends the reader looking for a refusal that never happened.
|
|
62
|
+
return undefined;
|
|
54
63
|
}
|
|
55
64
|
/**
|
|
56
65
|
* macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except
|
|
@@ -154,7 +163,8 @@ export function createMacosSandboxBackend(options) {
|
|
|
154
163
|
});
|
|
155
164
|
params.push("-D", `WORKSPACE=${workspace}`);
|
|
156
165
|
params.push("-D", `PROXY_ADDR=localhost:${proxyPort}`);
|
|
157
|
-
const
|
|
166
|
+
const spawnSandboxExec = options?.spawnChild ?? spawn;
|
|
167
|
+
const child = spawnSandboxExec("sandbox-exec", ["-f", profilePath, ...params, "--", launch.command, ...launch.args], {
|
|
158
168
|
cwd: workspace,
|
|
159
169
|
env: {
|
|
160
170
|
...launch.environment,
|
|
@@ -174,14 +184,12 @@ export function createMacosSandboxBackend(options) {
|
|
|
174
184
|
const exitCode = await waitForExit(child);
|
|
175
185
|
await proxy?.close();
|
|
176
186
|
const proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;
|
|
177
|
-
|
|
178
|
-
|
|
187
|
+
const kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;
|
|
188
|
+
if (kind) {
|
|
179
189
|
violationStore?.add({
|
|
180
190
|
kind,
|
|
181
191
|
command: [launch.command, ...launch.args].join(" "),
|
|
182
|
-
detail:
|
|
183
|
-
? "Sandboxed process exited unsuccessfully; inspect its stderr for the OS refusal."
|
|
184
|
-
: stderr.trim(),
|
|
192
|
+
detail: stderr.trim(),
|
|
185
193
|
timestamp: new Date(),
|
|
186
194
|
});
|
|
187
195
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"macos-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else stays \"unknown\"; network-proxy.ts's own precise per-request\n\t// recording is the reliable \"network\" signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\treturn \"unknown\";\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst child = spawn(\"sandbox-exec\", [\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args], {\n\t\t\t\tcwd: workspace,\n\t\t\t\tenv: {\n\t\t\t\t\t...launch.environment,\n\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t},\n\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t});\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tif (exitCode !== 0 && !proxyAlreadyRecordedAViolation) {\n\t\t\t\tconst kind = classifySandboxFailure(stderr);\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail:\n\t\t\t\t\t\tkind === \"unknown\"\n\t\t\t\t\t\t\t? \"Sandboxed process exited unsuccessfully; inspect its stderr for the OS refusal.\"\n\t\t\t\t\t\t\t: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"macos-backend.js","sourceRoot":"","sources":["../../../src/core/sandbox/macos-backend.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync } from \"node:child_process\";\nimport { mkdirSync, realpathSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join, resolve } from \"node:path\";\nimport { createSandboxNetworkProxy, type SandboxNetworkProxy } from \"./network-proxy.ts\";\nimport type { SandboxBackend, SandboxLaunch } from \"./supervisor.ts\";\nimport type { SandboxViolationStore } from \"./violations.ts\";\n\nexport interface MacosSandboxBackendOptions {\n\t/** Injectable only for preflight tests; production uses the running platform. */\n\tplatform?: NodeJS.Platform;\n\tcommandExists?: (command: string) => boolean;\n\tviolationStore?: SandboxViolationStore;\n\t/**\n\t * Injectable only so violation attribution can be tested off macOS; production\n\t * spawns `sandbox-exec` directly. Without this the decision about what counts as a\n\t * violation would be verifiable only on a macOS host.\n\t */\n\tspawnChild?: typeof spawn;\n}\n\nfunction sandboxExecExists(command: string): boolean {\n\t// sandbox-exec has no --version flag; a real, minimal profile run is the\n\t// standard existence-and-basic-functionality probe (see the 2b.5 prototype's\n\t// own positive control).\n\tconst result = spawnSync(command, [\"-p\", \"(version 1)(allow default)\", \"/usr/bin/true\"], {\n\t\tstdio: \"ignore\",\n\t\ttimeout: 2_000,\n\t});\n\treturn result.status === 0;\n}\n\nfunction waitForExit(child: ReturnType<typeof spawn>): Promise<number> {\n\treturn new Promise((resolve, reject) => {\n\t\tchild.once(\"error\", reject);\n\t\tchild.once(\"exit\", (code) => resolve(code ?? 1));\n\t});\n}\n\n/**\n * Unlike Linux's bwrap (which needs each mount ancestor declared for its bind-mount\n * tree), Seatbelt's `subpath` is recursive from a single root, so only the directory\n * containing each path is needed -- no ancestor walk. Each directory is resolved to\n * its real (symlink-free) path: Seatbelt canonicalizes the path it actually checks\n * against a `subpath` rule (confirmed empirically -- a rule built from an\n * unresolved symlinked path, e.g. Homebrew's own layout, silently never matches).\n */\nfunction readOnlyDirectories(paths: readonly string[]): string[] {\n\tconst directories = paths.map((path) => dirname(resolve(path)));\n\tconst canonical = directories.map((directory) => {\n\t\ttry {\n\t\t\treturn realpathSync(directory);\n\t\t} catch {\n\t\t\treturn directory;\n\t\t}\n\t});\n\treturn [...new Set(canonical)];\n}\n\n/** Seatbelt refuses through the blocked call's own errno text, not a wrapper message. */\nconst DENIAL_EVIDENCE = /Operation not permitted|Permission denied|EPERM|EACCES/i;\n\nfunction classifySandboxFailure(stderr: string): \"filesystem\" | \"network\" | \"unknown\" | undefined {\n\t// Confirmed empirically (2b.5 prototype): unlike Linux's bwrap, macOS Seatbelt\n\t// denials surface through the launched command's own generic \"Operation not\n\t// permitted\"/EPERM text for filesystem AND network refusals alike -- there is no\n\t// macOS-specific phrase equivalent to Linux's distinct \"Network is unreachable\".\n\t// Only sandbox-exec's own wrapper-level exec refusal (a sign our own profile\n\t// forgot to allow a binary, not a policy decision) is unambiguous from stderr\n\t// text. Anything else that still shows a refusal stays \"unknown\";\n\t// network-proxy.ts's own precise per-request recording is the reliable \"network\"\n\t// signal on this platform.\n\tif (/execvp\\(\\) of .* failed: Operation not permitted/i.test(stderr)) return \"filesystem\";\n\tif (DENIAL_EVIDENCE.test(stderr)) return \"unknown\";\n\t// No refusal in evidence. The coarseness above is about telling filesystem from\n\t// network, not about telling a denial from an ordinary failure -- a non-zero exit\n\t// on its own says nothing about the boundary, and reporting it as a violation\n\t// sends the reader looking for a refusal that never happened.\n\treturn undefined;\n}\n\n/**\n * macOS's platform adapter. Seatbelt (`sandbox-exec`) allows reads broadly except\n * the invoking account's home directory, denies writes outside the workspace, and\n * denies network outside the sandbox proxy's exact loopback port -- narrower than\n * the wildcard `localhost:*` pattern some other sandboxes use. This mirrors Linux's\n * actual posture (`--ro-bind / /` plus a hidden `/home`) rather than the narrower\n * read-allowlist this design started with, which broke ordinary process startup.\n */\nexport function createMacosSandboxBackend(options?: MacosSandboxBackendOptions): SandboxBackend {\n\tconst platform = options?.platform ?? process.platform;\n\tif (platform !== \"darwin\") {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"OS sandbox is supported on macOS only.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\tconst hasCommand = options?.commandExists ?? sandboxExecExists;\n\tconst violationStore = options?.violationStore;\n\tlet proxy: SandboxNetworkProxy | undefined;\n\n\tif (!hasCommand(\"sandbox-exec\")) {\n\t\treturn {\n\t\t\tstatus: { kind: \"unavailable\", reason: \"sandbox-exec (Seatbelt) is required for OS sandboxing.\" },\n\t\t\tasync launch() {\n\t\t\t\tthrow new Error(\"macOS sandbox backend is unavailable.\");\n\t\t\t},\n\t\t\tasync close() {},\n\t\t};\n\t}\n\treturn {\n\t\tstatus: { kind: \"enforced\" },\n\t\tasync launch(launch: SandboxLaunch): Promise<number> {\n\t\t\t// Same reasoning as readOnlyDirectories: on macOS the workspace itself can\n\t\t\t// be reached through a symlink (e.g. os.tmpdir()'s /var -> /private/var),\n\t\t\t// and Seatbelt's subpath match is against the canonical path, not the one\n\t\t\t// the caller happened to spell. Resolve once, use everywhere below.\n\t\t\tconst workspace = realpathSync(launch.policy.workspace);\n\t\t\tconst stateDirectory = join(workspace, \".apex-code\", \"sandbox-state\");\n\t\t\tmkdirSync(stateDirectory, { recursive: true });\n\n\t\t\tconst violationCountBeforeLaunch = violationStore?.totalCount ?? 0;\n\n\t\t\tproxy = await createSandboxNetworkProxy({\n\t\t\t\ttcpHost: \"127.0.0.1\",\n\t\t\t\tallowedHosts: launch.policy.allowedHosts,\n\t\t\t\tviolationStore,\n\t\t\t});\n\t\t\tconst proxyPort = proxy.port as number;\n\n\t\t\tconst readOnlyDirs = readOnlyDirectories([process.execPath, launch.command, ...(launch.readOnlyPaths ?? [])]);\n\t\t\tconst readOnlyFiles = (launch.readOnlyFiles ?? []).map((path) => {\n\t\t\t\ttry {\n\t\t\t\t\treturn realpathSync(path);\n\t\t\t\t} catch {\n\t\t\t\t\treturn resolve(path);\n\t\t\t\t}\n\t\t\t});\n\t\t\tlet userHome: string | undefined;\n\t\t\ttry {\n\t\t\t\tuserHome = realpathSync(homedir());\n\t\t\t} catch {\n\t\t\t\tuserHome = homedir();\n\t\t\t}\n\n\t\t\tconst profileLines = [\n\t\t\t\t\"(version 1)\",\n\t\t\t\t// bsd.sb's baseline is broader than Linux's opt-in bind-mount model --\n\t\t\t\t// a real gap this design accepts, documented in the spec's fourth\n\t\t\t\t// amendment -- but it is the only combination the 2b.5 prototype found\n\t\t\t\t// that lets a normal Unix child process (dyld, /bin/sh) actually start.\n\t\t\t\t'(import \"bsd.sb\")',\n\t\t\t\t\"(allow process-exec*)\",\n\t\t\t\t// Separate from process-exec* -- confirmed empirically: a shell that\n\t\t\t\t// forks a child shell (e.g. `sh -c 'a' && sh -c 'b'`) failed with\n\t\t\t\t// \"fork: Operation not permitted\" without this, even with exec allowed.\n\t\t\t\t\"(allow process-fork)\",\n\t\t\t\t// Matches Linux's actual posture (bwrap's `--ro-bind / /`): broad read,\n\t\t\t\t// narrow write. A read-only allowlist alone (this design's first cut)\n\t\t\t\t// breaks ordinary process startup -- confirmed empirically, getcwd()\n\t\t\t\t// itself needs to traverse the workspace's ancestor directories, which\n\t\t\t\t// no read rule scoped only to the workspace or runtime paths covers.\n\t\t\t\t\"(allow file-read*)\",\n\t\t\t\t'(deny file-read* (subpath (param \"USER_HOME\")))',\n\t\t\t\t...readOnlyDirs.map((_, index) => `(allow file-read* (subpath (param \"RO_${index}\")))`),\n\t\t\t\t...readOnlyFiles.map((_, index) => `(allow file-read* (subpath (param \"RO_FILE_${index}\")))`),\n\t\t\t\t'(allow file-read* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny file-write*)\",\n\t\t\t\t'(allow file-write* (subpath (param \"WORKSPACE\")))',\n\t\t\t\t\"(deny network*)\",\n\t\t\t\t'(allow network-outbound (remote ip (param \"PROXY_ADDR\")))',\n\t\t\t];\n\t\t\tconst profilePath = join(stateDirectory, \"profile.sb\");\n\t\t\twriteFileSync(profilePath, profileLines.join(\"\\n\"));\n\n\t\t\tconst params: string[] = [\"-D\", `USER_HOME=${userHome}`];\n\t\t\treadOnlyDirs.forEach((dir, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_${index}=${dir}`);\n\t\t\t});\n\t\t\treadOnlyFiles.forEach((file, index) => {\n\t\t\t\tparams.push(\"-D\", `RO_FILE_${index}=${file}`);\n\t\t\t});\n\t\t\tparams.push(\"-D\", `WORKSPACE=${workspace}`);\n\t\t\tparams.push(\"-D\", `PROXY_ADDR=localhost:${proxyPort}`);\n\n\t\t\tconst spawnSandboxExec = options?.spawnChild ?? spawn;\n\t\t\tconst child = spawnSandboxExec(\n\t\t\t\t\"sandbox-exec\",\n\t\t\t\t[\"-f\", profilePath, ...params, \"--\", launch.command, ...launch.args],\n\t\t\t\t{\n\t\t\t\t\tcwd: workspace,\n\t\t\t\t\tenv: {\n\t\t\t\t\t\t...launch.environment,\n\t\t\t\t\t\tHOME: launch.environment?.HOME ?? stateDirectory,\n\t\t\t\t\t\tTMPDIR: launch.environment?.TMPDIR ?? stateDirectory,\n\t\t\t\t\t\tHTTP_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t\tHTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,\n\t\t\t\t\t},\n\t\t\t\t\tstdio: [\"inherit\", \"inherit\", \"pipe\"],\n\t\t\t\t},\n\t\t\t);\n\t\t\tlet stderr = \"\";\n\t\t\tchild.stderr?.setEncoding(\"utf8\");\n\t\t\tchild.stderr?.on(\"data\", (chunk: string) => {\n\t\t\t\tstderr += chunk;\n\t\t\t\tprocess.stderr.write(chunk);\n\t\t\t});\n\t\t\tconst exitCode = await waitForExit(child);\n\t\t\tawait proxy?.close();\n\t\t\tconst proxyAlreadyRecordedAViolation = (violationStore?.totalCount ?? 0) > violationCountBeforeLaunch;\n\t\t\tconst kind = exitCode !== 0 && !proxyAlreadyRecordedAViolation ? classifySandboxFailure(stderr) : undefined;\n\t\t\tif (kind) {\n\t\t\t\tviolationStore?.add({\n\t\t\t\t\tkind,\n\t\t\t\t\tcommand: [launch.command, ...launch.args].join(\" \"),\n\t\t\t\t\tdetail: stderr.trim(),\n\t\t\t\t\ttimestamp: new Date(),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn exitCode;\n\t\t},\n\t\tasync close() {\n\t\t\tawait proxy?.close();\n\t\t},\n\t};\n}\n"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a sandbox allowlist refusal into a message the user can act on.
|
|
3
|
+
*
|
|
4
|
+
* The child reaches the network only through the supervisor's CONNECT proxy, which
|
|
5
|
+
* answers 403 for any host absent from `network.allowedHosts`. undici reports that as
|
|
6
|
+
* `Proxy response (403) !== 200 when HTTP Tunneling`, but fetch surfaces only the
|
|
7
|
+
* generic `fetch failed` at the top of the cause chain — so the caller sees a network
|
|
8
|
+
* error with no host, no reason, and no remedy. Recovering the detail here is what
|
|
9
|
+
* separates "something is broken" from "this host is not on the allowlist".
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Describe a refused request, or return undefined when the failure was something else.
|
|
13
|
+
* Only the sandboxed child installs this, so attributing the refusal to the sandbox
|
|
14
|
+
* allowlist is accurate rather than a guess about whose proxy answered.
|
|
15
|
+
*/
|
|
16
|
+
export declare function describeSandboxNetworkRefusal(error: unknown, url: string): string | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Wrap global fetch so every caller — model providers, the version check, catalogs —
|
|
19
|
+
* reports a refusal in the same actionable terms without each having to know the
|
|
20
|
+
* sandbox exists. The original error is preserved as `cause`, and anything that is not
|
|
21
|
+
* a refusal is rethrown untouched so retry and abort handling behave exactly as before.
|
|
22
|
+
*/
|
|
23
|
+
export declare function installSandboxNetworkRefusalMessages(): void;
|
|
24
|
+
//# sourceMappingURL=network-refusal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"network-refusal.d.ts","sourceRoot":"","sources":["../../../src/core/sandbox/network-refusal.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAwBH;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK7F;AASD;;;;;GAKG;AACH,wBAAgB,oCAAoC,IAAI,IAAI,CAW3D","sourcesContent":["/**\n * Turn a sandbox allowlist refusal into a message the user can act on.\n *\n * The child reaches the network only through the supervisor's CONNECT proxy, which\n * answers 403 for any host absent from `network.allowedHosts`. undici reports that as\n * `Proxy response (403) !== 200 when HTTP Tunneling`, but fetch surfaces only the\n * generic `fetch failed` at the top of the cause chain — so the caller sees a network\n * error with no host, no reason, and no remedy. Recovering the detail here is what\n * separates \"something is broken\" from \"this host is not on the allowlist\".\n */\n\n/** undici's own wording for a tunnel the proxy declined to open. */\nconst TUNNEL_REFUSAL = /Proxy response \\((\\d+)\\) !== 200 when HTTP Tunneling/;\n\nconst MAXIMUM_CAUSE_DEPTH = 8;\n\nfunction findTunnelRefusal(error: unknown): boolean {\n\tlet current: unknown = error;\n\tfor (let depth = 0; depth < MAXIMUM_CAUSE_DEPTH && current instanceof Error; depth++) {\n\t\tif (TUNNEL_REFUSAL.test(current.message)) return true;\n\t\tcurrent = current.cause;\n\t}\n\treturn false;\n}\n\nfunction hostFromUrl(url: string): string | undefined {\n\ttry {\n\t\treturn new URL(url).host;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Describe a refused request, or return undefined when the failure was something else.\n * Only the sandboxed child installs this, so attributing the refusal to the sandbox\n * allowlist is accurate rather than a guess about whose proxy answered.\n */\nexport function describeSandboxNetworkRefusal(error: unknown, url: string): string | undefined {\n\tif (!findTunnelRefusal(error)) return undefined;\n\tconst host = hostFromUrl(url);\n\tconst subject = host ? `Host ${host} is` : \"That host is\";\n\treturn `${subject} not on the sandbox network allowlist, so the request was refused. Add it to \"network.allowedHosts\" in your global Apex Code settings to permit it.`;\n}\n\nfunction requestUrl(input: unknown): string {\n\tif (typeof input === \"string\") return input;\n\tif (input instanceof URL) return input.href;\n\tif (input instanceof Request) return input.url;\n\treturn \"\";\n}\n\n/**\n * Wrap global fetch so every caller — model providers, the version check, catalogs —\n * reports a refusal in the same actionable terms without each having to know the\n * sandbox exists. The original error is preserved as `cause`, and anything that is not\n * a refusal is rethrown untouched so retry and abort handling behave exactly as before.\n */\nexport function installSandboxNetworkRefusalMessages(): void {\n\tconst wrapped = globalThis.fetch;\n\tglobalThis.fetch = async function fetchWithRefusalDetail(input, init) {\n\t\ttry {\n\t\t\treturn await wrapped(input, init);\n\t\t} catch (error) {\n\t\t\tconst description = describeSandboxNetworkRefusal(error, requestUrl(input));\n\t\t\tif (!description) throw error;\n\t\t\tthrow new Error(description, { cause: error });\n\t\t}\n\t} as typeof globalThis.fetch;\n}\n"]}
|