feat(sandbox): enforce macOS network isolation

This commit is contained in:
marius-kilocode
2026-06-23 20:40:13 +02:00
parent 614be76937
commit 9fbc456b75
56 changed files with 1463 additions and 34 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---
Block outbound network access from agent commands and in-process HTTP tools with the optional macOS sandbox, with a Sandboxing setting to allow network access when needed.
@@ -41,6 +41,9 @@ jobs:
- name: Check Effect Promise facade allowlist
run: bun run script/check-opencode-promise-facades.ts
- name: Check model tool network boundary
run: bun run script/check-model-tool-network.ts
- name: Check workflow allowlist
run: bun run script/check-workflows.ts
# kilocode_change end
+2 -1
View File
@@ -14,7 +14,8 @@
],
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000"
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"dependencies": {
"effect": "catalog:"
+6 -3
View File
@@ -2,6 +2,7 @@ import { Effect, PlatformError, Scope } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { current } from "./context"
import type { Profile } from "./profile"
import { assertProcessNetwork, networkEnvironment } from "./network"
import { seatbelt } from "./seatbelt"
export interface Launch {
@@ -47,9 +48,10 @@ const backend = select()
function environment(profile: Profile, launch: Launch) {
const source = { ...launch.environment, ...profile.environment.set }
const denied = new Set(profile.environment.deny)
return Object.fromEntries(
Object.entries(source).filter(([key, value]) => value !== undefined && !denied.has(key)),
) as Record<string, string>
const entries = Object.entries(source).filter(
(entry): entry is [string, string] => entry[1] !== undefined && !denied.has(entry[0]),
)
return networkEnvironment(profile, Object.fromEntries(entries))
}
export function prepare(launch: Launch) {
@@ -57,6 +59,7 @@ export function prepare(launch: Launch) {
const profile = yield* current
if (!profile) return launch
const next = { ...launch, environment: environment(profile, launch) }
yield* assertProcessNetwork(profile, launch.command)
if (!backend.support.available) return next
return yield* backend.prepare(profile, next)
})
+1
View File
@@ -1,4 +1,5 @@
export type { Profile } from "./profile"
export { assertWrite, enabled, run } from "./context"
export { decorateFileSystem } from "./filesystem"
export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network"
export { prepareCommand } from "./backend"
+89
View File
@@ -0,0 +1,89 @@
import { Effect, Layer, PlatformError } from "effect"
import { HttpClient, HttpClientError, type HttpClientRequest } from "effect/unstable/http"
import { current } from "./context"
import type { Profile } from "./profile"
const proxies = new Set([
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
])
function target(value: string) {
if (!URL.canParse(value)) return value
const url = new URL(value)
return url.origin
}
function denied(value: string, method: string) {
return PlatformError.systemError({
_tag: "PermissionDenied",
module: "Sandbox",
method,
pathOrDescriptor: target(value),
description: "Sandbox denied outbound network access",
})
}
function unsupported(value: string, method: string) {
return PlatformError.systemError({
_tag: "BadResource",
module: "Sandbox",
method,
pathOrDescriptor: target(value),
description: "Sandbox proxy network mode and allowedHosts are not supported",
})
}
function unsupportedProfile(profile: Profile) {
return profile.network.mode === "proxy" || profile.network.allowedHosts.length > 0
}
export function networkEnvironment(profile: Profile, environment: Record<string, string>) {
if (profile.network.mode === "allow" && profile.network.allowedHosts.length === 0) return environment
return Object.fromEntries(Object.entries(environment).filter(([key]) => !proxies.has(key)))
}
export function assertProcessNetwork(profile: Profile, command: string) {
if (!unsupportedProfile(profile)) return Effect.void
return Effect.fail(unsupported(command, "prepareNetwork"))
}
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))
})
}
function requestError(request: HttpClientRequest.HttpClientRequest, description: string) {
return new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request, 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))
})
}
export function decorateHttpClient(http: HttpClient.HttpClient): HttpClient.HttpClient {
return HttpClient.mapRequestEffect(http, assertRequest)
}
export const httpLayer = Layer.effect(HttpClient.HttpClient, Effect.map(HttpClient.HttpClient, decorateHttpClient))
+1 -3
View File
@@ -97,9 +97,7 @@ export const base = `(version 1)
(local-name "com.apple.cfprefsd.agent"))
(allow user-preference-read)
; network is not confined by the file-level sandbox; allow it fully
(allow network-outbound)
(allow network-inbound)
; system services required by common command-line runtimes
(allow system-socket)
(allow mach-lookup
(global-name "com.apple.bsd.dirhelper")
@@ -0,0 +1,12 @@
import type { Profile } from "./profile"
export function networkPolicy(profile: Profile) {
if (profile.network.mode === "allow") {
return "; sandbox network mode: allow\n(allow network-outbound)\n(allow network-inbound)"
}
return [
`; sandbox network mode: ${profile.network.mode}`,
'(deny network-outbound (with message "Sandbox denied outbound network access"))',
"(allow network-inbound)",
].join("\n")
}
+7 -1
View File
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import type { Backend, Launch, Support } from "./backend"
import type { PathRule, Profile } from "./profile"
import { base } from "./seatbelt-base"
import { networkPolicy } from "./seatbelt-network"
const executable = "/usr/bin/sandbox-exec"
@@ -47,7 +48,12 @@ function policy(profile: Profile) {
? ""
: `(allow file-write*\n (require-all\n (require-any ${allow.join(" ")})\n ${[...deny, ...names].join("\n ")}\n )\n)`
return {
value: [base, "; reads are not confined by the file-level sandbox\n(allow file-read*)", write].join("\n"),
value: [
base,
networkPolicy(profile),
"; reads are not confined by the file-level sandbox\n(allow file-read*)",
write,
].join("\n"),
params,
}
}
+48 -5
View File
@@ -1,18 +1,18 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Result } from "effect"
import { backendSupport, prepare, type Launch } from "../src/backend"
import { run } from "../src/context"
import type { Profile } from "../src/profile"
import { generate } from "../src/seatbelt"
function makeProfile(): Profile {
function makeProfile(mode: Profile["network"]["mode"] = "deny"): Profile {
return {
filesystem: {
allowWrite: [{ path: "/workspace", kind: "subtree" }],
denyWrite: [{ path: "/workspace/.git", kind: "subtree" }],
denyNames: [".git"],
},
network: { mode: "deny", allowedHosts: ["example.com"] },
network: { mode, allowedHosts: mode === "proxy" ? ["example.com"] : [] },
environment: { deny: ["DROP", "RESET"], set: { KEEP: "profile", RESET: "removed" } },
}
}
@@ -21,7 +21,12 @@ const launch: Launch = {
command: "/bin/echo",
args: ["hello"],
cwd: "/workspace",
environment: { KEEP: "launch", DROP: "secret" },
environment: {
KEEP: "launch",
DROP: "secret",
HTTPS_PROXY: "http://127.0.0.1:9000",
no_proxy: "*",
},
}
describe("sandbox launch preparation", () => {
@@ -33,7 +38,9 @@ describe("sandbox launch preparation", () => {
expect(policy).toContain('(require-not (subpath (param "DENY_WRITE_0")))')
expect(policy).toContain('(require-not (regex #"(^|/)\\.git(/|$)"))')
expect(policy).toContain("(allow file-read*)")
expect(policy).toContain("(allow network-outbound)")
expect(policy).toContain("sandbox network mode: deny")
expect(policy).toContain("(deny network-outbound")
expect(policy).not.toContain("(allow network-outbound)")
expect(policy).toContain("(allow network-inbound)")
expect(policy).not.toContain("/workspace/.git")
expect(result.args).toContain("-DALLOW_WRITE_0=/workspace")
@@ -41,6 +48,15 @@ describe("sandbox launch preparation", () => {
expect(result.args.slice(-3)).toEqual(["--", "/bin/echo", "hello"])
})
test("preserves unrestricted networking in allow mode", () => {
const result = generate(makeProfile("allow"), launch)
const policy = result.args[1]
expect(policy).toContain("sandbox network mode: allow")
expect(policy).toContain("(allow network-outbound)")
expect(policy).toContain("(allow network-inbound)")
expect(policy).not.toContain("(deny network-outbound")
})
test("places shell commands inside the sandbox backend", () => {
const result = generate(makeProfile(), { ...launch, command: "echo hello", args: [], shell: "/bin/zsh" })
expect(result.args.slice(-4)).toEqual(["--", "/bin/zsh", "-c", "echo hello"])
@@ -67,9 +83,36 @@ describe("sandbox launch preparation", () => {
expect(result.environment?.KEEP).toBe("profile")
expect(result.environment?.DROP).toBeUndefined()
expect(result.environment?.RESET).toBeUndefined()
expect(result.environment?.HTTPS_PROXY).toBeUndefined()
expect(result.environment?.no_proxy).toBeUndefined()
expect(result.environment?.PATH).toBeUndefined()
})
test("fails proxy mode closed before launching a process", async () => {
const result = await Effect.runPromise(
Effect.scoped(run(makeProfile("proxy"), 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")
}
})
test("fails non-empty allowedHosts closed before launching a process", async () => {
const input = makeProfile("allow")
const result = await Effect.runPromise(
Effect.scoped(run({ ...input, network: { mode: "allow", allowedHosts: ["example.com"] } }, 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")
}
})
test("reports backend support with a reason when unavailable", () => {
expect(typeof backendSupport.available).toBe("boolean")
if (!backendSupport.available) expect(backendSupport.reason?.length).toBeGreaterThan(0)
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, test } from "bun:test"
import { Effect, Result } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { run } from "../src/context"
import { assertNetwork, decorateHttpClient } from "../src/network"
import type { Profile } from "../src/profile"
function profile(mode: Profile["network"]["mode"]): Profile {
return {
filesystem: {
allowWrite: [{ path: process.cwd(), kind: "subtree" }],
denyWrite: [],
denyNames: [".git"],
},
network: { mode, allowedHosts: mode === "proxy" ? ["example.com"] : [] },
environment: { deny: [], set: {} },
}
}
function server() {
const paths: string[] = []
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const path = new URL(request.url).pathname
paths.push(path)
return new Response(path)
},
})
return { server, paths }
}
describe("sandbox in-process network capability", () => {
test("keeps concurrent allow, deny, and control-plane requests call-local", async () => {
const http = server()
try {
const effect = Effect.gen(function* () {
const raw = yield* HttpClient.HttpClient
const guarded = decorateHttpClient(raw)
return yield* Effect.all(
{
denied: run(profile("deny"), guarded.get(new URL("/denied", http.server.url))).pipe(Effect.result),
allowed: run(
profile("allow"),
Effect.flatMap(guarded.get(new URL("/allowed", http.server.url)), (response) => response.text),
),
control: run(
profile("deny"),
Effect.flatMap(raw.get(new URL("/control", http.server.url)), (response) => response.text),
),
},
{ concurrency: "unbounded" },
)
}).pipe(Effect.provide(FetchHttpClient.layer))
const result = await Effect.runPromise(effect)
expect(Result.isFailure(result.denied)).toBe(true)
if (Result.isFailure(result.denied)) {
expect(result.denied.failure.message).toContain("Sandbox denied outbound network access")
}
expect(result.allowed).toBe("/allowed")
expect(result.control).toBe("/control")
expect(http.paths.sort()).toEqual(["/allowed", "/control"])
} finally {
await http.server.stop(true)
}
})
test("fails closed when allowedHosts is set outside proxy mode", async () => {
for (const mode of ["allow", "deny"] as const) {
const input = profile(mode)
const result = await Effect.runPromise(
run(
{ ...input, network: { mode, allowedHosts: ["example.com"] } },
assertNetwork("https://example.com", "testRequest"),
).pipe(Effect.result),
)
expect(Result.isFailure(result)).toBe(true)
if (Result.isFailure(result)) {
expect(result.failure.message).toContain("proxy network mode and allowedHosts are not supported")
}
}
})
test("fails closed with a clear unsupported result for proxy mode", async () => {
const http = server()
try {
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(
Effect.result,
),
request: run(profile("proxy"), guarded.get(new URL("/proxy", http.server.url))).pipe(Effect.result),
})
}).pipe(Effect.provide(FetchHttpClient.layer)),
)
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.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([])
} finally {
await http.server.stop(true)
}
})
})
@@ -0,0 +1,257 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { prepare, type Launch } from "../src/backend"
import { run } from "../src/context"
import type { Profile } from "../src/profile"
const mac = process.platform === "darwin" ? test : test.skip
const roots: string[] = []
function profile(root: string, mode: Profile["network"]["mode"]): Profile {
return {
filesystem: {
allowWrite: [{ path: root, kind: "subtree" }],
denyWrite: [],
denyNames: [".protected"],
},
network: { mode, allowedHosts: [] },
environment: { deny: [], set: {} },
}
}
function prepareLaunch(profile: Profile, input: Launch) {
return Effect.runPromise(Effect.scoped(run(profile, prepare(input))))
}
async function launch(profile: Profile, input: Launch) {
const target = await prepareLaunch(profile, input)
const child = Bun.spawn([target.command, ...target.args], {
cwd: target.cwd,
env: target.environment,
stdout: "pipe",
stderr: "pipe",
})
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
return { code, stdout, stderr }
}
function server(hostname: string) {
let accepted = 0
const listener = Bun.listen({
hostname,
port: 0,
socket: {
open(socket) {
accepted++
socket.write("sandbox-tcp-ok")
socket.end()
},
data() {},
},
})
return {
listener,
accepted: () => accepted,
}
}
function http() {
let requests = 0
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
requests++
return new Response("sandbox-http-ok")
},
})
return { server, requests: () => requests }
}
async function root() {
const dir = await mkdtemp(join(tmpdir(), "kilo-seatbelt-network-"))
roots.push(dir)
await mkdir(join(dir, ".protected"))
return dir
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe("macOS Seatbelt network integration", () => {
mac("allows a sandboxed child to exchange loopback TCP data in allow mode", async () => {
const dir = await root()
const tcp = server("127.0.0.1")
try {
const result = await launch(profile(dir, "allow"), {
command: "/usr/bin/nc",
args: ["127.0.0.1", String(tcp.listener.port)],
cwd: dir,
})
expect(result.code).toBe(0)
expect(result.stdout).toBe("sandbox-tcp-ok")
expect(tcp.accepted()).toBe(1)
} finally {
tcp.listener.stop(true)
}
})
mac("allows a sandboxed child to listen for inbound loopback traffic in deny mode", async () => {
const dir = await root()
const probe = server("127.0.0.1")
const port = probe.listener.port
probe.listener.stop(true)
const target = await prepareLaunch(profile(dir, "deny"), {
command: "/usr/bin/nc",
args: ["-l", "127.0.0.1", String(port)],
cwd: dir,
})
const child = Bun.spawn([target.command, ...target.args], {
cwd: target.cwd,
env: target.environment,
stdout: "pipe",
stderr: "pipe",
})
const timeout = setTimeout(() => child.kill(), 5_000)
try {
const connected = await (async () => {
for (const _ of Array.from({ length: 100 })) {
const socket = await Bun.connect({
hostname: "127.0.0.1",
port,
socket: {
open(socket) {
socket.write("sandbox-inbound-ok")
socket.end()
},
data() {},
error() {},
},
}).catch(() => undefined)
if (socket) return true
await Bun.sleep(20)
}
return false
})()
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(connected).toBe(true)
expect(code).toBe(0)
expect(stdout).toBe("sandbox-inbound-ok")
expect(stderr).toBe("")
} finally {
clearTimeout(timeout)
child.kill()
}
})
mac("denies a sandboxed child loopback TCP connection with a kernel permission error", async () => {
const dir = await root()
const tcp = server("127.0.0.1")
try {
const result = await launch(profile(dir, "deny"), {
command: "/usr/bin/nc",
args: ["-v", "127.0.0.1", String(tcp.listener.port)],
cwd: dir,
})
expect(result.code).not.toBe(0)
expect(result.stderr).toContain("Operation not permitted")
expect(tcp.accepted()).toBe(0)
} finally {
tcp.listener.stop(true)
}
})
mac("enforces allow and deny modes for child HTTP requests", async () => {
const dir = await root()
const allowed = http()
const denied = http()
try {
const allow = await launch(profile(dir, "allow"), {
command: "/usr/bin/curl",
args: ["--noproxy", "*", "-fsS", allowed.server.url.toString()],
cwd: dir,
})
const deny = await launch(profile(dir, "deny"), {
command: "/usr/bin/curl",
args: ["--noproxy", "*", "-fsS", denied.server.url.toString()],
cwd: dir,
})
expect(allow.code).toBe(0)
expect(allow.stdout).toBe("sandbox-http-ok")
expect(allowed.requests()).toBe(1)
expect(deny.code).not.toBe(0)
expect(denied.requests()).toBe(0)
} finally {
await Promise.all([allowed.server.stop(true), denied.server.stop(true)])
}
})
mac("denies hostname and IPv6 loopback forms", async () => {
const dir = await root()
const ipv4 = server("127.0.0.1")
const ipv6 = server("::1")
try {
const localhost = await launch(profile(dir, "deny"), {
command: "/usr/bin/nc",
args: ["-v", "localhost", String(ipv4.listener.port)],
cwd: dir,
})
const direct = await launch(profile(dir, "deny"), {
command: "/usr/bin/nc",
args: ["-v", "::1", String(ipv6.listener.port)],
cwd: dir,
})
expect(localhost.code).not.toBe(0)
expect(localhost.stderr).toContain("Operation not permitted")
expect(direct.code).not.toBe(0)
expect(direct.stderr).toContain("Operation not permitted")
expect(ipv4.accepted()).toBe(0)
expect(ipv6.accepted()).toBe(0)
} finally {
ipv4.listener.stop(true)
ipv6.listener.stop(true)
}
})
for (const mode of ["allow", "deny"] as const) {
mac(`preserves filesystem policy in ${mode} network mode`, async () => {
const dir = await root()
const outside = join(await root(), `${mode}.txt`)
const allowed = join(dir, `${mode}.txt`)
const blocked = join(dir, ".protected", `${mode}.txt`)
const write = await launch(profile(dir, mode), {
command: "/bin/sh",
args: ["-c", 'printf allowed > "$1"', "sandbox-write", allowed],
cwd: dir,
})
const deny = await launch(profile(dir, mode), {
command: "/bin/sh",
args: ["-c", 'printf blocked > "$1"', "sandbox-write", blocked],
cwd: dir,
})
const escape = await launch(profile(dir, mode), {
command: "/bin/sh",
args: ["-c", 'printf blocked > "$1"', "sandbox-write", outside],
cwd: dir,
})
expect(write.code).toBe(0)
expect(await readFile(allowed, "utf8")).toBe("allowed")
expect(deny.code).not.toBe(0)
expect(await Bun.file(blocked).exists()).toBe(false)
expect(escape.code).not.toBe(0)
expect(await Bun.file(outside).exists()).toBe(false)
})
}
})
@@ -31,6 +31,7 @@ test.describe("settings tab accessibility", () => {
const tabs = page.getByRole("tab")
await expect(tabs).toHaveCount(NAMES.length)
await expect(page.getByRole("tab", { name: "Sandboxing" })).toHaveCount(0)
for (const name of NAMES) {
await expect(page.getByRole("tab", { name, exact: true })).toBeVisible()
}
@@ -52,4 +53,32 @@ test.describe("settings tab accessibility", () => {
await expect(models).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("tabpanel", { name: "Models" })).toBeVisible()
})
test("requires both the internal feature flag and sandbox experiment", async ({ page }) => {
await page.setViewportSize({ width: 420, height: 720 })
for (const story of ["sandbox-experiment-only", "sandbox-controls-only"]) {
await page.goto(`/iframe.html?id=settings--${story}&viewMode=story&globals=${GLOBALS}`, {
waitUntil: "load",
})
await expect(page.getByRole("tab", { name: "Sandboxing" })).toHaveCount(0)
}
})
test("shows sandboxing controls when the feature flag and experiment are enabled", async ({ page }) => {
await page.setViewportSize({ width: 420, height: 720 })
await page.goto(`/iframe.html?id=settings--sandboxing-panel&viewMode=story&globals=${GLOBALS}`, {
waitUntil: "load",
})
const tab = page.getByRole("tab", { name: "Sandboxing" })
await expect(tab).toBeVisible()
await expect(tab).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("tabpanel", { name: "Sandboxing" })).toBeVisible()
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).toBeChecked()
await page.locator('[data-slot="switch-control"]').click()
await expect(network).not.toBeChecked()
await expect(page.locator(".settings-save-bar")).toBeVisible()
})
})
@@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { visible } from "../../webview-ui/src/components/settings/sandboxing"
const features = { indexing: false, sandboxControls: false }
describe("Sandboxing settings visibility", () => {
test("requires both the internal feature flag and sandbox experiment", () => {
expect(visible(features, {})).toBe(false)
expect(visible({ ...features, sandboxControls: true }, {})).toBe(false)
expect(visible(features, { experimental: { sandbox: true } })).toBe(false)
expect(visible({ ...features, sandboxControls: true }, { experimental: { sandbox: false } })).toBe(false)
expect(visible({ ...features, sandboxControls: true }, { experimental: { sandbox: true } })).toBe(true)
})
})
@@ -0,0 +1,43 @@
import { Component, createMemo } from "solid-js"
import { Card } from "@kilocode/kilo-ui/card"
import { Switch } from "@kilocode/kilo-ui/switch"
import { useConfig } from "../../context/config"
import { useLanguage } from "../../context/language"
import SettingsRow from "./SettingsRow"
const description = "sandbox-network-description"
const SandboxingTab: Component = () => {
const { config, updateConfig } = useConfig()
const language = useLanguage()
const experimental = createMemo(() => config().experimental ?? {})
return (
<Card>
<SettingsRow
title={language.t("settings.sandboxing.network.title")}
description={language.t("settings.sandboxing.network.description")}
descriptionId={description}
last
>
<Switch
checked={experimental().sandbox_restrict_network !== false}
aria-describedby={description}
onChange={(checked) =>
updateConfig({
experimental: {
...experimental(),
sandbox_restrict_network: checked,
},
})
}
hideLabel
>
{language.t("settings.sandboxing.network.title")}
</Switch>
</SettingsRow>
</Card>
)
}
export default SandboxingTab
@@ -1,4 +1,4 @@
import { Component, createSignal, createEffect, on, Show } from "solid-js"
import { Component, createSignal, createEffect, createMemo, on, Show } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Tabs } from "@kilocode/kilo-ui/tabs"
import { Button } from "@kilocode/kilo-ui/button"
@@ -23,6 +23,8 @@ import ExperimentalTab from "./ExperimentalTab"
import LanguageTab from "./LanguageTab"
import AboutKiloCodeTab from "./AboutKiloCodeTab"
import IndexingTab from "./IndexingTab"
import SandboxingTab from "./SandboxingTab"
import * as Sandboxing from "./sandboxing"
import { useServer } from "../../context/server"
import type { MigrationSource } from "../../types/messages"
@@ -36,10 +38,11 @@ const Settings: Component<SettingsProps> = (props) => {
const server = useServer()
const language = useLanguage()
const vscode = useVSCode()
const { isDirty, saving, saveError, saveConfig, discardConfig, features } = useConfig()
const { config, loading, isDirty, saving, saveError, saveConfig, discardConfig, features } = useConfig()
const session = useSession()
const [active, setActive] = createSignal(props.tab ?? "models")
const [errorExpanded, setErrorExpanded] = createSignal(false)
const sandboxing = createMemo(() => Sandboxing.visible(features(), config()))
const busyCount = () => Object.values(session.allStatusMap()).filter((s) => s.type === "busy").length
@@ -107,6 +110,11 @@ const Settings: Component<SettingsProps> = (props) => {
onTabChange("providers")
})
createEffect(() => {
if (loading() || sandboxing() || active() !== "sandboxing") return
onTabChange("experimental")
})
const onTabChange = (tab: string) => {
setActive(tab)
props.onTabChange?.(tab)
@@ -201,6 +209,12 @@ const Settings: Component<SettingsProps> = (props) => {
<Icon name="settings-gear" />
<span class="label">{language.t("settings.experimental.title")}</span>
</Tabs.Trigger>
<Show when={sandboxing()}>
<Tabs.Trigger value="sandboxing" aria-label={language.t("settings.sandboxing.title")}>
<Icon name="shield" />
<span class="label">{language.t("settings.sandboxing.title")}</span>
</Tabs.Trigger>
</Show>
<Tabs.Trigger value="language" aria-label={language.t("settings.language.title")}>
<Icon name="speech-bubble" />
<span class="label">{language.t("settings.language.title")}</span>
@@ -266,6 +280,12 @@ const Settings: Component<SettingsProps> = (props) => {
<h3>{language.t("settings.experimental.title")}</h3>
<ExperimentalTab />
</Tabs.Content>
<Show when={sandboxing()}>
<Tabs.Content value="sandboxing">
<h3>{language.t("settings.sandboxing.title")}</h3>
<SandboxingTab />
</Tabs.Content>
</Show>
<Tabs.Content value="language">
<h3>{language.t("settings.language.title")}</h3>
<LanguageTab />
@@ -4,6 +4,7 @@ import { Component, JSX, Show } from "solid-js"
const SettingsRow: Component<{
title: string
description?: string
descriptionId?: string
tag?: () => string | undefined
last?: boolean
children: JSX.Element
@@ -32,7 +33,9 @@ const SettingsRow: Component<{
<Show when={props.tag?.()}>{(tag) => <Tag>{tag()}</Tag>}</Show>
</div>
{props.description !== null && props.description !== undefined && (
<div data-slot="settings-row-label-subtitle">{props.description}</div>
<div id={props.descriptionId} data-slot="settings-row-label-subtitle">
{props.description}
</div>
)}
</div>
<div data-slot="settings-row-input">{props.children}</div>
@@ -0,0 +1,5 @@
import type { Config, FeatureFlags } from "../../types/messages"
export function visible(features: FeatureFlags, config: Config) {
return features.sandboxControls && config.experimental?.sandbox === true
}
+4
View File
@@ -1303,6 +1303,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "اختر نموذج نسخ Kilo Gateway للإدخال الصوتي.",
"settings.experimental.continueOnDeny.title": "المتابعة عند الرفض",
"settings.experimental.continueOnDeny.description": "متابعة حلقة الوكيل عند رفض الإذن",
"settings.sandboxing.title": "العزل",
"settings.sandboxing.network.title": "تقييد الوصول إلى الشبكة",
"settings.sandboxing.network.description":
"احظر الوصول الصادر إلى الشبكة من الأوامر الصادرة عن النموذج وأدوات HTTP. تعمل خوادم MCP المحلية وخطافات المكونات الإضافية خارج هذا التقييد. تظل حركة مرور استدلال الموفّر والنموذج متاحة.",
"settings.experimental.mcpTimeout.title": "مهلة MCP (مللي ثانية)",
"settings.experimental.mcpTimeout.description": "مهلة طلبات خادم MCP بالمللي ثانية",
"settings.experimental.remote.title": "التحكم Remote",
+4
View File
@@ -1336,6 +1336,10 @@ export const dict = {
"Escolha o modelo de transcrição do Kilo Gateway para entrada de voz.",
"settings.experimental.continueOnDeny.title": "Continuar ao negar",
"settings.experimental.continueOnDeny.description": "Continuar o loop do agente quando uma permissão é negada",
"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.",
"settings.experimental.mcpTimeout.title": "Tempo limite MCP (ms)",
"settings.experimental.mcpTimeout.description": "Tempo limite para solicitações do servidor MCP em milissegundos",
"settings.experimental.remote.title": "Controle Remote",
+4
View File
@@ -1334,6 +1334,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "Odaberite Kilo Gateway model za transkripciju za glasovni unos.",
"settings.experimental.continueOnDeny.title": "Nastavi pri odbijanju",
"settings.experimental.continueOnDeny.description": "Nastavi petlju agenta kada je dozvola odbijena",
"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.",
"settings.experimental.mcpTimeout.title": "MCP istek vremena (ms)",
"settings.experimental.mcpTimeout.description": "Istek vremena za MCP server zahtjeve u milisekundama",
"settings.experimental.remote.title": "Remote kontrola",
+4
View File
@@ -1328,6 +1328,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "Vælg Kilo Gateway-transskriptionsmodellen til stemmeinput.",
"settings.experimental.continueOnDeny.title": "Fortsæt ved afvisning",
"settings.experimental.continueOnDeny.description": "Fortsæt agentløkken, når en tilladelse afvises",
"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.",
"settings.experimental.mcpTimeout.title": "MCP-timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout for MCP-serveranmodninger i millisekunder",
"settings.experimental.remote.title": "Remote-styring",
+4
View File
@@ -1353,6 +1353,10 @@ export const dict = {
"settings.experimental.continueOnDeny.title": "Bei Ablehnung fortfahren",
"settings.experimental.continueOnDeny.description":
"Agent-Schleife fortsetzen, wenn eine Berechtigung abgelehnt wird",
"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.",
"settings.experimental.mcpTimeout.title": "MCP-Zeitlimit (ms)",
"settings.experimental.mcpTimeout.description": "Zeitlimit für MCP-Server-Anfragen in Millisekunden",
"settings.experimental.remote.title": "Remote-Steuerung",
@@ -1314,6 +1314,10 @@ export const dict = {
"settings.experimental.sandbox.title": "Sandbox",
"settings.experimental.sandbox.description":
"Run agent shell commands inside an OS-level sandbox that restricts writes to the project and Kilo state directories",
"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.",
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout for MCP server requests in milliseconds",
"settings.experimental.remote.title": "Remote Control",
+4
View File
@@ -1344,6 +1344,10 @@ export const dict = {
"Elige el modelo de transcripción de Kilo Gateway para la entrada de voz.",
"settings.experimental.continueOnDeny.title": "Continuar al denegar",
"settings.experimental.continueOnDeny.description": "Continuar el bucle del agente cuando se deniega un permiso",
"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.",
"settings.experimental.mcpTimeout.title": "Tiempo de espera MCP (ms)",
"settings.experimental.mcpTimeout.description": "Tiempo de espera para solicitudes del servidor MCP en milisegundos",
"settings.experimental.remote.title": "Control Remote",
+4
View File
@@ -1360,6 +1360,10 @@ export const dict = {
"settings.experimental.continueOnDeny.title": "Continuer en cas de refus",
"settings.experimental.continueOnDeny.description":
"Continuer la boucle de l'agent lorsqu'une autorisation est refusée",
"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.",
"settings.experimental.mcpTimeout.title": "Délai MCP (ms)",
"settings.experimental.mcpTimeout.description": "Délai des requêtes du serveur MCP en millisecondes",
"settings.experimental.remote.title": "Contrôle Remote",
+4
View File
@@ -1161,6 +1161,10 @@ export const dict = {
"settings.experimental.codebaseSearch.description": "Abilita ricerca in linguaggio naturale con AI nel codebase",
"settings.experimental.continueOnDeny.title": "Continua dopo rifiuto",
"settings.experimental.continueOnDeny.description": "Continua il loop agente quando un'autorizzazione viene negata",
"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.",
"settings.experimental.mcpTimeout.title": "Timeout MCP (ms)",
"settings.experimental.mcpTimeout.description": "Timeout per richieste server MCP in millisecondi",
"settings.experimental.remote.title": "Controllo remoto",
+4
View File
@@ -1322,6 +1322,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "音声入力に使用するKilo Gateway文字起こしモデルを選択します。",
"settings.experimental.continueOnDeny.title": "拒否時に続行",
"settings.experimental.continueOnDeny.description": "権限が拒否された場合にエージェントループを続行",
"settings.sandboxing.title": "サンドボックス化",
"settings.sandboxing.network.title": "ネットワークアクセスを制限",
"settings.sandboxing.network.description":
"モデルから発行されたコマンドと HTTP ツールによる外部ネットワークアクセスをブロックします。ローカル MCP サーバーとプラグインフックは、この制限の対象外です。プロバイダーおよびモデルへの推論通信は引き続き利用できます。",
"settings.experimental.mcpTimeout.title": "MCPタイムアウト(ミリ秒)",
"settings.experimental.mcpTimeout.description": "MCPサーバーリクエストのタイムアウト(ミリ秒)",
"settings.experimental.remote.title": "Remote コントロール",
+4
View File
@@ -1316,6 +1316,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "음성 입력에 사용할 Kilo Gateway 변환 모델을 선택하세요.",
"settings.experimental.continueOnDeny.title": "거부 시 계속",
"settings.experimental.continueOnDeny.description": "권한이 거부되면 에이전트 루프 계속",
"settings.sandboxing.title": "샌드박스",
"settings.sandboxing.network.title": "네트워크 액세스 제한",
"settings.sandboxing.network.description":
"모델이 실행한 명령과 HTTP 도구의 아웃바운드 네트워크 액세스를 차단합니다. 로컬 MCP 서버와 플러그인 훅에는 이 제한이 적용되지 않습니다. 공급자 및 모델 추론 트래픽은 계속 사용할 수 있습니다.",
"settings.experimental.mcpTimeout.title": "MCP 타임아웃 (ms)",
"settings.experimental.mcpTimeout.description": "MCP 서버 요청의 타임아웃 시간 (밀리초)",
"settings.experimental.remote.title": "Remote 제어",
+4
View File
@@ -1330,6 +1330,10 @@ export const dict = {
"settings.experimental.continueOnDeny.title": "Doorgaan bij weigering",
"settings.experimental.continueOnDeny.description":
"Ga door met de agent loop wanneer een toestemming wordt geweigerd",
"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.",
"settings.experimental.mcpTimeout.title": "MCP Timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout voor MCP-serververzoeken in milliseconden",
"settings.experimental.remote.title": "Remote-bediening",
+4
View File
@@ -1290,6 +1290,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "Velg Kilo Gateway-transkripsjonsmodellen for taleinndata.",
"settings.experimental.continueOnDeny.title": "Fortsett ved avvisning",
"settings.experimental.continueOnDeny.description": "Fortsett agentløkken når en tillatelse avvises",
"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.",
"settings.experimental.mcpTimeout.title": "MCP-tidsavbrudd (ms)",
"settings.experimental.mcpTimeout.description": "Tidsavbrudd for MCP-serverforespørsler i millisekunder",
"settings.experimental.remote.title": "Remote-kontroll",
+4
View File
@@ -1291,6 +1291,10 @@ export const dict = {
"Wybierz model transkrypcji Kilo Gateway dla wprowadzania głosowego.",
"settings.experimental.continueOnDeny.title": "Kontynuuj przy odmowie",
"settings.experimental.continueOnDeny.description": "Kontynuuj pętlę agenta po odmowie uprawnienia",
"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.",
"settings.experimental.mcpTimeout.title": "Limit czasu MCP (ms)",
"settings.experimental.mcpTimeout.description": "Limit czasu żądań serwera MCP w milisekundach",
"settings.experimental.remote.title": "Sterowanie Remote",
+4
View File
@@ -1332,6 +1332,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "Выберите модель транскрипции Kilo Gateway для голосового ввода.",
"settings.experimental.continueOnDeny.title": "Продолжить при отказе",
"settings.experimental.continueOnDeny.description": "Продолжить цикл агента при отказе в разрешении",
"settings.sandboxing.title": "Изоляция в песочнице",
"settings.sandboxing.network.title": "Ограничить доступ к сети",
"settings.sandboxing.network.description":
"Блокировать исходящий доступ к сети для команд, инициированных моделью, и HTTP-инструментов. Локальные серверы MCP и хуки плагинов не подпадают под это ограничение. Трафик к провайдерам и моделям для инференса остаётся доступным.",
"settings.experimental.mcpTimeout.title": "Таймаут MCP (мс)",
"settings.experimental.mcpTimeout.description": "Таймаут запросов MCP-сервера в миллисекундах",
"settings.experimental.remote.title": "Управление Remote",
+4
View File
@@ -1312,6 +1312,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "เลือกโมเดลการถอดเสียง Kilo Gateway สำหรับการป้อนข้อมูลด้วยเสียง",
"settings.experimental.continueOnDeny.title": "ดำเนินต่อเมื่อถูกปฏิเสธ",
"settings.experimental.continueOnDeny.description": "ดำเนินลูปเอเจนต์ต่อเมื่อสิทธิ์ถูกปฏิเสธ",
"settings.sandboxing.title": "การทำงานในแซนด์บ็อกซ์",
"settings.sandboxing.network.title": "จำกัดการเข้าถึงเครือข่าย",
"settings.sandboxing.network.description":
"บล็อกการเข้าถึงเครือข่ายขาออกจากคำสั่งที่มาจากโมเดลและเครื่องมือ HTTP เซิร์ฟเวอร์ MCP ภายในเครื่องและฮุกของปลั๊กอินทำงานอยู่นอกข้อจำกัดนี้ การรับส่งข้อมูลสำหรับการอนุมานของผู้ให้บริการและโมเดลยังคงใช้งานได้",
"settings.experimental.mcpTimeout.title": "หมดเวลา MCP (มิลลิวินาที)",
"settings.experimental.mcpTimeout.description": "หมดเวลาสำหรับคำขอเซิร์ฟเวอร์ MCP เป็นมิลลิวินาที",
"settings.experimental.remote.title": "การควบคุม Remote",
+4
View File
@@ -1321,6 +1321,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "Sesli giriş için Kilo Gateway transkripsiyon modelini seçin.",
"settings.experimental.continueOnDeny.title": "Reddetme Durumunda Devam Et",
"settings.experimental.continueOnDeny.description": "Bir izin reddedildiğinde ajan döngüsüne devam et",
"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.",
"settings.experimental.mcpTimeout.title": "MCP Zaman Aşımı (ms)",
"settings.experimental.mcpTimeout.description": "MCP sunucu istekleri için milisaniye cinsinden zaman aşımı",
"settings.experimental.remote.title": "Remote Kontrolü",
+4
View File
@@ -1320,6 +1320,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "Виберіть модель транскрипції Kilo Gateway для голосового введення.",
"settings.experimental.continueOnDeny.title": "Продовжувати при відхиленні",
"settings.experimental.continueOnDeny.description": "Продовжувати цикл агента, коли дозвіл відхилено",
"settings.sandboxing.title": "Пісочниця",
"settings.sandboxing.network.title": "Обмежити доступ до мережі",
"settings.sandboxing.network.description":
"Блокуйте вихідний доступ до мережі для команд, ініційованих моделлю, та HTTP-інструментів. Локальні MCP-сервери й хуки плагінів працюють поза цим обмеженням. Трафік провайдерів та інференсу моделей залишається доступним.",
"settings.experimental.mcpTimeout.title": "Тайм-аут MCP (мс)",
"settings.experimental.mcpTimeout.description": "Тайм-аут у мілісекундах для запитів до MCP-сервера",
"settings.experimental.remote.title": "Керування Remote",
+4
View File
@@ -1290,6 +1290,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "选择用于语音输入的 Kilo Gateway 转录模型。",
"settings.experimental.continueOnDeny.title": "拒绝后继续",
"settings.experimental.continueOnDeny.description": "权限被拒绝时继续智能体循环",
"settings.sandboxing.title": "沙盒",
"settings.sandboxing.network.title": "限制网络访问",
"settings.sandboxing.network.description":
"阻止模型发起的命令和 HTTP 工具进行出站网络访问。本地 MCP 服务器和插件钩子不受此限制。提供商和模型推理流量仍然可用。",
"settings.experimental.mcpTimeout.title": "MCP 超时(毫秒)",
"settings.experimental.mcpTimeout.description": "MCP 服务器请求的超时时间(毫秒)",
"settings.experimental.remote.title": "Remote 控制",
+4
View File
@@ -1255,6 +1255,10 @@ export const dict = {
"settings.models.speechToTextModel.description": "選擇用於語音輸入的 Kilo Gateway 轉錄模型。",
"settings.experimental.continueOnDeny.title": "拒絕後繼續",
"settings.experimental.continueOnDeny.description": "權限被拒絕時繼續 Agent 迴圈",
"settings.sandboxing.title": "沙盒",
"settings.sandboxing.network.title": "限制網路存取",
"settings.sandboxing.network.description":
"封鎖模型發起的命令和 HTTP 工具的對外網路存取。本機 MCP 伺服器和外掛程式鉤子不受此限制。供應商與模型推論流量仍然可用。",
"settings.experimental.mcpTimeout.title": "MCP 逾時(毫秒)",
"settings.experimental.mcpTimeout.description": "MCP 伺服器請求的逾時時間(毫秒)",
"settings.experimental.remote.title": "Remote 控制",
@@ -41,6 +41,7 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import { resolveTemplate } from "../context/language-utils"
import type {
Config,
FeatureFlags,
KilocodeNotification,
PermissionRequest,
ProviderAuthState,
@@ -280,6 +281,7 @@ interface StoryProvidersProps {
sessionID?: string
/** When provided, injects a mock ConfigContext with this config instead of the real ConfigProvider. */
config?: Config
features?: Partial<FeatureFlags>
globalConfig?: Config
projectConfig?: Config
onConfigChange?: (config: Config) => void
@@ -295,6 +297,7 @@ interface StoryProvidersProps {
/** Wraps children with either a mock ConfigContext (when config prop is given) or the real ConfigProvider. */
const ConfigWrapper: ParentComponent<{
config?: Config
features?: Partial<FeatureFlags>
globalConfig?: Config
projectConfig?: Config
onConfigChange?: (config: Config) => void
@@ -314,8 +317,8 @@ const ConfigWrapper: ParentComponent<{
}
return {
indexing: hasIndexingPlugin(config.plugin ?? []),
sandboxControls: false,
indexing: props.features?.indexing ?? hasIndexingPlugin(config.plugin ?? []),
sandboxControls: props.features?.sandboxControls ?? false,
}
})
@@ -389,6 +392,7 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
<FeedbackProvider>
<ConfigWrapper
config={props.config}
features={props.features}
globalConfig={props.globalConfig}
projectConfig={props.projectConfig}
onConfigChange={props.onConfigChange}
@@ -51,6 +51,42 @@ export const SettingsPanel: Story = {
),
}
export const SandboxingPanel: Story = {
name: "Settings — sandboxing network restriction",
render: () => (
<StoryProviders
config={{ experimental: { sandbox: true, sandbox_restrict_network: true } }}
features={{ sandboxControls: true }}
>
<div style={{ height: "700px", display: "flex", "flex-direction": "column" }}>
<Settings tab="sandboxing" />
</div>
</StoryProviders>
),
}
export const SandboxExperimentOnly: Story = {
name: "Settings — sandbox experiment without internal controls",
render: () => (
<StoryProviders config={{ experimental: { sandbox: true } }} features={{ sandboxControls: false }}>
<div style={{ height: "700px", display: "flex", "flex-direction": "column" }}>
<Settings tab="experimental" />
</div>
</StoryProviders>
),
}
export const SandboxControlsOnly: Story = {
name: "Settings — internal controls without sandbox experiment",
render: () => (
<StoryProviders config={{ experimental: { sandbox: false } }} features={{ sandboxControls: true }}>
<div style={{ height: "700px", display: "flex", "flex-direction": "column" }}>
<Settings tab="experimental" />
</div>
</StoryProviders>
),
}
export const ProvidersConfigure: Story = {
name: "ProvidersTab — no providers configured",
render: () => (
@@ -45,6 +45,7 @@ export interface ExperimentalConfig {
continue_loop_on_deny?: boolean
mcp_timeout?: number
sandbox?: boolean
sandbox_restrict_network?: boolean
}
export interface CommitMessageConfig {
+6 -1
View File
@@ -405,7 +405,12 @@ export const Info = Schema.Struct({
}),
// kilocode_change start
sandbox: Schema.optional(Schema.Boolean).annotate({
description: "Run agent shell commands inside an OS-level sandbox that restricts writes to the project and Kilo state directories",
description:
"Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access",
}),
sandbox_restrict_network: Schema.optional(Schema.Boolean).annotate({
description:
"Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)",
}),
// kilocode_change end
mcp_timeout: Schema.optional(PositiveInt).annotate({
@@ -0,0 +1,36 @@
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { assertNetwork, networkHttpLayer } from "@kilocode/sandbox"
const Builtin = Symbol("kilo.sandbox.builtinTool")
const Remote = Symbol("kilo.sandbox.remoteMcp")
const indirect = new Set(["codebase_search", "semantic_search", "lsp"])
export const httpLayer = networkHttpLayer.pipe(Layer.provide(FetchHttpClient.layer))
export function builtin<A extends object>(value: A): A {
if (!(Builtin in value)) Object.defineProperty(value, Builtin, { value: true })
return value
}
export function isBuiltin(value: object) {
return Builtin in value
}
export function remote<A extends object>(value: A): A {
Object.defineProperty(value, Remote, { value: true })
return value
}
export function tool<A, E, R>(value: { id: string }, effect: Effect.Effect<A, E, R>) {
if (!(Builtin in value)) {
return assertNetwork(`custom 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))
}
@@ -6,6 +6,7 @@ import { run as runSandbox, type Profile } from "@kilocode/sandbox"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import type { InstanceContext } from "@/project/instance-context"
import * as Network from "./network"
function root(path: string) {
return { path, kind: "subtree" as const }
@@ -41,7 +42,7 @@ function isolated(ctx: InstanceContext) {
return linked(path.resolve(ctx.directory), path.resolve(ctx.worktree))
}
export function profile(ctx: InstanceContext): Profile {
export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] = "deny"): Profile {
const project = isolated(ctx)
? [ctx.directory]
: ctx.directory === ctx.worktree
@@ -66,7 +67,7 @@ export function profile(ctx: InstanceContext): Profile {
temporaryDirectory: Global.Path.tmp,
},
network: {
mode: "allow",
mode,
allowedHosts: [],
},
environment: {
@@ -85,6 +86,15 @@ export function execute<A, E, R>(effect: Effect.Effect<A, E, R>) {
const config = yield* Config.Service
const cfg = yield* config.get()
if (!cfg.experimental?.sandbox) return yield* effect
return yield* runSandbox(profile(yield* InstanceState.context), effect)
const mode = cfg.experimental.sandbox_restrict_network === false ? "allow" : "deny"
return yield* runSandbox(profile(yield* InstanceState.context, mode), effect)
})
}
export function executeTool<A, E, R>(tool: { id: string }, effect: Effect.Effect<A, E, R>) {
return execute(Network.tool(tool, effect))
}
export function executeMcp<A, E, R>(tool: object, effect: Effect.Effect<A, E, R>) {
return execute(Network.mcp(tool, effect))
}
+6 -1
View File
@@ -40,6 +40,7 @@ import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change
const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000
@@ -718,7 +719,11 @@ export const layer = Layer.effect(
const timeout = entry?.timeout ?? defaultTimeout
for (const mcpTool of listed) {
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
// kilocode_change start
const tool = convertMcpTool(mcpTool, client, timeout)
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] =
entry?.type === "remote" ? SandboxNetwork.remote(tool) : tool
// kilocode_change end
}
}),
{ concurrency: "unbounded" },
+10 -5
View File
@@ -90,7 +90,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
{ args },
)
// kilocode_change start
const result = yield* SandboxPolicy.execute(item.execute(args, ctx))
const result = yield* SandboxPolicy.executeTool(item, item.execute(args, ctx))
// kilocode_change end
const output = {
...result,
@@ -132,10 +132,15 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
{ args },
)
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
}).pipe(
// kilocode_change start
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* SandboxPolicy.executeMcp(
item,
Effect.gen(function* () {
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
return yield* Effect.promise(() => execute(args, opts))
}),
).pipe(
// kilocode_change end
Effect.withSpan("Tool.execute", {
attributes: {
"tool.name": key,
+17 -5
View File
@@ -39,7 +39,7 @@ import { Glob } from "@opencode-ai/core/util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { HttpClient } from "effect/unstable/http" // kilocode_change
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
@@ -60,6 +60,7 @@ import { SessionStatus } from "@/session/status" // kilocode_change
import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import * as ToolNetwork from "@/kilocode/sandbox/network" // kilocode_change
const log = Log.create({ service: "tool.registry" })
@@ -310,7 +311,7 @@ export const layer: Layer.Layer<
const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
const s = yield* InstanceState.get(state)
return [...s.builtin, ...s.custom] as Tool.Def[]
return [...s.builtin.map(ToolNetwork.builtin), ...s.custom] as Tool.Def[] // kilocode_change
})
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
@@ -382,7 +383,8 @@ export const layer: Layer.Layer<
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
? output.jsonSchema
: undefined
return {
// kilocode_change start
const result = {
id: tool.id,
description: [
output.description,
@@ -396,6 +398,8 @@ export const layer: Layer.Layer<
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
return ToolNetwork.isBuiltin(tool) ? ToolNetwork.builtin(result) : result
// kilocode_change end
}),
{ concurrency: "unbounded" },
)
@@ -429,10 +433,18 @@ export const defaultLayer = Layer.suspend(
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(ToolNetwork.httpLayer), // kilocode_change
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
// kilocode_change start
Layer.provide(
Ripgrep.layer.pipe(
Layer.provide(ToolNetwork.httpLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
),
),
// kilocode_change end
Layer.provide(Truncate.defaultLayer),
)
// kilocode_change start - provide Kilo-owned registry dependencies
@@ -0,0 +1,78 @@
import { Cause, Effect, Exit, Layer } from "effect"
import { expect } from "bun:test"
import { HttpClient } from "effect/unstable/http"
import { ProjectID } from "@/project/schema"
import { InstanceRef } from "@/effect/instance-ref"
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
import * as ToolNetwork from "@/kilocode/sandbox/network"
import { TestConfig } from "../../fixture/config"
import { testEffect } from "../../lib/effect"
const ctx = {
directory: process.cwd(),
worktree: process.cwd(),
project: {
id: ProjectID.make("sandbox-config-network"),
worktree: process.cwd(),
vcs: "git" as const,
time: { created: 0, updated: 0 },
sandboxes: [],
},
}
function layer(restrict?: boolean) {
return Layer.mergeAll(
ToolNetwork.httpLayer,
TestConfig.layer({
get: () =>
Effect.succeed({
experimental: {
sandbox: true,
sandbox_restrict_network: restrict,
},
}),
}),
)
}
function server() {
let requests = 0
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch() {
requests++
return new Response("sandbox-config-ok")
},
})
return { server, requests: () => requests }
}
const restricted = testEffect(layer())
const open = testEffect(layer(false))
restricted.live("keeps network restriction enabled by default", () => {
const target = server()
return Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const exit = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe(
Effect.provideService(InstanceRef, ctx),
Effect.exit,
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Sandbox denied outbound network access")
expect(target.requests()).toBe(0)
}).pipe(Effect.ensuring(Effect.promise(() => target.server.stop(true))))
})
open.live("allows tool network traffic when network restriction is disabled", () => {
const target = server()
return Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const response = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe(
Effect.provideService(InstanceRef, ctx),
)
expect(yield* response.text).toBe("sandbox-config-ok")
expect(target.requests()).toBe(1)
}).pipe(Effect.ensuring(Effect.promise(() => target.server.stop(true))))
})
@@ -0,0 +1,132 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import { HttpClient } from "effect/unstable/http"
import { run, type Profile } from "@kilocode/sandbox"
import { Agent } from "@/agent/agent"
import * as ToolNetwork from "@/kilocode/sandbox/network"
import { MessageID, SessionID } from "@/session/schema"
import * as McpWebSearch from "@/tool/mcp-websearch"
import { Tool } from "@/tool/tool"
import { Truncate } from "@/tool/truncate"
import { WebFetchTool } from "@/tool/webfetch"
import { testEffect } from "../../lib/effect"
const layer = Layer.mergeAll(ToolNetwork.httpLayer, Truncate.defaultLayer, Agent.defaultLayer)
const it = testEffect(layer)
const ctx = {
sessionID: SessionID.make("ses_sandbox_network"),
messageID: MessageID.make("msg_sandbox_network"),
callID: "call_sandbox_network",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
function profile(mode: Profile["network"]["mode"]): Profile {
return {
filesystem: {
allowWrite: [{ path: process.cwd(), kind: "subtree" }],
denyWrite: [],
denyNames: [".git"],
},
network: { mode, allowedHosts: [] },
environment: { deny: [], set: {} },
}
}
function serve(fetch: (request: Request) => Response) {
return Effect.acquireRelease(
Effect.sync(() => Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })),
(server) => Effect.promise(() => server.stop(true)),
)
}
const webfetch = Effect.fn("SandboxHttpToolsTest.webfetch")(function* (
args: Tool.InferParameters<typeof WebFetchTool>,
) {
const info = yield* WebFetchTool
const tool = yield* info.init()
return yield* tool.execute(args, ctx)
})
const websearch = (http: HttpClient.HttpClient, url: string) =>
McpWebSearch.call(
http,
url,
"web_search_exa",
McpWebSearch.SearchArgs,
{ query: "sandbox", type: "auto", numResults: 1, livecrawl: "fallback" },
"5 seconds",
)
describe("model HTTP tool network policy", () => {
it.instance("allows the actual webfetch tool under an allow profile", () =>
Effect.gen(function* () {
const http = yield* serve(
() => new Response("allowed tool request", { headers: { "content-type": "text/plain" } }),
)
const result = yield* run(
profile("allow"),
webfetch({ url: new URL("/allowed", http.url).toString(), format: "text" }),
)
expect(result.output).toBe("allowed tool request")
}).pipe(Effect.scoped),
)
it.instance("denies the actual webfetch tool before it reaches the server", () => {
let requests = 0
return Effect.gen(function* () {
const http = yield* serve(() => {
requests++
return new Response("unexpected")
})
const exit = yield* Effect.exit(
run(profile("deny"), webfetch({ url: new URL("/denied", http.url).toString(), format: "text" })),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("Sandbox denied outbound network access")
}
expect(requests).toBe(0)
}).pipe(Effect.scoped)
})
it.instance("allows the websearch provider helper under an allow profile", () =>
Effect.gen(function* () {
const payload = JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text: "local search results" }] },
})
const server = yield* serve(() => new Response(payload))
const http = yield* HttpClient.HttpClient
const result = yield* run(profile("allow"), websearch(http, server.url.toString()))
expect(result).toBe("local search results")
}).pipe(Effect.scoped),
)
it.instance("denies the websearch provider helper before it reaches the server", () => {
let requests = 0
return Effect.gen(function* () {
const payload = JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content: [{ type: "text", text: "unexpected" }] },
})
const server = yield* serve(() => {
requests++
return new Response(payload)
})
const http = yield* HttpClient.HttpClient
const exit = yield* Effect.exit(run(profile("deny"), websearch(http, server.url.toString())))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("Sandbox denied outbound network access")
}
expect(requests).toBe(0)
}).pipe(Effect.scoped)
})
})
@@ -0,0 +1,141 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import { run, type Profile } from "@kilocode/sandbox"
import * as Network from "@/kilocode/sandbox/network"
import { it } from "../../lib/effect"
function profile(mode: Profile["network"]["mode"]): Profile {
return {
filesystem: {
allowWrite: [{ path: process.cwd(), kind: "subtree" }],
denyWrite: [],
denyNames: [".git"],
},
network: { mode, allowedHosts: [] },
environment: { deny: [], set: {} },
}
}
describe("model network boundaries", () => {
it.effect("rejects MCP delegated authority without invoking it in deny mode", () =>
Effect.gen(function* () {
let called = false
const exit = yield* Effect.exit(
run(
profile("deny"),
Network.mcp(
Network.remote({}),
Effect.sync(() => {
called = true
}),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
expect(Cause.pretty(exit.cause)).toContain("Sandbox denied outbound network access")
expect(Cause.pretty(exit.cause)).toContain("remote MCP delegated authority")
}
expect(called).toBe(false)
}),
)
it.effect("allows MCP delegated authority in allow mode", () =>
Effect.gen(function* () {
let called = false
yield* run(
profile("allow"),
Network.mcp(
Network.remote({}),
Effect.sync(() => {
called = true
}),
),
)
expect(called).toBe(true)
}),
)
it.effect("keeps local MCP tools outside remote delegated-authority policy", () =>
Effect.gen(function* () {
let called = false
yield* run(
profile("deny"),
Network.mcp(
{},
Effect.sync(() => {
called = true
}),
),
)
expect(called).toBe(true)
}),
)
it.effect("keeps classified non-network built-in tools available in deny mode", () =>
Effect.gen(function* () {
let called = false
yield* run(
profile("deny"),
Network.tool(
Network.builtin({ id: "read" }),
Effect.sync(() => {
called = true
}),
),
)
expect(called).toBe(true)
}),
)
it.effect("fails closed before opaque network helper tools run", () =>
Effect.gen(function* () {
let called = false
const exit = yield* Effect.exit(
run(
profile("deny"),
Network.tool(
Network.builtin({ id: "codebase_search" }),
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(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch: () => {
requests++
return new Response("unexpected")
},
}),
),
(server) =>
Effect.gen(function* () {
const exit = yield* Effect.exit(
run(
profile("deny"),
Network.tool(
{ id: "custom_network_tool" },
Effect.promise(() => fetch(server.url)),
),
),
)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("custom tool:custom_network_tool")
expect(requests).toBe(0)
}),
(server) => Effect.promise(() => server.stop(true)),
)
})
})
@@ -181,6 +181,15 @@ describe("sandbox policy", () => {
expect(profile(ctx).filesystem.temporaryDirectory).toBe(Global.Path.tmp)
})
test("uses deny-by-default and configurable network profiles", async () => {
await using tmp = await fixture()
const dirs = tmp.extra
const ctx = context(dirs.a, dirs.a, dirs)
expect(profile(ctx).network).toEqual({ mode: "deny", allowedHosts: [] })
expect(profile(ctx, "allow").network).toEqual({ mode: "allow", allowedHosts: [] })
})
test("keeps .git denied inside overlapping writable roots", async () => {
await using tmp = await fixture()
const dirs = tmp.extra
@@ -13,6 +13,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import { InstanceRef } from "@/effect/instance-ref"
import { Format } from "@/format"
import { LSP } from "@/lsp/lsp"
import * as ToolNetwork from "@/kilocode/sandbox/network"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { ProjectID } from "@/project/schema"
@@ -139,7 +140,7 @@ const registry = Layer.effect(
Effect.gen(function* () {
const write = yield* WriteTool.pipe(Effect.flatMap(Tool.init))
const shell = yield* ShellTool.pipe(Effect.flatMap(Tool.init))
const list = [write, shell]
const list = [ToolNetwork.builtin(write), ToolNetwork.builtin(shell)]
return ToolRegistry.Service.of({
ids: () => Effect.succeed(list.map((item) => item.id)),
all: () => Effect.succeed(list),
@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Plugin } from "@/plugin"
import { Agent } from "@/agent/agent"
import { ShellTool } from "@/tool/shell"
import { Truncate } from "@/tool/truncate"
import { MessageID, SessionID } from "@/session/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { run as runSandbox, type Profile } from "@kilocode/sandbox"
import { provideInstance, tmpdirScoped } from "../../fixture/fixture"
const layer = Layer.mergeAll(
CrossSpawnSpawner.defaultLayer,
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
Agent.defaultLayer,
RuntimeFlags.defaultLayer,
)
const ctx = {
sessionID: SessionID.make("ses_sandbox_network"),
messageID: MessageID.make("msg_sandbox_network"),
callID: "call_sandbox_network",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
function profile(root: string, mode: Profile["network"]["mode"]): Profile {
return {
filesystem: {
allowWrite: [{ path: root, kind: "subtree" }],
denyWrite: [],
denyNames: [".git"],
},
network: { mode, allowedHosts: [] },
environment: { deny: [], set: {} },
}
}
function server() {
let accepted = 0
const listener = Bun.listen({
hostname: "127.0.0.1",
port: 0,
socket: {
open(socket) {
accepted++
socket.write("model-shell-network-ok")
socket.end()
},
data() {},
},
})
return { listener, accepted: () => accepted }
}
const execute = Effect.fn("ShellNetworkTest.execute")(function* (
root: string,
mode: Profile["network"]["mode"],
port: number,
) {
const info = yield* ShellTool
const shell = yield* info.init()
return yield* runSandbox(profile(root, mode), shell.execute({ command: `/usr/bin/nc -v 127.0.0.1 ${port}` }, ctx))
})
describe("model shell network integration", () => {
test.skipIf(process.platform !== "darwin")(
"enforces allow and deny profiles through the actual shell tool and process spawner",
async () => {
const effect = Effect.gen(function* () {
const root = yield* tmpdirScoped()
const allowed = server()
const denied = server()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
allowed.listener.stop(true)
denied.listener.stop(true)
}),
)
const allow = yield* execute(root, "allow", allowed.listener.port).pipe(provideInstance(root))
const deny = yield* execute(root, "deny", denied.listener.port).pipe(provideInstance(root))
expect(allow.output).toContain("model-shell-network-ok")
expect(allow.metadata.exit).toBe(0)
expect(allowed.accepted()).toBe(1)
expect(deny.output).toContain("Operation not permitted")
expect(deny.metadata.exit).not.toBe(0)
expect(denied.accepted()).toBe(0)
})
await Effect.runPromise(Effect.scoped(effect.pipe(Effect.provide(layer))))
},
)
})
+1
View File
@@ -1541,6 +1541,7 @@ export type Config = {
primary_tools?: Array<string>
continue_loop_on_deny?: boolean
sandbox?: boolean
sandbox_restrict_network?: boolean
mcp_timeout?: number
policies?: Array<ConfigV2ExperimentalPolicy>
}
+8
View File
@@ -21014,6 +21014,14 @@
"continue_loop_on_deny": {
"type": "boolean"
},
"sandbox": {
"type": "boolean",
"description": "Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access"
},
"sandbox_restrict_network": {
"type": "boolean",
"description": "Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)"
},
"mcp_timeout": {
"type": "integer",
"exclusiveMinimum": 0
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bun
// kilocode_change - new file
import path from "node:path"
const root = path.resolve(import.meta.dir, "..")
const source = path.join(root, "packages", "opencode", "src")
const dirs = ["tool", "kilocode/tool"]
const checks = [
{ name: "direct fetch", pattern: /\b(?:globalThis\.)?fetch\s*\(/g },
{ name: "raw FetchHttpClient layer", pattern: /\bFetchHttpClient\.layer\b/g },
{ name: "direct Bun socket", pattern: /\bBun\.(?:connect|udpSocket)\s*\(/g },
{
name: "raw network module",
pattern:
/\bfrom\s+["'](?:(?:node:)?(?:http|https|http2|net|tls|dgram)(?:\/[^"']*)?|(?:undici|axios|got)(?:\/[^"']*)?)["']/g,
},
{
name: "dynamic network module",
pattern:
/\b(?:require|import)\s*\(\s*["'](?:(?:node:)?(?:http|https|http2|net|tls|dgram)(?:\/[^"']*)?|(?:undici|axios|got)(?:\/[^"']*)?)["']/g,
},
{
name: "ad hoc network client",
pattern:
/\bnew\s+(?:WarpGrepClient|OpenAI|QdrantClient|BedrockRuntimeClient|WebSocket|EventSource|StreamableHTTPClientTransport|SSEClientTransport)\s*\(/g,
},
]
const allow: Record<string, { count: number; reason: string }> = {
"tool/warpgrep.ts:ad hoc network client": {
count: 1,
reason: "opaque SDK traffic is denied by the common executeTool network boundary",
},
}
const hits: Array<{ file: string; name: string; line: number }> = []
const glob = new Bun.Glob("**/*.ts")
for (const dir of dirs) {
for (const file of glob.scanSync({ cwd: path.join(source, dir), onlyFiles: true })) {
const rel = path.posix.join(dir, file.replaceAll("\\", "/"))
const text = await Bun.file(path.join(source, rel)).text()
for (const check of checks) {
for (const match of text.matchAll(check.pattern)) {
hits.push({
file: rel,
name: check.name,
line: text.slice(0, match.index ?? 0).split("\n").length,
})
}
}
}
}
const invalid = hits.filter((hit) => !allow[`${hit.file}:${hit.name}`])
const drift = Object.entries(allow).flatMap(([key, entry]) => {
const split = key.lastIndexOf(":")
const file = key.slice(0, split)
const name = key.slice(split + 1)
const count = hits.filter((hit) => hit.file === file && hit.name === name).length
if (count === entry.count) return []
return [` packages/opencode/src/${file}: expected ${entry.count} ${name} site(s), found ${count} (${entry.reason})`]
})
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 = [
...(!registry.includes("Layer.provide(ToolNetwork.httpLayer)")
? [" tool/registry.ts must provide the policy-aware ToolNetwork HTTP layer"]
: []),
...(registry.includes("FetchHttpClient.layer")
? [" tool/registry.ts must not provide a raw FetchHttpClient layer"]
: []),
...(!registry.includes("ToolNetwork.builtin(result)")
? [" tool/registry.ts must distinguish built-in tools from untrusted custom tools"]
: []),
...(!session.includes("SandboxPolicy.executeTool(item,")
? [" session/tools.ts must route built-in and custom tools through executeTool"]
: []),
...(!mcp.includes("SandboxNetwork.remote(tool)")
? [" mcp/index.ts must classify remote MCP delegated authority"]
: []),
...(!session.includes("SandboxPolicy.executeMcp(")
? [" session/tools.ts must route MCP delegated authority through executeMcp"]
: []),
]
if (invalid.length > 0 || drift.length > 0 || structure.length > 0) {
if (invalid.length > 0) {
console.error("Found model-tool network clients that bypass the sandbox capability:")
for (const hit of invalid) console.error(` packages/opencode/src/${hit.file}:${hit.line} (${hit.name})`)
console.error("")
}
if (drift.length > 0) {
console.error("Classified model-tool network exceptions no longer match source:")
for (const item of drift) console.error(item)
console.error("")
}
if (structure.length > 0) {
console.error("Model-tool network boundary wiring is incomplete:")
for (const item of structure) console.error(item)
console.error("")
}
console.error(
"Use the @kilocode/sandbox network capability or classify an opaque client at the common tool boundary.",
)
process.exit(1)
}
console.log(
`check-model-tool-network: ${hits.length} classified client site(s), policy-aware tool and MCP boundaries verified.`,
)