diff --git a/.changeset/sandbox-network-destinations.md b/.changeset/sandbox-network-destinations.md new file mode 100644 index 0000000000..546c41213f --- /dev/null +++ b/.changeset/sandbox-network-destinations.md @@ -0,0 +1,7 @@ +--- +"kilo-code": minor +"@kilocode/cli": minor +"@kilocode/sdk": minor +--- + +Support configuring network destinations that sandboxed tools can reach while network access is otherwise restricted. diff --git a/bun.lock b/bun.lock index 91b50523c9..be2de724ff 100644 --- a/bun.lock +++ b/bun.lock @@ -270,7 +270,9 @@ "name": "@kilocode/sandbox", "version": "7.4.5", "dependencies": { + "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", + "ipaddr.js": "catalog:", }, "devDependencies": { "@effect/platform-node": "catalog:", @@ -755,6 +757,7 @@ "vite": "7.3.5", }, "catalog": { + "@anthropic-ai/sandbox-runtime": "0.0.63", "@cloudflare/workers-types": "4.20251008.0", "@effect/opentelemetry": "4.0.0-beta.66", "@effect/platform-node": "4.0.0-beta.66", @@ -792,6 +795,7 @@ "fuzzysort": "3.1.0", "hono": "4.12.12", "hono-openapi": "1.1.2", + "ipaddr.js": "2.4.0", "luxon": "3.6.1", "marked": "17.0.1", "marked-shiki": "1.2.1", @@ -879,6 +883,8 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@anthropic-ai/sandbox-runtime": ["@anthropic-ai/sandbox-runtime@0.0.63", "", { "dependencies": { "@pondwader/socks5-server": "^1.0.10", "commander": "^12.1.0", "node-forge": "^1.4.0", "zod": "^3.24.1" }, "bin": { "srt": "dist/cli.js" } }, "sha512-rKxyYkfDczeDmPfZm6/cXigXwadwfVV7wBfmW0e5vfZER1tkDJXJ7rz7Yi0W5q+PFILG2kz/cAFpSOaQ76q7Jg=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="], "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], @@ -1741,6 +1747,8 @@ "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], + "@posthog/core": ["@posthog/core@1.32.3", "", { "dependencies": { "@posthog/types": "1.386.3" } }, "sha512-vwOEMfZvGv5XxNWV7p9I52NSmvFNMhyW2IHpIoUHW5jLkgUrknzJW1H/qxVGSIrNNVQkfsoaDFzDhJdg10pgrA=="], "@posthog/types": ["@posthog/types@1.386.3", "", {}, "sha512-LqJoiQi2eyWn7rCUgnn+D+F3Efp6+04o72bjSX6kWHx0nFaYNC/nJuAIRliDTY/X7GPIUAaHAcSjbMI/9wfX1Q=="], @@ -2637,7 +2645,7 @@ "command-line-usage": ["command-line-usage@7.0.4", "", { "dependencies": { "array-back": "^6.2.2", "chalk-template": "^0.4.0", "table-layout": "^4.1.1", "typical": "^7.3.0" } }, "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg=="], - "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], @@ -3571,6 +3579,8 @@ "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], + "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], @@ -4447,6 +4457,8 @@ "@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "@anthropic-ai/sandbox-runtime/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -4489,6 +4501,8 @@ "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@hey-api/openapi-ts/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -4633,8 +4647,6 @@ "@vscode/vsce/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "@vscode/vsce/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], - "@vscode/vsce/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@vscode/vsce/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], diff --git a/package.json b/package.json index 7e44028780..4a8f78d59b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "catalog": { "@effect/opentelemetry": "4.0.0-beta.66", "@effect/platform-node": "4.0.0-beta.66", + "@anthropic-ai/sandbox-runtime": "0.0.63", "@npmcli/arborist": "9.4.0", "@types/bun": "1.3.14", "@types/cross-spawn": "6.0.6", @@ -56,6 +57,7 @@ "cross-spawn": "7.0.6", "hono": "4.12.12", "hono-openapi": "1.1.2", + "ipaddr.js": "2.4.0", "fuzzysort": "3.1.0", "luxon": "3.6.1", "marked": "17.0.1", diff --git a/packages/core/test/kilocode/linux-sandbox.test.ts b/packages/core/test/kilocode/linux-sandbox.test.ts index 487d83ff8c..3e3e5eed92 100644 --- a/packages/core/test/kilocode/linux-sandbox.test.ts +++ b/packages/core/test/kilocode/linux-sandbox.test.ts @@ -1,12 +1,14 @@ import { expect, test } from "bun:test" import { spawnSync } from "node:child_process" import { createSocket } from "node:dgram" +import { createServer } from "node:net" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" -import { Effect } from "effect" +import { Effect, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { backendSupport, run, type Profile } from "@kilocode/sandbox" +import { CurrentProxyFactory, startProxy, type ProxyFactory } from "@kilocode/sandbox" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" const linux = process.platform === "linux" ? test : test.skip @@ -16,6 +18,7 @@ function profile( allow: ReadonlyArray, denyNames: ReadonlyArray = [], mode: Profile["network"]["mode"] = "allow", + allowedHosts: ReadonlyArray = [], ): Profile { return { filesystem: { @@ -23,7 +26,7 @@ function profile( denyWrite: [], denyNames, }, - network: { mode, allowedHosts: [] }, + network: { mode, allowedHosts }, environment: { deny: [], set: {} }, } } @@ -32,17 +35,41 @@ function denied(base: Profile, rules: Profile["filesystem"]["denyWrite"]): Profi return { ...base, filesystem: { ...base.filesystem, denyWrite: rules } } } -function spawn(script: string, cwd: string, policy: Profile) { - return Effect.scoped( +function execute(command: string, args: ReadonlyArray, cwd: string, policy: Profile, factory?: ProxyFactory) { + const effect = Effect.scoped( run( policy, ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner - .spawn(ChildProcess.make(process.execPath, ["-e", script], { cwd })) + .spawn(ChildProcess.make(command, args, { cwd })) .pipe(Effect.flatMap((handle) => handle.exitCode)), ), ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)), ) + return factory ? effect.pipe(Effect.provideService(CurrentProxyFactory, factory)) : effect +} + +function spawn(script: string, cwd: string, policy: Profile, factory?: ProxyFactory) { + return execute(process.execPath, ["-e", script], cwd, policy, factory) +} + +function output(command: string, args: ReadonlyArray, cwd: string, policy: Profile, factory: ProxyFactory) { + return Effect.scoped( + run( + policy, + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => + spawner.spawn(ChildProcess.make(command, args, { cwd })).pipe( + Effect.flatMap((handle) => + Effect.all({ + code: handle.exitCode, + stdout: Stream.mkString(Stream.decodeText(handle.stdout)), + stderr: Stream.mkString(Stream.decodeText(handle.stderr)), + }), + ), + ), + ), + ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer), Effect.provideService(CurrentProxyFactory, factory)), + ) } async function fixture() { @@ -197,6 +224,129 @@ linux("blocks UDP datagrams in network deny mode", async () => { } }) +linux("allows only configured HTTP proxy destinations", async () => { + requireNetwork() + const root = await fixture() + let allowedRequests = 0 + let blockedRequests = 0 + const allowed = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + allowedRequests++ + return new Response("sandbox-proxy-ok") + }, + }) + const blocked = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + blockedRequests++ + return new Response("sandbox-direct-bypass") + }, + }) + const port = allowed.port! + const factory: ProxyFactory = (hosts) => + startProxy(hosts, "linux", async (dest) => { + if (dest.port !== port) throw new Error("unexpected port") + return { address: "127.0.0.1", family: 4 } + }) + const policy = profile([root.project], [], "proxy", [`allowed.test:${port}`]) + + try { + const ok = await Effect.runPromise( + output("/usr/bin/curl", ["-fsS", `http://allowed.test:${port}/allowed`], root.project, policy, factory), + ) + const denied = await Effect.runPromise( + execute("/usr/bin/curl", ["-fsS", `http://blocked.test:${port}/blocked`], root.project, policy, factory), + ) + const direct = await Effect.runPromise( + execute( + "/usr/bin/curl", + ["--noproxy", "*", "-fsS", `http://127.0.0.1:${blocked.port}/direct`], + root.project, + policy, + factory, + ), + ) + expect(Number(ok.code), ok.stderr).toBe(0) + expect(ok.stdout).toBe("sandbox-proxy-ok") + expect(Number(denied)).not.toBe(0) + expect(Number(direct)).not.toBe(0) + expect(allowedRequests).toBe(1) + expect(blockedRequests).toBe(0) + } finally { + await Promise.all([allowed.stop(true), blocked.stop(true)]) + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("drops proxy setup capabilities and blocks nested user namespaces", async () => { + requireNetwork() + const root = await fixture() + const target = tcp() + const port = target.listener.port + const factory: ProxyFactory = (hosts) => + startProxy(hosts, "linux", async () => ({ address: "127.0.0.1", family: 4 })) + const policy = profile([root.project], [], "proxy", [`allowed.test:${port}`]) + const script = [ + 'const child = require("node:child_process")', + 'const fs = require("node:fs")', + 'const match = fs.readFileSync("/proc/self/status", "utf8").match(/^CapEff:\\s+([0-9a-f]+)$/m)', + "if (!match || (BigInt(`0x${match[1]}`) & (1n << 21n)) !== 0n) process.exit(2)", + 'const nested = child.spawnSync("/usr/bin/unshare", ["--user", "--map-root-user", "true"])', + "process.exit(nested.status === 0 ? 3 : 0)", + ].join("\n") + + try { + const result = await Effect.runPromise(output(process.execPath, ["-e", script], root.project, policy, factory)) + expect(Number(result.code), result.stderr).toBe(0) + } finally { + target.listener.stop(true) + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("blocks arbitrary host Unix sockets in proxy mode", async () => { + requireNetwork() + const root = await fixture() + const socket = path.join(root.outside, "escape.sock") + let accepted = 0 + const listener = createServer((client) => { + accepted++ + client.end("escaped") + }) + await new Promise((resolve, reject) => { + listener.once("error", reject) + listener.listen(socket, () => { + listener.off("error", reject) + resolve() + }) + }) + const target = tcp() + const port = target.listener.port + const factory: ProxyFactory = (hosts) => + startProxy(hosts, "linux", async () => ({ address: "127.0.0.1", family: 4 })) + const policy = profile([root.project], [], "proxy", [`allowed.test:${port}`]) + const script = [ + 'const net = require("node:net")', + `const socket = net.connect({ path: ${JSON.stringify(socket)} })`, + "socket.on('connect', () => process.exit(2))", + "socket.on('error', () => process.exit(0))", + "setTimeout(() => process.exit(4), 1000)", + ].join("\n") + + try { + const result = await Effect.runPromise(output(process.execPath, ["-e", script], root.project, policy, factory)) + expect(Number(result.code), result.stderr).toBe(0) + expect(accepted).toBe(0) + } finally { + target.listener.stop(true) + await new Promise((resolve) => listener.close(() => resolve())) + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + linux("blocks localhost connections in network deny mode", async () => { requireNetwork() const root = await fixture() diff --git a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md index c057d60959..72f1029bc4 100644 --- a/packages/kilo-docs/pages/getting-started/settings/sandboxing.md +++ b/packages/kilo-docs/pages/getting-started/settings/sandboxing.md @@ -10,7 +10,7 @@ The sandbox adds an operating-system boundary around agent tools. It limits wher The sandbox is **disabled by default**. It does not restrict filesystem reads. An agent can still read any file that your user account can read, but it can write only to explicitly allowed locations. {% callout type="warning" %} -Sandboxing is not available on Windows. If the macOS or Linux sandbox backend is unavailable, Kilo reports the reason and runs tools without sandbox confinement. The sandbox does not fail closed. +Sandboxing is not available on Windows. If an enabled macOS or Linux sandbox policy cannot be enforced, Kilo reports the reason and refuses to run the affected tool. It does not silently fall back to unrestricted execution. {% /callout %} ## Enable the sandbox @@ -32,6 +32,7 @@ You can also configure the default in the global `kilo.jsonc` file: "sandbox": { "enabled": true, "network": "deny", + "allowed_hosts": ["github.com:443", "api.github.com:443"], "writable_paths": ["~/shared-output"] } } @@ -41,9 +42,12 @@ You can also configure the default in the global `kilo.jsonc` file: |---|---|---| | `sandbox.enabled` | `false` | Use sandbox confinement by default for new sessions. | | `sandbox.network` | `"deny"` | Control outbound network access while filesystem confinement is active. Set this to `"allow"` to permit network access without removing filesystem write restrictions. | +| `sandbox.allowed_hosts` | `[]` | Allow configured DNS hosts and ports for sandboxed HTTP and HTTPS proxy traffic while network access is otherwise denied. Omitted ports default to `443`. Only global config may add destinations. | | `sandbox.writable_paths` | `[]` | Add writable files or directories outside the built-in writable locations. Only global config may set these paths. | -Project config may tighten sandbox policy by setting `enabled` to `true` or `network` to `"deny"`. It cannot disable a globally enabled sandbox, allow network denied by global config, or add writable paths. This prevents repository-controlled configuration from weakening the user's security boundary. +Project config may tighten sandbox policy by setting `enabled` to `true` or `network` to `"deny"`. It cannot disable a globally enabled sandbox, allow network denied by global config, add destinations, or add writable paths. A project-level network denial also clears global destination exceptions. This prevents repository-controlled configuration from weakening the user's security boundary. + +GitHub CLI and HTTPS Git commonly need both `github.com:443` and `api.github.com:443`. Specific workflows may require additional GitHub destinations. SSH Git remotes are not supported. ## When to use sandboxing @@ -58,7 +62,7 @@ The sandbox can reduce the impact of an unsafe tool call by: This can reduce the risk of auto-approving selected routine commands, such as builds and tests, by placing operating-system limits around many of their effects. It does **not** make **Allow Everything** safe. An allowed command can still modify or delete workspace files, alter other writable Kilo directories, consume data it can read, or write unsafe code that runs later outside the sandbox. -The sandbox does not protect against every result of prompt injection. In particular, it does not prevent the agent from reading accessible files or including their contents in model context. It also cannot confine local MCP servers, plugin hooks, or any integration that runs outside the sandbox boundary. +The sandbox does not protect against every result of prompt injection. In particular, it does not prevent the agent from reading accessible files or including their contents in model context. MCP client lifecycle and plugin hooks run as trusted host integrations. While network restriction is active, local and remote MCP tool calls and typed resource reads are unavailable, but MCP prompt retrieval remains outside the session network policy. {% callout type="warning" %} The network sandbox is not a provider privacy control. Provider and model inference traffic remains available. If Kilo reads a secret and includes it in a prompt, tool result, or conversation context, that content may be sent to the configured model provider even while network restriction is on. Choose providers with data-handling policies appropriate for your work, consider a local model for sensitive projects, and use read permissions to block or prompt for sensitive files. See [Prompt-Training Model Visibility](/docs/getting-started/settings#prompt-training-model-visibility). @@ -77,7 +81,7 @@ Permissions can ask or deny Kilo tool invocations that read or change data. For Permission rules are tool-specific and do not create a complete file-confidentiality boundary. A `read` denial controls Kilo's file-reading tool, but an allowed `grep` call, shell command, build script, or other process may read the same file through a different path. A child process can also print sensitive content into tool output, which may then become model context. Configure `grep`, `bash`, and other data-accessing tools separately, and avoid running untrusted code when sensitive files remain readable by your operating-system account. -For a given tool invocation, approving a shell command does not grant writes outside the sandbox, and a path being writable inside the sandbox does not bypass a matching permission rule. Some integration code runs outside this boundary: plugin hooks can run before a tool's internal permission check, and a local MCP server starts as a separate trusted process. MCP permissions control exposed tool invocations, not everything the server process can do during startup or in the background. Enable only local MCP servers and plugins you trust. +For a given tool invocation, approving a shell command does not grant writes outside the sandbox, and a path being writable inside the sandbox does not bypass a matching permission rule. Some integration code runs outside this boundary: plugin hooks can run before a tool's internal permission check, and MCP clients run as separate trusted host integrations. MCP permissions control exposed tool invocations, not local server startup, remote connections, reconnections, or other background lifecycle. Enable only MCP servers and plugins you trust. A practical setup for work on unfamiliar or partially trusted code is: @@ -105,7 +109,6 @@ Writes are allowed in: |---|---| | `$XDG_DATA_HOME/kilo` (normally `~/.local/share/kilo`) | Session data, logs, and Kilo's managed repository cache under `repos/` | | `$XDG_CACHE_HOME/kilo` (normally `~/.cache/kilo`) | Cached data and downloaded binaries | -| `$XDG_CONFIG_HOME/kilo` (normally `~/.config/kilo`) | Configuration and installed plugins | | `$XDG_STATE_HOME/kilo` (normally `~/.local/state/kilo`) | Runtime state | | `$TMPDIR/kilo` | Temporary files; on macOS this is commonly under `/var/folders/.../T/kilo` | @@ -113,12 +116,13 @@ Writes are denied everywhere else. The following rules still apply inside writab - `.git` directories are always read-only to sandboxed tools. - Kilo's stored sandbox policy and preference files are read-only. +- Kilo's global config root is read-only so a sandboxed command cannot widen network or write authority for a later session. - A permission approval for a path outside the sandbox does not make that path writable. Add the path to **Additional Writable Paths** if the tool must modify it. - Linked worktree sessions can write to their active worktree, not the primary checkout or sibling worktrees. Shell commands and their child processes inherit the same restrictions. Kilo's file tools perform mutations through a sandboxed worker. Writable file handles are unavailable, so a tool that requires an open read-write handle may fail even for an allowed path. -Because Kilo's config directory is writable, a shell command can change configuration, permissions, plugins, or additional writable paths that affect future tool calls. Direct filesystem access inside trusted integrations is confined only when the integration uses Kilo's sandbox-aware filesystem service. Starting or restarting a process with the background-process tool is unavailable while sandboxing is active. +Direct filesystem access inside trusted integrations is confined only when the integration uses Kilo's sandbox-aware filesystem service. Interactive terminals, notebook execution, and starting or restarting a background process are unavailable while sandboxing is active because those processes do not yet run inside the same boundary. {% callout type="info" %} The sandbox is a write boundary, not a privacy boundary. It does not prevent an agent from reading files outside your workspace if your operating-system account can read them. @@ -130,34 +134,45 @@ The sandbox is a write boundary, not a privacy boundary. It does not prevent an When network restriction is on, Kilo blocks: -- Outbound network access from model-originated shell commands and their child processes -- Requests from built-in HTTP tools such as web fetch and web search -- Remote MCP tool calls and custom or plugin tools that Kilo cannot prove will remain offline +- Direct outbound sockets from model-originated shell commands and their child processes +- HTTP and HTTPS requests from commands and built-in HTTP tools unless the destination is configured in `sandbox.allowed_hosts` +- Local and remote MCP tool calls and typed resource reads, plus custom or plugin tools that Kilo cannot prove will remain offline - Built-in tools such as codebase search, semantic search, and LSP that may use opaque or indirect network access Network restriction does not block: - Provider and model inference traffic, so conversations with the selected model continue to work -- Local MCP server processes +- Trusted MCP lifecycle, including local server startup and remote connections or reconnections. Restricted sessions still cannot invoke MCP tools or typed resources. +- MCP prompt retrieval, which runs outside the session network policy - Plugin hooks that run outside the sandboxed tool execution - Filesystem reads -This is not a system-wide firewall. It applies to the sandboxed tool execution boundary, not every Kilo, extension, or local process. Proxy environment variables are removed from sandboxed commands while network access is restricted. +This is not a system-wide firewall. It applies to the sandboxed tool execution boundary, not every Kilo, extension, or local process. + +When `sandbox.allowed_hosts` is non-empty, Kilo keeps direct sockets blocked and exposes an authenticated proxy as the only supported egress path. Entries are DNS hosts with an optional port; the default port is `443`. Use an explicit port such as `example.com:80` for HTTP. Wildcards, URLs, IP literals, private or reserved addresses, and implicit subdomains are rejected. Allowing `github.com:443` does not permit a `CONNECT` request for `api.github.com:443` or a tunnel whose TLS SNI is `api.github.com`. + +For HTTP, the proxy checks the requested DNS host and port. For HTTPS `CONNECT`, it also requires the TLS ClientHello to contain SNI matching the configured DNS host before opening the tunnel. Connections without matching SNI are rejected. The proxy resolves DNS outside the sandbox, rejects non-public results and recognized IPv4-transition ranges, and connects to the validated address. Unknown network-specific NAT64 prefixes cannot be identified without trusted prefix configuration, so such a network may translate an accepted global IPv6 result to an IPv4 destination that Kilo would otherwise reject. Redirects are checked again at the next proxy request. Linux keeps the command in a network namespace and bridges only the proxy socket; macOS Seatbelt permits only the scoped loopback proxy port. + +{% callout type="warning" %} +A configured destination is an egress route, not tenant, organization, repository, HTTP-origin, path, action, content, or data-loss-prevention isolation. The allowed service can receive, route, or store readable data and inherited credentials under its own policies. After an HTTPS tunnel passes the SNI check, Kilo cannot inspect encrypted requests, and the service may honor an alternate HTTP `Host` value within that connection. Allowing GitHub therefore grants the operations permitted by the active token, not access to only one organization or repository. HTTP and HTTPS are supported, including HTTPS Git remotes. SSH, arbitrary TCP, UDP, QUIC, SOCKS, CIDR ranges, and wildcard hosts remain blocked. +{% /callout %} ## Session behavior The config setting supplies the initial default for new sessions that do not have a saved preference. Use the lock button in the VS Code prompt or `/sandbox` in the CLI to change the current session. Your latest choice is saved as the default for future sessions in that project, takes precedence over the config default, and persists across restarts. -Each initialized session keeps its sandbox enabled state and network mode. Changing those settings affects new sessions; use the prompt control or `/sandbox` to change an existing session's enabled state. Changes to **Additional Writable Paths** are read when tools run and therefore also apply to existing sandboxed sessions. +Each initialized session snapshots its network mode, allowed destinations, and additional writable paths. Changing config affects new sessions. The prompt control or `/sandbox` can change the current session's enabled state, but it cannot change these authority lists, and they never expand during an active session. -Forked sessions retain the source session's confinement. Subagents inherit the stricter combination of the parent and child settings: sandboxing remains enabled if either requires it, and network remains blocked if either requires blocking. +Forked sessions retain the source session's confinement. Subagents inherit the stricter combination of parent and child settings: sandboxing remains enabled if either requires it, deny-all wins over destination exceptions, destination lists intersect, and additional writable paths intersect. Cloud sessions do not expose the local sandbox control because their tools do not run in your local sandbox. ## Platform support +On every platform, Kilo reports an error and refuses to run the affected tool if the configured confinement or destination proxy cannot be established. It does not fall back to unrestricted execution. + | Platform | Backend | Notes | |---|---|---| -| macOS | `sandbox-exec` (Seatbelt) | Uses a Seatbelt profile through `/usr/bin/sandbox-exec`. | -| Linux | Bubblewrap (`bwrap`) | Uses system `/usr/bin/bwrap` or a bundled, SHA-256-verified binary. `KILO_BWRAP_PATH` can select another binary. Kilo probes filesystem and network namespace support before enabling confinement. Additional writable paths must already exist before Bubblewrap starts. | -| Windows | None | Unsupported. The VS Code settings and prompt controls are hidden, and enabling the config has no effect. | +| macOS | `sandbox-exec` (Seatbelt) | Uses a Seatbelt profile through `/usr/bin/sandbox-exec`. Destination exceptions allow only the scoped authenticated loopback proxy port. | +| Linux | Bubblewrap (`bwrap`) | Uses system `/usr/bin/bwrap` or a bundled, SHA-256-verified binary. Destination exceptions retain the network namespace and use a Unix relay plus seccomp filtering to prevent direct host Unix-socket access. Additional writable paths must already exist before Bubblewrap starts. | +| Windows | None | Unsupported. The VS Code settings and prompt controls are hidden. A configured enabled policy cannot be enforced and therefore prevents restricted tool execution. | diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index f613ea5fac..804a0e8946 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -18,7 +18,9 @@ "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --dots --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "dependencies": { - "effect": "catalog:" + "@anthropic-ai/sandbox-runtime": "catalog:", + "effect": "catalog:", + "ipaddr.js": "catalog:" }, "devDependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/kilo-sandbox/src/backend.ts b/packages/kilo-sandbox/src/backend.ts index 7d541b46ea..20d44fff87 100644 --- a/packages/kilo-sandbox/src/backend.ts +++ b/packages/kilo-sandbox/src/backend.ts @@ -5,6 +5,7 @@ import { current } from "./context" import { assertProcessNetwork, networkEnvironment } from "./network" import type { Profile } from "./profile" import { seatbelt } from "./seatbelt" +import { currentProxy, type ProxyRuntime } from "./proxy" export interface Launch { readonly command: string @@ -24,6 +25,7 @@ export interface Backend { readonly prepare: ( profile: Profile, launch: Launch, + proxy?: ProxyRuntime, ) => Effect.Effect } @@ -49,23 +51,25 @@ function select(): Backend { const backend = select() -function environment(profile: Profile, launch: Launch) { +function environment(profile: Profile, launch: Launch, proxy?: ProxyRuntime) { const source = { ...launch.environment, ...profile.environment.set } const denied = new Set(profile.environment.deny) const entries = Object.entries(source).filter( (entry): entry is [string, string] => entry[1] !== undefined && !denied.has(entry[0]), ) - return networkEnvironment(profile, Object.fromEntries(entries)) + return networkEnvironment(profile, Object.fromEntries(entries), proxy) } export function prepare(launch: Launch) { return Effect.gen(function* () { const profile = yield* current if (!profile) return launch - const next = { ...launch, environment: environment(profile, launch) } + const proxy = yield* currentProxy + const next = { ...launch, environment: environment(profile, launch, proxy) } yield* assertProcessNetwork(profile, launch.command) - if (!backend.support().available) return next - return yield* backend.prepare(profile, next) + const support = backend.support(profile.network) + if (!support.available) return yield* Effect.fail(unsupported(launch.command, "prepare", support)) + return yield* backend.prepare(profile, next, proxy) }) } @@ -81,11 +85,12 @@ function unsupported(command: string, method: string, support: Support) { export function confine(profile: Profile, launch: Launch) { return Effect.gen(function* () { - const next = { ...launch, environment: environment(profile, launch) } + const proxy = yield* currentProxy + const next = { ...launch, environment: environment(profile, launch, proxy) } yield* assertProcessNetwork(profile, launch.command) const support = backend.support(profile.network) if (!support.available) return yield* Effect.fail(unsupported(launch.command, "confine", support)) - return yield* backend.prepare(profile, next) + return yield* backend.prepare(profile, next, proxy) }) } diff --git a/packages/kilo-sandbox/src/bubblewrap.ts b/packages/kilo-sandbox/src/bubblewrap.ts index 1440185c1c..6c28b2e42c 100644 --- a/packages/kilo-sandbox/src/bubblewrap.ts +++ b/packages/kilo-sandbox/src/bubblewrap.ts @@ -2,11 +2,15 @@ import { spawnSync } from "node:child_process" import { createHash } from "node:crypto" import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs" import path from "node:path" +import { fileURLToPath } from "node:url" import { Effect, PlatformError } from "effect" import type { Backend, Launch, Support } from "./backend" import type { PathRule, Profile } from "./profile" +import type { ProxyRuntime } from "./proxy" declare const KILO_BWRAP_SHA256: string | undefined +declare const KILO_SANDBOX_NETWORK_RELAY_PATH: string | undefined +declare const KILO_SANDBOX_SECCOMP_PATH: string | undefined const system = "/usr/bin/bwrap" @@ -20,6 +24,25 @@ function command(launch: Launch) { return [shell, "-c", [launch.command, ...launch.args.map(quote)].join(" ")] } +function relay() { + if (typeof KILO_SANDBOX_NETWORK_RELAY_PATH === "undefined") { + return { path: fileURLToPath(new URL("./kilo-sandbox-network-relay.ts", import.meta.url)), environment: {} } + } + const target = KILO_SANDBOX_NETWORK_RELAY_PATH.startsWith(".") + ? fileURLToPath(new URL(KILO_SANDBOX_NETWORK_RELAY_PATH, import.meta.url)) + : path.resolve(path.dirname(process.execPath), KILO_SANDBOX_NETWORK_RELAY_PATH) + return { path: target, environment: { BUN_BE_BUN: "1" } } +} + +function seccomp() { + if (typeof KILO_SANDBOX_SECCOMP_PATH !== "undefined") { + return path.resolve(path.dirname(process.execPath), KILO_SANDBOX_SECCOMP_PATH) + } + const entry = fileURLToPath(import.meta.resolve("@anthropic-ai/sandbox-runtime")) + const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : undefined + return arch ? path.resolve(path.dirname(entry), "../vendor/seccomp", arch, "apply-seccomp") : undefined +} + function exists(rule: PathRule) { if (!existsSync(rule.path)) return false const entry = statSync(rule.path) @@ -113,14 +136,24 @@ export function generate( launch: Launch, executable: string, mounts = process.platform === "linux" ? mountpoints() : [], + proxy?: ProxyRuntime, ): Launch { const allow = writable(profile) validate(allow, executable, mounts) + const worker = profile.network.mode === "proxy" ? relay() : undefined + const filter = profile.network.mode === "proxy" ? seccomp() : undefined + if (profile.network.mode === "proxy" && (!proxy?.socket || !worker || !filter)) { + throw new Error("Linux sandbox proxy dependencies are unavailable") + } + if (worker) validate(allow, worker.path, mounts) + if (filter) validate(allow, filter, mounts) + if (worker) validate(allow, process.execPath, mounts) const args = [ "--unshare-user", "--disable-userns", "--unshare-pid", - ...(profile.network.mode === "deny" ? ["--unshare-net"] : []), + ...(profile.network.mode !== "allow" ? ["--unshare-net"] : []), + ...(profile.network.mode === "proxy" ? ["--cap-add", "cap_sys_admin"] : []), "--die-with-parent", "--new-session", "--ro-bind", @@ -132,12 +165,20 @@ export function generate( for (const rule of allow) args.push("--bind", rule.path, rule.path) for (const target of protectedPaths(profile, allow)) args.push("--ro-bind", target, target) + if (proxy?.socket) args.push("--ro-bind", proxy.socket, proxy.socket) args.push("--proc", "/proc") if (launch.cwd) args.push("--chdir", launch.cwd) - args.push("--", ...command(launch)) + const target = command(launch) + args.push( + "--", + ...(worker && filter && proxy?.socket + ? [process.execPath, worker.path, proxy.socket, filter, "--", ...target] + : target), + ) return { ...launch, + environment: worker ? { ...launch.environment, ...worker.environment } : launch.environment, command: executable, args, } @@ -197,6 +238,7 @@ interface Selection { readonly executable: string | undefined readonly support: Support network: Support | undefined + proxy: Support | undefined } function select(): Selection { @@ -210,7 +252,7 @@ function select(): Selection { const executable = resolve(candidate.executable, candidate.expected) if (!executable) continue const failure = probe(executable) - if (!failure) return { executable, support: { available: true } satisfies Support, network: undefined } + if (!failure) return { executable, support: { available: true } satisfies Support, network: undefined, proxy: undefined } failures.push(failure) } @@ -221,6 +263,7 @@ function select(): Selection { reason: failures.at(-1) ?? "No usable Bubblewrap executable is available", } satisfies Support, network: undefined, + proxy: undefined, } } @@ -235,17 +278,37 @@ function selection(): Selection { executable: undefined, support: { available: false, reason: "Bubblewrap requires Linux" } satisfies Support, network: undefined, + proxy: undefined, } return selected } function support(network?: Profile["network"]): Support { const selected = selection() - if (!selected.support.available || network?.mode !== "deny" || !selected.executable) return selected.support - if (selected.network) return selected.network + if (!selected.support.available || !network || network.mode === "allow" || !selected.executable) return selected.support + if (network?.mode === "proxy" && selected.proxy) return selected.proxy + if (network?.mode === "deny" && selected.network) return selected.network const failure = probe(selected.executable, true) - selected.network = failure ? { available: false, reason: failure } : { available: true } - return selected.network + if (failure) { + const value = { available: false, reason: failure } + if (network?.mode === "proxy") selected.proxy = value + else selected.network = value + } + else if (network?.mode === "proxy") { + const worker = relay().path + const filter = seccomp() + const missing = !existsSync(worker) + ? worker + : filter === undefined + ? "unsupported architecture" + : !existsSync(filter) + ? filter + : undefined + selected.proxy = missing + ? { available: false, reason: `Linux sandbox proxy dependency is unavailable: ${missing ?? "unsupported architecture"}` } + : { available: true } + } else selected.network = { available: true } + return network?.mode === "proxy" ? selected.proxy! : selected.network! } function setup(cause: unknown, launch: Launch) { @@ -261,11 +324,11 @@ function setup(cause: unknown, launch: Launch) { export const bubblewrap: Backend = { support, - prepare: (profile, launch) => + prepare: (profile, launch, proxy) => Effect.try({ try: () => { const selected = selection() - return selected.executable ? generate(profile, launch, selected.executable) : launch + return selected.executable ? generate(profile, launch, selected.executable, undefined, proxy) : launch }, catch: (cause) => setup(cause, launch), }), diff --git a/packages/kilo-sandbox/src/context.ts b/packages/kilo-sandbox/src/context.ts index 71609d12a9..dfe409e518 100644 --- a/packages/kilo-sandbox/src/context.ts +++ b/packages/kilo-sandbox/src/context.ts @@ -1,6 +1,7 @@ import { Context, Effect, PlatformError } from "effect" import { canonicalize, canonicalizeEntry, matches, normalize } from "./path" import type { Profile } from "./profile" +import { withProxy } from "./proxy" export const CurrentProfile = Context.Reference("@kilocode/sandbox/CurrentProfile", { defaultValue: () => undefined, @@ -18,7 +19,7 @@ export function run( ): Effect.Effect { return Effect.gen(function* () { const value = yield* normalize(profile) - return yield* effect.pipe(Effect.provideService(CurrentProfile, value)) + return yield* withProxy(value, effect.pipe(Effect.provideService(CurrentProfile, value))) }) } diff --git a/packages/kilo-sandbox/src/destination.ts b/packages/kilo-sandbox/src/destination.ts new file mode 100644 index 0000000000..fc92c023db --- /dev/null +++ b/packages/kilo-sandbox/src/destination.ts @@ -0,0 +1,72 @@ +import { lookup } from "node:dns/promises" +import { isIP } from "node:net" +import { domainToASCII } from "node:url" +import ipaddr from "ipaddr.js" + +export interface Destination { + readonly host: string + readonly port: number + readonly authority: string +} + +function invalid(input: string) { + return new TypeError(`Invalid sandbox network destination: ${JSON.stringify(input)}`) +} + +function hostname(input: string) { + if (input.length === 0 || input.length > 253 || isIP(input) !== 0 || /^[0-9.]+$/.test(input)) throw invalid(input) + const host = domainToASCII(input.toLowerCase()) + if (!host || host.length > 253) throw invalid(input) + const labels = host.split(".") + if ( + labels.some( + (label) => + label.length === 0 || + label.length > 63 || + label.startsWith("-") || + label.endsWith("-") || + !/^[a-z0-9-]+$/.test(label), + ) + ) { + throw invalid(input) + } + return host +} + +export function parseDestination(input: string, defaultPort = 443): Destination { + if (input !== input.trim() || /[\u0000-\u0020\u007f/@?#*]/.test(input)) throw invalid(input) + const colon = input.lastIndexOf(":") + const hasPort = colon > -1 + const value = hasPort ? input.slice(0, colon) : input + const raw = value.endsWith(".") ? value.slice(0, -1) : value + const text = hasPort ? input.slice(colon + 1) : String(defaultPort) + if (!/^\d+$/.test(text)) throw invalid(input) + const port = Number(text) + if (!Number.isInteger(port) || port < 1 || port > 65535) throw invalid(input) + const host = hostname(raw) + return { host, port, authority: `${host}:${port}` } +} + +export function normalizeDestinations(input: ReadonlyArray) { + return [...new Set(input.map((value) => parseDestination(value).authority))].sort() +} + +export function isPublicAddress(input: string) { + if (!ipaddr.isValid(input)) return false + const address = ipaddr.parse(input) + if (address.kind() === "ipv6") { + const ipv6 = address as ipaddr.IPv6 + if (ipv6.isIPv4MappedAddress() || ipv6.match(ipaddr.IPv6.parse("::"), 96)) return false + } + // Unknown network-specific NAT64 prefixes are indistinguishable from ordinary global IPv6 addresses. + // Identifying them requires trusted prefix configuration; range() rejects the recognized transition ranges. + return address.range() === "unicast" +} + +export async function resolveDestination(dest: Destination) { + const addresses = await lookup(dest.host, { all: true, verbatim: true }) + if (addresses.length === 0 || addresses.some((entry) => !isPublicAddress(entry.address))) { + throw new Error(`Sandbox denied a non-public address for ${dest.authority}`) + } + return addresses[0] +} diff --git a/packages/kilo-sandbox/src/index.ts b/packages/kilo-sandbox/src/index.ts index cf942e3e7a..927f2f1ad3 100644 --- a/packages/kilo-sandbox/src/index.ts +++ b/packages/kilo-sandbox/src/index.ts @@ -1,7 +1,9 @@ export type { Profile } from "./profile" export { assertWrite, enabled, run, unrestricted } from "./context" export { decorateFileSystem, ensureDirectory } from "./filesystem" -export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network" +export { assertNetwork, assertSandbox, decorateHttpClient, httpLayer as networkHttpLayer } from "./network" export { batchMutations, mutate, withRunner, type Runner as MutationRunner } from "./mutation" export type { Request as MutationRequest } from "./mutation-protocol" export { backendSupport, prepareCommand } from "./backend" +export { isPublicAddress, normalizeDestinations, parseDestination } from "./destination" +export { CurrentProxyFactory, startProxy, type ProxyFactory, type ProxyResolver } from "./proxy" diff --git a/packages/kilo-sandbox/src/kilo-sandbox-network-relay.ts b/packages/kilo-sandbox/src/kilo-sandbox-network-relay.ts new file mode 100644 index 0000000000..a104ad57da --- /dev/null +++ b/packages/kilo-sandbox/src/kilo-sandbox-network-relay.ts @@ -0,0 +1,54 @@ +import { spawn } from "node:child_process" +import { constants } from "node:os" +import { createServer, connect, type Socket } from "node:net" + +const split = process.argv.indexOf("--") +const socket = process.argv[2] +const seccomp = process.argv[3] +const command = split > -1 ? process.argv.slice(split + 1) : [] + +if (!socket || !seccomp || command.length === 0) { + process.stderr.write("Invalid sandbox network relay invocation\n") + process.exit(2) +} + +const sockets = new Set() +const track = (socket: Socket) => { + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) + return socket +} +const server = createServer((client) => { + track(client) + const upstream = connect({ path: socket }) + track(upstream) + client.on("error", () => upstream.destroy()) + upstream.on("error", () => client.destroy()) + client.pipe(upstream) + upstream.pipe(client) +}) + +function finish(code: number) { + for (const socket of sockets) socket.destroy() + server.close(() => process.exit(code)) +} + +server.listen(3128, "127.0.0.1", () => { + const environment = { ...process.env } + delete environment.BUN_BE_BUN + const child = spawn(seccomp, command, { stdio: "inherit", env: environment }) + const forward = (signal: NodeJS.Signals) => child.kill(signal) + for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) process.on(signal, () => forward(signal)) + child.once("error", (cause) => { + process.stderr.write(`${cause.message}\n`) + finish(126) + }) + child.once("exit", (code, signal) => { + finish(signal ? 128 + constants.signals[signal] : (code ?? 1)) + }) +}) + +server.on("error", (cause) => { + process.stderr.write(`${cause.message}\n`) + process.exit(125) +}) diff --git a/packages/kilo-sandbox/src/network.ts b/packages/kilo-sandbox/src/network.ts index 5cd29117f3..e4566a7b1e 100644 --- a/packages/kilo-sandbox/src/network.ts +++ b/packages/kilo-sandbox/src/network.ts @@ -1,7 +1,9 @@ -import { Effect, Layer, PlatformError } from "effect" -import { HttpClient, HttpClientError, type HttpClientRequest } from "effect/unstable/http" +import { Effect, Layer, PlatformError, Stream } from "effect" +import { HttpClient, HttpClientError, HttpClientResponse, type HttpClientRequest } from "effect/unstable/http" import { current } from "./context" import type { Profile } from "./profile" +import { currentProxy, type ProxyRuntime } from "./proxy" +import { normalizeDestinations } from "./destination" const proxies = new Set([ "HTTP_PROXY", @@ -30,35 +32,68 @@ function denied(value: string, method: string) { }) } -function unsupported(value: string, method: string) { +function unavailable(value: string, method: string, description = "Sandbox network proxy is unavailable") { return PlatformError.systemError({ _tag: "BadResource", module: "Sandbox", method, pathOrDescriptor: target(value), - description: "Sandbox proxy network mode and allowedHosts are not supported", + description, }) } -function unsupportedProfile(profile: Profile) { - return profile.network.mode === "proxy" || profile.network.allowedHosts.length > 0 +function outside(value: string, method: string) { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "Sandbox", + method, + pathOrDescriptor: target(value), + description: "Sandbox denied execution outside its process boundary", + }) } -export function networkEnvironment(profile: Profile, environment: Record) { +function matches(profile: Profile, runtime: ProxyRuntime) { + return normalizeDestinations(profile.network.allowedHosts).join("\0") === runtime.allowedHosts.join("\0") +} + +export function networkEnvironment(profile: Profile, environment: Record, runtime?: ProxyRuntime) { if (profile.network.mode === "allow" && profile.network.allowedHosts.length === 0) return environment - return Object.fromEntries(Object.entries(environment).filter(([key]) => !proxies.has(key))) + const clean = Object.fromEntries(Object.entries(environment).filter(([key]) => !proxies.has(key))) + if (profile.network.mode !== "proxy" || !runtime) return clean + const url = runtime.socket ? `http://kilo:${encodeURIComponent(runtime.token)}@127.0.0.1:3128` : runtime.url + return { + ...clean, + HTTP_PROXY: url, + HTTPS_PROXY: url, + http_proxy: url, + https_proxy: url, + NO_PROXY: "", + no_proxy: "", + } } export function assertProcessNetwork(profile: Profile, command: string) { - if (!unsupportedProfile(profile)) return Effect.void - return Effect.fail(unsupported(command, "prepareNetwork")) + if (profile.network.mode !== "proxy" && profile.network.allowedHosts.length > 0) { + return Effect.fail(unavailable(command, "prepareNetwork", "Sandbox allowedHosts require proxy network mode")) + } + if (profile.network.mode !== "proxy") return Effect.void + return Effect.flatMap(currentProxy, (runtime) => + runtime && matches(profile, runtime) + ? Effect.void + : Effect.fail(unavailable(command, "prepareNetwork", "Sandbox network proxy policy does not match the session")), + ) +} + +export function assertSandbox(value: string, method = "sandbox") { + return Effect.flatMap(current, (profile) => + profile ? Effect.fail(outside(value, method)) : Effect.void, + ) } export function assertNetwork(value: string, method = "network") { return Effect.gen(function* () { const profile = yield* current if (!profile) return - if (unsupportedProfile(profile)) yield* Effect.fail(unsupported(value, method)) if (profile.network.mode === "allow") return yield* Effect.fail(denied(value, method)) }) @@ -70,20 +105,47 @@ function requestError(request: HttpClientRequest.HttpClientRequest, description: }) } -function assertRequest(request: HttpClientRequest.HttpClientRequest) { - return Effect.gen(function* () { - const profile = yield* current - if (!profile) return request - if (profile.network.mode === "allow" && profile.network.allowedHosts.length === 0) return request - const description = unsupportedProfile(profile) - ? "Sandbox proxy network mode and allowedHosts are not supported" - : "Sandbox denied outbound network access" - return yield* Effect.fail(requestError(request, description)) - }) +function proxied(request: HttpClientRequest.HttpClientRequest, url: URL, signal: AbortSignal, runtime: ProxyRuntime) { + const send = (body: BodyInit | undefined) => + Effect.tryPromise({ + try: () => + fetch(url, { + method: request.method, + headers: request.headers, + body, + duplex: request.body._tag === "Stream" ? "half" : undefined, + signal, + proxy: runtime.url, + } as RequestInit), + catch: (cause) => requestError(request, cause instanceof Error ? cause.message : "Sandbox proxy request failed"), + }).pipe(Effect.map((response) => HttpClientResponse.fromWeb(request, response))) + switch (request.body._tag) { + case "Raw": + case "Uint8Array": + return send(request.body.body as BodyInit) + case "FormData": + return send(request.body.formData) + case "Stream": + return Effect.flatMap(Stream.toReadableStreamEffect(request.body.stream), send) + } + return send(undefined) } export function decorateHttpClient(http: HttpClient.HttpClient): HttpClient.HttpClient { - return HttpClient.mapRequestEffect(http, assertRequest) + return HttpClient.make((request, url, signal) => + Effect.gen(function* () { + const profile = yield* current + if (!profile || profile.network.mode === "allow") return yield* http.execute(request) + if (profile.network.mode === "deny") { + return yield* Effect.fail(requestError(request, "Sandbox denied outbound network access")) + } + const runtime = yield* currentProxy + if (!runtime || !matches(profile, runtime)) { + return yield* Effect.fail(requestError(request, "Sandbox network proxy policy does not match the session")) + } + return yield* proxied(request, url, signal, runtime) + }), + ) } export const httpLayer = Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, decorateHttpClient)) diff --git a/packages/kilo-sandbox/src/proxy.ts b/packages/kilo-sandbox/src/proxy.ts new file mode 100644 index 0000000000..93e7e6461b --- /dev/null +++ b/packages/kilo-sandbox/src/proxy.ts @@ -0,0 +1,301 @@ +import { timingSafeEqual, randomBytes } from "node:crypto" +import { chmod, mkdtemp, rm } from "node:fs/promises" +import { createServer, request as requestHttp, type IncomingHttpHeaders, type Server } from "node:http" +import { request as requestHttps } from "node:https" +import { connect, createServer as createNetServer, type Socket } from "node:net" +import os from "node:os" +import path from "node:path" +import { Context, Effect, PlatformError } from "effect" +import { normalizeDestinations, parseDestination, resolveDestination } from "./destination" +import type { Profile } from "./profile" +import { TlsClientHello } from "./tls-client-hello" + +export interface ProxyRuntime { + readonly url: string + readonly token: string + readonly allowedHosts: ReadonlyArray + readonly port?: number | undefined + readonly socket?: string | undefined +} + +export const CurrentProxy = Context.Reference("@kilocode/sandbox/CurrentProxy", { + defaultValue: () => undefined, +}) + +export const currentProxy: Effect.Effect = Effect.gen(function* () { + return yield* CurrentProxy +}) + +export type ProxyResolver = typeof resolveDestination +export type ProxyFactory = ( + input: ReadonlyArray, +) => Promise Promise }> + +export const CurrentProxyFactory = Context.Reference("@kilocode/sandbox/CurrentProxyFactory", { + defaultValue: () => startProxy, +}) + +function error(method: string, description: string, cause?: unknown) { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "Sandbox", + method, + pathOrDescriptor: "network", + description, + cause, + }) +} + +function authenticate(value: string | undefined, token: string) { + const match = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(value ?? "") + if (!match) return false + const decoded = Buffer.from(match[1], "base64").toString("utf8") + const separator = decoded.indexOf(":") + if (separator < 1) return false + const actual = Buffer.from(decoded.slice(separator + 1)) + const expected = Buffer.from(token) + return actual.length === expected.length && timingSafeEqual(actual, expected) +} + +function headers(input: IncomingHttpHeaders, host?: string) { + const connection = new Set((input.connection ?? "").split(",").map((value) => value.trim().toLowerCase())) + const blocked = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ...connection, + ]) + return Object.fromEntries( + Object.entries({ ...input, ...(host ? { host } : {}) }).filter( + ([key, value]) => value !== undefined && !blocked.has(key.toLowerCase()), + ), + ) +} + +function shutdown(server: Server | ReturnType, sockets: Set) { + return new Promise((resolve) => { + for (const socket of sockets) socket.destroy() + server.close(() => resolve()) + }) +} + +export async function startProxy( + input: ReadonlyArray, + platform: NodeJS.Platform = process.platform, + resolve: ProxyResolver = resolveDestination, +): Promise Promise }> { + const allowed = new Set(normalizeDestinations(input)) + const allowedHosts = [...allowed] + const token = randomBytes(24).toString("base64url") + const sockets = new Set() + const server = createServer({ maxHeaderSize: 16 * 1024, requestTimeout: 30_000 }) + server.on("connection", (socket) => { + sockets.add(socket) + socket.once("close", () => sockets.delete(socket)) + }) + server.on("connect", (request, client, head) => { + client.on("error", () => undefined) + if (!authenticate(request.headers["proxy-authorization"], token)) { + client.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="kilo"\r\n\r\n') + return + } + try { + const dest = parseDestination(request.url ?? "") + if (!allowed.has(dest.authority)) { + client.end("HTTP/1.1 403 Forbidden\r\n\r\n") + return + } + const hello = new TlsClientHello(dest.host) + let upstream: Socket | undefined + let dialing = false + let connected = false + let tunneled = false + const fail = () => { + clearTimeout(timer) + upstream?.destroy() + client.destroy() + } + const timer = setTimeout(fail, 30_000) + timer.unref() + const forward = () => { + if (!upstream || !connected || tunneled || client.destroyed || hello.push(Buffer.alloc(0)) !== "valid") return + clearTimeout(timer) + client.pause() + client.off("data", inspect) + tunneled = true + upstream.pipe(client) + const pipe = () => { + if (!upstream || client.destroyed) { + upstream?.destroy() + return + } + client.pipe(upstream) + } + if (upstream.write(hello.bytes())) pipe() + else upstream.once("drain", pipe) + } + const inspect = (chunk: Buffer) => { + const state = hello.push(chunk) + if (state === "invalid") { + fail() + return + } + if (dialing) { + if (state === "valid") forward() + return + } + if (state === "pending") return + dialing = true + void resolve(dest).then((resolved) => { + if (client.destroyed) return + upstream = connect({ host: resolved.address, port: dest.port, family: resolved.family }) + upstream.on("error", fail) + upstream.once("connect", () => { + if (!upstream || client.destroyed) { + upstream?.destroy() + return + } + connected = true + forward() + }) + }, fail) + } + client.on("data", inspect) + client.once("end", () => { + if (!tunneled) fail() + }) + client.once("close", () => { + clearTimeout(timer) + upstream?.destroy() + }) + client.write("HTTP/1.1 200 Connection Established\r\n\r\n") + if (head.length > 0) inspect(head) + } catch { + client.end("HTTP/1.1 502 Bad Gateway\r\n\r\n") + } + }) + server.on("request", async (request, response) => { + if (!authenticate(request.headers["proxy-authorization"], token)) { + response.writeHead(407, { "Proxy-Authenticate": 'Basic realm="kilo"' }) + response.end() + return + } + try { + const url = new URL(request.url ?? "") + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) throw new Error() + const dest = parseDestination(`${url.hostname}:${url.port || (url.protocol === "https:" ? "443" : "80")}`) + if (!allowed.has(dest.authority)) { + response.writeHead(403) + response.end() + return + } + const resolved = await resolve(dest) + const send = url.protocol === "https:" ? requestHttps : requestHttp + const upstream = send( + { + hostname: resolved.address, + family: resolved.family, + port: dest.port, + servername: dest.host, + method: request.method, + path: `${url.pathname}${url.search}`, + headers: headers(request.headers, url.host), + }, + (incoming) => { + response.writeHead(incoming.statusCode ?? 502, headers(incoming.headers)) + incoming.pipe(response) + }, + ) + upstream.on("error", () => { + if (!response.headersSent) response.writeHead(502) + response.end() + }) + response.once("close", () => upstream.destroy()) + request.pipe(upstream) + } catch { + response.writeHead(400) + response.end() + } + }) + server.on("clientError", (_cause, socket) => socket.end("HTTP/1.1 400 Bad Request\r\n\r\n")) + + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen({ host: "127.0.0.1", port: 0 }, () => { + server.off("error", reject) + resolve() + }) + }) + const address = server.address() + const port = typeof address === "object" && address ? address.port : undefined + if (!port) throw new Error("Sandbox proxy did not bind a TCP port") + + const dir = platform === "linux" ? await mkdtemp(path.join(os.tmpdir(), "kilo-sandbox-proxy-")) : undefined + const socket = dir ? path.join(dir, "proxy.sock") : undefined + const bridgeSockets = new Set() + const bridge = socket + ? createNetServer((client) => { + bridgeSockets.add(client) + client.once("close", () => bridgeSockets.delete(client)) + const upstream = connect({ host: "127.0.0.1", port }) + bridgeSockets.add(upstream) + upstream.once("close", () => bridgeSockets.delete(upstream)) + client.on("error", () => upstream.destroy()) + upstream.on("error", () => client.destroy()) + client.pipe(upstream) + upstream.pipe(client) + }) + : undefined + if (bridge && socket) { + await new Promise((resolve, reject) => { + bridge.once("error", reject) + bridge.listen(socket, () => { + bridge.off("error", reject) + resolve() + }) + }).catch(async (cause) => { + await shutdown(server, sockets) + if (dir) await rm(dir, { recursive: true, force: true }) + throw cause + }) + await chmod(socket, 0o600) + } + const url = `http://kilo:${encodeURIComponent(token)}@127.0.0.1:${port}` + return { + url, + token, + allowedHosts, + port, + socket, + close: async () => { + if (bridge) await shutdown(bridge, bridgeSockets) + await shutdown(server, sockets) + if (dir) await rm(dir, { recursive: true, force: true }) + }, + } +} + +export function withProxy(profile: Profile, effect: Effect.Effect) { + if (profile.network.mode !== "proxy" && profile.network.allowedHosts.length > 0) { + return Effect.fail(error("validateProxy", "Sandbox allowedHosts require proxy network mode")) + } + if (profile.network.mode !== "proxy") { + return effect.pipe(Effect.provideService(CurrentProxy, undefined)) + } + return Effect.flatMap(CurrentProxyFactory, (factory) => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => factory(profile.network.allowedHosts), + catch: (cause) => error("startProxy", "Could not start the sandbox network proxy", cause), + }), + (runtime) => effect.pipe(Effect.provideService(CurrentProxy, runtime)), + (runtime) => Effect.promise(() => runtime.close()).pipe(Effect.ignore), + ), + ) +} diff --git a/packages/kilo-sandbox/src/seatbelt-base.ts b/packages/kilo-sandbox/src/seatbelt-base.ts index a439d94611..2f6d173281 100644 --- a/packages/kilo-sandbox/src/seatbelt-base.ts +++ b/packages/kilo-sandbox/src/seatbelt-base.ts @@ -98,7 +98,7 @@ export const base = `(version 1) (allow user-preference-read) ; system services required by common command-line runtimes -(allow system-socket) +(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2))) (allow mach-lookup (global-name "com.apple.bsd.dirhelper") (global-name "com.apple.system.opendirectoryd.membership") diff --git a/packages/kilo-sandbox/src/seatbelt-network.ts b/packages/kilo-sandbox/src/seatbelt-network.ts index 3b742d6b88..0676757704 100644 --- a/packages/kilo-sandbox/src/seatbelt-network.ts +++ b/packages/kilo-sandbox/src/seatbelt-network.ts @@ -1,9 +1,18 @@ import type { Profile } from "./profile" +import type { ProxyRuntime } from "./proxy" -export function networkPolicy(profile: Profile) { +export function networkPolicy(profile: Profile, proxy?: ProxyRuntime) { if (profile.network.mode === "allow") { return "; sandbox network mode: allow\n(allow network-outbound)\n(allow network-inbound)" } + if (profile.network.mode === "proxy" && proxy?.port) { + return [ + "; sandbox network mode: proxy", + '(deny network-outbound (with message "Sandbox denied direct outbound network access"))', + '(deny network-inbound (with message "Sandbox denied inbound network access"))', + `(allow network-outbound (remote ip "localhost:${proxy.port}"))`, + ].join("\n") + } return [ `; sandbox network mode: ${profile.network.mode}`, '(deny network-outbound (with message "Sandbox denied outbound network access"))', diff --git a/packages/kilo-sandbox/src/seatbelt.ts b/packages/kilo-sandbox/src/seatbelt.ts index ad2bb9ac9d..08801cfcda 100644 --- a/packages/kilo-sandbox/src/seatbelt.ts +++ b/packages/kilo-sandbox/src/seatbelt.ts @@ -4,6 +4,7 @@ import type { Backend, Launch, Support } from "./backend" import type { PathRule, Profile } from "./profile" import { base } from "./seatbelt-base" import { networkPolicy } from "./seatbelt-network" +import type { ProxyRuntime } from "./proxy" const executable = "/usr/bin/sandbox-exec" @@ -30,7 +31,7 @@ function exclude(rule: PathRule, key: string) { return [`(require-not (literal (param "${key}")))`, `(require-not (subpath (param "${key}")))`] } -function policy(profile: Profile) { +function policy(profile: Profile, proxy?: ProxyRuntime) { const params: Array = [] const allow = profile.filesystem.allowWrite.map((rule, index) => { const key = `ALLOW_WRITE_${index}` @@ -50,7 +51,7 @@ function policy(profile: Profile) { return { value: [ base, - networkPolicy(profile), + networkPolicy(profile, proxy), "; reads are not confined by the file-level sandbox\n(allow file-read*)", write, ].join("\n"), @@ -58,8 +59,8 @@ function policy(profile: Profile) { } } -export function generate(profile: Profile, launch: Launch): Launch { - const generated = policy(profile) +export function generate(profile: Profile, launch: Launch, proxy?: ProxyRuntime): Launch { + const generated = policy(profile, proxy) const args = ["-p", generated.value, ...generated.params.map((param) => `-D${param.key}=${param.value}`)] const command = launch.shell ? (typeof launch.shell === "string" ? launch.shell : "/bin/sh") : launch.command const commandArgs = launch.shell ? ["-c", [launch.command, ...launch.args.map(quote)].join(" ")] : launch.args @@ -77,5 +78,5 @@ const available: Support = existsSync(executable) export const seatbelt: Backend = { support: () => available, - prepare: (profile, launch) => Effect.succeed(generate(profile, launch)), + prepare: (profile, launch, proxy) => Effect.succeed(generate(profile, launch, proxy)), } diff --git a/packages/kilo-sandbox/src/tls-client-hello.ts b/packages/kilo-sandbox/src/tls-client-hello.ts new file mode 100644 index 0000000000..026251affa --- /dev/null +++ b/packages/kilo-sandbox/src/tls-client-hello.ts @@ -0,0 +1,172 @@ +import { parseDestination } from "./destination" + +const MAX_RECORD = 16 * 1024 +const MAX_HELLO = 64 * 1024 - 1 +const MAX_INPUT = 128 * 1024 +const COMPAT = Buffer.from([20, 3, 3, 0, 1, 1]) + +type State = "pending" | "valid" | "invalid" + +function hostname(input: Buffer) { + if ( + input.length === 0 || + input.some( + (byte) => + !( + (byte >= 0x30 && byte <= 0x39) || + (byte >= 0x41 && byte <= 0x5a) || + (byte >= 0x61 && byte <= 0x7a) || + byte === 0x2d || + byte === 0x2e + ), + ) + ) { + return + } + try { + return parseDestination(input.toString("ascii")).host + } catch { + return + } +} + +function validate(input: Buffer, expected: string) { + let offset = 0 + const take = (length: number) => { + if (offset + length > input.length) return + const value = input.subarray(offset, offset + length) + offset += length + return value + } + const uint16 = () => { + const value = take(2) + return value?.readUInt16BE(0) + } + + const version = take(2) + if (!version || version[0] !== 3 || version[1] < 1 || version[1] > 3) return false + if (!take(32)) return false + + const session = take(1)?.[0] + if (session === undefined || session > 32 || !take(session)) return false + + const ciphers = uint16() + if (ciphers === undefined || ciphers < 2 || ciphers % 2 !== 0 || !take(ciphers)) return false + + const compression = take(1)?.[0] + const methods = compression === undefined ? undefined : take(compression) + if (!methods || methods.length === 0 || !methods.includes(0)) return false + + const length = uint16() + if (length === undefined || length !== input.length - offset) return false + + const seen = new Set() + let sni: string | undefined + while (offset < input.length) { + const type = uint16() + const size = uint16() + if (type === undefined || size === undefined || seen.has(type)) return false + seen.add(type) + const data = take(size) + if (!data) return false + + // Encrypted ClientHello and TLS 1.3 early data cannot be safely authorized by outer SNI inspection. + if (type === 0xfe0d || type === 0xffce || type === 42) return false + if (type !== 0) continue + + if (data.length < 5 || data.readUInt16BE(0) !== data.length - 2) return false + let index = 2 + let count = 0 + while (index < data.length) { + if (index + 3 > data.length) return false + const kind = data[index] + const size = data.readUInt16BE(index + 1) + index += 3 + if (kind !== 0 || size === 0 || index + size > data.length) return false + const host = hostname(data.subarray(index, index + size)) + if (!host) return false + sni = host + count++ + index += size + } + if (index !== data.length || count !== 1) return false + } + return offset === input.length && sni === expected +} + +export class TlsClientHello { + private readonly header = Buffer.alloc(5) + private readonly handshake = Buffer.alloc(4) + private readonly chunks: Buffer[] = [] + private hpos = 0 + private mpos = 0 + private remaining = 0 + private body: Buffer | undefined + private bpos = 0 + private cpos = 0 + private total = 0 + private state: State = "pending" + private validated = false + + constructor(private readonly expected: string) {} + + push(input: Buffer): State { + if (this.state === "invalid" || input.length === 0) return this.state + if (this.total + input.length > MAX_INPUT) return this.reject() + this.chunks.push(Buffer.from(input)) + this.total += input.length + + for (let index = 0; index < input.length; index++) { + const byte = input[index] + if (this.validated) { + if (this.cpos >= COMPAT.length || byte !== COMPAT[this.cpos]) return this.reject() + this.cpos++ + this.state = this.cpos === COMPAT.length ? "valid" : "pending" + continue + } + if (this.remaining === 0) { + this.header[this.hpos++] = byte + if (this.hpos < this.header.length) continue + if (this.header[0] !== 22 || this.header[1] !== 3 || this.header[2] < 1 || this.header[2] > 3) { + return this.reject() + } + this.remaining = this.header.readUInt16BE(3) + this.hpos = 0 + if (this.remaining === 0 || this.remaining > MAX_RECORD) return this.reject() + continue + } + + this.remaining-- + if (!this.body) { + this.handshake[this.mpos++] = byte + if (this.mpos === this.handshake.length) { + if (this.handshake[0] !== 1) return this.reject() + const size = this.handshake.readUIntBE(1, 3) + if (size === 0 || size > MAX_HELLO) return this.reject() + this.body = Buffer.alloc(size) + } + } else { + this.body[this.bpos++] = byte + } + + if (this.body && this.bpos === this.body.length) { + if (this.remaining !== 0 || !validate(this.body, this.expected)) { + return this.reject() + } + this.validated = true + this.state = "valid" + } + } + return this.state + } + + bytes() { + if (this.state !== "valid") throw new Error("TLS ClientHello is not valid") + return Buffer.concat(this.chunks, this.total) + } + + private reject(): State { + this.state = "invalid" + return this.state + } +} diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts index 0555032b58..df6981f9d9 100644 --- a/packages/kilo-sandbox/test/backend.test.ts +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -189,24 +189,33 @@ describe("sandbox launch preparation", () => { }) test("merges profile environment values and applies exact deny names", async () => { - const result = await Effect.runPromise(Effect.scoped(run(makeProfile("allow"), prepare(launch)))) - expect(result.environment?.KEEP).toBe("profile") - expect(result.environment?.DROP).toBeUndefined() - expect(result.environment?.RESET).toBeUndefined() - expect(result.environment?.HTTPS_PROXY).toBe("http://127.0.0.1:9000") - expect(result.environment?.no_proxy).toBe("*") - expect(result.environment?.PATH).toBeUndefined() + const input = makeProfile("allow") + const result = await Effect.runPromise(Effect.scoped(run(input, prepare(launch))).pipe(Effect.result)) + if (!backendSupport(input.network).available) { + expect(Result.isFailure(result)).toBe(true) + return + } + expect(Result.isSuccess(result)).toBe(true) + if (Result.isFailure(result)) return + expect(result.success.environment?.KEEP).toBe("profile") + expect(result.success.environment?.DROP).toBeUndefined() + expect(result.success.environment?.RESET).toBeUndefined() + expect(result.success.environment?.HTTPS_PROXY).toBe("http://127.0.0.1:9000") + expect(result.success.environment?.no_proxy).toBe("*") + expect(result.success.environment?.PATH).toBeUndefined() }) - test("fails proxy mode closed before launching a process", async () => { + test("prepares proxy mode when platform support is available", async () => { + const input = makeProfile("proxy") const result = await Effect.runPromise( - Effect.scoped(run(makeProfile("proxy"), prepare(launch))).pipe(Effect.result), + Effect.scoped(run(input, prepare(launch))).pipe(Effect.result), ) - expect(Result.isFailure(result)).toBe(true) - if (Result.isFailure(result)) { - expect(result.failure.reason._tag).toBe("BadResource") - expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + if (!backendSupport(input.network).available) { + expect(Result.isFailure(result)).toBe(true) + return } + expect(Result.isSuccess(result)).toBe(true) + if (Result.isSuccess(result)) expect(result.success.environment?.HTTPS_PROXY).toContain("http://kilo:") }) test("fails non-empty allowedHosts closed before launching a process", async () => { @@ -218,8 +227,7 @@ describe("sandbox launch preparation", () => { ) expect(Result.isFailure(result)).toBe(true) if (Result.isFailure(result)) { - expect(result.failure.reason._tag).toBe("BadResource") - expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + expect(result.failure.message).toContain("allowedHosts require proxy network mode") } }) @@ -233,7 +241,7 @@ describe("sandbox launch preparation", () => { expect(Result.isFailure(result)).toBe(true) if (Result.isFailure(result)) { expect(result.failure.reason._tag).toBe("BadResource") - expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + expect(result.failure.message).toContain("allowedHosts require proxy network mode") } }) diff --git a/packages/kilo-sandbox/test/destination.test.ts b/packages/kilo-sandbox/test/destination.test.ts new file mode 100644 index 0000000000..f461e153bb --- /dev/null +++ b/packages/kilo-sandbox/test/destination.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test" +import { isPublicAddress, normalizeDestinations, parseDestination } from "../src/destination" + +describe("sandbox network destinations", () => { + test("normalizes exact DNS hosts and ports", () => { + expect(parseDestination("GitHub.COM.")).toEqual({ + host: "github.com", + port: 443, + authority: "github.com:443", + }) + expect(parseDestination("api.github.com:8443").authority).toBe("api.github.com:8443") + expect(normalizeDestinations(["github.com", "GITHUB.com:443", "api.github.com"])).toEqual([ + "api.github.com:443", + "github.com:443", + ]) + }) + + test("rejects ambiguous and widening inputs", () => { + for (const value of [ + "https://github.com", + "*.github.com", + ".github.com", + "github.com/path", + "github.com?x=1", + "user@github.com", + " github.com", + "github.com ", + "github.com:0", + "github.com:65536", + "127.0.0.1", + "127.1", + "[::1]", + "github.com\0.evil.test", + ]) { + expect(() => parseDestination(value), value).toThrow("Invalid sandbox network destination") + } + }) + + test("rejects known non-public resolved address ranges", () => { + for (const [input, expected, note] of [ + ["8.8.8.8", true, "public IPv4"], + ["1.1.1.1", true, "public IPv4"], + ["2606:4700:4700::1111", true, "public IPv6 with unspecified-looking low 32 bits"], + ["2001:4860:4860::8888", true, "Google public IPv6"], + ["2607:f8b0:4005:805::200e", true, "Google public IPv6 endpoint"], + ["2606:4700:4700:1:a:0:100:0", true, "unknown network-specific /64 NAT64 layout"], + ["2606:4700:4700:1::a9fe:a9fe", true, "unknown network-specific /96 NAT64 layout"], + ["169.254.169.254", false, "metadata and link-local IPv4"], + ["10.0.0.1", false, "RFC1918 10/8"], + ["172.16.0.1", false, "RFC1918 172.16/12"], + ["192.168.0.1", false, "RFC1918 192.168/16"], + ["127.0.0.1", false, "loopback IPv4"], + ["100.64.0.1", false, "CGNAT IPv4"], + ["192.0.2.1", false, "documentation IPv4"], + ["198.51.100.1", false, "documentation IPv4"], + ["203.0.113.1", false, "documentation IPv4"], + ["240.0.0.1", false, "reserved IPv4"], + ["224.0.0.1", false, "multicast IPv4"], + ["::1", false, "loopback IPv6"], + ["fe80::1", false, "link-local IPv6"], + ["fc00::1", false, "unique-local IPv6"], + ["::ffff:8.8.8.8", false, "IPv4-mapped IPv6 with public IPv4"], + ["::8.8.8.8", false, "IPv4-compatible embedded IPv6"], + ["::808:808", false, "hexadecimal IPv4-compatible IPv6"], + ["64:ff9b::808:808", false, "well-known NAT64 with public IPv4"], + ["64:ff9b::a9fe:a9fe", false, "well-known NAT64 with metadata IPv4"], + ["64:ff9b:1::808:808", false, "local-use NAT64 with public IPv4"], + ["2002:808:808::1", false, "6to4 with public IPv4"], + ["2001:0:4136:e378:8000:63bf:3fff:fdd2", false, "Teredo"], + ["not-an-address", false, "invalid address"], + ] as const) { + expect(isPublicAddress(input), `${note}: ${input}`).toBe(expected) + } + }) +}) diff --git a/packages/kilo-sandbox/test/network.test.ts b/packages/kilo-sandbox/test/network.test.ts index 380fdc758f..3a1846224c 100644 --- a/packages/kilo-sandbox/test/network.test.ts +++ b/packages/kilo-sandbox/test/network.test.ts @@ -4,6 +4,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { run } from "../src/context" import { assertNetwork, decorateHttpClient } from "../src/network" import type { Profile } from "../src/profile" +import { CurrentProxyFactory, startProxy, type ProxyFactory } from "../src/proxy" function profile(mode: Profile["network"]["mode"]): Profile { return { @@ -78,38 +79,45 @@ describe("sandbox in-process network capability", () => { ) expect(Result.isFailure(result)).toBe(true) if (Result.isFailure(result)) { - expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported") + expect(result.failure.message).toContain("allowedHosts require proxy network mode") } } }) - test("fails closed with a clear unsupported result for proxy mode", async () => { + test("routes supported HTTP requests through proxy mode and denies opaque capability", async () => { const http = server() try { + const port = http.server.port! + const factory: ProxyFactory = (hosts) => + startProxy(hosts, process.platform, async () => ({ address: "127.0.0.1", family: 4 })) + const input = { + ...profile("proxy"), + network: { mode: "proxy" as const, allowedHosts: [`allowed.test:${port}`] }, + } const result = await Effect.runPromise( Effect.gen(function* () { const raw = yield* HttpClient.HttpClient const guarded = decorateHttpClient(raw) return yield* Effect.all({ - capability: run(profile("proxy"), assertNetwork("https://example.com/path", "testRequest")).pipe( + capability: run(input, assertNetwork("https://allowed.test/path", "testRequest")).pipe( Effect.result, ), - request: run(profile("proxy"), guarded.get(new URL("/proxy", http.server.url))).pipe(Effect.result), + request: run( + input, + Effect.flatMap(guarded.get(`http://allowed.test:${port}/proxy`), (response) => response.text), + ).pipe(Effect.result), }) - }).pipe(Effect.provide(FetchHttpClient.layer)), + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.provideService(CurrentProxyFactory, factory)), ) expect(Result.isFailure(result.capability)).toBe(true) if (Result.isFailure(result.capability)) { - expect(result.capability.failure.reason._tag).toBe("BadResource") - expect(result.capability.failure.message).toContain("proxy network mode and allowedHosts are not supported") - expect(result.capability.failure.message).toContain("https://example.com") + expect(result.capability.failure.reason._tag).toBe("PermissionDenied") + expect(result.capability.failure.message).toContain("Sandbox denied outbound network access") + expect(result.capability.failure.message).toContain("https://allowed.test") expect(result.capability.failure.message).not.toContain("/path") } - expect(Result.isFailure(result.request)).toBe(true) - if (Result.isFailure(result.request)) { - expect(result.request.failure.message).toContain("proxy network mode and allowedHosts are not supported") - } - expect(http.paths).toEqual([]) + expect(result.request).toEqual(Result.succeed("/proxy")) + expect(http.paths).toEqual(["/proxy"]) } finally { await http.server.stop(true) } diff --git a/packages/kilo-sandbox/test/proxy.test.ts b/packages/kilo-sandbox/test/proxy.test.ts new file mode 100644 index 0000000000..d47ea20db3 --- /dev/null +++ b/packages/kilo-sandbox/test/proxy.test.ts @@ -0,0 +1,323 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { lstat } from "node:fs/promises" +import { connect, type Socket } from "node:net" +import { startProxy, type ProxyResolver, type ProxyRuntime } from "../src/proxy" + +const close: Array<() => Promise | void> = [] +const posix = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + await Promise.all(close.splice(0).map((dispose) => dispose())) +}) + +function upstream() { + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + return new Response(new URL(request.url).pathname) + }, + }) + close.push(() => server.stop(true)) + return server +} + +function resolver(port: number, calls: string[]): ProxyResolver { + return async (dest) => { + calls.push(dest.authority) + if (dest.port !== port) throw new Error("unexpected port") + return { address: "127.0.0.1", family: 4 as const } + } +} + +function uint16(value: number) { + const result = Buffer.alloc(2) + result.writeUInt16BE(value) + return result +} + +function extension(type: number, data: Buffer) { + return Buffer.concat([uint16(type), uint16(data.length), data]) +} + +function record(type: number, data: Buffer, minor = 1) { + return Buffer.concat([Buffer.from([type, 3, minor]), uint16(data.length), data]) +} + +function hello(host?: string, extra: Buffer[] = []) { + const name = host ? Buffer.from(host, "ascii") : undefined + const sni = name + ? extension(0, Buffer.concat([uint16(name.length + 3), Buffer.from([0]), uint16(name.length), name])) + : Buffer.alloc(0) + const extensions = Buffer.concat([sni, ...extra]) + const body = Buffer.concat([ + Buffer.from([3, 3]), + Buffer.alloc(32), + Buffer.from([0]), + uint16(2), + Buffer.from([0x13, 0x01]), + Buffer.from([1, 0]), + uint16(extensions.length), + extensions, + ]) + const handshake = Buffer.alloc(4) + handshake[0] = 1 + handshake.writeUIntBE(body.length, 1, 3) + const payload = Buffer.concat([handshake, body]) + return record(22, payload) +} + +function fragmented(input: Buffer) { + const payload = input.subarray(5) + return Buffer.concat([ + record(22, payload.subarray(0, 2)), + record(22, payload.subarray(2, 19)), + record(22, payload.subarray(19)), + ]) +} + +function target() { + let accepted = 0 + const chunks: Buffer[] = [] + const server = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + socket: { + open() { + accepted++ + }, + data(socket, data) { + chunks.push(Buffer.from(data)) + socket.write(data) + }, + }, + }) + close.push(() => server.stop(true)) + return { + port: server.port, + accepted: () => accepted, + bytes: () => Buffer.concat(chunks), + } +} + +async function tunnel(proxy: ProxyRuntime, authority: string) { + const socket = connect(proxy.port!, "127.0.0.1") + close.push(() => { + socket.destroy() + }) + const auth = Buffer.from(`kilo:${proxy.token}`).toString("base64") + await new Promise((resolve, reject) => { + let response = Buffer.alloc(0) + const error = (cause: Error) => reject(cause) + socket.once("error", error) + socket.once("connect", () => + socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\nProxy-Authorization: Basic ${auth}\r\n\r\n`), + ) + const data = (chunk: Buffer) => { + response = Buffer.concat([response, chunk]) + if (!response.includes("\r\n\r\n")) return + socket.off("data", data) + socket.off("error", error) + if (!response.includes("200 Connection Established")) { + reject(new Error(`CONNECT failed: ${response.toString()}`)) + return + } + resolve() + } + socket.on("data", data) + }) + socket.on("error", () => undefined) + return socket +} + +function closed(socket: Socket) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("proxy did not close rejected CONNECT")), 1_000) + socket.once("close", () => { + clearTimeout(timer) + resolve() + }) + }) +} + +function receive(socket: Socket, length: number) { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + const timer = setTimeout(() => reject(new Error("tunnel did not return ClientHello bytes")), 1_000) + const data = (chunk: Buffer) => { + chunks.push(chunk) + const result = Buffer.concat(chunks) + if (result.length < length) return + clearTimeout(timer) + socket.off("data", data) + resolve(result) + } + socket.on("data", data) + }) +} + +describe("sandbox trusted proxy", () => { + test("allows only authenticated exact destinations", async () => { + const target = upstream() + const port = target.port! + const calls: string[] = [] + const proxy = await startProxy([`allowed.test:${port}`], "darwin", resolver(port, calls)) + close.push(proxy.close) + + const allowed = await fetch(`http://allowed.test:${port}/allowed`, { proxy: proxy.url }) + const denied = await fetch(`http://blocked.allowed.test:${port}/blocked`, { proxy: proxy.url }) + const unauthenticated = await fetch(`http://allowed.test:${port}/unauthenticated`, { + proxy: proxy.url.replace(/kilo:[^@]+@/, ""), + }) + + expect(allowed.status).toBe(200) + expect(await allowed.text()).toBe("/allowed") + expect(denied.status).toBe(403) + expect(unauthenticated.status).toBe(407) + expect(calls).toEqual([`allowed.test:${port}`]) + }) + + test("forwards CONNECT only when SNI matches the authorized host", async () => { + const upstream = target() + const calls: string[] = [] + const proxy = await startProxy([`allowed.test:${upstream.port}`], "darwin", resolver(upstream.port, calls)) + close.push(proxy.close) + const socket = await tunnel(proxy, `allowed.test:${upstream.port}`) + const input = Buffer.concat([hello("ALLOWED.TEST"), record(20, Buffer.from([1]), 3)]) + const output = receive(socket, input.length) + socket.write(input) + + expect(await output).toEqual(input) + expect(upstream.accepted()).toBe(1) + expect(upstream.bytes()).toEqual(input) + expect(calls).toEqual([`allowed.test:${upstream.port}`]) + }) + + test("preserves fragmented ClientHello before opening CONNECT upstream", async () => { + const upstream = target() + const calls: string[] = [] + const proxy = await startProxy([`allowed.test:${upstream.port}`], "darwin", resolver(upstream.port, calls)) + close.push(proxy.close) + const socket = await tunnel(proxy, `allowed.test:${upstream.port}`) + const input = fragmented(hello("allowed.test")) + const output = receive(socket, input.length) + + for (const byte of input.subarray(0, -1)) { + socket.write(Buffer.from([byte])) + await Bun.sleep(1) + } + expect(upstream.accepted()).toBe(0) + expect(calls).toEqual([]) + socket.write(input.subarray(-1)) + + expect(await output).toEqual(input) + expect(upstream.accepted()).toBe(1) + expect(upstream.bytes()).toEqual(input) + }) + + test("rejects mismatched and absent CONNECT SNI before reaching upstream", async () => { + const upstream = target() + const calls: string[] = [] + const proxy = await startProxy([`allowed.test:${upstream.port}`], "darwin", resolver(upstream.port, calls)) + close.push(proxy.close) + + for (const input of [hello("blocked.test"), hello()]) { + const socket = await tunnel(proxy, `allowed.test:${upstream.port}`) + const end = closed(socket) + socket.write(input) + await end + } + + expect(upstream.accepted()).toBe(0) + expect(upstream.bytes()).toHaveLength(0) + expect(calls).toEqual([]) + }) + + test("fails closed on malformed, truncated, oversized, and encrypted ClientHello", async () => { + const upstream = target() + const calls: string[] = [] + const proxy = await startProxy([`allowed.test:${upstream.port}`], "darwin", resolver(upstream.port, calls)) + close.push(proxy.close) + const malformed = hello("allowed.test") + malformed.writeUInt16BE(0xffff, 50) + const oversized = Buffer.from([22, 3, 1, 0, 4, 1, 1, 0, 0]) + const encrypted = hello("allowed.test", [extension(0xfe0d, Buffer.from([0]))]) + const early = hello("allowed.test", [extension(42, Buffer.alloc(0))]) + + for (const input of [Buffer.from("GET /"), malformed, oversized, encrypted, early]) { + const socket = await tunnel(proxy, `allowed.test:${upstream.port}`) + const end = closed(socket) + socket.write(input) + await end + } + + const socket = await tunnel(proxy, `allowed.test:${upstream.port}`) + const end = closed(socket) + socket.end(hello("allowed.test").subarray(0, -1)) + await end + + expect(upstream.accepted()).toBe(0) + expect(upstream.bytes()).toHaveLength(0) + expect(calls).toEqual([]) + }) + + test("rejects application data while CONNECT resolution is pending", async () => { + const upstream = target() + const calls: string[] = [] + const started = Promise.withResolvers() + const gate = Promise.withResolvers() + const resolve: ProxyResolver = async (dest) => { + calls.push(dest.authority) + started.resolve() + await gate.promise + return { address: "127.0.0.1", family: 4 } + } + const proxy = await startProxy([`allowed.test:${upstream.port}`], "darwin", resolve) + close.push(proxy.close) + const socket = await tunnel(proxy, `allowed.test:${upstream.port}`) + socket.write(hello("allowed.test")) + await started.promise + + const end = closed(socket) + socket.write(record(23, Buffer.from([0]), 3)) + await end + gate.resolve() + await Bun.sleep(0) + + expect(upstream.accepted()).toBe(0) + expect(upstream.bytes()).toHaveLength(0) + expect(calls).toEqual([`allowed.test:${upstream.port}`]) + }) + + test("rechecks redirect destinations without resolving denied hosts", async () => { + let requests = 0 + const target = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests++ + return Response.redirect(`http://blocked.test:${new URL(request.url).port}/exfiltrate`, 302) + }, + }) + close.push(() => target.stop(true)) + const port = target.port! + const calls: string[] = [] + const proxy = await startProxy([`allowed.test:${port}`], "darwin", resolver(port, calls)) + close.push(proxy.close) + + const response = await fetch(`http://allowed.test:${port}/redirect`, { proxy: proxy.url }) + expect(response.status).toBe(403) + expect(requests).toBe(1) + expect(calls).toEqual([`allowed.test:${port}`]) + }) + + posix("creates a private Unix listener for Linux relay mode", async () => { + const target = upstream() + const port = target.port! + const proxy = await startProxy([`allowed.test:${port}`], "linux", resolver(port, [])) + close.push(proxy.close) + expect(proxy.socket).toContain("kilo-sandbox-proxy-") + expect(proxy.port).toBeGreaterThan(0) + expect((await lstat(proxy.socket!)).isSocket()).toBe(true) + }) +}) diff --git a/packages/kilo-sandbox/test/seatbelt-network.test.ts b/packages/kilo-sandbox/test/seatbelt-network.test.ts index bb2eb4dead..9a9dd4123c 100644 --- a/packages/kilo-sandbox/test/seatbelt-network.test.ts +++ b/packages/kilo-sandbox/test/seatbelt-network.test.ts @@ -3,21 +3,23 @@ import { Effect } from "effect" import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises" import { join } from "node:path" import { tmpdir } from "node:os" +import { createServer } from "node:net" import { prepare, type Launch } from "../src/backend" import { run } from "../src/context" import type { Profile } from "../src/profile" +import { CurrentProxyFactory, startProxy, type ProxyFactory } from "../src/proxy" const mac = process.platform === "darwin" ? test : test.skip const roots: string[] = [] -function profile(root: string, mode: Profile["network"]["mode"]): Profile { +function profile(root: string, mode: Profile["network"]["mode"], allowedHosts: ReadonlyArray = []): Profile { return { filesystem: { allowWrite: [{ path: root, kind: "subtree" }], denyWrite: [], denyNames: [".protected"], }, - network: { mode, allowedHosts: [] }, + network: { mode, allowedHosts }, environment: { deny: [], set: {} }, } } @@ -27,7 +29,20 @@ function prepareLaunch(profile: Profile, input: Launch) { } async function launch(profile: Profile, input: Launch) { - const target = await prepareLaunch(profile, input) + return Effect.runPromise( + Effect.scoped( + run( + profile, + Effect.gen(function* () { + const target = yield* prepare(input) + return yield* Effect.promise(() => spawn(target)) + }), + ), + ), + ) +} + +async function spawn(target: Launch) { const child = Bun.spawn([target.command, ...target.args], { cwd: target.cwd, env: target.environment, @@ -42,6 +57,20 @@ async function launch(profile: Profile, input: Launch) { return { code, stdout, stderr } } +function proxied(profile: Profile, input: Launch, factory: ProxyFactory) { + return Effect.runPromise( + Effect.scoped( + run( + profile, + Effect.gen(function* () { + const target = yield* prepare(input) + return yield* Effect.promise(() => spawn(target)) + }), + ).pipe(Effect.provideService(CurrentProxyFactory, factory)), + ), + ) +} + function server(hostname: string) { let accepted = 0 const listener = Bun.listen({ @@ -198,6 +227,86 @@ describe("macOS Seatbelt network integration", () => { } }) + mac("allows only the configured proxy destination and denies direct bypasses", async () => { + const dir = await root() + const allowed = http() + const blocked = http() + const port = allowed.server.port! + const factory: ProxyFactory = (hosts) => + startProxy(hosts, "darwin", async (dest) => { + if (dest.port !== port) throw new Error("unexpected port") + return { address: "127.0.0.1", family: 4 } + }) + const policy = profile(dir, "proxy", [`allowed.test:${port}`]) + try { + const allow = await proxied( + policy, + { command: "/usr/bin/curl", args: ["-fsS", `http://allowed.test:${port}/allowed`], cwd: dir }, + factory, + ) + const deny = await proxied( + policy, + { command: "/usr/bin/curl", args: ["-fsS", `http://blocked.test:${port}/blocked`], cwd: dir }, + factory, + ) + const direct = await proxied( + policy, + { + command: "/usr/bin/curl", + args: ["--noproxy", "*", "-fsS", `http://127.0.0.1:${blocked.server.port}/direct`], + cwd: dir, + }, + factory, + ) + expect(allow.code).toBe(0) + expect(allow.stdout).toBe("sandbox-http-ok") + expect(deny.code).not.toBe(0) + expect(direct.code).not.toBe(0) + expect(allowed.requests()).toBe(1) + expect(blocked.requests()).toBe(0) + } finally { + await Promise.all([allowed.server.stop(true), blocked.server.stop(true)]) + } + }) + + mac("denies host Unix socket bypasses in proxy mode", async () => { + const dir = await root() + const socket = join(await root(), "escape.sock") + let accepted = 0 + const listener = createServer((client) => { + accepted++ + client.end("escaped") + }) + await new Promise((resolve, reject) => { + listener.once("error", reject) + listener.listen(socket, () => { + listener.off("error", reject) + resolve() + }) + }) + const factory: ProxyFactory = (hosts) => + startProxy(hosts, "darwin", async () => ({ address: "127.0.0.1", family: 4 })) + const policy = profile(dir, "proxy", ["allowed.test:443"]) + const script = [ + 'const net = require("node:net")', + `const socket = net.connect({ path: ${JSON.stringify(socket)} })`, + "socket.on('connect', () => process.exit(2))", + "socket.on('error', () => process.exit(0))", + "setTimeout(() => process.exit(4), 1000)", + ].join("\n") + try { + const result = await proxied( + policy, + { command: process.execPath, args: ["-e", script], cwd: dir }, + factory, + ) + expect(result.code).toBe(0) + expect(accepted).toBe(0) + } finally { + await new Promise((resolve) => listener.close(() => resolve())) + } + }) + mac("denies hostname and IPv6 loopback forms", async () => { const dir = await root() const ipv4 = server("127.0.0.1") diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts index ee78be1774..5caf3b1387 100644 --- a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -9,6 +9,8 @@ const kiloSandboxWorker = "kilo-sandbox-mutation-worker.js" const bwrap = "bwrap" const bwrapLicense = path.join("licenses", "bubblewrap") const bwrapLicenseFiles = ["NOTICE", "COPYING", "MUSL-COPYRIGHT", "build.ts"] +const sandboxNetworkFiles = ["kilo-sandbox-network-relay.js", "kilo-sandbox-seccomp"] +const sandboxRuntimeLicense = path.join("licenses", "sandbox-runtime") function paths(file: string) { if (/^[a-z]:[\\/]/i.test(file) || file.includes("\\")) return path.win32 @@ -60,6 +62,20 @@ export async function copySandboxResources(source: string, target: string): Prom const destination = path.join(to, bwrapLicense) await fs.promises.rm(helper, { force: true }) await fs.promises.rm(destination, { recursive: true, force: true }) + await Promise.all(sandboxNetworkFiles.map((file) => fs.promises.rm(path.join(to, file), { force: true }))) + await fs.promises.rm(path.join(to, sandboxRuntimeLicense), { recursive: true, force: true }) + + for (const file of sandboxNetworkFiles) { + const input = path.join(from, file) + if (!fs.existsSync(input)) continue + const output = path.join(to, file) + await fs.promises.copyFile(input, output) + if (file === "kilo-sandbox-seccomp") await fs.promises.chmod(output, 0o755) + } + const runtimeLicense = path.join(from, sandboxRuntimeLicense) + if (fs.existsSync(runtimeLicense)) { + await fs.promises.cp(runtimeLicense, path.join(to, sandboxRuntimeLicense), { recursive: true }) + } const executable = path.join(from, bwrap) if (!fs.existsSync(executable)) return diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index 75bd0343b0..03073ea4c5 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -68,18 +68,26 @@ test.describe("settings tab accessibility", () => { await expect(sandbox).toHaveAccessibleDescription(/restricts writes to the project and Kilo state directories/) await expect(sandbox).not.toBeChecked() const network = page.getByRole("switch", { name: "Restrict Network Access" }) - await expect(network).toHaveAccessibleDescription(/Local MCP servers and plugin hooks run outside this restriction/) + await expect(network).toHaveAccessibleDescription(/MCP tools are unavailable while restricted/) await expect(network).toBeChecked() await expect(network).toBeDisabled() + const host = page.getByRole("textbox", { name: "Allowed Network Destinations" }) + await expect(host).toBeDisabled() const path = page.getByRole("textbox", { name: "Additional Writable Paths" }) await expect(path).toBeDisabled() - await expect(page.getByRole("button", { name: "Add" })).toBeDisabled() + const add = page.getByRole("button", { name: "Add" }) + await expect(add).toHaveCount(2) + await expect(add.nth(0)).toBeDisabled() + await expect(add.nth(1)).toBeDisabled() await page.locator('[data-slot="switch-control"]').nth(0).click() await expect(sandbox).toBeChecked() await expect(network).toBeEnabled() + await expect(host).toBeEnabled() await expect(path).toBeEnabled() await page.locator('[data-slot="switch-control"]').nth(1).click() await expect(network).not.toBeChecked() + await expect(host).toBeDisabled() + await expect(path).toBeEnabled() await expect(page.locator(".settings-save-bar")).toBeVisible() }) }) diff --git a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts index 153a3570c8..55c251d07d 100644 --- a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts +++ b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts @@ -22,6 +22,7 @@ describe("Sandboxing settings visibility", () => { test("edits global sandbox config without promoting project policy", async () => { const src = await Bun.file("webview-ui/src/components/settings/SandboxingTab.tsx").text() expect(src).toContain("const { globalConfig, updateGlobalConfig } = useConfig()") + expect(src).toContain("allowed_hosts") expect(src).not.toContain("const { config, updateConfig } = useConfig()") }) diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index c574e9fed3..db9ac71a41 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -136,14 +136,21 @@ describe("cli tree-sitter resources", () => { const helper = path.join(path.dirname(source), "bwrap") const license = path.join(path.dirname(source), "licenses", "bubblewrap", "COPYING") const notice = path.join(path.dirname(license), "NOTICE") + const relay = path.join(path.dirname(source), "kilo-sandbox-network-relay.js") + const seccomp = path.join(path.dirname(source), "kilo-sandbox-seccomp") + const runtimeLicense = path.join(path.dirname(source), "licenses", "sandbox-runtime", "LICENSE") await fs.mkdir(path.dirname(license), { recursive: true }) + await fs.mkdir(path.dirname(runtimeLicense), { recursive: true }) await fs.mkdir(path.dirname(target), { recursive: true }) await fs.writeFile(source, "binary") await fs.writeFile(target, "binary") await fs.writeFile(helper, "helper") await fs.writeFile(license, "LGPL") await fs.writeFile(notice, "SPDX-License-Identifier: LGPL-2.0-or-later") + await fs.writeFile(relay, "relay") + await fs.writeFile(seccomp, "seccomp") + await fs.writeFile(runtimeLicense, "Apache-2.0") await copySandboxResources(source, target) @@ -156,6 +163,42 @@ describe("cli tree-sitter resources", () => { expect(await fs.readFile(path.join(path.dirname(target), "licenses", "bubblewrap", "NOTICE"), "utf8")).toBe( "SPDX-License-Identifier: LGPL-2.0-or-later", ) + expect(await fs.readFile(path.join(path.dirname(target), "kilo-sandbox-network-relay.js"), "utf8")).toBe("relay") + const copiedSeccomp = path.join(path.dirname(target), "kilo-sandbox-seccomp") + expect(await fs.readFile(copiedSeccomp, "utf8")).toBe("seccomp") + expect((await fs.stat(copiedSeccomp)).mode & 0o111).not.toBe(0) + expect(await fs.readFile(path.join(path.dirname(target), "licenses", "sandbox-runtime", "LICENSE"), "utf8")).toBe( + "Apache-2.0", + ) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) + + it("copies seccomp licensing when bundled Bubblewrap is omitted", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-seccomp-license-")) + try { + const source = path.join(root, "dist", "bin", "kilo") + const target = path.join(root, "extension", "bin", "kilo") + const relay = path.join(path.dirname(source), "kilo-sandbox-network-relay.js") + const seccomp = path.join(path.dirname(source), "kilo-sandbox-seccomp") + const license = path.join(path.dirname(source), "licenses", "sandbox-runtime", "LICENSE") + + await fs.mkdir(path.dirname(license), { recursive: true }) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(source, "binary") + await fs.writeFile(target, "binary") + await fs.writeFile(relay, "relay") + await fs.writeFile(seccomp, "seccomp") + await fs.writeFile(license, "Apache-2.0") + + await copySandboxResources(source, target) + + expect(await fs.readFile(path.join(path.dirname(target), "kilo-sandbox-network-relay.js"), "utf8")).toBe("relay") + expect(await fs.readFile(path.join(path.dirname(target), "kilo-sandbox-seccomp"), "utf8")).toBe("seccomp") + expect(await fs.readFile(path.join(path.dirname(target), "licenses", "sandbox-runtime", "LICENSE"), "utf8")).toBe( + "Apache-2.0", + ) } finally { await fs.rm(root, { recursive: true, force: true }) } diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index c1f08e0862..3f210bb7cb 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -74,10 +74,13 @@ async function settle(page: Page) { // Stories to skip from visual regression (add IDs here if needed) // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. +// Sandboxing rows can settle at different scroll heights after settings context updates. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", "composite-webview--permission-dock-config-preloaded", + "settings--sandboxing-allowlist", + "settings--sandboxing-panel", ]) const DOCS = new Map([ diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index c1f08e0862..3f210bb7cb 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -74,10 +74,13 @@ async function settle(page: Page) { // Stories to skip from visual regression (add IDs here if needed) // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. +// Sandboxing rows can settle at different scroll heights after settings context updates. const SKIP = new Set([ "agentmanager--worktree-item-busy", "agentmanager--full-screen-diff-agent-edit-scroll", "composite-webview--permission-dock-config-preloaded", + "settings--sandboxing-allowlist", + "settings--sandboxing-panel", ]) const DOCS = new Map([ diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx index 36b11756b4..4aa910606a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx @@ -10,15 +10,54 @@ import SettingsRow from "./SettingsRow" const enabledDescription = "sandbox-enabled-description" const networkDescription = "sandbox-network-description" +const allowedHostsDescription = "sandbox-allowed-hosts-description" const writablePathsDescription = "sandbox-writable-paths-description" +function destination(input: string) { + const match = /^([a-z0-9](?:[a-z0-9.-]*[a-z0-9])?)(?::(\d{1,5}))?$/.exec(input) + if (!match) return + if (/^[0-9.]+$/.test(match[1]) || match[1].length > 253) return + const port = Number(match[2] ?? "443") + if ( + port < 1 || + port > 65535 || + match[1].split(".").some((label) => !label || label.length > 63 || label.startsWith("-") || label.endsWith("-")) + ) + return + return `${match[1]}:${port}` +} + const SandboxingTab: Component = () => { const { globalConfig, updateGlobalConfig } = useConfig() const language = useLanguage() const sandbox = createMemo(() => globalConfig().sandbox ?? {}) const [newPath, setNewPath] = createSignal("") + const [newHost, setNewHost] = createSignal("") const writablePaths = () => sandbox().writable_paths ?? [] + const allowedHosts = () => sandbox().allowed_hosts ?? [] + + const addHost = () => { + const input = newHost().trim().toLowerCase() + const value = destination(input) + if (!value) return + const current = [...allowedHosts()] + if (!current.includes(value)) { + current.push(value) + updateGlobalConfig({ + sandbox: { ...sandbox(), allowed_hosts: current }, + }) + } + setNewHost("") + } + + const removeHost = (index: number) => { + const current = [...allowedHosts()] + current.splice(index, 1) + updateGlobalConfig({ + sandbox: { ...sandbox(), allowed_hosts: current }, + }) + } const addPath = () => { const value = newPath().trim() @@ -82,6 +121,76 @@ const SandboxingTab: Component = () => { +
+ +
+
0 ? "1px solid var(--border-weak-base)" : "none", + }} + > +
+ setNewHost(val)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") addHost() + }} + hideLabel + label={language.t("settings.sandboxing.allowedHosts.title")} + /> +
+ +
+ + {(host, index) => ( +
+ + {host} + + removeHost(index())} + /> +
+ )} +
+
+
+
+ {/* wide-input widens the input column so long filesystem paths are readable */}
( + +
+ +
+
+ ), +} + export const ProvidersConfigure: Story = { name: "ProvidersTab — no providers configured", render: () => ( diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 6e3b980137..cc25ccf2f8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -56,6 +56,7 @@ export interface SandboxConfig { enabled?: boolean network?: "allow" | "deny" writable_paths?: string[] + allowed_hosts?: string[] } export interface CommitMessageConfig { diff --git a/packages/opencode/script/build-node.ts b/packages/opencode/script/build-node.ts index 53099f5b58..338c62aa31 100755 --- a/packages/opencode/script/build-node.ts +++ b/packages/opencode/script/build-node.ts @@ -45,7 +45,13 @@ console.log(`Loaded ${migrations.length} migrations`) await Bun.build({ target: "node", - entrypoints: ["./src/node.ts", "../kilo-sandbox/src/kilo-sandbox-mutation-worker.ts"], // kilocode_change + // kilocode_change start + entrypoints: [ + "./src/node.ts", + "../kilo-sandbox/src/kilo-sandbox-mutation-worker.ts", + "../kilo-sandbox/src/kilo-sandbox-network-relay.ts", + ], + // kilocode_change end outdir: "./dist/node", format: "esm", sourcemap: "linked", @@ -54,6 +60,8 @@ await Bun.build({ KILO_MIGRATIONS: JSON.stringify(migrations), KILO_MODELS_DEV: generated.modelsData, KILO_SANDBOX_MUTATION_WORKER_PATH: `'./kilo-sandbox-mutation-worker.js'`, // kilocode_change + KILO_SANDBOX_NETWORK_RELAY_PATH: `'./kilo-sandbox-network-relay.js'`, // kilocode_change + KILO_SANDBOX_SECCOMP_PATH: "undefined", // kilocode_change KILO_CHANNEL: `'${Script.channel}'`, }, files: { diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 796beac84d..5a63a64532 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -23,6 +23,7 @@ import pkg from "../package.json" import { stageBubblewrap } from "./kilocode/bubblewrap" import { LanceDBRuntime } from "../src/kilocode/lancedb" import { KiloSandboxWorker } from "./kilocode/kilo-sandbox-worker" +import { KiloSandboxNetwork } from "./kilocode/kilo-sandbox-network" // kilocode_change end // Load migrations from migration directories @@ -250,6 +251,7 @@ await $`rm -rf dist` // kilocode_change start const kiloConsoleDist = await buildKiloConsole() const kiloSandboxWorker = await KiloSandboxWorker.bundle() +const kiloSandboxNetwork = await KiloSandboxNetwork.bundle() // kilocode_change end const binaries: Record = {} @@ -336,6 +338,8 @@ for (const item of targets) { KILO_SESSION_EXPORT_WORKER_PATH: sessionExportWorkerPath, KILO_INDEXING_WORKER_PATH: indexingWorkerPath, KILO_SANDBOX_MUTATION_WORKER_PATH: JSON.stringify(KiloSandboxWorker.filename), + KILO_SANDBOX_NETWORK_RELAY_PATH: item.os === "linux" ? JSON.stringify(KiloSandboxNetwork.relay) : "undefined", + KILO_SANDBOX_SECCOMP_PATH: item.os === "linux" ? JSON.stringify(KiloSandboxNetwork.seccomp) : "undefined", // kilocode_change end KILO_CHANNEL: `'${Script.channel}'`, KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", @@ -350,6 +354,9 @@ for (const item of targets) { await copyTreeSitterWasms(path.resolve(dir, `dist/${name}/bin`)) await copyKiloConsole(kiloConsoleDist, path.resolve(dir, `dist/${name}/bin`)) await KiloSandboxWorker.copy(kiloSandboxWorker, path.resolve(dir, `dist/${name}/bin`)) + if (item.os === "linux") { + await KiloSandboxNetwork.copy(kiloSandboxNetwork, path.resolve(dir, `dist/${name}/bin`), item.arch) + } if (item.os === "linux") { const interpreters: Record = { @@ -395,13 +402,15 @@ for (const item of targets) { await $`rm -rf ./dist/${name}/bin/tui` // kilocode_change start - if (bwrap) { - const licenses = path.resolve(dir, `dist/${name}/bin/licenses/bubblewrap`) + if (item.os === "linux") { const content = await Promise.all([ Bun.file(path.resolve(dir, "../../LICENSE")).text(), - Bun.file(path.join(licenses, "NOTICE")).text(), - Bun.file(path.join(licenses, "COPYING")).text(), - Bun.file(path.join(licenses, "MUSL-COPYRIGHT")).text(), + Bun.file(path.resolve(dir, `dist/${name}/bin/licenses/sandbox-runtime/LICENSE`)).text(), + ...(bwrap + ? ["NOTICE", "COPYING", "MUSL-COPYRIGHT"].map((file) => + Bun.file(path.resolve(dir, `dist/${name}/bin/licenses/bubblewrap/${file}`)).text(), + ) + : []), ]) await Bun.write(`dist/${name}/LICENSE`, content.join("\n\n---\n\n")) } @@ -411,7 +420,7 @@ for (const item of targets) { { name, version: Script.version, - license: bwrap ? "SEE LICENSE IN LICENSE" : pkg.license, // kilocode_change + license: item.os === "linux" ? "SEE LICENSE IN LICENSE" : pkg.license, // kilocode_change preferUnplugged: true, os: [item.os], cpu: [item.arch], diff --git a/packages/opencode/script/kilocode/bubblewrap.ts b/packages/opencode/script/kilocode/bubblewrap.ts index d6f5aa779a..d19a3bae89 100644 --- a/packages/opencode/script/kilocode/bubblewrap.ts +++ b/packages/opencode/script/kilocode/bubblewrap.ts @@ -12,6 +12,7 @@ const cache = process.env.KILO_BWRAP_CACHE ?? path.join(os.tmpdir(), "kilo-bubbl const capability = `#pragma once #include #include +#include #include #include @@ -28,8 +29,10 @@ static inline int capset(cap_user_header_t header, const cap_user_data_t data) { } static inline int cap_from_name(const char *name, cap_value_t *cap) { - (void) name; - (void) cap; + if (strcmp(name, "cap_sys_admin") == 0) { + *cap = CAP_SYS_ADMIN; + return 0; + } errno = EINVAL; return -1; } diff --git a/packages/opencode/script/kilocode/kilo-sandbox-network.ts b/packages/opencode/script/kilocode/kilo-sandbox-network.ts new file mode 100644 index 0000000000..d365e0790a --- /dev/null +++ b/packages/opencode/script/kilocode/kilo-sandbox-network.ts @@ -0,0 +1,37 @@ +import { createRequire } from "node:module" +import fs from "node:fs/promises" +import path from "node:path" + +const require = createRequire(path.resolve(import.meta.dirname, "../../../kilo-sandbox/package.json")) + +export namespace KiloSandboxNetwork { + export const relay = "kilo-sandbox-network-relay.js" + export const seccomp = "kilo-sandbox-seccomp" + + export async function bundle() { + const result = await Bun.build({ + entrypoints: ["../kilo-sandbox/src/kilo-sandbox-network-relay.ts"], + target: "bun", + format: "esm", + minify: true, + }) + if (!result.success || result.outputs.length !== 1) throw new Error("Could not bundle Kilo sandbox network relay") + return result.outputs[0] + } + + export async function copy(worker: Blob, dir: string, arch: "arm64" | "x64") { + const relayPath = path.join(dir, relay) + await Bun.write(relayPath, worker) + + const pkg = path.dirname(require.resolve("@anthropic-ai/sandbox-runtime/package.json")) + const source = path.join(pkg, "vendor", "seccomp", arch, "apply-seccomp") + const target = path.join(dir, seccomp) + await fs.copyFile(source, target) + await fs.chmod(target, 0o755) + + const licenses = path.join(dir, "licenses", "sandbox-runtime") + await fs.mkdir(licenses, { recursive: true }) + await fs.copyFile(path.join(pkg, "LICENSE"), path.join(licenses, "LICENSE")) + console.log(`copied Kilo sandbox network relay and seccomp helper to ${dir}`) + } +} diff --git a/packages/opencode/src/kilocode/notebook/service.ts b/packages/opencode/src/kilocode/notebook/service.ts index f3d2c8f35d..cc05d32c19 100644 --- a/packages/opencode/src/kilocode/notebook/service.ts +++ b/packages/opencode/src/kilocode/notebook/service.ts @@ -47,6 +47,7 @@ function matches(request: Request, result: Result) { export interface Interface { readonly request: (input: Input) => Effect.Effect readonly list: () => Effect.Effect> + readonly cancelSession: (sessionID: Request["sessionID"]) => Effect.Effect readonly reply: (input: { requestID: RequestID result: Result @@ -121,6 +122,14 @@ export function layer(timeout: Duration.Input = "10 minutes") { return Array.from((yield* InstanceState.get(state)).pending.values(), (entry) => entry.info) }) + const cancelSession: Interface["cancelSession"] = Effect.fn("Notebook.cancelSession")(function* (sessionID) { + const pending = (yield* InstanceState.get(state)).pending + const ids = Array.from(pending.values()) + .filter((entry) => entry.info.sessionID === sessionID) + .map((entry) => entry.info.id) + yield* Effect.forEach(ids, (id) => cancel(id, "cancelled"), { discard: true }) + }) + const reply: Interface["reply"] = Effect.fn("Notebook.reply")(function* (input) { const pending = (yield* InstanceState.get(state)).pending const entry = pending.get(input.requestID) @@ -153,7 +162,7 @@ export function layer(timeout: Duration.Input = "10 minutes") { ) }) - return Service.of({ request, list, reply, reject }) + return Service.of({ request, list, cancelSession, reply, reject }) }), ) } diff --git a/packages/opencode/src/kilocode/sandbox/activation.ts b/packages/opencode/src/kilocode/sandbox/activation.ts new file mode 100644 index 0000000000..30f515dd41 --- /dev/null +++ b/packages/opencode/src/kilocode/sandbox/activation.ts @@ -0,0 +1,99 @@ +import { Effect } from "effect" +import { BackgroundJob } from "@/background/job" +import { BackgroundProcess } from "@/kilocode/background-process" +import { InteractiveTerminal } from "@/kilocode/interactive-terminal" +import { Service as Notebook } from "@/kilocode/notebook/service" +import type { Target } from "@/kilocode/sandbox/policy" +import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "@/project/instance-store" +import type { SessionID } from "@/session/schema" +import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" + +export const family = Effect.fn("SandboxActivation.family")(function* (sessionID: SessionID) { + const sessions = yield* Session.Service + const visit = (id: SessionID): Effect.Effect => + Effect.gen(function* () { + const children = yield* sessions.children(id) + const nested = yield* Effect.forEach(children, (child) => visit(child.id)) + return [...children.map((child) => ({ id: child.id, directory: child.directory })), ...nested.flat()] + }) + return [{ id: sessionID, directory: yield* InstanceState.directory }, ...(yield* visit(sessionID))] +}) + +export const idle = Effect.fn("SandboxActivation.idle")(function* ( + sessionID: SessionID, + family: readonly Target[], +) { + const status = yield* SessionStatus.Service + const background = yield* BackgroundJob.Service + const notebook = yield* Notebook + const current = yield* InstanceState.directory + const ids = new Set(family.map((target) => target.id)) + const root = family.find((target) => target.id === sessionID) ?? family[0] + const groups = new Map() + for (const target of family) { + const group = groups.get(target.directory) ?? [] + group.push(target) + groups.set(target.directory, group) + } + const scans = yield* Effect.forEach([...groups.entries()], ([directory, targets]) => { + const scan = Effect.all( + [ + status.list(), + background.list(), + Effect.promise(() => BackgroundProcess.list()), + Effect.promise(() => InteractiveTerminal.list()), + notebook.list(), + ] as const, + ).pipe(Effect.map((resources) => ({ directory, targets, resources }))) + if (directory === current) return scan + return Effect.flatMap(InstanceStore.Service, (store) => store.provide({ directory }, scan)) + }) + + for (const scan of scans) { + const [states, jobs, processes, terminals, requests] = scan.resources + if (scan.targets.some((target) => states.has(target.id))) return false + if ( + jobs.some((job) => { + const child = job.metadata?.sessionId + const parent = job.metadata?.parentSessionId + return ( + job.status === "running" && + (ids.has(job.id) || + (typeof child === "string" && ids.has(child)) || + (typeof parent === "string" && ids.has(parent))) + ) + }) + ) + return false + if ( + processes.some((process) => { + if (!ids.has(process.sessionID) || ["exited", "failed", "stopped"].includes(process.status)) return false + return ( + process.sessionID !== sessionID || + process.lifetime !== "session" || + scan.directory !== root?.directory + ) + }) + ) + return false + if ( + terminals.some( + (terminal) => + ids.has(terminal.sessionID) && + (terminal.sessionID !== sessionID || scan.directory !== root?.directory), + ) + ) + return false + if ( + requests.some( + (request) => + ids.has(request.sessionID) && + (request.sessionID !== sessionID || scan.directory !== root?.directory), + ) + ) + return false + } + return true +}) diff --git a/packages/opencode/src/kilocode/sandbox/config.ts b/packages/opencode/src/kilocode/sandbox/config.ts index 42209d30c2..8a0024d703 100644 --- a/packages/opencode/src/kilocode/sandbox/config.ts +++ b/packages/opencode/src/kilocode/sandbox/config.ts @@ -1,9 +1,21 @@ import { Schema } from "effect" +import { normalizeDestinations, parseDestination } from "@kilocode/sandbox" export namespace SandboxConfig { export const Network = Schema.Literals(["allow", "deny"]) export type Network = Schema.Schema.Type + const Destination = Schema.String.check( + Schema.makeFilter((value: string) => { + try { + parseDestination(value) + return undefined + } catch { + return "Expected an exact public DNS host with an optional port, for example api.github.com:443" + } + }), + ) + export const Info = Schema.Struct({ enabled: Schema.optional( Schema.Boolean.annotate({ description: "Enable sandbox confinement for new sessions (default: false)" }), @@ -16,13 +28,22 @@ export namespace SandboxConfig { description: "Additional filesystem paths that sandboxed tools may write to", }), ), + allowed_hosts: Schema.optional( + Schema.mutable(Schema.Array(Destination)).annotate({ + description: "Exact network destinations sandboxed tools may access while network restriction is enabled", + }), + ), }).annotate({ description: "Sandbox configuration for agent tools" }) export type Info = Schema.Schema.Type export function resolve(config: { sandbox?: Info }) { + const hosts = normalizeDestinations(config.sandbox?.allowed_hosts ?? []) + const restricted = config.sandbox?.network !== "allow" return { enabled: config.sandbox?.enabled ?? false, - mode: config.sandbox?.network ?? "deny", + mode: restricted ? (hosts.length > 0 ? ("proxy" as const) : ("deny" as const)) : ("allow" as const), + allowedHosts: restricted ? hosts : [], + writablePaths: [...(config.sandbox?.writable_paths ?? [])], } } @@ -31,7 +52,7 @@ export namespace SandboxConfig { const scoped = { ...config } const sandbox: Info = { ...(config.sandbox.enabled === true ? { enabled: true } : {}), - ...(config.sandbox.network === "deny" ? { network: "deny" as const } : {}), + ...(config.sandbox.network === "deny" ? { network: "deny" as const, allowed_hosts: [] } : {}), } if (Object.keys(sandbox).length > 0) scoped.sandbox = sandbox else delete scoped.sandbox diff --git a/packages/opencode/src/kilocode/sandbox/network-tools.ts b/packages/opencode/src/kilocode/sandbox/network-tools.ts index 08293fbc5e..ad2caa9dda 100644 --- a/packages/opencode/src/kilocode/sandbox/network-tools.ts +++ b/packages/opencode/src/kilocode/sandbox/network-tools.ts @@ -11,3 +11,9 @@ export const opaque = [ { id: "semantic_search", file: "kilocode/tool/semantic-search.ts" }, { id: "lsp", file: "tool/lsp.ts" }, ] as const + +export const host = [ + { id: "interactive_terminal", file: "kilocode/tool/interactive-terminal.ts" }, + { id: "notebook_execute", file: "kilocode/tool/notebook-host.ts" }, + { id: "background_process", file: "kilocode/tool/background-process.ts" }, +] as const diff --git a/packages/opencode/src/kilocode/sandbox/network.ts b/packages/opencode/src/kilocode/sandbox/network.ts index a374441d89..47df32217e 100644 --- a/packages/opencode/src/kilocode/sandbox/network.ts +++ b/packages/opencode/src/kilocode/sandbox/network.ts @@ -1,11 +1,12 @@ import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" -import { assertNetwork, networkHttpLayer } from "@kilocode/sandbox" -import { opaque } from "./network-tools" +import { assertNetwork, assertSandbox, networkHttpLayer } from "@kilocode/sandbox" +import { host, opaque } from "./network-tools" const Builtin = Symbol("kilo.sandbox.builtinTool") const Remote = Symbol("kilo.sandbox.remoteMcp") const indirect = new Set(opaque.map((item) => item.id)) +const external = new Set(host.map((item) => item.id)) export const httpLayer = networkHttpLayer.pipe(Layer.provide(FetchHttpClient.layer)) @@ -27,11 +28,13 @@ export function tool(value: { id: string }, effect: Effect.Effect(value: object, effect: Effect.Effect) { - if (!(Remote in value)) return effect - return assertNetwork("remote MCP delegated authority", "executeMcp").pipe(Effect.andThen(effect)) + return assertNetwork(Remote in value ? "remote MCP delegated authority" : "local MCP delegated authority", "executeMcp").pipe( + Effect.andThen(effect), + ) } diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index 4a4b80e178..433521c015 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -17,9 +17,12 @@ import { SandboxConfig } from "./config" import { SandboxStore } from "./store" export type Snapshot = SandboxStore.Snapshot +export type Target = { id: SessionID; directory: string } const snapshots = new Map() const locks = new Map() +const gates = new Map() +const permits = 1_000_000 function key(directory: string, sessionID: SessionID) { return directory + "\0" + sessionID @@ -29,11 +32,19 @@ function initial( chosen: boolean | undefined, pref: boolean | undefined, cfgDefault: boolean, - mode: Snapshot["mode"], + fallback: ReturnType, ): Snapshot { - if (chosen !== undefined) return { enabled: chosen, mode, version: 0 } - if (pref !== undefined) return { enabled: pref, mode, version: 0 } - return { enabled: cfgDefault, mode, version: 0 } + const state = { + mode: fallback.mode, + allowedHosts: fallback.allowedHosts, + writablePaths: fallback.writablePaths.map((value) => + value.startsWith("~") ? path.join(os.homedir(), value.slice(1)) : value, + ), + version: 0, + } + if (chosen !== undefined) return { ...state, enabled: chosen } + if (pref !== undefined) return { ...state, enabled: pref } + return { ...state, enabled: cfgDefault } } const resolveInitial = Effect.fn("SandboxPolicy.resolveInitial")(function* (directory: string, sessionID: SessionID) { @@ -41,7 +52,7 @@ const resolveInitial = Effect.fn("SandboxPolicy.resolveInitial")(function* (dire const chosen = yield* SandboxState.read(sessionID) const pref = yield* Effect.promise(() => SandboxPreference.read(directory)) const fallback = SandboxConfig.resolve(cfg) - return initial(chosen?.enabled, pref, fallback.enabled, fallback.mode) + return initial(chosen?.enabled, pref, fallback.enabled, fallback) }) function locked(sessionID: SessionID, effect: Effect.Effect) { return Effect.acquireUseRelease( @@ -60,6 +71,31 @@ function locked(sessionID: SessionID, effect: Effect.Effect) { ) } +function lockedAll(sessions: readonly SessionID[], effect: Effect.Effect) { + return [...new Set(sessions)].reduceRight((next, sessionID) => locked(sessionID, next), effect) +} + +function gated(sessionID: SessionID, count: number, effect: Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const entry = gates.get(sessionID) ?? { semaphore: Semaphore.makeUnsafe(permits), refs: 0 } + entry.refs++ + gates.set(sessionID, entry) + return entry + }), + (entry) => entry.semaphore.withPermits(count)(effect), + (entry) => + Effect.sync(() => { + entry.refs-- + if (entry.refs === 0 && gates.get(sessionID) === entry) gates.delete(sessionID) + }), + ) +} + +function gatedAll(sessions: readonly SessionID[], effect: Effect.Effect) { + return [...new Set(sessions)].reduceRight((next, sessionID) => gated(sessionID, permits, next), effect) +} + function root(path: string) { return { path, kind: "subtree" as const } } @@ -98,6 +134,7 @@ export function profile( ctx: InstanceContext, mode: Profile["network"]["mode"] = "deny", extraWritable?: readonly string[], + allowedHosts: readonly string[] = [], ): Profile { const project = isolated(ctx) ? [ctx.directory] @@ -119,16 +156,22 @@ export function profile( return { filesystem: { allowWrite: writable, - denyWrite: [root(SandboxStore.root), root(SandboxPreference.root)], + denyWrite: [root(SandboxStore.root), root(SandboxPreference.root), root(Global.Path.config)], denyNames: [".git"], temporaryDirectory: Global.Path.tmp, }, network: { mode, - allowedHosts: [], + allowedHosts, }, environment: { - deny: ["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"], + deny: [ + "KILO_CONFIG", + "KILO_CONFIG_CONTENT", + "KILO_CONFIG_DIR", + "KILO_SERVER_PASSWORD", + "KILO_SERVER_USERNAME", + ], set: { TMPDIR: Global.Path.tmp, TMP: Global.Path.tmp, @@ -171,7 +214,8 @@ const snapshot = Effect.fn("SandboxPolicy.snapshot")(function* (sessionID: Sessi export const configuredSupport = Effect.fn("SandboxPolicy.configuredSupport")(function* () { const cfg = yield* (yield* Config.Service).get() - return backendSupport({ mode: SandboxConfig.resolve(cfg).mode, allowedHosts: [] }) + const state = SandboxConfig.resolve(cfg) + return backendSupport({ mode: state.mode, allowedHosts: state.allowedHosts }) }) export function fallback(config: Config.Info) { @@ -180,7 +224,7 @@ export function fallback(config: Config.Info) { export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) { const current = yield* snapshot(sessionID) - const support = backendSupport({ mode: current.state.mode, allowedHosts: [] }) + const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts }) return { directory: current.directory, enabled: current.state.enabled && support.available, @@ -190,16 +234,27 @@ export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: Se } }) -function change(sessionID: SessionID, guard: Effect.Effect) { +export const networkRestricted = Effect.fn("SandboxPolicy.networkRestricted")(function* (sessionID: SessionID) { + const current = yield* snapshot(sessionID) + return current.state.enabled && current.state.mode !== "allow" +}) + +function change( + sessionID: SessionID, + guard: + | Effect.Effect + | ((enabling: boolean, family: readonly Target[]) => Effect.Effect), + family?: Effect.Effect, + preflight?: (family: readonly Target[]) => Effect.Effect, +) { return Effect.gen(function* () { const directory = yield* InstanceState.directory return yield* locked( sessionID, Effect.gen(function* () { - yield* guard const stored = yield* read(directory, sessionID) const current = stored ?? (yield* resolveInitial(directory, sessionID)) - const support = backendSupport({ mode: current.mode, allowedHosts: [] }) + const support = backendSupport({ mode: current.mode, allowedHosts: current.allowedHosts }) const status = { directory, enabled: current.enabled && support.available, @@ -207,19 +262,44 @@ function change(sessionID: SessionID, guard: Effect.Effect) reason: support.reason, version: current.version, } - if (!status.enabled && !status.available) return status - const next: Snapshot = { ...current, enabled: !status.enabled, version: status.version + 1 } - yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) - snapshots.set(key(directory, sessionID), next) - // The per-session SandboxStore is the authoritative state; the per-directory - // preference only seeds future sessions. A preference write failure must not - // fail the toggle or desync the in-memory cache from the persisted snapshot. - yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe( - Effect.catch(() => Effect.void), - ) - const value = { ...status, enabled: next.enabled, version: next.version } - yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value }) - return value + const enabling = !current.enabled + if (enabling && !status.available) return status + const targets = enabling && family ? yield* family : [{ id: sessionID, directory }] + const sessions = targets.map((target) => target.id) + const update = Effect.gen(function* () { + yield* typeof guard === "function" ? guard(enabling, targets) : guard + const next: Snapshot = { ...current, enabled: enabling, version: status.version + 1 } + yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) + snapshots.set(key(directory, sessionID), next) + if (enabling) { + yield* Effect.forEach( + targets, + (target) => + target.id === sessionID ? Effect.void : inheritSnapshot(target.directory, next, target.id), + { discard: true }, + ) + } + // The per-session SandboxStore is the authoritative state; the per-directory + // preference only seeds future sessions. A preference write failure must not + // fail the toggle or desync the in-memory cache from the persisted snapshot. + yield* Effect.promise(() => SandboxPreference.write(directory, next.enabled)).pipe( + Effect.catch(() => Effect.void), + ) + const value = { ...status, enabled: next.enabled && support.available, version: next.version } + yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value }) + return value + }) + if (enabling) { + const children = sessions.filter((id) => id !== sessionID) + return yield* lockedAll( + children, + Effect.gen(function* () { + if (preflight) yield* preflight(targets) + return yield* gatedAll(sessions, update) + }), + ) + } + return yield* update }), ) }) @@ -232,6 +312,52 @@ export const peek = Effect.fn("SandboxPolicy.peek")(function* (directory: string return yield* read(directory, sessionID) }) +function intersect(parent: Snapshot, child: Snapshot) { + const paths = child.writablePaths.filter((value) => parent.writablePaths.includes(value)) + if (parent.mode === "deny" || child.mode === "deny") { + return { mode: "deny" as const, allowedHosts: [], writablePaths: paths } + } + if (parent.mode === "allow" && child.mode === "allow") { + return { mode: "allow" as const, allowedHosts: [], writablePaths: paths } + } + const hosts = + parent.mode === "proxy" && child.mode === "proxy" + ? child.allowedHosts.filter((value) => parent.allowedHosts.includes(value)) + : parent.mode === "proxy" + ? parent.allowedHosts + : child.allowedHosts + return { + mode: hosts.length > 0 ? ("proxy" as const) : ("deny" as const), + allowedHosts: hosts, + writablePaths: paths, + } +} + +const inheritSnapshot = Effect.fn("SandboxPolicy.inheritSnapshot")(function* ( + directory: string, + parent: Snapshot, + sessionID: SessionID, +) { + const child = yield* read(directory, sessionID) + const next: Snapshot = child + ? { + enabled: parent.enabled || child.enabled, + ...intersect(parent, child), + version: child.version + 1, + } + : { ...parent, version: 0 } + if ( + child && + child.enabled === next.enabled && + child.mode === next.mode && + child.allowedHosts.join("\0") === next.allowedHosts.join("\0") && + child.writablePaths.join("\0") === next.writablePaths.join("\0") + ) + return + yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) + snapshots.set(key(directory, sessionID), next) +}) + export const inherit = Effect.fn("SandboxPolicy.inherit")(function* ( parentID: SessionID, sessionID: SessionID, @@ -249,26 +375,21 @@ export const inherit = Effect.fn("SandboxPolicy.inherit")(function* ( // written back under the parent's key here, or it leaks a phantom parent record. yield* locked( sessionID, - Effect.gen(function* () { - const child = yield* read(directory, sessionID) - const next: Snapshot = child - ? { - enabled: parent.enabled || child.enabled, - mode: parent.mode === "deny" || child.mode === "deny" ? "deny" : "allow", - version: child.version + 1, - } - : { ...parent, version: 0 } - if (child && child.enabled === next.enabled && child.mode === next.mode) return - yield* Effect.promise(() => SandboxStore.write(directory, sessionID, next)) - snapshots.set(key(directory, sessionID), next) - }), + inheritSnapshot(directory, parent, sessionID), ) }), ) }) -export function toggleGuarded(sessionID: SessionID, guard: Effect.Effect) { - return change(sessionID, guard) +export function toggleGuarded( + sessionID: SessionID, + guard: + | Effect.Effect + | ((enabling: boolean, family: readonly Target[]) => Effect.Effect), + family?: Effect.Effect, + preflight?: (family: readonly Target[]) => Effect.Effect, +) { + return change(sessionID, guard, family, preflight) } export function retire( @@ -304,13 +425,32 @@ export function dispose(sessionID: SessionID, effect: Effect.Effect(sessionID: SessionID, effect: Effect.Effect) { return Effect.gen(function* () { - const current = yield* snapshot(sessionID) - const support = backendSupport({ mode: current.state.mode, allowedHosts: [] }) - if (!current.state.enabled || !support.available) return yield* unrestricted(effect) - const cfg = yield* (yield* Config.Service).get() - const raw = cfg.sandbox?.writable_paths - const extraWritable = raw?.map((p) => (p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p)) - return yield* runSandbox(profile(yield* InstanceState.context, current.state.mode, extraWritable), effect) + // Initialize before taking the execution gate so activation can safely hold the policy lock while + // waiting for an already-started tool to finish without deadlocking snapshot initialization. + yield* snapshot(sessionID) + return yield* gated( + sessionID, + 1, + Effect.gen(function* () { + const current = yield* snapshot(sessionID) + if (!current.state.enabled) return yield* unrestricted(effect) + const support = backendSupport({ mode: current.state.mode, allowedHosts: current.state.allowedHosts }) + if (!support.available) { + return yield* Effect.fail( + new Error(support.reason ?? "The configured sandbox backend is unavailable"), + ) + } + return yield* runSandbox( + profile( + yield* InstanceState.context, + current.state.mode, + current.state.writablePaths, + current.state.allowedHosts, + ), + effect, + ) + }), + ) }) } diff --git a/packages/opencode/src/kilocode/sandbox/store.ts b/packages/opencode/src/kilocode/sandbox/store.ts index 4d09c627b4..b9486b69a0 100644 --- a/packages/opencode/src/kilocode/sandbox/store.ts +++ b/packages/opencode/src/kilocode/sandbox/store.ts @@ -10,7 +10,9 @@ export namespace SandboxStore { /** Session confinement authority captured independently from later configuration reloads. */ export type Snapshot = { enabled: boolean - mode: Extract + mode: Profile["network"]["mode"] + allowedHosts: string[] + writablePaths: string[] version: number } @@ -28,15 +30,21 @@ export namespace SandboxStore { return path.join(dir(sessionID), hash(directory) + ".json") } - function valid(value: unknown): value is Snapshot { + function valid(value: unknown) { if (!value || typeof value !== "object") return false const state = value as Record - return ( + const base = typeof state.enabled === "boolean" && - (state.mode === "allow" || state.mode === "deny") && + (state.mode === "allow" || state.mode === "deny" || state.mode === "proxy") && Number.isSafeInteger(state.version) && Number(state.version) >= 0 - ) + if (!base) return false + if (state.allowedHosts !== undefined && !Array.isArray(state.allowedHosts)) return false + if (state.writablePaths !== undefined && !Array.isArray(state.writablePaths)) return false + if (Array.isArray(state.allowedHosts) && state.allowedHosts.some((value) => typeof value !== "string")) return false + if (Array.isArray(state.writablePaths) && state.writablePaths.some((value) => typeof value !== "string")) return false + if (state.mode === "proxy" && (!Array.isArray(state.allowedHosts) || state.allowedHosts.length === 0)) return false + return true } export async function read(directory: string, sessionID: SessionID) { @@ -48,7 +56,14 @@ export namespace SandboxStore { if (text === undefined) return const value: unknown = JSON.parse(text) if (!valid(value)) throw new Error(`Invalid sandbox policy state at ${target}`) - return value + const state = value as Record + return { + enabled: state.enabled as boolean, + mode: state.mode as Snapshot["mode"], + allowedHosts: (state.allowedHosts as string[] | undefined) ?? [], + writablePaths: (state.writablePaths as string[] | undefined) ?? [], + version: state.version as number, + } satisfies Snapshot } export async function write(directory: string, sessionID: SessionID, snapshot: Snapshot) { diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts index ae80925883..80c4643af1 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/sandbox.ts @@ -1,22 +1,59 @@ import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" +import * as SandboxActivation from "@/kilocode/sandbox/activation" import * as SandboxPolicy from "@/kilocode/sandbox/policy" import { Session } from "@/session/session" import type { SessionID } from "@/session/schema" import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api" import * as SessionError from "@/server/routes/instance/httpapi/handlers/session-errors" +import { BackgroundProcess } from "@/kilocode/background-process" +import { InteractiveTerminal } from "@/kilocode/interactive-terminal" +import { Service as Notebook } from "@/kilocode/notebook/service" +import { InvalidRequestError } from "@/server/routes/instance/httpapi/errors" export const sandboxHandlers = HttpApiBuilder.group(InstanceHttpApi, "sandbox", (handlers) => Effect.gen(function* () { const session = yield* Session.Service + const notebook = yield* Notebook const exists = (sessionID: SessionID) => SessionError.mapStorageNotFound(session.get(sessionID)) + const inactive = (sessionID: SessionID, family: readonly SandboxPolicy.Target[]) => + Effect.gen(function* () { + if (!(yield* SandboxActivation.idle(sessionID, family))) { + yield* new InvalidRequestError({ + message: "Stop the active session and its subagents before enabling sandbox confinement", + }) + } + }) return handlers .handle("support", () => SandboxPolicy.configuredSupport()) .handle("status", (ctx: { params: { sessionID: SessionID } }) => exists(ctx.params.sessionID).pipe(Effect.andThen(SandboxPolicy.status(ctx.params.sessionID))), ) .handle("toggle", (ctx: { params: { sessionID: SessionID } }) => - SandboxPolicy.toggleGuarded(ctx.params.sessionID, exists(ctx.params.sessionID)), + SandboxPolicy.toggleGuarded( + ctx.params.sessionID, + (enabling, family) => + exists(ctx.params.sessionID).pipe( + Effect.andThen( + enabling + ? Effect.gen(function* () { + yield* inactive(ctx.params.sessionID, family) + yield* Effect.all( + [ + Effect.promise(() => BackgroundProcess.stopSession(ctx.params.sessionID)), + Effect.promise(() => InteractiveTerminal.stopSession(ctx.params.sessionID)), + notebook.cancelSession(ctx.params.sessionID), + ], + { discard: true }, + ) + }) + : Effect.void, + ), + ), + SandboxActivation.family(ctx.params.sessionID), + (family) => + exists(ctx.params.sessionID).pipe(Effect.andThen(inactive(ctx.params.sessionID, family))), + ), ) }), ) diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index a6bd9210c8..765c9cb640 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -6,6 +6,7 @@ import { corsVaryFix } from "@/server/routes/instance/httpapi/middleware/cors-va import { errorLayer } from "@/server/routes/instance/httpapi/middleware/error" import { fenceLayer } from "@/server/routes/instance/httpapi/middleware/fence" import * as AnacondaDesktop from "@/kilocode/anaconda-desktop/service" +import { BackgroundJob } from "@/background/job" import { agentBuilderHandlers } from "./handlers/agent-builder" import { anacondaDesktopHandlers } from "./handlers/anaconda-desktop" @@ -43,7 +44,7 @@ export const provide = Layer.provide([ memoryHandlers, networkHandlers, remoteHandlers, - sandboxHandlers, + sandboxHandlers.pipe(Layer.provide(BackgroundJob.defaultLayer)), sessionImportHandlers, suggestionHandlers, telemetryHandlers, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c078952295..56d221bd31 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -7,6 +7,7 @@ import { KiloSession } from "@/kilocode/session" // kilocode_change import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change import { KiloSessionOverflow } from "@/kilocode/session/overflow" // kilocode_change +import * as SandboxPolicy from "@/kilocode/sandbox/policy" // kilocode_change import { CommandTimeout } from "@/kilocode/command-timeout" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change @@ -858,6 +859,11 @@ export const layer = Layer.effect( }) }) + // kilocode_change start + const networkRestricted = yield* SandboxPolicy.networkRestricted(input.sessionID).pipe( + Effect.provideService(Config.Service, config), + ) + // kilocode_change end const resolvePart: (part: PromptInput["parts"][number]) => Effect.Effect[]> = Effect.fn( "SessionPrompt.resolveUserPart", )(function* (part) { @@ -874,7 +880,12 @@ export const layer = Layer.effect( text: `Reading MCP resource: ${part.filename} (${uri})`, }, ] - const exit = yield* mcp.readResource(clientName, uri).pipe(Effect.exit) + // kilocode_change start + const exit = yield* (networkRestricted + ? Effect.fail(new Error("Sandbox denied MCP resource access")) + : mcp.readResource(clientName, uri) + ).pipe(Effect.exit) + // kilocode_change end if (Exit.isSuccess(exit)) { const content = exit.value if (!content) throw new Error(`Resource not found: ${clientName}/${uri}`) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f5d075099b..1e7cb4f240 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -137,7 +137,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }) } - for (const [key, item] of Object.entries(yield* mcp.tools())) { + const mcpTools = (yield* SandboxPolicy.networkRestricted(input.session.id)) ? {} : yield* mcp.tools() // kilocode_change + for (const [key, item] of Object.entries(mcpTools)) { // kilocode_change const execute = item.execute if (!execute) continue diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 89c6b8749a..89ee93e5a4 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -255,17 +255,32 @@ describe("kilocode sandbox config", () => { try { await writeConfig(globalTmp.path, { $schema: "https://app.kilo.ai/config.json", - sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/global"] }, + sandbox: { + enabled: true, + network: "deny", + writable_paths: ["/tmp/global"], + allowed_hosts: ["api.github.com"], + }, }) await writeConfig(tmp.path, { - sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/project"] }, + sandbox: { + enabled: false, + network: "allow", + writable_paths: ["/tmp/project"], + allowed_hosts: ["evil.example"], + }, }) await provideTestInstance({ directory: tmp.path, fn: async () => { const config = await load() - expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] }) + expect(config.sandbox).toEqual({ + enabled: true, + network: "deny", + writable_paths: ["/tmp/global"], + allowed_hosts: ["api.github.com"], + }) }, }) } finally { @@ -286,17 +301,32 @@ describe("kilocode sandbox config", () => { try { await writeConfig(globalTmp.path, { - sandbox: { enabled: false, network: "allow", writable_paths: ["/tmp/global"] }, + sandbox: { + enabled: false, + network: "allow", + writable_paths: ["/tmp/global"], + allowed_hosts: ["api.github.com"], + }, }) await writeConfig(tmp.path, { - sandbox: { enabled: true, network: "deny", writable_paths: ["/tmp/project"] }, + sandbox: { + enabled: true, + network: "deny", + writable_paths: ["/tmp/project"], + allowed_hosts: ["evil.example"], + }, }) await provideTestInstance({ directory: tmp.path, fn: async () => { const config = await load() - expect(config.sandbox).toEqual({ enabled: true, network: "deny", writable_paths: ["/tmp/global"] }) + expect(config.sandbox).toEqual({ + enabled: true, + network: "deny", + writable_paths: ["/tmp/global"], + allowed_hosts: [], + }) }, }) } finally { diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts index cc0f9b4d9d..5e8596e80b 100644 --- a/packages/opencode/test/kilocode/sandbox/config-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -1,7 +1,7 @@ import { Cause, Effect, Exit, Layer } from "effect" -import { expect } from "bun:test" +import { expect, test } from "bun:test" import { HttpClient } from "effect/unstable/http" -import { backendSupport } from "@kilocode/sandbox" +import { backendSupport, CurrentProxyFactory, startProxy, type ProxyFactory } from "@kilocode/sandbox" import { ProjectID } from "@/project/schema" import { InstanceRef } from "@/effect/instance-ref" import * as SandboxPolicy from "@/kilocode/sandbox/policy" @@ -23,13 +23,17 @@ const ctx = { }, } -function layer(restrict?: boolean) { +function layer(restrict?: boolean, allowedHosts: string[] = []) { return Layer.mergeAll( ToolNetwork.httpLayer, TestConfig.layer({ get: () => Effect.succeed({ - sandbox: { enabled: true, network: restrict === false ? "allow" : "deny" }, + sandbox: { + enabled: true, + network: restrict === false ? "allow" : "deny", + allowed_hosts: allowedHosts, + }, }), }), ) @@ -50,6 +54,39 @@ function server() { const restricted = testEffect(layer()) const open = testEffect(layer(false)) +const supported = process.platform === "win32" ? test.skip : test + +supported("allows only configured HTTP destinations through the scoped proxy", async () => { + const target = server() + const port = target.server.port! + const factory: ProxyFactory = (hosts) => + startProxy(hosts, process.platform, async (dest) => { + if (dest.port !== port) throw new Error("unexpected port") + return { address: "127.0.0.1", family: 4 } + }) + await Effect.runPromise(Effect.gen(function* () { + const http = yield* HttpClient.HttpClient + const sessionID = SessionID.make(`ses_sandbox_config_network_proxy_${Date.now()}`) + const allowed = yield* SandboxPolicy.executeTool( + sessionID, + tool, + http.get(`http://allowed.test:${port}/allowed`), + ).pipe(Effect.provideService(InstanceRef, ctx), Effect.exit) + const denied = yield* SandboxPolicy.executeTool( + sessionID, + tool, + http.get(`http://blocked.allowed.test:${port}/blocked`), + ).pipe(Effect.provideService(InstanceRef, ctx), Effect.exit) + expect(Exit.isSuccess(allowed)).toBe(true) + expect(Exit.isSuccess(denied)).toBe(true) + if (Exit.isSuccess(denied)) expect(denied.value.status).toBe(403) + expect(target.requests()).toBe(1) + }).pipe( + Effect.provide(layer(true, [`allowed.test:${port}`])), + Effect.provideService(CurrentProxyFactory, factory), + Effect.ensuring(Effect.promise(() => target.server.stop(true))), + )) +}) restricted.live("keeps network restriction enabled by default when the sandbox is available", () => { const target = server() @@ -61,8 +98,8 @@ restricted.live("keeps network restriction enabled by default when the sandbox i Effect.exit, ) if (!backendSupport().available) { - expect(Exit.isSuccess(exit)).toBe(true) - expect(target.requests()).toBe(1) + expect(Exit.isFailure(exit)).toBe(true) + expect(target.requests()).toBe(0) return } expect(Exit.isFailure(exit)).toBe(true) @@ -82,8 +119,8 @@ open.live("allows network when restriction is disabled without authenticated ser Effect.exit, ) if (!backendSupport().available) { - expect(Exit.isSuccess(exit)).toBe(true) - expect(target.requests()).toBe(1) + expect(Exit.isFailure(exit)).toBe(true) + expect(target.requests()).toBe(0) return } expect(status.enabled).toBe(true) diff --git a/packages/opencode/test/kilocode/sandbox/network.test.ts b/packages/opencode/test/kilocode/sandbox/network.test.ts index c975034dde..a07bc4588f 100644 --- a/packages/opencode/test/kilocode/sandbox/network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/network.test.ts @@ -56,19 +56,23 @@ describe("model network boundaries", () => { }), ) - it.effect("keeps local MCP tools outside remote delegated-authority policy", () => + it.effect("rejects local MCP delegated authority in deny mode", () => Effect.gen(function* () { let called = false - yield* run( - profile("deny"), - Network.mcp( - {}, - Effect.sync(() => { - called = true - }), + const exit = yield* Effect.exit( + run( + profile("deny"), + Network.mcp( + {}, + Effect.sync(() => { + called = true + }), + ), ), ) - expect(called).toBe(true) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("local MCP delegated authority") + expect(called).toBe(false) }), ) @@ -107,6 +111,29 @@ describe("model network boundaries", () => { }), ) + for (const id of ["interactive_terminal", "notebook_execute", "background_process"]) { + for (const mode of ["allow", "deny"] as const) { + it.effect(`fails closed before ${id} can execute outside the ${mode} sandbox`, () => + Effect.gen(function* () { + let called = false + const exit = yield* Effect.exit( + run( + profile(mode), + Network.tool( + Network.builtin({ id }), + Effect.sync(() => { + called = true + }), + ), + ), + ) + expect(Exit.isFailure(exit)).toBe(true) + expect(called).toBe(false) + }), + ) + } + } + it.live("fails closed before custom tool network code runs", () => { let requests = 0 return Effect.acquireUseRelease( diff --git a/packages/opencode/test/kilocode/sandbox/policy.test.ts b/packages/opencode/test/kilocode/sandbox/policy.test.ts index 240c1e1f38..6cf9424ac0 100644 --- a/packages/opencode/test/kilocode/sandbox/policy.test.ts +++ b/packages/opencode/test/kilocode/sandbox/policy.test.ts @@ -191,8 +191,15 @@ describe("sandbox policy", () => { expect(policy.filesystem.denyWrite).toEqual([ { path: SandboxStore.root, kind: "subtree" }, { path: SandboxPreference.root, kind: "subtree" }, + { path: Global.Path.config, kind: "subtree" }, + ]) + expect(policy.environment.deny).toEqual([ + "KILO_CONFIG", + "KILO_CONFIG_CONTENT", + "KILO_CONFIG_DIR", + "KILO_SERVER_PASSWORD", + "KILO_SERVER_USERNAME", ]) - expect(policy.environment.deny).toEqual(["KILO_SERVER_PASSWORD", "KILO_SERVER_USERNAME"]) expect(Exit.isFailure(storeWrite)).toBe(true) expect(Exit.isFailure(prefWrite)).toBe(true) }) diff --git a/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts b/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts index eb09ead790..bc8bf77d8f 100644 --- a/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts +++ b/packages/opencode/test/kilocode/sandbox/sdk-config.test.ts @@ -3,7 +3,12 @@ import type { Config as ConfigV1 } from "@kilocode/sdk" import type { Config as ConfigV2 } from "@kilocode/sdk/v2" const value = { - sandbox: { enabled: true, network: "allow" as const, writable_paths: ["/tmp/output"] }, + sandbox: { + enabled: true, + network: "deny" as const, + writable_paths: ["/tmp/output"], + allowed_hosts: ["api.github.com:443"], + }, } test("both public SDK Config types expose sandbox policy fields", () => { diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts index 954ab6972f..c79fc99720 100644 --- a/packages/opencode/test/kilocode/sandbox/session.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -1,18 +1,29 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { $ } from "bun" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Deferred, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { BackgroundJob } from "@/background/job" import { Bus } from "@/bus" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" +import { BackgroundProcess } from "@/kilocode/background-process" +import { Notebook } from "@/kilocode/notebook/service" +import * as SandboxActivation from "@/kilocode/sandbox/activation" import * as SandboxPolicy from "@/kilocode/sandbox/policy" import { SandboxStore } from "@/kilocode/sandbox/store" +import { InstanceBootstrap } from "@/project/bootstrap-service" +import { InstanceStore } from "@/project/instance-store" import { Session } from "@/session/session" +import { SessionStatus } from "@/session/status" +import { Shell } from "@/shell/shell" import { Storage } from "@/storage/storage" import { SyncEvent } from "@/sync" import { provideInstance, tmpdirScoped } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" +const bootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const it = testEffect( Layer.mergeAll( Session.layer.pipe( @@ -22,12 +33,63 @@ const it = testEffect( Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), Layer.provide(BackgroundJob.defaultLayer), ), + BackgroundJob.defaultLayer, Bus.layer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, + InstanceStore.defaultLayer.pipe(Layer.provide(bootstrap)), + Notebook.defaultLayer, + SessionStatus.defaultLayer, ), ) +function quote(input: string) { + const value = input.replaceAll("\\", "/") + if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"` + return `'${value.replaceAll("'", "'\\''")}'` +} + +async function script(dir: string) { + const file = path.join(dir, "sandbox-parent-process.mjs") + await Bun.write(file, `console.log("ready")\nsetInterval(() => {}, 1_000)\n`) + const bin = quote(process.execPath) + const arg = quote(file) + if (Shell.ps(Shell.acceptable())) return `& ${bin} ${arg}` + return `${bin} ${arg}` +} + +function linked(root: string) { + return Effect.gen(function* () { + const dir = path.join(path.dirname(root), path.basename(root) + "-sandbox-worktree") + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await $`git worktree remove --force ${dir}`.cwd(root).quiet().nothrow() + await fs.rm(dir, { recursive: true, force: true }) + }), + ) + yield* Effect.promise(() => + $`git worktree add --quiet -b sandbox-activation-${Date.now()} ${dir} HEAD`.cwd(root).quiet(), + ) + return dir + }) +} + +function activate(sessionID: Session.Info["id"]) { + const check = (family: readonly SandboxPolicy.Target[]) => + Effect.gen(function* () { + if (!(yield* SandboxActivation.idle(sessionID, family))) yield* Effect.fail("busy") + }) + return SandboxPolicy.toggleGuarded( + sessionID, + (enabling, family) => + enabling + ? check(family).pipe(Effect.andThen(Effect.promise(() => BackgroundProcess.stopSession(sessionID)))) + : Effect.void, + SandboxActivation.family(sessionID), + check, + ) +} + describe("sandbox session cleanup", () => { it.live("forks inherit the source session snapshot", () => Effect.gen(function* () { @@ -100,3 +162,133 @@ describe("sandbox session cleanup", () => { }), ) }) + +describe("sandbox activation", () => { + it.live("refuses activation while a background descendant is running", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const background = yield* BackgroundJob.Service + const dir = yield* tmpdirScoped({ git: true }) + yield* provideInstance(dir)( + Effect.gen(function* () { + const parent = yield* sessions.create({ title: "sandbox-parent" }) + const child = yield* sessions.create({ parentID: parent.id, title: "sandbox-child" }) + const support = yield* SandboxPolicy.status(parent.id) + if (!support.available) return + const release = yield* Deferred.make() + yield* background.start({ + id: child.id, + type: "task", + metadata: { background: true, parentSessionId: parent.id, sessionId: child.id }, + run: Deferred.await(release).pipe(Effect.as("complete")), + }) + + const result = yield* activate(parent.id).pipe(Effect.exit) + expect(Exit.isFailure(result)).toBe(true) + expect((yield* SandboxPolicy.status(parent.id)).enabled).toBe(false) + yield* Deferred.succeed(release, undefined) + }), + ) + }), + ) + + it.live("activates idle descendants without relaxing disable behavior", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const dir = yield* tmpdirScoped({ git: true }) + yield* provideInstance(dir)( + Effect.gen(function* () { + const parent = yield* sessions.create({ title: "sandbox-parent" }) + const child = yield* sessions.create({ parentID: parent.id, title: "sandbox-child" }) + const grandchild = yield* sessions.create({ parentID: child.id, title: "sandbox-grandchild" }) + const support = yield* SandboxPolicy.status(parent.id) + if (!support.available) return + + expect((yield* activate(parent.id)).enabled).toBe(true) + expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(true) + expect((yield* SandboxPolicy.status(grandchild.id)).enabled).toBe(true) + + expect((yield* activate(parent.id)).enabled).toBe(false) + expect((yield* SandboxPolicy.status(child.id)).enabled).toBe(true) + expect((yield* SandboxPolicy.status(grandchild.id)).enabled).toBe(true) + }), + ) + }), + ) + + it.live("activates an idle descendant in its linked worktree", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const root = yield* tmpdirScoped({ git: true }) + const worktree = yield* linked(root) + const parent = yield* provideInstance(root)(sessions.create({ title: "sandbox-parent" })) + const child = yield* provideInstance(worktree)( + sessions.create({ parentID: parent.id, title: "sandbox-worktree-child" }), + ) + const support = yield* provideInstance(root)(SandboxPolicy.status(parent.id)) + if (!support.available) return + expect((yield* provideInstance(worktree)(SandboxPolicy.status(child.id))).enabled).toBe(false) + + const family = yield* provideInstance(root)(SandboxActivation.family(parent.id)) + expect(family.find((target) => target.id === child.id)?.directory).toBe(worktree) + expect((yield* provideInstance(root)(activate(parent.id))).enabled).toBe(true) + expect((yield* Effect.promise(() => SandboxStore.read(worktree, child.id)))?.enabled).toBe(true) + expect(yield* Effect.promise(() => SandboxStore.read(root, child.id))).toBeUndefined() + }), + ) + + it.live("rejects linked-worktree jobs and parent-lifetime processes", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const background = yield* BackgroundJob.Service + const root = yield* tmpdirScoped({ git: true }) + const worktree = yield* linked(root) + const parent = yield* provideInstance(root)(sessions.create({ title: "sandbox-parent" })) + const child = yield* provideInstance(worktree)( + sessions.create({ parentID: parent.id, title: "sandbox-worktree-child" }), + ) + const support = yield* provideInstance(root)(SandboxPolicy.status(parent.id)) + if (!support.available) return + const release = yield* Deferred.make() + yield* provideInstance(worktree)( + background.start({ + id: child.id, + type: "task", + metadata: { background: true, parentSessionId: parent.id, sessionId: child.id }, + run: Deferred.await(release).pipe(Effect.as("complete")), + }), + ) + expect(Exit.isFailure(yield* provideInstance(root)(activate(parent.id).pipe(Effect.exit)))).toBe(true) + yield* Deferred.succeed(release, undefined) + yield* provideInstance(worktree)(background.wait({ id: child.id })) + + const command = yield* Effect.promise(() => script(worktree)) + const process = yield* provideInstance(worktree)( + Effect.promise(() => + BackgroundProcess.start({ + sessionID: child.id, + parentID: parent.id, + command, + cwd: worktree, + lifetime: "parent", + ready: { pattern: "ready", timeout: 5_000 }, + }), + ), + ) + yield* Effect.addFinalizer(() => + provideInstance(worktree)(Effect.promise(() => BackgroundProcess.stop(process.id))).pipe(Effect.ignore), + ) + + expect(Exit.isFailure(yield* provideInstance(root)(activate(parent.id).pipe(Effect.exit)))).toBe(true) + expect(Exit.isFailure(yield* provideInstance(worktree)(activate(child.id).pipe(Effect.exit)))).toBe(true) + const children = yield* provideInstance(worktree)( + Effect.promise(() => BackgroundProcess.list({ sessionID: child.id })), + ) + const parents = yield* provideInstance(worktree)( + Effect.promise(() => BackgroundProcess.list({ sessionID: parent.id })), + ) + expect(children.find((item) => item.id === process.id)?.lifetime).toBe("parent") + expect(parents).toEqual([]) + }), + ) +}) diff --git a/packages/opencode/test/kilocode/sandbox/state.test.ts b/packages/opencode/test/kilocode/sandbox/state.test.ts index 9a8906b021..5ab0321511 100644 --- a/packages/opencode/test/kilocode/sandbox/state.test.ts +++ b/packages/opencode/test/kilocode/sandbox/state.test.ts @@ -64,14 +64,29 @@ test("restores the session snapshot after a backend restart", async () => { expect(result.exitCode, result.stderr.toString()).toBe(0) return JSON.parse(result.stdout.toString().trim().split("\n").at(-1)!) as { status: { enabled: boolean; available: boolean; version: number } - state: { enabled: boolean; mode: string; version: number } + state: { enabled: boolean; mode: string; allowedHosts: string[]; writablePaths: string[]; version: number } } } try { - const initial = run({ sandbox: { enabled: true, network: "deny" } }) - expect(initial.state).toEqual({ enabled: true, mode: "deny", version: 0 }) - const restored = run({ sandbox: { enabled: false, network: "allow" } }) + const initial = run({ + sandbox: { + enabled: true, + network: "deny", + allowed_hosts: ["API.GITHUB.COM."], + writable_paths: ["~/sandbox-output"], + }, + }) + expect(initial.state).toEqual({ + enabled: true, + mode: "proxy", + allowedHosts: ["api.github.com:443"], + writablePaths: [path.join(os.homedir(), "sandbox-output")], + version: 0, + }) + const restored = run({ + sandbox: { enabled: false, network: "deny", allowed_hosts: ["evil.example"], writable_paths: ["/tmp/evil"] }, + }) expect(restored.state).toEqual(initial.state) expect(restored.status.enabled).toBe(restored.status.available) } finally { @@ -132,6 +147,8 @@ linux("reports configured network namespace availability", async () => { "const allow = await status(false)", 'if (deny.available || deny.enabled || !deny.reason?.includes("Linux network sandbox")) process.exit(2)', "if (!allow.available || !allow.enabled) process.exit(3)", + 'const blocked = await SandboxPolicy.executeTool(SessionID.make("ses_sandbox_status_true"), { id: "read" }, Effect.succeed("escaped")).pipe(Effect.provide(Layer.mock(Config.Service, { get: () => Effect.succeed({ sandbox: { enabled: true, network: "deny" } }) })), Effect.provideService(InstanceRef, context), Effect.exit, Effect.runPromise)', + "if (blocked._tag !== 'Failure') process.exit(4)", ].join("\n") try { @@ -242,7 +259,13 @@ it.instance( const id = SessionID.make("ses_sandbox_default_on") const status = yield* SandboxPolicy.status(id) expect(status.enabled).toBe(status.available) - expect(yield* execute(id, sandboxed)).toBe(status.available) + const result = yield* execute(id, sandboxed).pipe(Effect.exit) + if (!status.available) { + expect(Exit.isFailure(result)).toBe(true) + return + } + expect(Exit.isSuccess(result)).toBe(true) + if (Exit.isSuccess(result)) expect(result.value).toBe(true) }), { config: { sandbox: { enabled: true } } }, ) @@ -339,10 +362,48 @@ it.instance("serializes concurrent toggles for a session", () => }), ) +it.instance("serializes activation with unrestricted tool start", () => + Effect.gen(function* () { + const test = yield* TestInstance + const id = SessionID.make("ses_sandbox_activation_tool_race") + if (!(yield* SandboxPolicy.status(id)).available) return + const entered = yield* Deferred.make() + const release = yield* Deferred.make() + const family = yield* Deferred.make() + const preflight = yield* Deferred.make() + const guard = yield* Deferred.make() + const running = yield* execute( + id, + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined) + yield* Deferred.await(release) + return yield* sandboxed + }), + ).pipe(Effect.forkChild) + yield* Deferred.await(entered) + const activation = yield* SandboxPolicy.toggleGuarded( + id, + () => Deferred.succeed(guard, undefined), + Deferred.succeed(family, undefined).pipe(Effect.as([{ id, directory: test.directory }])), + () => Deferred.succeed(preflight, undefined), + ).pipe(Effect.forkChild) + yield* Deferred.await(family) + yield* Deferred.await(preflight) + expect(yield* Deferred.isDone(guard)).toBe(false) + + yield* Deferred.succeed(release, undefined) + expect(yield* Fiber.join(running)).toBe(false) + expect((yield* Fiber.join(activation)).enabled).toBe(true) + expect(yield* Deferred.isDone(guard)).toBe(true) + expect(yield* execute(id, sandboxed)).toBe(true) + }), +) + it.instance("prevents a queued toggle from restoring a retired override", () => Effect.gen(function* () { const test = yield* TestInstance const id = SessionID.make("ses_sandbox_retire_race") + if (!(yield* SandboxPolicy.status(id)).available) return const entered = yield* Deferred.make() const release = yield* Deferred.make() const removal = yield* SandboxPolicy.retire( @@ -372,7 +433,12 @@ it.instance( const status = yield* SandboxPolicy.status(parent) if (!status.available) return - yield* SandboxPolicy.inherit(parent, child, { enabled: true, mode: "deny" }) + yield* SandboxPolicy.inherit(parent, child, { + enabled: true, + mode: "deny", + allowedHosts: [], + writablePaths: [], + }) yield* SandboxPolicy.toggle(parent) expect((yield* SandboxPolicy.status(parent)).enabled).toBe(false) expect((yield* SandboxPolicy.status(child)).enabled).toBe(true) @@ -387,6 +453,41 @@ it.instance( { config: { sandbox: { enabled: true } } }, ) +it.instance("intersects inherited network and write authority", () => + Effect.gen(function* () { + const test = yield* TestInstance + const parent = SessionID.make("ses_sandbox_intersection_parent") + const child = SessionID.make("ses_sandbox_intersection_child") + yield* Effect.promise(() => + SandboxStore.write(test.directory, parent, { + enabled: true, + mode: "proxy", + allowedHosts: ["api.github.com:443", "github.com:443"], + writablePaths: ["/shared", "/parent"], + version: 0, + }), + ) + yield* Effect.promise(() => + SandboxStore.write(test.directory, child, { + enabled: false, + mode: "proxy", + allowedHosts: ["api.github.com:443", "example.com:443"], + writablePaths: ["/child", "/shared"], + version: 0, + }), + ) + + yield* SandboxPolicy.inherit(parent, child) + expect(yield* SandboxPolicy.peek(test.directory, child)).toEqual({ + enabled: true, + mode: "proxy", + allowedHosts: ["api.github.com:443"], + writablePaths: ["/shared"], + version: 1, + }) + }), +) + it.instance("enforces writes only while the macOS session override is active", () => Effect.gen(function* () { if (process.platform !== "darwin") return diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 620fc1e238..bc0f7bffa3 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -202,7 +202,7 @@ function statusName(status: Record | MCPNS.Status, server: // kilocode_change start it.instance( - "classifies production remote MCP tools while leaving local MCP tools available", + "denies local and remote MCP tools while network sandboxing is active", () => MCP.Service.use((mcp: MCPNS.Interface) => Effect.gen(function* () { @@ -246,8 +246,8 @@ it.instance( ), ).pipe(Effect.exit) - expect(Exit.isSuccess(localExit)).toBe(true) - expect(localCalled).toBe(true) + expect(Exit.isFailure(localExit)).toBe(true) + expect(localCalled).toBe(false) expect(Exit.isFailure(remoteExit)).toBe(true) expect(remoteCalled).toBe(false) }), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 25ae889e6e..9d391eebe2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1593,6 +1593,10 @@ export type Config = { * Additional filesystem paths that sandboxed tools may write to */ writable_paths?: Array + /** + * Exact network destinations sandboxed tools may access while network restriction is enabled + */ + allowed_hosts?: Array } model?: string small_model?: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index ba8a20a801..8b11d68b1b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14387,16 +14387,6 @@ } } } - }, - "401": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/effect_HttpApiError_Unauthorized" - } - } - } } }, "description": "List image-capable models from the Kilo Gateway OpenRouter passthrough", @@ -14404,7 +14394,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.kilo.models.images({\n ...\n})" + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilo.models.images({\n ...\n})" } ] } @@ -24934,6 +24924,13 @@ "type": "string" }, "description": "Additional filesystem paths that sandboxed tools may write to" + }, + "allowed_hosts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Exact network destinations sandboxed tools may access while network restriction is enabled" } }, "additionalProperties": false, diff --git a/script/check-model-tool-network.ts b/script/check-model-tool-network.ts index be679b6237..11aee69bb7 100644 --- a/script/check-model-tool-network.ts +++ b/script/check-model-tool-network.ts @@ -9,7 +9,7 @@ // outside the scanned directories. Runtime enforcement remains in @kilocode/sandbox. import path from "node:path" -import { opaque } from "../packages/opencode/src/kilocode/sandbox/network-tools" +import { host, opaque } from "../packages/opencode/src/kilocode/sandbox/network-tools" const root = path.resolve(import.meta.dir, "..") const source = path.join(root, "packages", "opencode", "src") @@ -84,10 +84,10 @@ const clients = [...allow.entries()].flatMap(([key, entry]) => { }) const tools = ( await Promise.all( - opaque.map(async (item) => { + [...opaque, ...host].map(async (item) => { const text = await Bun.file(path.join(source, item.file)).text() const id = item.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - if (new RegExp(`Tool\\.define\\(\\s*["']${id}["']`).test(text)) return [] + if (new RegExp(`Tool\\.define(?:<[\\s\\S]{0,500}?>)?\\(\\s*["']${id}["']`).test(text)) return [] return [` packages/opencode/src/${item.file}: opaque classification must match Tool.define("${item.id}")`] }), ) @@ -99,10 +99,13 @@ const registry = await Bun.file(path.join(source, "tool", "registry.ts")).text() const session = await Bun.file(path.join(source, "session", "tools.ts")).text() const mcp = await Bun.file(path.join(source, "mcp", "index.ts")).text() const structure = [ - ...(!network.includes('import { opaque } from "./network-tools"') || + ...(!network.includes('import { host, opaque } from "./network-tools"') || !network.includes("opaque.map((item) => item.id)") ? [" kilocode/sandbox/network.ts must derive runtime opaque tool IDs from network-tools.ts"] : []), + ...(!network.includes("host.map((item) => item.id)") + ? [" kilocode/sandbox/network.ts must derive host-executed tool IDs from network-tools.ts"] + : []), ...(!registry.includes("Layer.provide(ToolNetwork.httpLayer)") ? [" tool/registry.ts must provide the policy-aware ToolNetwork HTTP layer"] : []),