From 9fbc456b75887ee314c339bc1eba7decba79c6c0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 20:40:13 +0200 Subject: [PATCH] feat(sandbox): enforce macOS network isolation --- .changeset/macos-network-sandbox.md | 6 + .../workflows/check-opencode-annotations.yml | 3 + packages/kilo-sandbox/package.json | 3 +- packages/kilo-sandbox/src/backend.ts | 9 +- packages/kilo-sandbox/src/index.ts | 1 + packages/kilo-sandbox/src/network.ts | 89 ++++++ packages/kilo-sandbox/src/seatbelt-base.ts | 4 +- packages/kilo-sandbox/src/seatbelt-network.ts | 12 + packages/kilo-sandbox/src/seatbelt.ts | 8 +- packages/kilo-sandbox/test/backend.test.ts | 53 +++- packages/kilo-sandbox/test/network.test.ts | 117 ++++++++ .../test/seatbelt-network.test.ts | 257 ++++++++++++++++++ .../tests/settings-accessibility.spec.ts | 29 ++ .../tests/unit/sandboxing-settings.test.ts | 14 + .../src/components/settings/SandboxingTab.tsx | 43 +++ .../src/components/settings/Settings.tsx | 24 +- .../src/components/settings/SettingsRow.tsx | 5 +- .../src/components/settings/sandboxing.ts | 5 + .../kilo-vscode/webview-ui/src/i18n/ar.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 4 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 4 + .../webview-ui/src/stories/StoryProviders.tsx | 8 +- .../src/stories/settings.stories.tsx | 36 +++ .../webview-ui/src/types/messages/config.ts | 1 + packages/opencode/src/config/config.ts | 7 +- .../opencode/src/kilocode/sandbox/network.ts | 36 +++ .../opencode/src/kilocode/sandbox/policy.ts | 16 +- packages/opencode/src/mcp/index.ts | 7 +- packages/opencode/src/session/tools.ts | 15 +- packages/opencode/src/tool/registry.ts | 22 +- .../kilocode/sandbox/config-network.test.ts | 78 ++++++ .../test/kilocode/sandbox/http-tools.test.ts | 132 +++++++++ .../test/kilocode/sandbox/network.test.ts | 141 ++++++++++ .../test/kilocode/sandbox/policy.test.ts | 9 + .../kilocode/sandbox/session-tools.test.ts | 3 +- .../kilocode/sandbox/shell-network.test.ts | 103 +++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + packages/sdk/openapi.json | 8 + script/check-model-tool-network.ts | 112 ++++++++ 56 files changed, 1463 insertions(+), 34 deletions(-) create mode 100644 .changeset/macos-network-sandbox.md create mode 100644 packages/kilo-sandbox/src/network.ts create mode 100644 packages/kilo-sandbox/src/seatbelt-network.ts create mode 100644 packages/kilo-sandbox/test/network.test.ts create mode 100644 packages/kilo-sandbox/test/seatbelt-network.test.ts create mode 100644 packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx create mode 100644 packages/kilo-vscode/webview-ui/src/components/settings/sandboxing.ts create mode 100644 packages/opencode/src/kilocode/sandbox/network.ts create mode 100644 packages/opencode/test/kilocode/sandbox/config-network.test.ts create mode 100644 packages/opencode/test/kilocode/sandbox/http-tools.test.ts create mode 100644 packages/opencode/test/kilocode/sandbox/network.test.ts create mode 100644 packages/opencode/test/kilocode/sandbox/shell-network.test.ts create mode 100644 script/check-model-tool-network.ts diff --git a/.changeset/macos-network-sandbox.md b/.changeset/macos-network-sandbox.md new file mode 100644 index 0000000000..d36fc0c2c7 --- /dev/null +++ b/.changeset/macos-network-sandbox.md @@ -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. diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index e30997d848..f494bbb4c8 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -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 diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index 1d90567afc..2bf4ea60d1 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -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:" diff --git a/packages/kilo-sandbox/src/backend.ts b/packages/kilo-sandbox/src/backend.ts index 1b46971e1d..74c8564235 100644 --- a/packages/kilo-sandbox/src/backend.ts +++ b/packages/kilo-sandbox/src/backend.ts @@ -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 + 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) }) diff --git a/packages/kilo-sandbox/src/index.ts b/packages/kilo-sandbox/src/index.ts index 2c57b2221b..1484f49443 100644 --- a/packages/kilo-sandbox/src/index.ts +++ b/packages/kilo-sandbox/src/index.ts @@ -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" diff --git a/packages/kilo-sandbox/src/network.ts b/packages/kilo-sandbox/src/network.ts new file mode 100644 index 0000000000..5cd29117f3 --- /dev/null +++ b/packages/kilo-sandbox/src/network.ts @@ -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) { + 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)) diff --git a/packages/kilo-sandbox/src/seatbelt-base.ts b/packages/kilo-sandbox/src/seatbelt-base.ts index 6478064cc3..a439d94611 100644 --- a/packages/kilo-sandbox/src/seatbelt-base.ts +++ b/packages/kilo-sandbox/src/seatbelt-base.ts @@ -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") diff --git a/packages/kilo-sandbox/src/seatbelt-network.ts b/packages/kilo-sandbox/src/seatbelt-network.ts new file mode 100644 index 0000000000..3b742d6b88 --- /dev/null +++ b/packages/kilo-sandbox/src/seatbelt-network.ts @@ -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") +} diff --git a/packages/kilo-sandbox/src/seatbelt.ts b/packages/kilo-sandbox/src/seatbelt.ts index 40b2a7fd2e..2c01b64e06 100644 --- a/packages/kilo-sandbox/src/seatbelt.ts +++ b/packages/kilo-sandbox/src/seatbelt.ts @@ -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, } } diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts index 5cd9266800..9646f96a04 100644 --- a/packages/kilo-sandbox/test/backend.test.ts +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -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) diff --git a/packages/kilo-sandbox/test/network.test.ts b/packages/kilo-sandbox/test/network.test.ts new file mode 100644 index 0000000000..380fdc758f --- /dev/null +++ b/packages/kilo-sandbox/test/network.test.ts @@ -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) + } + }) +}) diff --git a/packages/kilo-sandbox/test/seatbelt-network.test.ts b/packages/kilo-sandbox/test/seatbelt-network.test.ts new file mode 100644 index 0000000000..bb2eb4dead --- /dev/null +++ b/packages/kilo-sandbox/test/seatbelt-network.test.ts @@ -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) + }) + } +}) diff --git a/packages/kilo-vscode/tests/settings-accessibility.spec.ts b/packages/kilo-vscode/tests/settings-accessibility.spec.ts index e9c93e86e3..d6c26749fa 100644 --- a/packages/kilo-vscode/tests/settings-accessibility.spec.ts +++ b/packages/kilo-vscode/tests/settings-accessibility.spec.ts @@ -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() + }) }) diff --git a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts new file mode 100644 index 0000000000..08ce002d0e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx new file mode 100644 index 0000000000..04aa30a7e5 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SandboxingTab.tsx @@ -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 ( + + + + updateConfig({ + experimental: { + ...experimental(), + sandbox_restrict_network: checked, + }, + }) + } + hideLabel + > + {language.t("settings.sandboxing.network.title")} + + + + ) +} + +export default SandboxingTab diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index d4c4027462..2ec4c32d94 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -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 = (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 = (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 = (props) => { {language.t("settings.experimental.title")} + + + + {language.t("settings.sandboxing.title")} + + {language.t("settings.language.title")} @@ -266,6 +280,12 @@ const Settings: Component = (props) => {

{language.t("settings.experimental.title")}

+ + +

{language.t("settings.sandboxing.title")}

+ +
+

{language.t("settings.language.title")}

diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx index 5596527526..f913d9426c 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/SettingsRow.tsx @@ -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<{ {(tag) => {tag()}} {props.description !== null && props.description !== undefined && ( -
{props.description}
+
+ {props.description} +
)}
{props.children}
diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/sandboxing.ts b/packages/kilo-vscode/webview-ui/src/components/settings/sandboxing.ts new file mode 100644 index 0000000000..023549e77d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/settings/sandboxing.ts @@ -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 +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 718be10a2d..b6c5a18655 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index d4927279f6..78a2294ffd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index c8be77708b..4fb516fcac 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index c7a4449fbd..92ee09fdd3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index d66810c269..03441e4f5b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 6891f32621..f3996d1581 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 407eddc52f..8ad90a3577 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 4dff72678d..656e647fb7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index a150563f9d..9ae8414475 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 15b1a84bfc..9ec27bf703 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -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 コントロール", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 646b77c8e8..a26e3306f6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -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 제어", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index f2ee0a05a1..ad3a5a966d 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index acee5b27a1..fddcc85e4b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 0b5a3b3e33..f7db244072 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index b34d18c94c..6990d1f7e7 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 8faa0d02b1..6b917cc042 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 5a17670584..462b1187da 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -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ü", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index f555524280..3fd1915ba1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index d88febb867..c1414a9f14 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -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 控制", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index d639535bc4..597f38049a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -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 控制", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 441dbbdd9a..4e9c67b005 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -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 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 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 = (props) => { ( + +
+ +
+
+ ), +} + +export const SandboxExperimentOnly: Story = { + name: "Settings — sandbox experiment without internal controls", + render: () => ( + +
+ +
+
+ ), +} + +export const SandboxControlsOnly: Story = { + name: "Settings — internal controls without sandbox experiment", + render: () => ( + +
+ +
+
+ ), +} + export const ProvidersConfigure: Story = { name: "ProvidersTab — no providers configured", render: () => ( diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 078ff799c0..65ce65617e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -45,6 +45,7 @@ export interface ExperimentalConfig { continue_loop_on_deny?: boolean mcp_timeout?: number sandbox?: boolean + sandbox_restrict_network?: boolean } export interface CommitMessageConfig { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d433b1c3ba..b974767433 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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({ diff --git a/packages/opencode/src/kilocode/sandbox/network.ts b/packages/opencode/src/kilocode/sandbox/network.ts new file mode 100644 index 0000000000..dc6e3efe56 --- /dev/null +++ b/packages/opencode/src/kilocode/sandbox/network.ts @@ -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(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(value: A): A { + Object.defineProperty(value, Remote, { value: true }) + return value +} + +export function tool(value: { id: string }, effect: Effect.Effect) { + 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(value: object, effect: Effect.Effect) { + if (!(Remote in value)) return effect + return assertNetwork("remote MCP delegated authority", "executeMcp").pipe(Effect.andThen(effect)) +} diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index 156c2eb048..afe7167e84 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -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(effect: Effect.Effect) { 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(tool: { id: string }, effect: Effect.Effect) { + return execute(Network.tool(tool, effect)) +} + +export function executeMcp(tool: object, effect: Effect.Effect) { + return execute(Network.mcp(tool, effect)) +} diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index a8f2726b01..4ad1d3b65d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -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" }, diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index da8e076f58..63756fb4ad 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -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>> = 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>> = 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, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a5f2798c73..c184b6e77e 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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 diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts new file mode 100644 index 0000000000..c42a9815c5 --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -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)))) +}) diff --git a/packages/opencode/test/kilocode/sandbox/http-tools.test.ts b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts new file mode 100644 index 0000000000..a60f8f17a5 --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/http-tools.test.ts @@ -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, +) { + 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) + }) +}) diff --git a/packages/opencode/test/kilocode/sandbox/network.test.ts b/packages/opencode/test/kilocode/sandbox/network.test.ts new file mode 100644 index 0000000000..c975034dde --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/network.test.ts @@ -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)), + ) + }) +}) diff --git a/packages/opencode/test/kilocode/sandbox/policy.test.ts b/packages/opencode/test/kilocode/sandbox/policy.test.ts index 8026102099..bf280fb830 100644 --- a/packages/opencode/test/kilocode/sandbox/policy.test.ts +++ b/packages/opencode/test/kilocode/sandbox/policy.test.ts @@ -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 diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index 88b1dfa3c9..a0d4b20f16 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -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), diff --git a/packages/opencode/test/kilocode/sandbox/shell-network.test.ts b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts new file mode 100644 index 0000000000..41b1d480cc --- /dev/null +++ b/packages/opencode/test/kilocode/sandbox/shell-network.test.ts @@ -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)))) + }, + ) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 53db5ebe02..e57b353091 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1541,6 +1541,7 @@ export type Config = { primary_tools?: Array continue_loop_on_deny?: boolean sandbox?: boolean + sandbox_restrict_network?: boolean mcp_timeout?: number policies?: Array } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a0f3ffca47..2772ddb796 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -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 diff --git a/script/check-model-tool-network.ts b/script/check-model-tool-network.ts new file mode 100644 index 0000000000..df3b3ecd1e --- /dev/null +++ b/script/check-model-tool-network.ts @@ -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 = { + "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.`, +)