mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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*")
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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"/>`,
|
||||
|
||||
Reference in New Issue
Block a user