mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
refactor(sandbox): simplify capability implementation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
Reference in New Issue
Block a user