feat(cli): add macOS file-level sandbox for agent tools

Add an OS-level sandbox that confines agent writes to the project and Kilo
state directories. Currently macOS-only (via sandbox-exec/seatbelt), with
Linux, Windows, network isolation, and worktree isolation deferred to
follow-up issues (#11538, #11540, #11542, #11544, #11546, #11547).

Two enforcement layers:
- Bash tool: kernel-level seatbelt confinement via sandbox-exec
- File tools (write/edit/apply_patch): TS-level AppFileSystem layer wrapper

Both share the same configurable scope, which respects existing permission
config (external_directory allows, project sandboxes).

Opt-in via experimental.sandbox config toggle or the lock button in the
prompt input.
This commit is contained in:
marius-kilocode
2026-06-22 17:06:51 +02:00
parent d378114b8b
commit b3050cbffd
18 changed files with 749 additions and 4 deletions
@@ -78,7 +78,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const session = useSession()
const server = useServer()
const indexing = useIndexing()
const { config, features } = useConfig()
const { config, features, updateConfig, saveConfig } = useConfig()
const provider = useProvider()
const language = useLanguage()
const vscode = useVSCode()
@@ -142,6 +142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [reviewComments, setReviewComments] = createSignal<ReviewComment[]>([])
const [enhancing, setEnhancing] = createSignal(false)
const [autoApprove, setAutoApprove] = createSignal(false)
const sandbox = () => config().experimental?.sandbox ?? false
let enhanceCounter = 0
let preEnhanceText: string | null = null
@@ -1064,6 +1065,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Icon name="shield" size="small" />
</Button>
</Tooltip>
<Tooltip
value={
sandbox()
? language.t("prompt.action.sandbox.enabled")
: language.t("prompt.action.sandbox.disabled")
}
placement="top"
>
<Button
variant="ghost"
size="small"
onClick={() => {
updateConfig({
experimental: { ...config().experimental, sandbox: !sandbox() },
})
saveConfig()
}}
aria-label={
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" : ""}`}
>
<Icon name="lock" size="small" />
</Button>
</Tooltip>
<Tooltip value={language.t("prompt.action.enhance")} placement="top">
<Button
variant="ghost"
@@ -171,7 +171,6 @@ const ExperimentalTab: Component = () => {
<SettingsRow
title={language.t("settings.experimental.mcpTimeout.title")}
description={language.t("settings.experimental.mcpTimeout.description")}
last
>
<TextField
value={String(experimental().mcp_timeout ?? 60000)}
@@ -183,6 +182,20 @@ const ExperimentalTab: Component = () => {
}}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.sandbox.title")}
description={language.t("settings.experimental.sandbox.description")}
last
>
<Switch
checked={experimental().sandbox ?? false}
onChange={(checked) => updateExperimental("sandbox", checked)}
hideLabel
>
{language.t("settings.experimental.sandbox.title")}
</Switch>
</SettingsRow>
</Card>
{/* Tool toggles */}
@@ -283,6 +283,10 @@ export const dict = {
"prompt.action.autoApprove.disable": "Disable auto-approve",
"prompt.action.autoApprove.enabled": "Auto-approve is enabled. Permission prompts will be approved automatically.",
"prompt.action.autoApprove.disabled": "Auto-approve is disabled. Click to approve permission prompts automatically.",
"prompt.action.sandbox.enable": "Enable sandbox",
"prompt.action.sandbox.disable": "Disable sandbox",
"prompt.action.sandbox.enabled": "Sandbox is enabled. Agent shell commands are confined to the project and Kilo directories.",
"prompt.action.sandbox.disabled": "Sandbox is disabled. Click to confine agent shell command writes to the project and Kilo directories.",
"prompt.action.resetModel": "Reset model to default",
"prompt.action.enhanceDescription":
"The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
@@ -1302,6 +1306,9 @@ export const dict = {
"settings.models.speechToTextModel.description": "Choose the Kilo Gateway transcription model for voice input.",
"settings.experimental.continueOnDeny.title": "Continue on Deny",
"settings.experimental.continueOnDeny.description": "Continue the agent loop when a permission is denied",
"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.experimental.mcpTimeout.title": "MCP Timeout (ms)",
"settings.experimental.mcpTimeout.description": "Timeout for MCP server requests in milliseconds",
"settings.experimental.remote.title": "Remote Control",
@@ -584,6 +584,30 @@
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 {
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-speech-button {
position: relative;
}
@@ -44,6 +44,7 @@ export interface ExperimentalConfig {
primary_tools?: string[]
continue_loop_on_deny?: boolean
mcp_timeout?: number
sandbox?: boolean
}
export interface CommitMessageConfig {
+5
View File
@@ -403,6 +403,11 @@ export const Info = Schema.Struct({
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
description: "Continue the agent loop when a tool call is denied",
}),
// 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",
}),
// kilocode_change end
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
+6 -1
View File
@@ -16,6 +16,7 @@ import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { ModelCache } from "@/provider/model-cache" // kilocode_change
import { layer as sandboxFsRaw } from "@/kilocode/sandbox/fs-layer" // kilocode_change
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
@@ -60,9 +61,13 @@ import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
// kilocode_change start - wrap AppFileSystem with sandbox enforcement layer
const SandboxFsLayer = sandboxFsRaw.pipe(Layer.provide(Layer.mergeAll(AppFileSystem.defaultLayer, Config.defaultLayer)))
// kilocode_change end
const CoreLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
SandboxFsLayer, // kilocode_change - was AppFileSystem.defaultLayer
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
@@ -0,0 +1,93 @@
import { Effect, Layer } from "effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { Filesystem } from "@/util/filesystem"
import { available } from "./seatbelt"
import { resolve, withExternalDirs, type Scope } from "./scope"
function longestExistingAncestor(p: string): string {
let dir = p
let rest = ""
const fs = require("fs")
while (dir && dir !== "/" && !fs.existsSync(dir)) {
const idx = dir.lastIndexOf("/")
rest = `${dir.slice(idx)}${rest}`
dir = dir.slice(0, idx)
}
if (!dir) return Filesystem.resolve(p)
return Filesystem.resolve(dir) + rest
}
function isUnder(child: string, parent: string): boolean {
if (child === parent) return true
if (parent === "/") return true
return child.startsWith(`${parent}/`)
}
function writable(scope: Scope, filepath: string): boolean {
const target = longestExistingAncestor(filepath)
for (const root of scope.writableRoots) {
if (!isUnder(target, root.path)) continue
for (const sub of root.readonlySubpaths) {
if (isUnder(target, sub) || target === sub) return false
}
return true
}
return false
}
function externalDirAllows(cfg: Config.Info): string[] {
const rule = cfg.permission?.external_directory
if (!rule) return []
if (typeof rule === "string") return rule === "allow" ? ["*"] : []
const patterns: string[] = []
for (const [pattern, action] of Object.entries(rule)) {
if (action === "allow") patterns.push(pattern)
}
return patterns
}
function wrap(fs: AppFileSystem.Interface, config: Config.Interface): AppFileSystem.Interface {
const guard = (path: string) =>
Effect.gen(function* () {
const cfg = yield* config.get()
if (!cfg.experimental?.sandbox) return
if (process.platform !== "darwin" || !available()) return
const ctx = yield* InstanceState.context
const scope = withExternalDirs(resolve(ctx), externalDirAllows(cfg))
if (!writable(scope, path)) {
throw new Error(
`Sandbox blocked write to ${path}: outside the allowed project and Kilo directories. ` +
`To allow this directory, approve it as an external directory or disable the sandbox.`,
)
}
})
return {
...fs,
writeFile: (path, data) => Effect.gen(function* () { yield* guard(path); return yield* fs.writeFile(path, data) }),
writeFileString: (path, data) => Effect.gen(function* () { yield* guard(path); return yield* fs.writeFileString(path, data) }),
writeJson: (path, data, mode) => Effect.gen(function* () { yield* guard(path); return yield* fs.writeJson(path, data, mode) }),
ensureDir: (path) => Effect.gen(function* () { yield* guard(path); return yield* fs.ensureDir(path) }),
writeWithDirs: (path, content, mode) => Effect.gen(function* () { yield* guard(path); return yield* fs.writeWithDirs(path, content, mode) }),
makeDirectory: (path) => Effect.gen(function* () { yield* guard(path); return yield* fs.makeDirectory(path) }),
rename: (oldPath, newPath) => Effect.gen(function* () { yield* guard(oldPath); yield* guard(newPath); return yield* fs.rename(oldPath, newPath) }),
copyFile: (oldPath, newPath) => Effect.gen(function* () { yield* guard(newPath); return yield* fs.copyFile(oldPath, newPath) }),
copy: (oldPath, newPath) => Effect.gen(function* () { yield* guard(newPath); return yield* fs.copy(oldPath, newPath) }),
remove: (path) => Effect.gen(function* () { yield* guard(path); return yield* fs.remove(path) }),
chmod: (path, mode) => Effect.gen(function* () { yield* guard(path); return yield* fs.chmod(path, mode) }),
truncate: (path, size) => Effect.gen(function* () { yield* guard(path); return yield* fs.truncate(path, size) }),
link: (target, linkPath) => Effect.gen(function* () { yield* guard(linkPath); return yield* fs.link(target, linkPath) }),
symlink: (target, linkPath) => Effect.gen(function* () { yield* guard(linkPath); return yield* fs.symlink(target, linkPath) }),
}
}
export const layer = Layer.effect(
AppFileSystem.Service,
Effect.gen(function* () {
const inner = yield* AppFileSystem.Service
const config = yield* Config.Service
return AppFileSystem.Service.of(wrap(inner, config))
}),
)
@@ -0,0 +1,78 @@
import { Effect } from "effect"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { Filesystem } from "@/util/filesystem"
import type { InstanceContext } from "@/project/instance-context"
import { available } from "./seatbelt"
import { resolve, withExternalDirs, type Scope } from "./scope"
function longestExistingAncestor(p: string): string {
let dir = p
let rest = ""
const fs = require("fs")
while (dir && dir !== "/" && !fs.existsSync(dir)) {
const idx = dir.lastIndexOf("/")
rest = `${dir.slice(idx)}${rest}`
dir = dir.slice(0, idx)
}
if (!dir) return Filesystem.resolve(p)
return Filesystem.resolve(dir) + rest
}
function isWritable(scope: Scope, filepath: string): boolean {
const target = longestExistingAncestor(filepath)
for (const root of scope.writableRoots) {
if (!isUnder(target, root.path)) continue
for (const sub of root.readonlySubpaths) {
if (isUnder(target, sub) || target === sub) return false
}
return true
}
return false
}
function isUnder(child: string, parent: string): boolean {
if (child === parent) return true
if (parent === "/") return true
return child.startsWith(`${parent}/`)
}
function externalDirAllows(cfg: Config.Info): string[] {
const rule = cfg.permission?.external_directory
if (!rule) return []
if (typeof rule === "string") {
return rule === "allow" ? ["*"] : []
}
const patterns: string[] = []
for (const [pattern, action] of Object.entries(rule)) {
if (action === "allow") patterns.push(pattern)
}
return patterns
}
export function assertWritable(
config: Config.Interface,
filepath: string,
): Effect.Effect<void> {
return Effect.gen(function* () {
const cfg = yield* config.get()
if (!cfg.experimental?.sandbox) return
if (process.platform !== "darwin" || !available()) return
const ctx = yield* InstanceState.context
const scope = enrichedScope(ctx, cfg)
if (!isWritable(scope, filepath)) {
throw new Error(
`Sandbox blocked write to ${filepath}: outside the allowed project and Kilo directories. ` +
`To allow this directory, approve it as an external directory or disable the sandbox.`,
)
}
})
}
export function enrichedScope(ctx: InstanceContext, cfg: Config.Info): Scope {
return withExternalDirs(resolve(ctx), externalDirAllows(cfg))
}
@@ -0,0 +1,6 @@
export { generate, available } from "./seatbelt"
export { wrap } from "./spawn"
export { resolve, withExternalDirs } from "./scope"
export { assertWritable, enrichedScope } from "./guard"
export { layer as fsLayer } from "./fs-layer"
export type { Scope, Root } from "./scope"
@@ -0,0 +1,81 @@
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import type { InstanceContext } from "@/project/instance-context"
export interface Root {
path: string
readonlySubpaths: string[]
}
export interface Scope {
writableRoots: Root[]
}
// Only .git is hard-protected from writes. .kilo is intentionally left writable
// because ConfigProtection (kilocode/permission/config-paths.ts) already forces
// an ask prompt for edits to .kilo/ config files — the sandbox should not
// override that softer, user-consent-based gate with a hard block.
const PROTECTED_METADATA_NAMES = [".git"]
function real(p: string): string {
return Filesystem.resolve(p)
}
function addRoot(writable: Root[], dir: string) {
const r = real(dir)
if (!writable.some((w) => w.path === r)) writable.push({ path: r, readonlySubpaths: [] })
}
function protectMeta(root: Root) {
for (const name of PROTECTED_METADATA_NAMES) {
const meta = `${root.path}/${name}`
if (!root.readonlySubpaths.includes(meta)) root.readonlySubpaths.push(meta)
}
}
export function resolve(ctx: InstanceContext): Scope {
const writable: Root[] = []
if (ctx.worktree !== "/") addRoot(writable, ctx.worktree)
addRoot(writable, ctx.directory)
for (const s of ctx.project.sandboxes ?? []) addRoot(writable, s)
const dirs = [
Global.Path.data,
Global.Path.cache,
Global.Path.config,
Global.Path.state,
Global.Path.tmp,
Global.Path.bin,
Global.Path.log,
Global.Path.repos,
]
for (const dir of dirs) addRoot(writable, dir)
for (const root of writable) protectMeta(root)
return { writableRoots: writable }
}
function expandHome(p: string): string {
if (p.startsWith("~/")) return real(`${process.env.HOME ?? ""}${p.slice(1)}`)
if (p === "~") return real(process.env.HOME ?? "")
return p
}
export function withExternalDirs(scope: Scope, patterns: string[]): Scope {
const writable = [...scope.writableRoots]
for (const pattern of patterns) {
let dir = expandHome(pattern.trim())
if (dir.endsWith("/*")) dir = dir.slice(0, -2)
else if (dir.endsWith("/")) dir = dir.slice(0, -1)
if (!dir || dir === "*" || dir.includes("*")) continue
let root: Root | undefined = writable.find((w) => w.path === real(dir))
if (!root) {
root = { path: real(dir), readonlySubpaths: [] }
writable.push(root)
}
protectMeta(root)
}
return { writableRoots: writable }
}
@@ -0,0 +1,114 @@
export const base = `(version 1)
(deny default)
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
(allow file-write-data
(require-all
(path "/dev/null")
(vnode-type CHARACTER-DEVICE)))
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.model")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "kern.argmax")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "net.routetable.")
)
(allow sysctl-write
(sysctl-name "kern.grade_cputype"))
(allow iokit-open
(iokit-registry-entry-class "RootDomainUserClient"))
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo"))
(allow ipc-posix-sem)
(allow ipc-posix-shm-read-data
ipc-posix-shm-write-create
ipc-posix-shm-write-unlink
(ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$"))
(allow mach-lookup
(global-name "com.apple.PowerManagement.control"))
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write*
(require-all
(regex #"^/dev/ttys[0-9]+")
(extension "com.apple.sandbox.pty")))
(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))
(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs."))
(allow mach-lookup
(global-name "com.apple.cfprefsd.daemon")
(global-name "com.apple.cfprefsd.agent")
(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)
(allow system-socket)
(allow mach-lookup
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd"))
(allow sysctl-read (sysctl-name-regex #"^net.routetable"))
`
@@ -0,0 +1,84 @@
import { base } from "./seatbelt-base"
import type { Scope, Root } from "./scope"
const SEATBELT = "/usr/bin/sandbox-exec"
// Only .git is hard-protected from writes. .kilo is left writable because
// ConfigProtection already gates .kilo/ edits with an ask prompt.
const META = [".git"]
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
function protectedMetaRegex(root: string, name: string): string {
let r = root
while (r.length > 1 && r.endsWith("/")) r = r.slice(0, -1)
const er = escapeRegex(r)
const en = escapeRegex(name)
if (r === "/") return `^/${en}(/.*)?$`
return `^${er}/${en}(/.*)?$`
}
interface Param {
key: string
value: string
}
function buildWritePolicy(roots: Root[]): { policy: string; params: Param[] } {
const parts: string[] = []
const params: Param[] = []
roots.forEach((root, i) => {
const rp = `WRITABLE_ROOT_${i}`
params.push({ key: rp, value: root.path })
const require: string[] = [`(subpath (param "${rp}"))`]
root.readonlySubpaths.forEach((sub, j) => {
const ep = `${rp}_EXCL_${j}`
params.push({ key: ep, value: sub })
require.push(`(require-not (literal (param "${ep}")))`)
require.push(`(require-not (subpath (param "${ep}")))`)
})
for (const name of META) {
if (root.readonlySubpaths.some((s) => s === `${root.path}/${name}`)) continue
const re = protectedMetaRegex(root.path, name).replace(/"/g, '\\"')
require.push(`(require-not (regex #"${re}"))`)
}
parts.push(`(require-all ${require.join(" ")} )`)
})
if (parts.length === 0) return { policy: "", params }
return { policy: `(allow file-write*\n${parts.join(" ")}\n)`, params }
}
export interface Command {
command: string
args: string[]
}
export function available(): boolean {
try {
const fs = require("fs")
return fs.existsSync(SEATBELT)
} catch {
return false
}
}
export function generate(scope: Scope, command: string, args: string[]): Command {
const write = buildWritePolicy(scope.writableRoots)
const policy = [base, "; reads are not confined by the file-level sandbox\n(allow file-read*)", write.policy].join(
"\n",
)
const sbArgs: string[] = ["-p", policy]
for (const p of write.params) sbArgs.push(`-D${p.key}=${p.value}`)
sbArgs.push("--", command, ...args)
return { command: SEATBELT, args: sbArgs }
}
@@ -0,0 +1,17 @@
import { generate, available } from "./seatbelt"
import type { Scope } from "./scope"
export interface WrapResult {
sandboxed: boolean
command: string
args: string[]
}
export function wrap(scope: Scope, command: string, args: string[]): WrapResult {
if (process.platform === "darwin" && available()) {
const r = generate(scope, command, args)
return { sandboxed: true, command: r.command, args: r.args }
}
return { sandboxed: false, command, args }
}
+48 -1
View File
@@ -20,6 +20,7 @@ import * as Truncate from "./truncate"
import { Plugin } from "@/plugin"
import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change
import { CommandTimeout } from "@/kilocode/command-timeout" // kilocode_change
import * as Sandbox from "@/kilocode/sandbox" // kilocode_change
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { ShellPrompt, type Parameters } from "./shell/prompt"
@@ -310,6 +311,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (
})
})
// kilocode_change start - sandbox wrapper
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && Shell.ps(shell)) {
// kilocode_change start - encoded PowerShell args
@@ -330,6 +332,32 @@ function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv
detached: process.platform !== "win32",
})
}
function sandboxedCmd(
scope: Sandbox.Scope,
shell: string,
command: string,
cwd: string,
env: NodeJS.ProcessEnv,
) {
if (process.platform === "win32" && Shell.ps(shell)) {
return ChildProcess.make(shell, Shell.args(shell, command, cwd), {
cwd,
env,
stdin: "ignore",
detached: false,
})
}
const wrapped = Sandbox.wrap(scope, shell, ["-c", command])
return ChildProcess.make(wrapped.command, wrapped.args, {
cwd,
env,
stdin: "ignore",
detached: process.platform !== "win32",
})
}
// kilocode_change end
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -467,6 +495,7 @@ export const ShellTool = Tool.define(
env: NodeJS.ProcessEnv
timeout: number
description: string
sandbox?: boolean // kilocode_change
},
ctx: Tool.Context,
) {
@@ -517,7 +546,21 @@ export const ShellTool = Tool.define(
const code: number | null = yield* Effect.scoped(
Effect.gen(function* () {
yield* Effect.addFinalizer(closeSink)
const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
// kilocode_change start - sandbox wrapping
const handle = yield* spawner.spawn(
input.sandbox
? sandboxedCmd(
// kilocode_change start - enrich sandbox scope with approved external dirs
Sandbox.enrichedScope(yield* InstanceState.context, yield* config.get()),
input.shell,
input.command,
input.cwd,
input.env,
)
// kilocode_change end
: cmd(input.shell, input.command, input.cwd, input.env),
)
// kilocode_change end
yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
@@ -675,6 +718,8 @@ export const ShellTool = Tool.define(
}),
)
// kilocode_change start - read config fresh per execution
const liveCfg = yield* config.get()
return yield* run(
{
shell,
@@ -683,9 +728,11 @@ export const ShellTool = Tool.define(
env: yield* shellEnv(ctx, cwd),
timeout,
description: params.description ?? params.command, // kilocode_change
sandbox: liveCfg.experimental?.sandbox ?? false,
},
ctx,
)
// kilocode_change end
}),
}
})
@@ -0,0 +1,139 @@
import { test, expect, describe } from "bun:test"
import { generate } from "@/kilocode/sandbox/seatbelt"
import type { Scope } from "@/kilocode/sandbox/scope"
function makeScope(overrides: Partial<Scope> = {}): Scope {
return {
writableRoots: [],
...overrides,
}
}
describe("seatbelt.generate", () => {
test("produces sandbox-exec command with -p flag", () => {
const result = generate(makeScope(), "/bin/echo", ["hello"])
expect(result.command).toBe("/usr/bin/sandbox-exec")
expect(result.args[0]).toBe("-p")
expect(typeof result.args[1]).toBe("string")
})
test("ends with -- and the original command", () => {
const result = generate(makeScope(), "/bin/echo", ["hello", "world"])
const tail = result.args.slice(-4)
expect(tail).toEqual(["--", "/bin/echo", "hello", "world"])
})
test("profile starts with version 1 and deny default", () => {
const result = generate(makeScope(), "/bin/echo", [])
const policy = result.args[1]
expect(policy).toContain("(version 1)")
expect(policy).toContain("(deny default)")
})
test("reads are always allowed (file-level sandbox confines writes only)", () => {
const result = generate(makeScope(), "/bin/echo", [])
expect(result.args[1]).toContain("(allow file-read*)")
})
test("writable root without exclusions produces a require-all subpath", () => {
const result = generate(
makeScope({
writableRoots: [{ path: "/private/tmp/proj", readonlySubpaths: [] }],
}),
"/bin/echo",
[],
)
const policy = result.args[1]
expect(policy).toContain('(subpath (param "WRITABLE_ROOT_0"))')
expect(result.args).toContain("-DWRITABLE_ROOT_0=/private/tmp/proj")
})
test("writable root with exclusions produces require-all with require-not", () => {
const result = generate(
makeScope({
writableRoots: [
{
path: "/private/tmp/proj",
readonlySubpaths: ["/private/tmp/proj/.git"],
},
],
}),
"/bin/echo",
[],
)
const policy = result.args[1]
expect(policy).toContain("(require-all")
expect(policy).toContain('(subpath (param "WRITABLE_ROOT_0"))')
expect(policy).toContain('(require-not (literal (param "WRITABLE_ROOT_0_EXCL_0")))')
expect(policy).toContain('(require-not (subpath (param "WRITABLE_ROOT_0_EXCL_0")))')
expect(result.args).toContain("-DWRITABLE_ROOT_0_EXCL_0=/private/tmp/proj/.git")
})
test("adds regex denies for protected metadata not already in exclusions", () => {
const result = generate(
makeScope({
writableRoots: [
{
path: "/private/tmp/proj",
readonlySubpaths: [],
},
],
}),
"/bin/echo",
[],
)
// .git is not in exclusions, so it gets a regex deny
expect(result.args[1]).toContain("\\.git")
})
test("protected metadata already in exclusions is not duplicated as regex", () => {
const result = generate(
makeScope({
writableRoots: [
{
path: "/private/tmp/proj",
readonlySubpaths: ["/private/tmp/proj/.git"],
},
],
}),
"/bin/echo",
[],
)
const policy = result.args[1]
expect(policy).not.toContain("\\.git")
})
test("network is always allowed (file-level sandbox does not confine network)", () => {
const result = generate(makeScope(), "/bin/echo", [])
const policy = result.args[1]
expect(policy).toContain("(allow network-outbound)")
expect(policy).toContain("(allow network-inbound)")
expect(policy).toContain("com.apple.trustd.agent")
expect(policy).toContain("com.apple.networkd")
})
test("multiple writable roots get indexed params", () => {
const result = generate(
makeScope({
writableRoots: [
{ path: "/private/tmp/proj", readonlySubpaths: [] },
{ path: "/private/tmp/kilo", readonlySubpaths: [] },
],
}),
"/bin/echo",
[],
)
expect(result.args).toContain("-DWRITABLE_ROOT_0=/private/tmp/proj")
expect(result.args).toContain("-DWRITABLE_ROOT_1=/private/tmp/kilo")
const policy = result.args[1]
expect(policy).toContain('(subpath (param "WRITABLE_ROOT_0"))')
expect(policy).toContain('(subpath (param "WRITABLE_ROOT_1"))')
})
test("no writable roots produces no write policy", () => {
const result = generate(makeScope({ writableRoots: [] }), "/bin/echo", [])
const policy = result.args[1]
expect(policy).toContain("(allow file-read*)")
expect(policy).not.toContain("(allow file-write*")
})
})
+1
View File
@@ -1540,6 +1540,7 @@ export type Config = {
openTelemetry?: boolean
primary_tools?: Array<string>
continue_loop_on_deny?: boolean
sandbox?: boolean
mcp_timeout?: number
policies?: Array<ConfigV2ExperimentalPolicy>
}
+1
View File
@@ -86,6 +86,7 @@ const icons = {
photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
shield: `<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" stroke="currentColor" stroke-linecap="square"/>`,
lock: `<path d="M5.833 8.33366V6.25033C5.833 3.71903 7.96805 1.66699 10.4993 1.66699C13.0307 1.66699 15.166 3.71903 15.166 6.25033V8.33366M4.16634 8.33366H16.833C17.7535 8.33366 18.4997 9.07985 18.4997 10.0003V16.667C18.4997 17.5875 17.7535 18.3337 16.833 18.3337H4.16634C3.24586 18.3337 2.49967 17.5875 2.49967 16.667V10.0003C2.49967 9.07985 3.24586 8.33366 4.16634 8.33366Z" stroke="currentColor" stroke-linecap="square"/>`,
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,