refactor(sandbox): simplify capability implementation

This commit is contained in:
marius-kilocode
2026-06-22 23:29:18 +02:00
parent a37d3bf1f0
commit a47617f48a
11 changed files with 75 additions and 164 deletions
+23 -34
View File
@@ -17,21 +17,15 @@ export interface Support {
readonly reason?: string | undefined
}
export interface PreparedLaunch extends Launch {
readonly sandboxed: boolean
readonly support: Support
}
export interface Backend {
readonly support: Support
readonly prepare: (profile: Profile, launch: Launch) => Effect.Effect<PreparedLaunch, never, Scope.Scope>
readonly prepare: (profile: Profile, launch: Launch) => Effect.Effect<Launch, never, Scope.Scope>
}
function unavailable(reason: string): Backend {
const support: Support = { available: false, reason }
return {
support,
prepare: (_profile, launch) => Effect.succeed({ ...launch, sandboxed: false, support }),
support: { available: false, reason },
prepare: (_profile, launch) => Effect.succeed(launch),
}
}
@@ -50,28 +44,32 @@ function select(): Backend {
const backend = select()
export const support: Effect.Effect<Support> = Effect.succeed(backend.support)
function environment(profile: Profile, launch: Launch) {
const source = { ...launch.environment, ...profile.environment.set }
const denied = new Set(profile.environment.deny)
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(source)) {
if (value !== undefined && !denied.has(key)) result[key] = value
}
return result
return Object.fromEntries(
Object.entries(source).filter(([key, value]) => value !== undefined && !denied.has(key)),
) as Record<string, string>
}
function acquire(launch: Launch): Effect.Effect<PreparedLaunch, never, Scope.Scope> {
export function prepare(launch: Launch) {
return Effect.gen(function* () {
const profile = yield* current
if (!profile) return { ...launch, sandboxed: false, support: backend.support }
return yield* backend.prepare(profile, { ...launch, environment: environment(profile, launch) })
if (!profile) return launch
const next = { ...launch, environment: environment(profile, launch) }
if (!backend.support.available) return next
return yield* backend.prepare(profile, next)
})
}
export function prepare(launch: Launch): Effect.Effect<PreparedLaunch, never, Scope.Scope> {
return acquire(launch)
function unsupported(command: string) {
return PlatformError.systemError({
_tag: "PermissionDenied",
module: "Sandbox",
method: "prepareCommand",
pathOrDescriptor: command,
description: backend.support.reason ?? "The process sandbox backend is unavailable",
})
}
export function prepareCommand(
@@ -80,8 +78,8 @@ export function prepareCommand(
env: Readonly<Record<string, string | undefined>> | undefined,
) {
return Effect.gen(function* () {
const profile = yield* current
if (!profile) return command
if (!(yield* current)) return command
if (!backend.support.available) return yield* Effect.fail(unsupported(command.command))
const launch = yield* prepare({
command: command.command,
args: command.args,
@@ -89,17 +87,6 @@ export function prepareCommand(
environment: env,
shell: command.options.shell,
})
if (!launch.sandboxed) {
return yield* Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "Sandbox",
method: "prepareCommand",
pathOrDescriptor: command.command,
description: launch.support.reason ?? "The process sandbox backend is unavailable",
}),
)
}
return ChildProcess.make(launch.command, launch.args, {
...command.options,
cwd: launch.cwd,
@@ -109,3 +96,5 @@ export function prepareCommand(
})
})
}
export const backendSupport = backend.support
+17 -55
View File
@@ -2,65 +2,27 @@ import { tmpdir } from "node:os"
import { Effect, FileSystem, Layer, Sink } from "effect"
import { assertEntry, assertPath, current } from "./context"
function tempDirectory(fs: FileSystem.FileSystem, options?: Parameters<FileSystem.FileSystem["makeTempDirectory"]>[0]) {
return Effect.gen(function* () {
const profile = yield* current
if (!profile) return yield* fs.makeTempDirectory(options)
const directory = options?.directory ?? profile.filesystem.temporaryDirectory ?? tmpdir()
yield* assertPath(directory, "makeTempDirectory")
const next =
options?.directory === undefined && profile.filesystem.temporaryDirectory
? { ...options, directory: profile.filesystem.temporaryDirectory }
: options
return yield* fs.makeTempDirectory(next)
})
interface TempOptions {
readonly directory?: string | undefined
readonly prefix?: string | undefined
readonly suffix?: string | undefined
}
function tempDirectoryScoped(
fs: FileSystem.FileSystem,
options?: Parameters<FileSystem.FileSystem["makeTempDirectoryScoped"]>[0],
function temp<E, R>(
method: string,
options: TempOptions | undefined,
create: (options?: TempOptions) => Effect.Effect<string, E, R>,
) {
return Effect.gen(function* () {
const profile = yield* current
if (!profile) return yield* fs.makeTempDirectoryScoped(options)
if (!profile) return yield* create(options)
const directory = options?.directory ?? profile.filesystem.temporaryDirectory ?? tmpdir()
yield* assertPath(directory, "makeTempDirectoryScoped")
const next =
yield* assertPath(directory, method)
return yield* create(
options?.directory === undefined && profile.filesystem.temporaryDirectory
? { ...options, directory: profile.filesystem.temporaryDirectory }
: options
return yield* fs.makeTempDirectoryScoped(next)
})
}
function tempFile(fs: FileSystem.FileSystem, options?: Parameters<FileSystem.FileSystem["makeTempFile"]>[0]) {
return Effect.gen(function* () {
const profile = yield* current
if (!profile) return yield* fs.makeTempFile(options)
const directory = options?.directory ?? profile.filesystem.temporaryDirectory ?? tmpdir()
yield* assertPath(directory, "makeTempFile")
const next =
options?.directory === undefined && profile.filesystem.temporaryDirectory
? { ...options, directory: profile.filesystem.temporaryDirectory }
: options
return yield* fs.makeTempFile(next)
})
}
function tempFileScoped(
fs: FileSystem.FileSystem,
options?: Parameters<FileSystem.FileSystem["makeTempFileScoped"]>[0],
) {
return Effect.gen(function* () {
const profile = yield* current
if (!profile) return yield* fs.makeTempFileScoped(options)
const directory = options?.directory ?? profile.filesystem.temporaryDirectory ?? tmpdir()
yield* assertPath(directory, "makeTempFileScoped")
const next =
options?.directory === undefined && profile.filesystem.temporaryDirectory
? { ...options, directory: profile.filesystem.temporaryDirectory }
: options
return yield* fs.makeTempFileScoped(next)
: options,
)
})
}
@@ -75,10 +37,10 @@ export function decorateFileSystem(fs: FileSystem.FileSystem): FileSystem.FileSy
assertPath(from, "link").pipe(Effect.andThen(assertPath(to, "link")), Effect.andThen(fs.link(from, to))),
makeDirectory: (path, options) =>
assertPath(path, "makeDirectory").pipe(Effect.andThen(fs.makeDirectory(path, options))),
makeTempDirectory: (options) => tempDirectory(fs, options),
makeTempDirectoryScoped: (options) => tempDirectoryScoped(fs, options),
makeTempFile: (options) => tempFile(fs, options),
makeTempFileScoped: (options) => tempFileScoped(fs, options),
makeTempDirectory: (options) => temp("makeTempDirectory", options, fs.makeTempDirectory),
makeTempDirectoryScoped: (options) => temp("makeTempDirectoryScoped", options, fs.makeTempDirectoryScoped),
makeTempFile: (options) => temp("makeTempFile", options, fs.makeTempFile),
makeTempFileScoped: (options) => temp("makeTempFileScoped", options, fs.makeTempFileScoped),
open: (path, options) => {
if ((options?.flag ?? "r") === "r") return fs.open(path, options)
return assertPath(path, "open").pipe(Effect.andThen(fs.open(path, options)))
+4 -6
View File
@@ -1,6 +1,4 @@
export type { EnvironmentProfile, FilesystemProfile, NetworkProfile, PathKind, PathRule, Profile } from "./profile"
export { canonicalize, canonicalizeEntry } from "./path"
export { CurrentProfile, assertEntry, assertWrite, current, enabled, run } from "./context"
export { decorateFileSystem, layer } from "./filesystem"
export { prepare, prepareCommand, support } from "./backend"
export type { Backend, Launch, PreparedLaunch, Support } from "./backend"
export type { Profile } from "./profile"
export { assertWrite, enabled, run } from "./context"
export { decorateFileSystem } from "./filesystem"
export { prepareCommand } from "./backend"
+3 -8
View File
@@ -1,6 +1,6 @@
import { existsSync } from "node:fs"
import { Effect } from "effect"
import type { Backend, Launch, PreparedLaunch, Support } from "./backend"
import type { Backend, Launch, Support } from "./backend"
import type { PathRule, Profile } from "./profile"
import { base } from "./seatbelt-base"
@@ -52,7 +52,7 @@ function policy(profile: Profile) {
}
}
export function generate(profile: Profile, launch: Launch, support: Support): PreparedLaunch {
export function generate(profile: Profile, launch: Launch): Launch {
const generated = policy(profile)
const args = ["-p", generated.value, ...generated.params.map((param) => `-D${param.key}=${param.value}`)]
const command = launch.shell ? (typeof launch.shell === "string" ? launch.shell : "/bin/sh") : launch.command
@@ -62,8 +62,6 @@ export function generate(profile: Profile, launch: Launch, support: Support): Pr
...launch,
command: executable,
args,
sandboxed: true,
support,
}
}
@@ -73,8 +71,5 @@ const available: Support = existsSync(executable)
export const seatbelt: Backend = {
support: available,
prepare: (profile, launch) =>
available.available
? Effect.succeed(generate(profile, launch, available))
: Effect.succeed({ ...launch, sandboxed: false, support: available }),
prepare: (profile, launch) => Effect.succeed(generate(profile, launch)),
}
+14 -22
View File
@@ -1,6 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { prepare, run, support, type Launch, type Profile } from "../src"
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 {
@@ -24,8 +26,7 @@ const launch: Launch = {
describe("sandbox launch preparation", () => {
test("generates a globally overriding overlapping deny policy with parameterized paths", () => {
const state = { available: true }
const result = generate(makeProfile(), launch, state)
const result = generate(makeProfile(), launch)
const policy = result.args[1]
expect(policy).toContain('(require-any (literal (param "ALLOW_WRITE_0")) (subpath (param "ALLOW_WRITE_0")))')
expect(policy).toContain('(require-not (literal (param "DENY_WRITE_0")))')
@@ -41,22 +42,15 @@ describe("sandbox launch preparation", () => {
})
test("places shell commands inside the sandbox backend", () => {
const result = generate(
makeProfile(),
{ ...launch, command: "echo hello", args: [], shell: "/bin/zsh" },
{
available: true,
},
)
const result = generate(makeProfile(), { ...launch, command: "echo hello", args: [], shell: "/bin/zsh" })
expect(result.args.slice(-4)).toEqual(["--", "/bin/zsh", "-c", "echo hello"])
const args = generate(
makeProfile(),
{ ...launch, command: "printf", args: ["%s", "hello world"], shell: true },
{
available: true,
},
)
const args = generate(makeProfile(), {
...launch,
command: "printf",
args: ["%s", "hello world"],
shell: true,
})
expect(args.args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "printf '%s' 'hello world'"])
})
@@ -66,7 +60,6 @@ describe("sandbox launch preparation", () => {
expect(result.args).toBe(launch.args)
expect(result.cwd).toBe(launch.cwd)
expect(result.environment).toBe(launch.environment)
expect(result.sandboxed).toBe(false)
})
test("merges profile environment values and applies exact deny names", async () => {
@@ -77,9 +70,8 @@ describe("sandbox launch preparation", () => {
expect(result.environment?.PATH).toBeUndefined()
})
test("reports backend support with a reason when unavailable", async () => {
const result = await Effect.runPromise(support)
expect(typeof result.available).toBe("boolean")
if (!result.available) expect(result.reason?.length).toBeGreaterThan(0)
test("reports backend support with a reason when unavailable", () => {
expect(typeof backendSupport.available).toBe("boolean")
if (!backendSupport.available) expect(backendSupport.reason?.length).toBeGreaterThan(0)
})
})
+2 -1
View File
@@ -3,7 +3,8 @@ import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { assertWrite, current, enabled, run, type Profile } from "../src"
import { assertWrite, current, enabled, run } from "../src/context"
import type { Profile } from "../src/profile"
function makeProfile(
allowWrite: Profile["filesystem"]["allowWrite"],
@@ -4,7 +4,9 @@ import { tmpdir } from "node:os"
import path from "node:path"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer, Scope, Stream } from "effect"
import { layer, run, type Profile } from "../src"
import { run } from "../src/context"
import { layer } from "../src/filesystem"
import type { Profile } from "../src/profile"
const live = layer.pipe(Layer.provide(NodeFileSystem.layer))
@@ -1060,7 +1060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
: language.t("prompt.action.autoApprove.enable")
}
aria-pressed={autoApprove()}
class={`prompt-auto-approve-button ${autoApprove() ? "prompt-auto-approve-button--active" : ""}`}
class={`prompt-status-button ${autoApprove() ? "prompt-status-button--active" : ""}`}
>
<Icon name="shield" size="small" />
</Button>
@@ -1084,7 +1084,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
sandbox() ? language.t("prompt.action.sandbox.disable") : language.t("prompt.action.sandbox.enable")
}
aria-pressed={sandbox()}
class={`prompt-sandbox-button ${sandbox() ? "prompt-sandbox-button--active" : ""}`}
class={`prompt-status-button ${sandbox() ? "prompt-status-button--active" : ""}`}
>
<Icon name="lock" size="small" />
</Button>
@@ -560,11 +560,11 @@
color: var(--vscode-errorForeground, #f48771);
}
.prompt-auto-approve-button {
.prompt-status-button {
position: relative;
}
.prompt-auto-approve-button--active {
.prompt-status-button--active {
color: var(--vscode-testing-iconPassed, #73c991);
[data-slot="icon-svg"] {
@@ -572,31 +572,7 @@
}
}
.prompt-auto-approve-button--active::after {
content: "";
position: absolute;
right: 4px;
bottom: 4px;
width: 5px;
height: 5px;
border-radius: 999px;
background: var(--vscode-testing-iconPassed, #73c991);
box-shadow: 0 0 0 1px var(--surface-base, var(--vscode-editor-background));
}
.prompt-sandbox-button {
position: relative;
}
.prompt-sandbox-button--active {
color: var(--vscode-testing-iconPassed, #73c991);
[data-slot="icon-svg"] {
color: currentColor !important;
}
}
.prompt-sandbox-button--active::after {
.prompt-status-button--active::after {
content: "";
position: absolute;
right: 4px;
@@ -1,12 +1,12 @@
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { run as runSandbox, type PathRule, type Profile } from "@kilocode/sandbox"
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"
function root(path: string): PathRule {
return { path, kind: "subtree" }
function root(path: string) {
return { path, kind: "subtree" as const }
}
export function profile(ctx: InstanceContext): Profile {
@@ -1,5 +1,4 @@
import { Effect } from "effect"
import { dirname } from "node:path"
import type { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Encoding from "../encoding"
import * as Bom from "@/util/bom"
@@ -20,10 +19,7 @@ export const read = (fs: AppFileSystem.Interface, path: string) =>
})
export const write = (fs: AppFileSystem.Interface, path: string, text: string, encoding: string = Encoding.DEFAULT) =>
Effect.gen(function* () {
yield* fs.ensureDir(dirname(path)).pipe(Effect.mapError(wrap))
yield* fs.writeFile(path, Encoding.encode(text, encoding)).pipe(Effect.mapError(wrap))
})
fs.writeWithDirs(path, Encoding.encode(text, encoding)).pipe(Effect.mapError(wrap))
export const sync = (fs: AppFileSystem.Interface, path: string, bom: boolean, encoding: string) =>
Effect.gen(function* () {