test(sandbox): cover production network classification

This commit is contained in:
Marius
2026-06-23 21:14:33 +02:00
parent 9ce0e1cc81
commit 513f26c2b0
6 changed files with 127 additions and 5 deletions
@@ -65,7 +65,7 @@ test.describe("settings tab accessibility", () => {
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(page.getByText(/Local MCP servers and plugin hooks run outside this restriction/)).toBeVisible()
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()
@@ -5,6 +5,8 @@ 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()
@@ -15,10 +17,12 @@ const SandboxingTab: Component = () => {
<SettingsRow
title={language.t("settings.sandboxing.network.title")}
description={language.t("settings.sandboxing.network.description")}
descriptionId={description}
last
>
<Switch
checked={experimental().sandbox_restrict_network !== false}
inputProps={{ "aria-describedby": description }}
onChange={(checked) =>
updateConfig({
experimental: {
@@ -4,6 +4,7 @@ import { Component, JSX, Show } from "solid-js"
const SettingsRow: Component<{
title: string
description?: string
descriptionId?: string
tag?: () => string | undefined
last?: boolean
children: JSX.Element
@@ -32,7 +33,9 @@ const SettingsRow: Component<{
<Show when={props.tag?.()}>{(tag) => <Tag>{tag()}</Tag>}</Show>
</div>
{props.description !== null && props.description !== undefined && (
<div data-slot="settings-row-label-subtitle">{props.description}</div>
<div id={props.descriptionId} data-slot="settings-row-label-subtitle">
{props.description}
</div>
)}
</div>
<div data-slot="settings-row-input">{props.children}</div>
@@ -2,6 +2,8 @@ import { expect, mock, beforeEach } from "bun:test"
import { Cause, Effect, Exit } from "effect"
import type { MCP as MCPNS } from "../../src/mcp/index"
import { testEffect } from "../lib/effect"
import * as SandboxNetwork from "../../src/kilocode/sandbox/network" // kilocode_change
import { run as runSandbox, type Profile } from "@kilocode/sandbox" // kilocode_change
// --- Mock infrastructure ---
@@ -183,11 +185,77 @@ const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
const it = testEffect(MCP.defaultLayer)
// kilocode_change start
function sandboxProfile(): Profile {
return {
filesystem: { allowWrite: [], denyWrite: [], denyNames: [] },
network: { mode: "deny", allowedHosts: [] },
environment: { deny: [], set: {} },
}
}
// kilocode_change end
function statusName(status: Record<string, MCPNS.Status> | MCPNS.Status, server: string) {
if ("status" in status) return status.status
return status[server]?.status
}
// kilocode_change start
it.instance(
"classifies production remote MCP tools while leaving local MCP tools available",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "local-server"
getOrCreateClientState("local-server")
yield* mcp.add("local-server", { type: "local", command: ["node", "fake.js"] })
lastCreatedClientName = "remote-server"
getOrCreateClientState("remote-server")
yield* mcp.add("remote-server", {
type: "remote",
url: "http://localhost:9999/mcp",
oauth: false,
})
const tools = yield* mcp.tools()
const local = Object.entries(tools).find(([key]) => key.startsWith("local") && key.endsWith("_test_tool"))?.[1]
const remote = Object.entries(tools).find(
([key]) => key.startsWith("remote") && key.endsWith("_test_tool"),
)?.[1]
if (!local || !remote) return yield* Effect.die(new Error("expected MCP tools are missing"))
let localCalled = false
let remoteCalled = false
const localExit = yield* runSandbox(
sandboxProfile(),
SandboxNetwork.mcp(
local,
Effect.sync(() => {
localCalled = true
}),
),
).pipe(Effect.exit)
const remoteExit = yield* runSandbox(
sandboxProfile(),
SandboxNetwork.mcp(
remote,
Effect.sync(() => {
remoteCalled = true
}),
),
).pipe(Effect.exit)
expect(Exit.isSuccess(localExit)).toBe(true)
expect(localCalled).toBe(true)
expect(Exit.isFailure(remoteExit)).toBe(true)
expect(remoteCalled).toBe(false)
}),
),
{ config: { mcp: {} } },
)
// kilocode_change end
// ========================================================================
// Test: tools() are cached after connect
// ========================================================================
+47 -1
View File
@@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import { Effect, Layer, Result, Schema } from "effect"
import { Effect, Exit, Layer, Result, Schema } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { Tool } from "@/tool/tool"
@@ -35,6 +35,8 @@ import { ToolJsonSchema } from "@/tool/json-schema"
import { MessageID, SessionID } from "@/session/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Command } from "@/command" // kilocode_change
import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change
import { run as runSandbox, type Profile } from "@kilocode/sandbox" // kilocode_change
const node = CrossSpawnSpawner.defaultLayer
const configLayer = TestConfig.layer({
@@ -106,12 +108,56 @@ const scout = testEffect(
const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
)
const sandboxed = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalLspTool: true } }), node, Agent.defaultLayer),
) // kilocode_change
afterEach(async () => {
await disposeAllInstances()
})
// kilocode_change start
function sandboxProfile(): Profile {
return {
filesystem: { allowWrite: [], denyWrite: [], denyNames: [] },
network: { mode: "deny", allowedHosts: [] },
environment: { deny: [], set: {} },
}
}
// kilocode_change end
describe("tool.registry", () => {
// kilocode_change start
sandboxed.instance("preserves built-in network classification through production tool definition processing", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const agent = yield* Agent.Service
const build = yield* agent.get("build")
if (!build) return yield* Effect.die(new Error("build agent not found"))
const tools = yield* registry.tools({
providerID: ProviderID.opencode,
modelID: ModelID.make("test"),
agent: build,
})
const all = yield* registry.all()
const read = tools.find((tool) => tool.id === "read")
const search = all.find((tool) => tool.id === "lsp")
if (!read || !search) return yield* Effect.die(new Error("expected built-in tools are missing"))
const allowed = yield* runSandbox(sandboxProfile(), SandboxNetwork.tool(read, Effect.succeed("allowed"))).pipe(
Effect.exit,
)
const denied = yield* runSandbox(
sandboxProfile(),
SandboxNetwork.tool(search, Effect.succeed("unexpected")),
).pipe(Effect.exit)
expect(Exit.isSuccess(allowed)).toBe(true)
expect(Exit.isFailure(denied)).toBe(true)
}),
)
// kilocode_change end
it.instance("hides repo research tools unless experimental", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
+3 -2
View File
@@ -5,13 +5,14 @@ import type { ComponentProps, ParentProps } from "solid-js"
export interface SwitchProps extends ParentProps<ComponentProps<typeof Kobalte>> {
hideLabel?: boolean
description?: string
inputProps?: ComponentProps<typeof Kobalte.Input>
}
export function Switch(props: SwitchProps) {
const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"])
const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description", "inputProps"])
return (
<Kobalte {...others} class={local.class} data-component="switch">
<Kobalte.Input data-slot="switch-input" />
<Kobalte.Input {...local.inputProps} data-slot="switch-input" />
<Show when={local.children}>
<Kobalte.Label data-slot="switch-label" classList={{ "sr-only": local.hideLabel }}>
{local.children}