Merge pull request #12075 from Kilo-Org/network-domain-exceptions

feat: allow sandbox network destinations
This commit is contained in:
Marius
2026-07-12 07:39:34 +02:00
committed by GitHub
78 changed files with 2771 additions and 250 deletions
@@ -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.
+15 -3
View File
@@ -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=="],
+2
View File
@@ -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",
@@ -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<string>,
denyNames: ReadonlyArray<string> = [],
mode: Profile["network"]["mode"] = "allow",
allowedHosts: ReadonlyArray<string> = [],
): 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<string>, 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<string>, 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<void>((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<void>((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()
@@ -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. |
+3 -1
View File
@@ -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:",
+12 -7
View File
@@ -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<Launch, PlatformError.PlatformError, Scope.Scope>
}
@@ -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)
})
}
+72 -9
View File
@@ -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),
}),
+2 -1
View File
@@ -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<Profile | undefined>("@kilocode/sandbox/CurrentProfile", {
defaultValue: () => undefined,
@@ -18,7 +19,7 @@ export function run<A, E, R>(
): Effect.Effect<A, E | PlatformError.PlatformError, R> {
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)))
})
}
+72
View File
@@ -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<string>) {
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]
}
+3 -1
View File
@@ -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"
@@ -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<Socket>()
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)
})
+84 -22
View File
@@ -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<string, string>) {
function matches(profile: Profile, runtime: ProxyRuntime) {
return normalizeDestinations(profile.network.allowedHosts).join("\0") === runtime.allowedHosts.join("\0")
}
export function networkEnvironment(profile: Profile, environment: Record<string, string>, 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))
+301
View File
@@ -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<string>
readonly port?: number | undefined
readonly socket?: string | undefined
}
export const CurrentProxy = Context.Reference<ProxyRuntime | undefined>("@kilocode/sandbox/CurrentProxy", {
defaultValue: () => undefined,
})
export const currentProxy: Effect.Effect<ProxyRuntime | undefined> = Effect.gen(function* () {
return yield* CurrentProxy
})
export type ProxyResolver = typeof resolveDestination
export type ProxyFactory = (
input: ReadonlyArray<string>,
) => Promise<ProxyRuntime & { readonly close: () => Promise<void> }>
export const CurrentProxyFactory = Context.Reference<ProxyFactory>("@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<typeof createNetServer>, sockets: Set<Socket>) {
return new Promise<void>((resolve) => {
for (const socket of sockets) socket.destroy()
server.close(() => resolve())
})
}
export async function startProxy(
input: ReadonlyArray<string>,
platform: NodeJS.Platform = process.platform,
resolve: ProxyResolver = resolveDestination,
): Promise<ProxyRuntime & { readonly close: () => Promise<void> }> {
const allowed = new Set(normalizeDestinations(input))
const allowedHosts = [...allowed]
const token = randomBytes(24).toString("base64url")
const sockets = new Set<Socket>()
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<void>((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<Socket>()
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<void>((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<A, E, R>(profile: Profile, effect: Effect.Effect<A, E, R>) {
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),
),
)
}
+1 -1
View File
@@ -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")
+10 -1
View File
@@ -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"))',
+6 -5
View File
@@ -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<Param> = []
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)),
}
@@ -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<number>()
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
}
}
+24 -16
View File
@@ -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")
}
})
@@ -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)
}
})
})
+21 -13
View File
@@ -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)
}
+323
View File
@@ -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> | 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<void>((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<void>((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<Buffer>((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<void>()
const gate = Promise.withResolvers<void>()
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)
})
})
@@ -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<string> = []): 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<void>((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<void>((resolve) => listener.close(() => resolve()))
}
})
mac("denies hostname and IPv6 loopback forms", async () => {
const dir = await root()
const ipv4 = server("127.0.0.1")
@@ -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
@@ -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()
})
})
@@ -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()")
})
@@ -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 })
}
@@ -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<string>([
"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<string, string[]>([
@@ -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<string>([
"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<string, string[]>([
@@ -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 = () => {
</Switch>
</SettingsRow>
<div data-variant="wide-input">
<SettingsRow
title={language.t("settings.sandboxing.allowedHosts.title")}
description={language.t("settings.sandboxing.allowedHosts.description")}
descriptionId={allowedHostsDescription}
>
<div style={{ width: "100%" }}>
<div
style={{
display: "flex",
gap: "8px",
"align-items": "center",
padding: "8px 0",
"border-bottom": allowedHosts().length > 0 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<div style={{ flex: 1 }}>
<TextField
value={newHost()}
disabled={sandbox().enabled !== true || sandbox().network === "allow"}
placeholder="api.github.com:443"
onChange={(val) => setNewHost(val)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === "Enter") addHost()
}}
hideLabel
label={language.t("settings.sandboxing.allowedHosts.title")}
/>
</div>
<Button
variant="secondary"
disabled={sandbox().enabled !== true || sandbox().network === "allow"}
onClick={addHost}
>
{language.t("common.add")}
</Button>
</div>
<For each={allowedHosts()}>
{(host, index) => (
<div
style={{
display: "flex",
"align-items": "center",
"justify-content": "space-between",
padding: "6px 0",
"border-bottom": index() < allowedHosts().length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<span
style={{
"font-family": "var(--vscode-editor-font-family, monospace)",
"font-size": "var(--kilo-font-size-12)",
}}
>
{host}
</span>
<IconButton
size="small"
variant="ghost"
icon="close"
disabled={sandbox().enabled !== true || sandbox().network === "allow"}
onClick={() => removeHost(index())}
/>
</div>
)}
</For>
</div>
</SettingsRow>
</div>
{/* wide-input widens the input column so long filesystem paths are readable */}
<div data-variant="wide-input">
<SettingsRow
+4 -1
View File
@@ -1420,8 +1420,11 @@ export const dict = {
"settings.sandboxing.title": "العزل",
"settings.sandboxing.network.title": "تقييد الوصول إلى الشبكة",
"settings.sandboxing.network.description":
"احظر الوصول الصادر إلى الشبكة من الأوامر الصادرة عن النموذج وأدوات HTTP. تعمل خوادم MCP المحلية وخطافات المكونات الإضافية خارج هذا التقييد. تظل حركة مرور استدلال الموفّر والنموذج متاحة.",
"حظر الوصول الصادر المباشر من الأوامر الصادرة عن النموذج وأدوات HTTP. تصبح أدوات MCP المحلية والبعيدة غير متاحة أثناء تفعيل التقييد. تظل حركة مرور المزوّد وخطافات الإضافات الموثوقة خارج هذا التقييد.",
"settings.sandboxing.allowedHosts.title": "وجهات الشبكة المسموح بها",
"settings.sandboxing.allowedHosts.description":
"وجهات مضيف ومنفذ DNS لحركة مرور وكيل HTTP وHTTPS المعزولة. يحتاج GitHub CLI وHTTPS Git عادةً إلى github.com:443 وapi.github.com:443. تنطبق التغييرات على الجلسات الجديدة.",
"settings.sandboxing.writablePaths.title": "مسارات قابلة للكتابة إضافية",
"settings.sandboxing.writablePaths.description":
"مسارات نظام ملفات إضافية يسمح صندوق الرمل بالكتابة إليها (مثل /tmp، /var/log). يتم دمجها مع مسارات الكتابة الافتراضية عندما يكون صندوق الرمل نشطًا.",
+4 -1
View File
@@ -1456,8 +1456,11 @@ export const dict = {
"settings.sandboxing.title": "Isolamento em sandbox",
"settings.sandboxing.network.title": "Restringir acesso à rede",
"settings.sandboxing.network.description":
"Bloqueie o acesso de saída à rede para comandos originados pelo modelo e ferramentas HTTP. Servidores MCP locais e hooks de plugins são executados fora dessa restrição. O tráfego de inferência de provedores e modelos permanece disponível.",
"Bloqueia o acesso direto de saída de comandos originados pelo modelo e ferramentas HTTP. As ferramentas MCP locais e remotas ficam indisponíveis enquanto a restrição estiver ativa. O tráfego do provedor e os hooks de plugins confiáveis permanecem fora desta restrição.",
"settings.sandboxing.allowedHosts.title": "Destinos de rede permitidos",
"settings.sandboxing.allowedHosts.description":
"Destinos de host e porta DNS para o tráfego de proxy HTTP e HTTPS em sandbox. GitHub CLI e HTTPS Git geralmente precisam de github.com:443 e api.github.com:443. As alterações se aplicam a novas sessões.",
"settings.sandboxing.writablePaths.title": "Caminhos graváveis adicionais",
"settings.sandboxing.writablePaths.description":
"Caminhos adicionais do sistema de arquivos onde o sandbox permite gravação (por exemplo, /tmp, /var/log). Eles são mesclados com os caminhos graváveis padrão quando o sandbox está ativo.",
+4 -1
View File
@@ -1452,8 +1452,11 @@ export const dict = {
"settings.sandboxing.title": "Rad u izoliranom okruženju",
"settings.sandboxing.network.title": "Ograniči pristup mreži",
"settings.sandboxing.network.description":
"Blokiraj odlazni mrežni pristup za naredbe koje potiču od modela i HTTP alate. Lokalni MCP serveri i hookovi dodataka izvršavaju se izvan ovog ograničenja. Saobraćaj za inferenciju pružatelja i modela ostaje dostupan.",
"Blokira direktni odlazni pristup iz naredbi koje potiču od modela i HTTP alata. Lokalni i udaljeni MCP alati nisu dostupni dok je ograničenje aktivno. Saobraćaj provajdera i pouzdane zakačke dodataka ostaju izvan ovog ograničenja.",
"settings.sandboxing.allowedHosts.title": "Dozvoljena mrežna odredišta",
"settings.sandboxing.allowedHosts.description":
"DNS odredišta hosta i porta za sandboxirani HTTP i HTTPS proxy promet. GitHub CLI i HTTPS Git obično trebaju github.com:443 i api.github.com:443. Promjene se primjenjuju na nove sesije.",
"settings.sandboxing.writablePaths.title": "Dodatne upisive putanje",
"settings.sandboxing.writablePaths.description":
"Dodatne putanje sistema datoteka u koje sandbox dozvoljava upis (npr. /tmp, /var/log). Spajaju se sa zadanim upisivim putanjama kada je sandbox aktivan.",
+4 -1
View File
@@ -1446,8 +1446,11 @@ export const dict = {
"settings.sandboxing.title": "Sandboxing",
"settings.sandboxing.network.title": "Begræns netværksadgang",
"settings.sandboxing.network.description":
"Bloker udgående netværksadgang fra kommandoer, der stammer fra modellen, og HTTP-værktøjer. Lokale MCP-servere og plugin-hooks er ikke underlagt denne begrænsning. Inferenstrafik til udbydere og modeller er fortsat tilgængelig.",
"Blokerer direkte udgående adgang fra modelgenererede kommandoer og HTTP-værktøjer. Lokale og eksterne MCP-værktøjer er ikke tilgængelige, mens begrænsningen er aktiv. Udbydertrafik og pålidelige plugin-hooks forbliver uden for denne begrænsning.",
"settings.sandboxing.allowedHosts.title": "Tilladte netværksdestinationer",
"settings.sandboxing.allowedHosts.description":
"DNS-værts- og portdestinationer for sandboxet HTTP- og HTTPS-proxytrafik. GitHub CLI og HTTPS Git kræver typisk github.com:443 og api.github.com:443. Ændringer gælder for nye sessioner.",
"settings.sandboxing.writablePaths.title": "Yderligere skrivbare stier",
"settings.sandboxing.writablePaths.description":
"Yderligere filsystemstier, som sandkassen tillader skrivning til (f.eks. /tmp, /var/log). Disse flettes med de standardskrivbare stier, når sandkassen er aktiv.",
@@ -1476,8 +1476,11 @@ export const dict = {
"settings.sandboxing.title": "Sandbox",
"settings.sandboxing.network.title": "Netzwerkzugriff einschränken",
"settings.sandboxing.network.description":
"Blockiert den ausgehenden Netzwerkzugriff für vom Modell initiierte Befehle und HTTP-Tools. Lokale MCP-Server und Plugin-Hooks sind von dieser Einschränkung ausgenommen. Anbieter- und Modellinferenzdatenverkehr bleibt verfügbar.",
"Blockiert den direkten ausgehenden Zugriff durch vom Modell initiierte Befehle und HTTP-Tools. Lokale und entfernte MCP-Tools sind während der Einschränkung nicht verfügbar. Provider-Datenverkehr und vertrauenswürdige Plugin-Hooks bleiben von dieser Einschränkung ausgenommen.",
"settings.sandboxing.allowedHosts.title": "Zulässige Netzwerkziele",
"settings.sandboxing.allowedHosts.description":
"DNS-Host- und Portziele für Sandbox-HTTP- und HTTPS-Proxy-Datenverkehr. GitHub CLI und HTTPS Git benötigen üblicherweise github.com:443 und api.github.com:443. Änderungen gelten für neue Sitzungen.",
"settings.sandboxing.writablePaths.title": "Zusätzliche schreibbare Pfade",
"settings.sandboxing.writablePaths.description":
"Zusätzliche Dateisystempfade, in die die Sandbox Schreibvorgänge erlaubt (z. B. /tmp, /var/log). Diese werden mit den Standard-Schreibpfaden zusammengeführt, wenn die Sandbox aktiv ist.",
@@ -1431,7 +1431,10 @@ export const dict = {
"settings.sandboxing.title": "Sandboxing",
"settings.sandboxing.network.title": "Restrict Network Access",
"settings.sandboxing.network.description":
"Block outbound network access from model-originated commands and HTTP tools. Local MCP servers and plugin hooks run outside this restriction. Provider and model inference traffic remains available.",
"Block direct outbound access from model-originated commands and HTTP tools. Local and remote MCP tools are unavailable while restricted. Provider traffic and trusted plugin hooks remain outside this restriction.",
"settings.sandboxing.allowedHosts.title": "Allowed Network Destinations",
"settings.sandboxing.allowedHosts.description":
"DNS host and port destinations for sandboxed HTTP and HTTPS proxy traffic. GitHub CLI and HTTPS Git commonly need github.com:443 and api.github.com:443. Changes apply to new sessions.",
"settings.sandboxing.writablePaths.title": "Additional Writable Paths",
"settings.sandboxing.writablePaths.description":
"Extra filesystem paths the sandbox allows writes to (e.g. /tmp, /var/log). These are merged with the default writable paths when the sandbox is active.",
+4 -1
View File
@@ -1464,8 +1464,11 @@ export const dict = {
"settings.sandboxing.title": "Sandbox",
"settings.sandboxing.network.title": "Restringir el acceso a la red",
"settings.sandboxing.network.description":
"Bloquea el acceso saliente a la red para los comandos iniciados por el modelo y las herramientas HTTP. Los servidores MCP locales y los hooks de plugins no están sujetos a esta restricción. El tráfico de proveedores y de inferencia de modelos sigue estando disponible.",
"Bloquea el acceso saliente directo de los comandos originados por el modelo y las herramientas HTTP. Las herramientas MCP locales y remotas no están disponibles mientras se aplica la restricción. El tráfico del proveedor y los hooks de plugins de confianza permanecen fuera de esta restricción.",
"settings.sandboxing.allowedHosts.title": "Destinos de red permitidos",
"settings.sandboxing.allowedHosts.description":
"Destinos de host y puerto DNS para el tráfico de proxy HTTP y HTTPS en zona de pruebas. GitHub CLI y HTTPS Git suelen necesitar github.com:443 y api.github.com:443. Los cambios se aplican a las sesiones nuevas.",
"settings.sandboxing.writablePaths.title": "Rutas de escritura adicionales",
"settings.sandboxing.writablePaths.description":
"Rutas del sistema de archivos adicionales donde el sandbox permite escritura (por ej., /tmp, /var/log). Se combinan con las rutas de escritura predeterminadas cuando el sandbox está activo.",
+4 -1
View File
@@ -1480,8 +1480,11 @@ export const dict = {
"settings.sandboxing.title": "Mise en bac à sable",
"settings.sandboxing.network.title": "Restreindre l'accès au réseau",
"settings.sandboxing.network.description":
"Bloquer l'accès réseau sortant des commandes provenant du modèle et des outils HTTP. Les serveurs MCP locaux et les hooks de plugin ne sont pas soumis à cette restriction. Le trafic d'inférence des fournisseurs et des modèles reste disponible.",
"Bloque laccès sortant direct des commandes lancées par le modèle et des outils HTTP. Les outils MCP locaux et distants ne sont pas disponibles tant que la restriction sapplique. Le trafic du fournisseur et les hooks de plugins approuvés restent en dehors de cette restriction.",
"settings.sandboxing.allowedHosts.title": "Destinations réseau autorisées",
"settings.sandboxing.allowedHosts.description":
"Destinations dhôte et de port DNS pour le trafic proxy HTTP et HTTPS isolé. GitHub CLI et HTTPS Git nécessitent généralement github.com:443 et api.github.com:443. Les modifications sappliquent aux nouvelles sessions.",
"settings.sandboxing.writablePaths.title": "Chemins en écriture supplémentaires",
"settings.sandboxing.writablePaths.description":
"Chemins système supplémentaires autorisés en écriture par le bac à sable (par ex. /tmp, /var/log). Ils sont fusionnés avec les chemins en écriture par défaut lorsque le bac à sable est actif.",
+4 -1
View File
@@ -1237,8 +1237,11 @@ export const dict = {
"settings.sandboxing.title": "Sandbox",
"settings.sandboxing.network.title": "Limita l'accesso alla rete",
"settings.sandboxing.network.description":
"Blocca l'accesso in uscita alla rete per i comandi avviati dal modello e gli strumenti HTTP. I server MCP locali e gli hook dei plugin operano al di fuori di questa restrizione. Il traffico verso i provider e per l'inferenza dei modelli rimane disponibile.",
"Blocca l'accesso in uscita diretto dai comandi avviati dal modello e dagli strumenti HTTP. Gli strumenti MCP locali e remoti non sono disponibili mentre la restrizione è attiva. Il traffico del provider e gli hook dei plugin attendibili restano al di fuori di questa restrizione.",
"settings.sandboxing.allowedHosts.title": "Destinazioni di rete consentite",
"settings.sandboxing.allowedHosts.description":
"Destinazioni DNS di host e porta per il traffico proxy HTTP e HTTPS in sandbox. GitHub CLI e HTTPS Git richiedono comunemente github.com:443 e api.github.com:443. Le modifiche si applicano alle nuove sessioni.",
"settings.sandboxing.writablePaths.title": "Percorsi di scrittura aggiuntivi",
"settings.sandboxing.writablePaths.description":
"Percorsi aggiuntivi del file system in cui la sandbox consente la scrittura (es. /tmp, /var/log). Vengono uniti con i percorsi di scrittura predefiniti quando la sandbox è attiva.",
+4 -1
View File
@@ -1441,8 +1441,11 @@ export const dict = {
"settings.sandboxing.title": "サンドボックス化",
"settings.sandboxing.network.title": "ネットワークアクセスを制限",
"settings.sandboxing.network.description":
"モデルから発行されたコマンドと HTTP ツールによる外部ネットワークアクセスをブロックします。ローカル MCP サーバーとプラグインフックは、この制限の対象外です。プロバイダーおよびモデルへの推論通信は引き続き利用できます。",
"モデル起点のコマンドと HTTP ツールによる直接のアウトバウンドアクセスをブロックします。この制限が適用されている間、ローカルおよびリモートの MCP ツールは利用できません。プロバイダーのトラフィックと信頼済みプラグインフックは、この制限の対象外です。",
"settings.sandboxing.allowedHosts.title": "許可されたネットワーク接続先",
"settings.sandboxing.allowedHosts.description":
"サンドボックス化された HTTP および HTTPS プロキシトラフィックの DNS ホストとポートの宛先。GitHub CLI と HTTPS Git では通常、github.com:443 と api.github.com:443 が必要です。変更は新しいセッションに適用されます。",
"settings.sandboxing.writablePaths.title": "追加の書き込み可能パス",
"settings.sandboxing.writablePaths.description":
"サンドボックスでの書き込みを許可する追加のファイルシステムパス(例: /tmp、/var/log)。サンドボックス有効時、デフォルトの書き込み可能パスと統合されます。",
+4 -1
View File
@@ -1433,8 +1433,11 @@ export const dict = {
"settings.sandboxing.title": "샌드박스",
"settings.sandboxing.network.title": "네트워크 액세스 제한",
"settings.sandboxing.network.description":
"모델이 실행한 명령 HTTP 도구의 아웃바운드 네트워크 액세스를 차단합니다. 로컬 MCP 서버와 플러그인 훅에는 이 제한이 적용되지 않습니다. 공급자 및 모델 추론 트래픽은 계속 사용할 수 있습니다.",
"모델에서 시작된 명령 HTTP 도구의 직접적인 아웃바운드 액세스를 차단합니다. 제한이 적용되는 동안 로컬 및 원격 MCP 도구를 사용할 수 없습니다. 공급자 트래픽과 신뢰할 수 있는 플러그인 후크는 이 제한의 적용 대상이 아닙니다.",
"settings.sandboxing.allowedHosts.title": "허용된 네트워크 대상",
"settings.sandboxing.allowedHosts.description":
"샌드박스 처리된 HTTP 및 HTTPS 프록시 트래픽의 DNS 호스트 및 포트 대상입니다. GitHub CLI 및 HTTPS Git에는 일반적으로 github.com:443 및 api.github.com:443가 필요합니다. 변경 사항은 새 세션에 적용됩니다.",
"settings.sandboxing.writablePaths.title": "추가 쓰기 가능 경로",
"settings.sandboxing.writablePaths.description":
"샌드박스에서 쓰기를 허용하는 추가 파일시스템 경로(예: /tmp, /var/log). 샌드박스가 활성화되면 기본 쓰기 가능 경로와 병합됩니다.",
+4 -1
View File
@@ -1450,8 +1450,11 @@ export const dict = {
"settings.sandboxing.title": "Sandbox",
"settings.sandboxing.network.title": "Netwerktoegang beperken",
"settings.sandboxing.network.description":
"Blokkeer uitgaande netwerktoegang voor door het model geïnitieerde opdrachten en HTTP-tools. Lokale MCP-servers en plugin-hooks vallen buiten deze beperking. Netwerkverkeer voor providers en modelinferentie blijft beschikbaar.",
"Blokkeer directe uitgaande toegang vanuit opdrachten die door het model zijn geïnitieerd en HTTP-hulpprogramma's. Lokale en externe MCP-hulpprogramma's zijn niet beschikbaar zolang deze beperking actief is. Verkeer van providers en hooks van vertrouwde plug-ins vallen buiten deze beperking.",
"settings.sandboxing.allowedHosts.title": "Toegestane netwerkbestemmingen",
"settings.sandboxing.allowedHosts.description":
"DNS-host- en poortbestemmingen voor HTTP- en HTTPS-proxyverkeer in een sandbox. GitHub CLI en HTTPS Git hebben doorgaans github.com:443 en api.github.com:443 nodig. Wijzigingen gelden voor nieuwe sessies.",
"settings.sandboxing.writablePaths.title": "Extra schrijfbare paden",
"settings.sandboxing.writablePaths.description":
"Extra bestandssysteempaden waar de sandbox schrijftoestemming voor geeft (bijv. /tmp, /var/log). Deze worden samengevoegd met de standaard schrijfbare paden wanneer de sandbox actief is.",
+4 -1
View File
@@ -1409,8 +1409,11 @@ export const dict = {
"settings.sandboxing.title": "Kjøring i sandkasse",
"settings.sandboxing.network.title": "Begrens nettverkstilgang",
"settings.sandboxing.network.description":
"Blokker utgående nettverkstilgang fra kommandoer generert av modellen og HTTP-verktøy. Lokale MCP-servere og programtilleggskroker kjører utenfor denne begrensningen. Trafikk for leverandør- og modellinferens forblir tilgjengelig.",
"Blokker direkte utgående tilgang fra kommandoer initiert av modellen og HTTP-verktøy. Lokale og eksterne MCP-verktøy er utilgjengelige mens denne begrensningen er aktiv. Leverandørtrafikk og pålitelige plugin-kroker omfattes ikke av denne begrensningen.",
"settings.sandboxing.allowedHosts.title": "Tillatte nettverksmål",
"settings.sandboxing.allowedHosts.description":
"DNS-verts- og portdestinasjoner for HTTP- og HTTPS-proxytrafikk i sandkassen. GitHub CLI og HTTPS Git trenger vanligvis github.com:443 og api.github.com:443. Endringer gjelder for nye økter.",
"settings.sandboxing.writablePaths.title": "Ytterligere skrivbare baner",
"settings.sandboxing.writablePaths.description":
"Ytterligere filsystembaner som sandkassen tillater skriving til (f.eks. /tmp, /var/log). Disse flettes med de standardskrivbare banene når sandkassen er aktiv.",
+4 -1
View File
@@ -1409,8 +1409,11 @@ export const dict = {
"settings.sandboxing.title": "Izolacja w piaskownicy",
"settings.sandboxing.network.title": "Ogranicz dostęp do sieci",
"settings.sandboxing.network.description":
"Blokuj wychodzący dostęp do sieci z poleceń pochodzących od modelu i narzędzi HTTP. Lokalne serwery MCP i hooki wtyczek nie podlegają temu ograniczeniu. Ruch do dostawców i modeli na potrzeby wnioskowania pozostaje dostępny.",
"Blokuj bezpośredni dostęp wychodzący z poleceń inicjowanych przez model i narzędzi HTTP. Lokalne i zdalne narzędzia MCP są niedostępne, gdy to ograniczenie jest aktywne. Ruch dostawców i zaufane hooki wtyczek pozostają poza tym ograniczeniem.",
"settings.sandboxing.allowedHosts.title": "Dozwolone miejsca docelowe sieci",
"settings.sandboxing.allowedHosts.description":
"Docelowe hosty DNS i porty dla ruchu HTTP i HTTPS przez proxy w piaskownicy. GitHub CLI i HTTPS Git zwykle wymagają github.com:443 i api.github.com:443. Zmiany dotyczą nowych sesji.",
"settings.sandboxing.writablePaths.title": "Dodatkowe ścieżki zapisu",
"settings.sandboxing.writablePaths.description":
"Dodatkowe ścieżki systemu plików, do których sandbox zezwala na zapis (np. /tmp, /var/log). Są one łączone z domyślnymi ścieżkami zapisu, gdy sandbox jest aktywny.",
+4 -1
View File
@@ -1450,8 +1450,11 @@ export const dict = {
"settings.sandboxing.title": "Изоляция в песочнице",
"settings.sandboxing.network.title": "Ограничить доступ к сети",
"settings.sandboxing.network.description":
"Блокировать исходящий доступ к сети для команд, инициированных моделью, и HTTP-инструментов. Локальные серверы MCP и хуки плагинов не подпадают под это ограничение. Трафик к провайдерам и моделям для инференса остаётся доступным.",
"Блокировать прямой исходящий доступ из команд, инициированных моделью, и HTTP-инструментов. Локальные и удалённые MCP-инструменты недоступны, пока это ограничение активно. Трафик провайдеров и доверенные хуки плагинов не подпадают под это ограничение.",
"settings.sandboxing.allowedHosts.title": "Разрешенные сетевые назначения",
"settings.sandboxing.allowedHosts.description":
"Целевые DNS-хосты и порты для прокси-трафика HTTP и HTTPS в песочнице. GitHub CLI и HTTPS Git обычно требуют github.com:443 и api.github.com:443. Изменения применяются к новым сеансам.",
"settings.sandboxing.writablePaths.title": "Дополнительные пути для записи",
"settings.sandboxing.writablePaths.description":
"Дополнительные пути файловой системы, в которые разрешена запись в песочнице (например, /tmp, /var/log). Они объединяются с путями записи по умолчанию при активной песочнице.",
+4 -1
View File
@@ -1429,8 +1429,11 @@ export const dict = {
"settings.sandboxing.title": "การทำงานในแซนด์บ็อกซ์",
"settings.sandboxing.network.title": "จำกัดการเข้าถึงเครือข่าย",
"settings.sandboxing.network.description":
"บล็อกการเข้าถึงเครือข่ายขาออกจากคำสั่งที่มาจากโมเดลและเครื่องมือ HTTP เซิร์ฟเวอร์ MCP ภายในเครื่องและฮุกของปลั๊กอินทำงานอยู่นอกข้อจำกัดนี้ การรับส่งข้อมูลสำหรับการอนุมานของผู้ให้บริการและโมเดลยังคงใช้งานได้",
"บล็อกการเข้าถึงขาออกโดยตรงจากคำสั่งที่เริ่มต้นโดยโมเดลและเครื่องมือ HTTP เครื่องมือ MCP ทั้งในเครื่องและระยะไกลจะใช้งานไม่ได้ในขณะที่มีการจำกัดนี้ การรับส่งข้อมูลของผู้ให้บริการและฮุกของปลั๊กอินที่เชื่อถือได้จะไม่อยู่ภายใต้ข้อจำกัดนี้",
"settings.sandboxing.allowedHosts.title": "ปลายทางเครือข่ายที่อนุญาต",
"settings.sandboxing.allowedHosts.description":
"โฮสต์ DNS และพอร์ตปลายทางสำหรับทราฟฟิกพร็อกซี HTTP และ HTTPS ในแซนด์บ็อกซ์ GitHub CLI และ HTTPS Git มักต้องใช้ github.com:443 และ api.github.com:443 การเปลี่ยนแปลงจะมีผลกับเซสชันใหม่",
"settings.sandboxing.writablePaths.title": "เส้นทางที่เขียนได้เพิ่มเติม",
"settings.sandboxing.writablePaths.description":
"เส้นทางระบบไฟล์เพิ่มเติมที่แซนด์บ็อกซ์อนุญาตให้เขียนได้ (เช่น /tmp, /var/log) จะถูกรวมเข้ากับเส้นทางที่เขียนได้เริ่มต้นเมื่อแซนด์บ็อกซ์เปิดใช้งาน",
+4 -1
View File
@@ -1440,8 +1440,11 @@ export const dict = {
"settings.sandboxing.title": "Sandbox",
"settings.sandboxing.network.title": "Ağ Erişimini Kısıtla",
"settings.sandboxing.network.description":
"Model tarafından başlatılan komutların ve HTTP araçlarının giden ağ erişimini engelleyin. Yerel MCP sunucuları ve eklenti kancaları bu kısıtlamanın dışında çalışır. Sağlayıcı ve model çıkarım trafiği kullanılabilir durumda kalır.",
"Model kaynaklı komutlar ve HTTP araçlarından doğrudan dışa yönelik erişimi engelleyin. Yerel ve uzak MCP araçları, kısıtlama etkin durumdayken kullanılamaz. Sağlayıcı trafiği ve güvenilir eklenti kancaları bu kısıtlamanın dışında kalır.",
"settings.sandboxing.allowedHosts.title": "İzin Verilen Ağ Hedefleri",
"settings.sandboxing.allowedHosts.description":
"Korumalı alana alınmış HTTP ve HTTPS proxy trafiği için DNS ana bilgisayar ve bağlantı noktası hedefleri. GitHub CLI ve HTTPS Git genellikle github.com:443 ve api.github.com:443 gerektirir. Değişiklikler yeni oturumlara uygulanır.",
"settings.sandboxing.writablePaths.title": "Ek Yazılabilir Yollar",
"settings.sandboxing.writablePaths.description":
"Sandığın yazılmasına izin veren ek dosya sistemi yolları (ör. /tmp, /var/log). Sandık etkinken varsayılan yazılabilir yollarla birleştirilir.",
+4 -1
View File
@@ -1438,8 +1438,11 @@ export const dict = {
"settings.sandboxing.title": "Пісочниця",
"settings.sandboxing.network.title": "Обмежити доступ до мережі",
"settings.sandboxing.network.description":
"Блокуйте вихідний доступ до мережі для команд, ініційованих моделлю, та HTTP-інструментів. Локальні MCP-сервери й хуки плагінів працюють поза цим обмеженням. Трафік провайдерів та інференсу моделей залишається доступним.",
"Блокуйте прямий вихідний доступ із команд, ініційованих моделлю, та інструментів HTTP. Локальні й віддалені інструменти MCP недоступні, коли обмеження активне. Трафік постачальника та довірені хуки плагінів не підпадають під це обмеження.",
"settings.sandboxing.allowedHosts.title": "Дозволені мережеві адреси",
"settings.sandboxing.allowedHosts.description":
"DNS-вузли та порти призначення для ізольованого proxy-трафіку HTTP і HTTPS. GitHub CLI та HTTPS Git зазвичай потребують github.com:443 і api.github.com:443. Зміни застосовуються до нових сеансів.",
"settings.sandboxing.writablePaths.title": "Додаткові шляхи для запису",
"settings.sandboxing.writablePaths.description":
"Додаткові шляхи файлової системи, у які дозволено запис у пісочниці (наприклад, /tmp, /var/log). Вони об'єднуються зі шляхами запису за замовчуванням, коли пісочниця активна.",
+4 -1
View File
@@ -1403,8 +1403,11 @@ export const dict = {
"settings.sandboxing.title": "沙盒",
"settings.sandboxing.network.title": "限制网络访问",
"settings.sandboxing.network.description":
"阻止模型发起的命令和 HTTP 工具进行出站网络访问。本地 MCP 服务器和插件钩子不受此限制。提供商和模型推理流量仍然可用。",
"阻止模型发起的命令和 HTTP 工具直接进行出站访问。受限时,本地和远程 MCP 工具均不可用。提供商流量和受信任的插件钩子不受此限制。",
"settings.sandboxing.allowedHosts.title": "允许的网络目标",
"settings.sandboxing.allowedHosts.description":
"用于沙盒化 HTTP 和 HTTPS 代理流量的 DNS 主机和端口目标。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。更改将应用于新会话。",
"settings.sandboxing.writablePaths.title": "额外可写路径",
"settings.sandboxing.writablePaths.description":
"沙盒允许写入的额外文件系统路径(例如 /tmp、/var/log)。沙盒启用后,这些路径会与默认可写路径合并。",
+4 -1
View File
@@ -1367,8 +1367,11 @@ export const dict = {
"settings.sandboxing.title": "沙盒",
"settings.sandboxing.network.title": "限制網路存取",
"settings.sandboxing.network.description":
"封鎖模型發起的命令和 HTTP 工具的對外網路存取。本機 MCP 伺服器和外掛程式鉤不受此限制。供應商與模型推論流量仍然可用。",
"阻止由模型發起的命令和 HTTP 工具直接進行對外存取。受限時,本機和遠端 MCP 工具均無法使用。提供者流量和受信任的外掛程式鉤不受此限制。",
"settings.sandboxing.allowedHosts.title": "允許的網路目的地",
"settings.sandboxing.allowedHosts.description":
"適用於沙盒 HTTP 和 HTTPS Proxy 流量的 DNS 主機與連接埠目標。GitHub CLI 和 HTTPS Git 通常需要 github.com:443 和 api.github.com:443。變更將套用至新工作階段。",
"settings.sandboxing.writablePaths.title": "額外可寫路徑",
"settings.sandboxing.writablePaths.description":
"沙盒允許寫入的額外檔案系統路徑(例如 /tmp、/var/log)。沙盒啟用後,這些路徑會與預設可寫路徑合併。",
@@ -74,6 +74,27 @@ export const SandboxingPanel: Story = {
),
}
export const SandboxingAllowlist: Story = {
name: "Settings — sandboxing with network destinations",
render: () => (
<StoryProviders
config={{
sandbox: {
enabled: true,
network: "deny",
allowed_hosts: ["github.com:443", "api.github.com:443"],
writable_paths: ["~/shared-output"],
},
}}
features={{ sandboxControls: true }}
>
<div style={{ height: "700px", display: "flex", "flex-direction": "column" }}>
<Settings tab="sandboxing" />
</div>
</StoryProviders>
),
}
export const ProvidersConfigure: Story = {
name: "ProvidersTab — no providers configured",
render: () => (
@@ -56,6 +56,7 @@ export interface SandboxConfig {
enabled?: boolean
network?: "allow" | "deny"
writable_paths?: string[]
allowed_hosts?: string[]
}
export interface CommitMessageConfig {
+9 -1
View File
@@ -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: {
+15 -6
View File
@@ -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<string, string> = {}
@@ -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<string, string> = {
@@ -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],
@@ -12,6 +12,7 @@ const cache = process.env.KILO_BWRAP_CACHE ?? path.join(os.tmpdir(), "kilo-bubbl
const capability = `#pragma once
#include <errno.h>
#include <linux/capability.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
@@ -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;
}
@@ -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}`)
}
}
@@ -47,6 +47,7 @@ function matches(request: Request, result: Result) {
export interface Interface {
readonly request: (input: Input) => Effect.Effect<Result, HostError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly cancelSession: (sessionID: Request["sessionID"]) => Effect.Effect<void>
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 })
}),
)
}
@@ -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<Target[]> =>
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<string>(family.map((target) => target.id))
const root = family.find((target) => target.id === sessionID) ?? family[0]
const groups = new Map<string, Target[]>()
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
})
@@ -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<typeof Network>
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<typeof Info>
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
@@ -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
@@ -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<string>(opaque.map((item) => item.id))
const external = new Set<string>(host.map((item) => item.id))
export const httpLayer = networkHttpLayer.pipe(Layer.provide(FetchHttpClient.layer))
@@ -27,11 +28,13 @@ export function tool<A, E, R>(value: { id: string }, effect: Effect.Effect<A, E,
if (!(Builtin in value)) {
return assertNetwork(`custom tool:${value.id}`, "executeTool").pipe(Effect.andThen(effect))
}
if (external.has(value.id)) return assertSandbox(`tool:${value.id}`, "executeTool").pipe(Effect.andThen(effect))
if (!indirect.has(value.id)) return effect
return assertNetwork(`tool:${value.id}`, "executeTool").pipe(Effect.andThen(effect))
}
export function mcp<A, E, R>(value: object, effect: Effect.Effect<A, E, R>) {
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),
)
}
+188 -48
View File
@@ -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<string, Snapshot>()
const locks = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
const gates = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
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<typeof SandboxConfig.resolve>,
): 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<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
@@ -60,6 +71,31 @@ function locked<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
)
}
function lockedAll<A, E, R>(sessions: readonly SessionID[], effect: Effect.Effect<A, E, R>) {
return [...new Set(sessions)].reduceRight((next, sessionID) => locked(sessionID, next), effect)
}
function gated<A, E, R>(sessionID: SessionID, count: number, effect: Effect.Effect<A, E, R>) {
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<A, E, R>(sessions: readonly SessionID[], effect: Effect.Effect<A, E, R>) {
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<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
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<E, R, F = never, Q = never, P = never, S = never>(
sessionID: SessionID,
guard:
| Effect.Effect<unknown, E, R>
| ((enabling: boolean, family: readonly Target[]) => Effect.Effect<unknown, E, R>),
family?: Effect.Effect<readonly Target[], F, Q>,
preflight?: (family: readonly Target[]) => Effect.Effect<unknown, P, S>,
) {
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<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>)
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<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
return change(sessionID, guard)
export function toggleGuarded<E, R, F = never, Q = never, P = never, S = never>(
sessionID: SessionID,
guard:
| Effect.Effect<unknown, E, R>
| ((enabling: boolean, family: readonly Target[]) => Effect.Effect<unknown, E, R>),
family?: Effect.Effect<readonly Target[], F, Q>,
preflight?: (family: readonly Target[]) => Effect.Effect<unknown, P, S>,
) {
return change(sessionID, guard, family, preflight)
}
export function retire<A, E, R>(
@@ -304,13 +425,32 @@ export function dispose<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A,
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
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,
)
}),
)
})
}
@@ -10,7 +10,9 @@ export namespace SandboxStore {
/** Session confinement authority captured independently from later configuration reloads. */
export type Snapshot = {
enabled: boolean
mode: Extract<Profile["network"]["mode"], "allow" | "deny">
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<string, unknown>
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<string, unknown>
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) {
@@ -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))),
),
)
}),
)
@@ -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,
+12 -1
View File
@@ -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<Draft<MessageV2.Part>[]> = 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}`)
+2 -1
View File
@@ -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
@@ -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 {
@@ -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)
@@ -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(
@@ -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)
})
@@ -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", () => {
@@ -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<void>()
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<void>()
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([])
}),
)
})
@@ -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<void>()
const release = yield* Deferred.make<void>()
const family = yield* Deferred.make<void>()
const preflight = yield* Deferred.make<void>()
const guard = yield* Deferred.make<void>()
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<void>()
const release = yield* Deferred.make<void>()
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
+3 -3
View File
@@ -202,7 +202,7 @@ function statusName(status: Record<string, MCPNS.Status> | 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)
}),
+4
View File
@@ -1593,6 +1593,10 @@ export type Config = {
* Additional filesystem paths that sandboxed tools may write to
*/
writable_paths?: Array<string>
/**
* Exact network destinations sandboxed tools may access while network restriction is enabled
*/
allowed_hosts?: Array<string>
}
model?: string
small_model?: string
+8 -11
View File
@@ -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,
+7 -4
View File
@@ -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"]
: []),