From 209e7a71d5d211b44f4831f43b989c70c78b12de Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 17:50:51 +0200 Subject: [PATCH 01/10] feat(cli): add Linux filesystem sandbox --- .changeset/sandbox-agent-writes.md | 2 +- .github/workflows/publish.yml | 14 + .github/workflows/test.yml | 19 + nix/kilo.nix | 5 +- .../core/test/kilocode/linux-sandbox.test.ts | 499 ++++++++++++++++++ packages/kilo-sandbox/src/backend.ts | 25 +- packages/kilo-sandbox/src/bubblewrap.ts | 244 +++++++++ packages/kilo-sandbox/src/index.ts | 2 +- packages/kilo-sandbox/src/seatbelt.ts | 2 +- packages/kilo-sandbox/test/backend.test.ts | 58 +- packages/kilo-vscode/script/build.ts | 3 +- packages/kilo-vscode/script/local-bin.ts | 7 +- packages/kilo-vscode/script/watch-cli.ts | 3 +- .../src/services/cli-backend/cli-resources.ts | 17 + .../tests/unit/server-manager-utils.test.ts | 29 + packages/opencode/Dockerfile | 10 +- packages/opencode/script/build.ts | 9 + .../opencode/script/kilocode/bubblewrap.ts | 163 ++++++ packages/opencode/script/postinstall.mjs | 14 + packages/opencode/script/publish.ts | 10 +- 20 files changed, 1111 insertions(+), 24 deletions(-) create mode 100644 packages/core/test/kilocode/linux-sandbox.test.ts create mode 100644 packages/kilo-sandbox/src/bubblewrap.ts create mode 100644 packages/opencode/script/kilocode/bubblewrap.ts diff --git a/.changeset/sandbox-agent-writes.md b/.changeset/sandbox-agent-writes.md index ea727050768..638c8ac4e52 100644 --- a/.changeset/sandbox-agent-writes.md +++ b/.changeset/sandbox-agent-writes.md @@ -3,4 +3,4 @@ "kilo-code": minor --- -Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS sandbox. +Confine agent shell and file-tool writes to project and Kilo state directories with the optional macOS and Linux sandboxes. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c2f0f56de15..b9a76b6b74d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,6 +84,12 @@ jobs: - uses: ./.github/actions/setup-bun + - name: Setup Zig for Linux sandbox helpers + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: 0.14.0 + use-cache: false + - name: Build id: build run: | @@ -179,6 +185,13 @@ jobs: smoke_host() { binary="$1" "$binary" --version + helper="$(dirname "$binary")/bwrap" + if [[ "${{ matrix.target }}" == linux-* ]]; then + test -x "$helper" + "$helper" --version + "$helper" --unshare-user --disable-userns --unshare-pid --die-with-parent --new-session \ + --ro-bind / / --dev /dev --proc /proc -- "$helper" --version + fi root="$(mktemp -d)" trap 'rm -rf "$root"' RETURN ( @@ -214,6 +227,7 @@ jobs: # kilocode_change end binary="/dist/$PACKAGE/bin/kilo" "$binary" --version + "/dist/$PACKAGE/bin/bwrap" --version root="$(mktemp -d)" trap '\''rm -rf "$root"'\'' EXIT unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b59bbb1208..a37de39bfac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,6 +63,19 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + - name: Setup Zig for Linux sandbox helper + if: runner.os == 'Linux' + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: 0.14.0 + use-cache: false + + - name: Build Linux sandbox helper + if: runner.os == 'Linux' + run: | + bun packages/opencode/script/kilocode/bubblewrap.ts --arch x64 --output "$RUNNER_TEMP/bwrap" + echo "KILO_BWRAP_PATH=$RUNNER_TEMP/bwrap" >> "$GITHUB_ENV" + - name: Configure git identity run: | git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com" @@ -83,6 +96,12 @@ jobs: KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} KILO_TEST_PROFILE: ${{ runner.os == 'macOS' && github.event_name == 'pull_request' && 'darwin' || '' }} # kilocode_change + - name: Test nested mount rejection + if: runner.os == 'Linux' + run: | + sudo env PATH="$PATH" KILO_BWRAP_PATH="$KILO_BWRAP_PATH" KILO_TEST_PRIVILEGED_MOUNTS=1 \ + "$(command -v bun)" test packages/core/test/kilocode/linux-sandbox.test.ts -t "nested mount" + - name: Run HttpApi exerciser gates if: runner.os == 'Linux' # kilocode_change working-directory: packages/opencode diff --git a/nix/kilo.nix b/nix/kilo.nix index 9d01a0ee26a..8ad056e09d7 100644 --- a/nix/kilo.nix +++ b/nix/kilo.nix @@ -3,6 +3,7 @@ stdenvNoCC, callPackage, bun, + bubblewrap, nodejs, sysctl, makeBinaryWrapper, @@ -39,6 +40,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; env.KILO_DISABLE_MODELS_FETCH = true; + env.KILO_SKIP_BUNDLED_BWRAP = "1"; env.KILO_VERSION = finalAttrs.version; env.KILO_CHANNEL = "local"; @@ -59,6 +61,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { install -Dm644 schema.json $out/share/kilo/schema.json wrapProgram $out/bin/kilo \ + ${lib.optionalString stdenvNoCC.hostPlatform.isLinux "--set KILO_BWRAP_PATH ${bubblewrap}/bin/bwrap"} \ --prefix PATH : ${ lib.makeBinPath ( [ @@ -97,7 +100,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { meta = { description = "AI-powered development tool"; homepage = "https://kilo.ai/"; - license = lib.licenses.mit; + license = [ lib.licenses.mit ] ++ lib.optional stdenvNoCC.hostPlatform.isLinux lib.licenses.lgpl2Plus; mainProgram = "kilo"; inherit (node_modules.meta) platforms; }; diff --git a/packages/core/test/kilocode/linux-sandbox.test.ts b/packages/core/test/kilocode/linux-sandbox.test.ts new file mode 100644 index 00000000000..773145ded8e --- /dev/null +++ b/packages/core/test/kilocode/linux-sandbox.test.ts @@ -0,0 +1,499 @@ +import { expect, test } from "bun:test" +import { spawnSync } from "node:child_process" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Effect } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import { backendSupport, run, type Profile } from "@kilocode/sandbox" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" + +const linux = process.platform === "linux" ? test : test.skip +const privileged = process.platform === "linux" && process.env.KILO_TEST_PRIVILEGED_MOUNTS === "1" ? test : test.skip + +function profile(allow: ReadonlyArray, denyNames: ReadonlyArray = []): Profile { + return { + filesystem: { + allowWrite: allow.map((path) => ({ path, kind: "subtree" })), + denyWrite: [], + denyNames, + }, + network: { mode: "allow", allowedHosts: [] }, + environment: { deny: [], set: {} }, + } +} + +function denied(base: Profile, rules: Profile["filesystem"]["denyWrite"]): Profile { + return { ...base, filesystem: { ...base.filesystem, denyWrite: rules } } +} + +function spawn(script: string, cwd: string, policy: Profile) { + return Effect.scoped( + run( + policy, + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => + spawner + .spawn(ChildProcess.make(process.execPath, ["-e", script], { cwd })) + .pipe(Effect.flatMap((handle) => handle.exitCode)), + ), + ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)), + ) +} + +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-linux-sandbox-")) + const project = path.join(root, "project") + const outside = path.join(root, "outside") + await fs.mkdir(project) + await fs.mkdir(outside) + return { root, project, outside } +} + +linux("confines writes from spawned processes to the profile allowlist", async () => { + const support = backendSupport() + expect(support.available, support.reason).toBe(true) + const root = await fixture() + const allowed = path.join(root.project, "allowed.txt") + const sentinel = path.join(root.outside, "sentinel.txt") + await fs.writeFile(sentinel, "original") + + const script = [ + 'const fs = require("node:fs")', + `fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`, + "try {", + ` fs.writeFileSync(${JSON.stringify(sentinel)}, "escaped")`, + " process.exit(2)", + "} catch {", + " process.exit(0)", + "}", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0) + expect(await fs.readFile(allowed, "utf8")).toBe("allowed") + expect(await fs.readFile(sentinel, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("keeps reads available when no paths are writable", async () => { + const root = await fixture() + const sentinel = path.join(root.project, "sentinel.txt") + await fs.writeFile(sentinel, "original") + const script = [ + 'const fs = require("node:fs")', + `if (fs.readFileSync(${JSON.stringify(sentinel)}, "utf8") !== "original") process.exit(2)`, + "try {", + ` fs.writeFileSync(${JSON.stringify(sentinel)}, "escaped")`, + " process.exit(3)", + "} catch {", + " process.exit(0)", + "}", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([]))))).toBe(0) + expect(await fs.readFile(sentinel, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("keeps existing git metadata read-only under a writable project", async () => { + const root = await fixture() + const git = path.join(root.project, ".git") + const config = path.join(git, "config") + const allowed = path.join(root.project, "allowed.txt") + await fs.mkdir(git) + await fs.writeFile(config, "original") + + const script = [ + 'const fs = require("node:fs")', + `fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`, + "try {", + ` fs.writeFileSync(${JSON.stringify(config)}, "escaped")`, + " process.exit(2)", + "} catch {", + " process.exit(0)", + "}", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [".git"]))))).toBe(0) + expect(await fs.readFile(allowed, "utf8")).toBe("allowed") + expect(await fs.readFile(config, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("keeps existing nested git metadata read-only", async () => { + const root = await fixture() + const git = path.join(root.project, "packages", "nested", ".git") + const config = path.join(git, "config") + const allowed = path.join(root.project, "allowed.txt") + await fs.mkdir(git, { recursive: true }) + await fs.writeFile(config, "original") + const script = [ + 'const fs = require("node:fs")', + `fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`, + "try {", + ` fs.writeFileSync(${JSON.stringify(config)}, "escaped")`, + " process.exit(2)", + "} catch {", + " process.exit(0)", + "}", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [".git"]))))).toBe(0) + expect(await fs.readFile(config, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("keeps worktree git marker files read-only", async () => { + const root = await fixture() + const marker = path.join(root.project, ".git") + const renamed = path.join(root.project, ".git-moved") + await fs.writeFile(marker, "gitdir: /outside") + const script = [ + 'const fs = require("node:fs")', + "let blocked = 0", + `try { fs.writeFileSync(${JSON.stringify(marker)}, "escaped") } catch { blocked++ }`, + `try { fs.renameSync(${JSON.stringify(marker)}, ${JSON.stringify(renamed)}) } catch { blocked++ }`, + "process.exit(blocked === 2 ? 0 : 2)", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project], [".git"]))))).toBe(0) + expect(await fs.readFile(marker, "utf8")).toBe("gitdir: /outside") + expect( + await fs.stat(renamed).then( + () => true, + () => false, + ), + ).toBe(false) + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("applies explicit file and subtree denies after a writable parent", async () => { + const root = await fixture() + const file = path.join(root.project, "protected.txt") + const dir = path.join(root.project, "protected") + const nested = path.join(dir, "value.txt") + const allowed = path.join(root.project, "allowed.txt") + await fs.writeFile(file, "original") + await fs.mkdir(dir) + await fs.writeFile(nested, "original") + const policy = denied(profile([root.project]), [ + { path: file, kind: "literal" }, + { path: dir, kind: "subtree" }, + ]) + const script = [ + 'const fs = require("node:fs")', + `fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`, + "let blocked = 0", + `try { fs.writeFileSync(${JSON.stringify(file)}, "escaped") } catch { blocked++ }`, + `try { fs.writeFileSync(${JSON.stringify(nested)}, "escaped") } catch { blocked++ }`, + "process.exit(blocked === 2 ? 0 : 2)", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0) + expect(await fs.readFile(allowed, "utf8")).toBe("allowed") + expect(await fs.readFile(file, "utf8")).toBe("original") + expect(await fs.readFile(nested, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("supports writable literal files without opening writable siblings", async () => { + const root = await fixture() + const allowed = path.join(root.project, "allowed.txt") + const sibling = path.join(root.project, "sibling.txt") + await fs.writeFile(allowed, "original") + await fs.writeFile(sibling, "original") + const base = profile([]) + const policy: Profile = { + ...base, + filesystem: { ...base.filesystem, allowWrite: [{ path: allowed, kind: "literal" }] }, + } + const script = [ + 'const fs = require("node:fs")', + `fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`, + "try {", + ` fs.writeFileSync(${JSON.stringify(sibling)}, "escaped")`, + " process.exit(2)", + "} catch {", + " process.exit(0)", + "}", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0) + expect(await fs.readFile(allowed, "utf8")).toBe("allowed") + expect(await fs.readFile(sibling, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("blocks writes through a project symlink to an outside path", async () => { + const root = await fixture() + const sentinel = path.join(root.outside, "sentinel.txt") + const link = path.join(root.project, "outside") + await fs.writeFile(sentinel, "original") + await fs.symlink(root.outside, link) + + const script = [ + 'const fs = require("node:fs")', + "try {", + ` fs.writeFileSync(${JSON.stringify(path.join(link, "sentinel.txt"))}, "escaped")`, + " process.exit(2)", + "} catch {", + " process.exit(0)", + "}", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0) + expect(await fs.readFile(sentinel, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("allows every profile root including configured temp and cache paths", async () => { + const root = await fixture() + const temp = path.join(root.root, "temp") + const cache = path.join(root.root, "cache") + await fs.mkdir(temp) + await fs.mkdir(cache) + const base = profile([root.project, temp, cache]) + const policy: Profile = { + ...base, + filesystem: { ...base.filesystem, temporaryDirectory: temp }, + environment: { ...base.environment, set: { TMPDIR: temp } }, + } + + const files = [path.join(root.project, "project.txt"), path.join(temp, "temp.txt"), path.join(cache, "cache.txt")] + const script = [ + 'const fs = require("node:fs")', + ...files.map((file) => `fs.writeFileSync(${JSON.stringify(file)}, "allowed")`), + `if (process.env.TMPDIR !== ${JSON.stringify(temp)}) process.exit(2)`, + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, policy)))).toBe(0) + expect(await Promise.all(files.map((file) => fs.readFile(file, "utf8")))).toEqual(["allowed", "allowed", "allowed"]) + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("applies the profile environment without inheriting denied values", async () => { + const root = await fixture() + const base = profile([root.project]) + const policy: Profile = { + ...base, + environment: { deny: ["KILO_SANDBOX_DENIED"], set: { KILO_SANDBOX_SET: "expected" } }, + } + const script = [ + 'if (process.env.KILO_SANDBOX_SET !== "expected") process.exit(2)', + "if (process.env.KILO_SANDBOX_DENIED !== undefined) process.exit(3)", + ].join("\n") + + try { + const effect = Effect.scoped( + run( + policy, + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => + spawner + .spawn( + ChildProcess.make(process.execPath, ["-e", script], { + cwd: root.project, + env: { KILO_SANDBOX_DENIED: "ambient" }, + extendEnv: true, + }), + ) + .pipe(Effect.flatMap((handle) => handle.exitCode)), + ), + ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)), + ) + expect(Number(await Effect.runPromise(effect))).toBe(0) + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("confines writes from descendant processes", async () => { + const root = await fixture() + const allowed = path.join(root.project, "child.txt") + const sentinel = path.join(root.outside, "sentinel.txt") + await fs.writeFile(sentinel, "original") + const child = [ + 'const fs = require("node:fs")', + `fs.writeFileSync(${JSON.stringify(allowed)}, "allowed")`, + "try {", + ` fs.writeFileSync(${JSON.stringify(sentinel)}, "escaped")`, + " process.exit(2)", + "} catch {", + " process.exit(0)", + "}", + ].join("\n") + const script = [ + 'const child = require("node:child_process")', + `const result = child.spawnSync(process.execPath, ["-e", ${JSON.stringify(child)}])`, + "process.exit(result.status ?? 3)", + ].join("\n") + + try { + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0) + expect(await fs.readFile(allowed, "utf8")).toBe("allowed") + expect(await fs.readFile(sentinel, "utf8")).toBe("original") + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("terminates daemonized descendants when the command scope closes", async () => { + const root = await fixture() + const ready = path.join(root.project, "ready") + const marker = path.join(root.project, "marker") + const child = [ + 'const fs = require("node:fs")', + `setInterval(() => fs.writeFileSync(${JSON.stringify(marker)}, String(Date.now())), 20)`, + ].join("\n") + const script = [ + 'const fs = require("node:fs")', + 'const child = require("node:child_process")', + `const proc = child.spawn(process.execPath, ["-e", ${JSON.stringify(child)}], { detached: true, stdio: "ignore" })`, + "proc.unref()", + `fs.writeFileSync(${JSON.stringify(ready)}, "ready")`, + "setInterval(() => {}, 10_000)", + ].join("\n") + + try { + await Effect.runPromise( + Effect.scoped( + run( + profile([root.project]), + ChildProcessSpawner.ChildProcessSpawner.use((spawner) => + Effect.gen(function* () { + yield* spawner.spawn(ChildProcess.make(process.execPath, ["-e", script], { cwd: root.project })) + yield* Effect.promise(async () => { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const started = await Promise.all( + [ready, marker].map((file) => + fs.stat(file).then( + () => true, + () => false, + ), + ), + ) + if (started.every(Boolean)) return + await Bun.sleep(20) + } + throw new Error("daemonized child did not start") + }) + }), + ), + ).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)), + ), + ) + + await Bun.sleep(100) + const stopped = await fs.readFile(marker, "utf8") + await Bun.sleep(150) + expect(await fs.readFile(marker, "utf8")).toBe(stopped) + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("rejects a Bubblewrap helper inside a writable root", async () => { + const root = await fixture() + const source = process.env.KILO_BWRAP_PATH ?? "/usr/bin/bwrap" + const helper = path.join(root.project, "bwrap") + const link = path.join(root.outside, "bwrap") + await fs.copyFile(source, helper) + await fs.chmod(helper, 0o755) + await fs.symlink(helper, link) + const script = [ + 'import { Effect } from "effect"', + 'import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"', + 'import { backendSupport, run } from "@kilocode/sandbox"', + 'import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"', + "if (!backendSupport().available) process.exit(2)", + `const profile = { filesystem: { allowWrite: [{ path: ${JSON.stringify(root.project)}, kind: "subtree" }], denyWrite: [], denyNames: [] }, network: { mode: "allow", allowedHosts: [] }, environment: { deny: [], set: {} } }`, + 'const effect = Effect.scoped(run(profile, ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.spawn(ChildProcess.make(process.execPath, ["-e", "process.exit(0)"])))).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)))', + "try { await Effect.runPromise(effect); process.exit(3) } catch { process.exit(0) }", + ].join("\n") + + try { + const result = spawnSync(process.execPath, ["-e", script], { + cwd: import.meta.dir, + env: { ...process.env, KILO_BWRAP_PATH: link }, + encoding: "utf8", + }) + expect(result.status, result.stderr).toBe(0) + } finally { + await fs.rm(root.root, { recursive: true, force: true }) + } +}) + +linux("fails closed when Bubblewrap is unavailable", () => { + const script = [ + 'import { Effect } from "effect"', + 'import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"', + 'import { backendSupport, run } from "@kilocode/sandbox"', + 'import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"', + "if (backendSupport().available) process.exit(2)", + 'const profile = { filesystem: { allowWrite: [], denyWrite: [], denyNames: [] }, network: { mode: "allow", allowedHosts: [] }, environment: { deny: [], set: {} } }', + 'const effect = Effect.scoped(run(profile, ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.spawn(ChildProcess.make(process.execPath, ["-e", "process.exit(0)"])))).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)))', + "try { await Effect.runPromise(effect); process.exit(3) } catch { process.exit(0) }", + ].join("\n") + const result = spawnSync(process.execPath, ["-e", script], { + cwd: import.meta.dir, + env: { ...process.env, KILO_BWRAP_PATH: "/missing/kilo-bwrap" }, + encoding: "utf8", + }) + expect(result.status, result.stderr).toBe(0) +}) + +privileged("allows a mounted writable root but rejects its nested mount points", async () => { + const root = await fixture() + const nested = path.join(root.project, "nested mount") + const mounted = spawnSync("mount", ["-t", "tmpfs", "tmpfs", root.project], { encoding: "utf8" }) + expect(mounted.status, mounted.stderr).toBe(0) + + try { + const allowed = path.join(root.project, "allowed.txt") + const script = `require("node:fs").writeFileSync(${JSON.stringify(allowed)}, "allowed")` + expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0) + expect(await fs.readFile(allowed, "utf8")).toBe("allowed") + + await fs.mkdir(nested) + const child = spawnSync("mount", ["-t", "tmpfs", "tmpfs", nested], { encoding: "utf8" }) + expect(child.status, child.stderr).toBe(0) + try { + await expect(Effect.runPromise(spawn("process.exit(0)", root.project, profile([root.project])))).rejects.toThrow( + "nested mount point", + ) + } finally { + const unmounted = spawnSync("umount", [nested], { encoding: "utf8" }) + expect(unmounted.status, unmounted.stderr).toBe(0) + } + } finally { + const unmounted = spawnSync("umount", [root.project], { encoding: "utf8" }) + expect(unmounted.status, unmounted.stderr).toBe(0) + await fs.rm(root.root, { recursive: true, force: true }) + } +}) diff --git a/packages/kilo-sandbox/src/backend.ts b/packages/kilo-sandbox/src/backend.ts index 1b46971e1da..a72f4b17eb3 100644 --- a/packages/kilo-sandbox/src/backend.ts +++ b/packages/kilo-sandbox/src/backend.ts @@ -1,5 +1,6 @@ import { Effect, PlatformError, Scope } from "effect" import { ChildProcess } from "effect/unstable/process" +import { bubblewrap } from "./bubblewrap" import { current } from "./context" import type { Profile } from "./profile" import { seatbelt } from "./seatbelt" @@ -18,13 +19,16 @@ export interface Support { } export interface Backend { - readonly support: Support - readonly prepare: (profile: Profile, launch: Launch) => Effect.Effect + readonly support: () => Support + readonly prepare: ( + profile: Profile, + launch: Launch, + ) => Effect.Effect } function unavailable(reason: string): Backend { return { - support: { available: false, reason }, + support: () => ({ available: false, reason }), prepare: (_profile, launch) => Effect.succeed(launch), } } @@ -34,7 +38,7 @@ function select(): Backend { case "darwin": return seatbelt case "linux": - return unavailable("The Linux sandbox backend is not available") + return bubblewrap case "win32": return unavailable("The Windows sandbox backend is not available") default: @@ -57,18 +61,18 @@ export function prepare(launch: Launch) { const profile = yield* current if (!profile) return launch const next = { ...launch, environment: environment(profile, launch) } - if (!backend.support.available) return next + if (!backend.support().available) return next return yield* backend.prepare(profile, next) }) } -function unsupported(command: string) { +function unsupported(command: string, support: Support) { return PlatformError.systemError({ _tag: "PermissionDenied", module: "Sandbox", method: "prepareCommand", pathOrDescriptor: command, - description: backend.support.reason ?? "The process sandbox backend is unavailable", + description: support.reason ?? "The process sandbox backend is unavailable", }) } @@ -79,7 +83,8 @@ export function prepareCommand( ) { return Effect.gen(function* () { if (!(yield* current)) return command - if (!backend.support.available) return yield* Effect.fail(unsupported(command.command)) + const support = backend.support() + if (!support.available) return yield* Effect.fail(unsupported(command.command, support)) const launch = yield* prepare({ command: command.command, args: command.args, @@ -97,4 +102,6 @@ export function prepareCommand( }) } -export const backendSupport = backend.support +export function backendSupport() { + return backend.support() +} diff --git a/packages/kilo-sandbox/src/bubblewrap.ts b/packages/kilo-sandbox/src/bubblewrap.ts new file mode 100644 index 00000000000..ab7e807fd25 --- /dev/null +++ b/packages/kilo-sandbox/src/bubblewrap.ts @@ -0,0 +1,244 @@ +import { spawnSync } from "node:child_process" +import { createHash } from "node:crypto" +import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs" +import path from "node:path" +import { Effect, PlatformError } from "effect" +import type { Backend, Launch, Support } from "./backend" +import type { PathRule, Profile } from "./profile" + +declare const KILO_BWRAP_SHA256: string | undefined + +const system = "/usr/bin/bwrap" + +function quote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function command(launch: Launch) { + if (!launch.shell) return [launch.command, ...launch.args] + const shell = typeof launch.shell === "string" ? launch.shell : "/bin/sh" + return [shell, "-c", [launch.command, ...launch.args.map(quote)].join(" ")] +} + +function exists(rule: PathRule) { + if (!existsSync(rule.path)) return false + const entry = statSync(rule.path) + if (rule.kind === "literal") return entry.isFile() + return entry.isDirectory() +} + +function writable(profile: Profile) { + const seen = new Set() + return profile.filesystem.allowWrite + .filter(exists) + .filter((rule) => { + if (seen.has(rule.path)) return false + seen.add(rule.path) + return true + }) + .sort((a, b) => a.path.length - b.path.length) +} + +function beneath(root: string, target: string) { + const relative = path.relative(root, target) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +function unescape(value: string) { + return value.replace(/\\([0-7]{3})/g, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 8))) +} + +function mountpoints() { + return readFileSync("/proc/self/mountinfo", "utf8") + .split("\n") + .filter(Boolean) + .map((line) => { + const value = line.split(" ")[4] + if (!value) throw new Error("Could not parse /proc/self/mountinfo") + return unescape(value) + }) +} + +function validate(allow: ReadonlyArray, executable: string) { + if (allow.some((rule) => beneath(rule.path, executable))) { + throw new Error(`Bubblewrap executable is writable by the sandbox profile: ${executable}`) + } + if (process.platform !== "linux") return + + const mounts = mountpoints() + for (const rule of allow) { + if (rule.kind !== "subtree") continue + const nested = mounts.find((mount) => mount !== rule.path && beneath(rule.path, mount)) + if (nested) throw new Error(`Writable root contains a nested mount point: ${nested}`) + } +} + +function scan(root: string, names: ReadonlySet, found: Set) { + if (names.has(path.basename(root))) { + found.add(root) + return + } + if (!statSync(root).isDirectory()) return + + const pending = [root] + while (pending.length > 0) { + const dir = pending.pop() + if (!dir) continue + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const target = path.join(dir, entry.name) + if (names.has(entry.name)) { + found.add(target) + continue + } + if (entry.isDirectory()) pending.push(target) + } + } +} + +function protectedPaths(profile: Profile, allow: ReadonlyArray) { + const found = new Set(profile.filesystem.denyWrite.filter((rule) => existsSync(rule.path)).map((rule) => rule.path)) + if (profile.filesystem.denyNames.length === 0) return [...found] + + const names = new Set(profile.filesystem.denyNames) + for (const rule of allow) { + if (rule.kind === "subtree") scan(rule.path, names, found) + } + return [...found].sort((a, b) => a.length - b.length) +} + +export function generate(profile: Profile, launch: Launch, executable: string): Launch { + const allow = writable(profile) + validate(allow, executable) + const args = [ + "--unshare-user", + "--disable-userns", + "--unshare-pid", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/", + "/", + "--dev", + "/dev", + ] + + for (const rule of allow) args.push("--bind", rule.path, rule.path) + for (const target of protectedPaths(profile, allow)) args.push("--ro-bind", target, target) + args.push("--proc", "/proc") + if (launch.cwd) args.push("--chdir", launch.cwd) + args.push("--", ...command(launch)) + + return { + ...launch, + command: executable, + args, + } +} + +function bundled() { + return path.join(path.dirname(process.execPath), "bwrap") +} + +function digest() { + return typeof KILO_BWRAP_SHA256 === "undefined" ? undefined : KILO_BWRAP_SHA256 +} + +function resolve(executable: string, expected?: string) { + try { + if (!path.isAbsolute(executable)) return + const target = realpathSync.native(executable) + const entry = statSync(target) + if (!entry.isFile() || (entry.mode & 0o6000) !== 0) return + if (expected && createHash("sha256").update(readFileSync(target)).digest("hex") !== expected) return + return target + } catch { + return + } +} + +function probe(executable: string) { + const result = spawnSync( + executable, + [ + "--unshare-user", + "--disable-userns", + "--unshare-pid", + "--die-with-parent", + "--new-session", + "--ro-bind", + "/", + "/", + "--dev", + "/dev", + "--proc", + "/proc", + "--", + executable, + "--version", + ], + { encoding: "utf8", timeout: 5_000 }, + ) + if (result.status === 0) return undefined + const detail = result.error?.message ?? (result.stderr.trim() || `exited with status ${result.status}`) + return `${executable} could not create the Linux sandbox: ${detail}` +} + +function select() { + const override = process.env.KILO_BWRAP_PATH + const candidates = override + ? [{ executable: override }] + : [{ executable: system }, { executable: bundled(), expected: digest() }] + const failures: Array = [] + + for (const candidate of candidates) { + const executable = resolve(candidate.executable, candidate.expected) + if (!executable) continue + const failure = probe(executable) + if (!failure) return { executable, support: { available: true } satisfies Support } + failures.push(failure) + } + + return { + executable: undefined, + support: { + available: false, + reason: failures.at(-1) ?? "No usable Bubblewrap executable is available", + } satisfies Support, + } +} + +type Selection = ReturnType + +let selected: Selection | undefined + +function selection(): Selection { + if (selected) return selected + selected = + process.platform === "linux" + ? select() + : { executable: undefined, support: { available: false, reason: "Bubblewrap requires Linux" } satisfies Support } + return selected +} + +function setup(cause: unknown, launch: Launch) { + return PlatformError.systemError({ + _tag: "PermissionDenied", + module: "Sandbox", + method: "prepareCommand", + pathOrDescriptor: launch.command, + description: cause instanceof Error ? cause.message : "Could not construct the Linux sandbox", + cause, + }) +} + +export const bubblewrap: Backend = { + support: () => selection().support, + prepare: (profile, launch) => + Effect.try({ + try: () => { + const selected = selection() + return selected.executable ? generate(profile, launch, selected.executable) : launch + }, + catch: (cause) => setup(cause, launch), + }), +} diff --git a/packages/kilo-sandbox/src/index.ts b/packages/kilo-sandbox/src/index.ts index 2c57b2221bf..2ac25094fe5 100644 --- a/packages/kilo-sandbox/src/index.ts +++ b/packages/kilo-sandbox/src/index.ts @@ -1,4 +1,4 @@ export type { Profile } from "./profile" export { assertWrite, enabled, run } from "./context" export { decorateFileSystem } from "./filesystem" -export { prepareCommand } from "./backend" +export { backendSupport, prepareCommand } from "./backend" diff --git a/packages/kilo-sandbox/src/seatbelt.ts b/packages/kilo-sandbox/src/seatbelt.ts index 40b2a7fd2e8..94932495c00 100644 --- a/packages/kilo-sandbox/src/seatbelt.ts +++ b/packages/kilo-sandbox/src/seatbelt.ts @@ -70,6 +70,6 @@ const available: Support = existsSync(executable) : { available: false, reason: `${executable} is not available` } export const seatbelt: Backend = { - support: available, + support: () => available, prepare: (profile, launch) => Effect.succeed(generate(profile, launch)), } diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts index 5cd92668002..44c8138c0de 100644 --- a/packages/kilo-sandbox/test/backend.test.ts +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" import { Effect } from "effect" import { backendSupport, prepare, type Launch } from "../src/backend" +import { generate as generateBubblewrap } from "../src/bubblewrap" import { run } from "../src/context" import type { Profile } from "../src/profile" import { generate } from "../src/seatbelt" @@ -54,6 +58,55 @@ describe("sandbox launch preparation", () => { expect(args.args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "printf '%s' 'hello world'"]) }) + test("layers Linux writable roots before protected git metadata without changing the network namespace", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-policy-")) + const git = path.join(root, ".git") + mkdirSync(git) + writeFileSync(path.join(git, "config"), "original") + const profile: Profile = { + ...makeProfile(), + filesystem: { + allowWrite: [{ path: root, kind: "subtree" }], + denyWrite: [], + denyNames: [".git"], + }, + } + + try { + const result = generateBubblewrap(profile, { ...launch, cwd: root }, "/opt/kilo/bwrap") + const writable = result.args.indexOf("--bind") + const protectedPath = result.args.indexOf("--ro-bind", writable + 1) + expect(result.command).toBe("/opt/kilo/bwrap") + expect(writable).toBeGreaterThan(-1) + expect(protectedPath).toBeGreaterThan(writable) + expect(result.args.slice(protectedPath, protectedPath + 3)).toEqual(["--ro-bind", git, git]) + expect(result.args).not.toContain("--unshare-net") + expect(result.args.slice(-3)).toEqual(["--", "/bin/echo", "hello"]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test("rejects a Bubblewrap executable inside a writable root", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-helper-")) + const helper = path.join(root, "bwrap") + writeFileSync(helper, "helper") + const profile: Profile = { + ...makeProfile(), + filesystem: { + allowWrite: [{ path: root, kind: "subtree" }], + denyWrite: [], + denyNames: [], + }, + } + + try { + expect(() => generateBubblewrap(profile, launch, helper)).toThrow("writable by the sandbox profile") + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + test("passes the launch through unchanged when no profile is active", async () => { const result = await Effect.runPromise(Effect.scoped(prepare(launch))) expect(result.command).toBe(launch.command) @@ -71,7 +124,8 @@ describe("sandbox launch preparation", () => { }) test("reports backend support with a reason when unavailable", () => { - expect(typeof backendSupport.available).toBe("boolean") - if (!backendSupport.available) expect(backendSupport.reason?.length).toBeGreaterThan(0) + const support = backendSupport() + expect(typeof support.available).toBe("boolean") + if (!support.available) expect(support.reason?.length).toBeGreaterThan(0) }) }) diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index 56f65d9f5c0..74abe402ab8 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -2,7 +2,7 @@ import { $ } from "bun" import { join } from "node:path" import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs" -import { copyTreeSitterResources } from "../src/services/cli-backend/cli-resources" +import { copySandboxResources, copyTreeSitterResources } from "../src/services/cli-backend/cli-resources" import { ensureFfmpegForTarget } from "./ffmpeg-helper" const packageJsonPath = join(import.meta.dir, "..", "package.json") @@ -77,6 +77,7 @@ for (const config of targets) { console.log(` 📥 Copying binary from ${config.cliDir}/bin/${config.binary}...`) await $`cp ${sourceBinary} ${targetBinary}` await copyTreeSitterResources(sourceBinary, targetBinary) + await copySandboxResources(sourceBinary, targetBinary) if (config.binary !== "kilo.exe") { chmodSync(targetBinary, 0o755) diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index 9547b67aaef..f969b910a41 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -2,7 +2,11 @@ import { $ } from "bun" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" -import { copyTreeSitterResources, hasTreeSitterResources } from "../src/services/cli-backend/cli-resources" +import { + copySandboxResources, + copyTreeSitterResources, + hasTreeSitterResources, +} from "../src/services/cli-backend/cli-resources" import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") @@ -234,6 +238,7 @@ async function main() { await $`mkdir -p ${targetBinDir}` await $`cp ${sourceBinPath} ${targetBinPath}` await copyTreeSitterResources(sourceBinPath, targetBinPath) + await copySandboxResources(sourceBinPath, targetBinPath) chmodSync(targetBinPath, 0o755) await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) diff --git a/packages/kilo-vscode/script/watch-cli.ts b/packages/kilo-vscode/script/watch-cli.ts index cf90082f38d..e7326362843 100644 --- a/packages/kilo-vscode/script/watch-cli.ts +++ b/packages/kilo-vscode/script/watch-cli.ts @@ -9,7 +9,7 @@ import { watch, chmodSync } from "node:fs" import { join, relative } from "node:path" import { $ } from "bun" -import { copyTreeSitterResources } from "../src/services/cli-backend/cli-resources" +import { copySandboxResources, copyTreeSitterResources } from "../src/services/cli-backend/cli-resources" const kiloVscodeDir = join(import.meta.dir, "..") const packagesDir = join(kiloVscodeDir, "..") @@ -59,6 +59,7 @@ async function rebuild() { await $`mkdir -p ${targetBinDir}` await $`cp ${source} ${targetBinPath}` await copyTreeSitterResources(source, targetBinPath) + await copySandboxResources(source, targetBinPath) chmodSync(targetBinPath, 0o755) const elapsed = ((performance.now() - start) / 1000).toFixed(1) diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts index 29b53f093da..a397b1e1943 100644 --- a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -37,3 +37,20 @@ export async function copyTreeSitterResources(source: string, target: string): P await fs.promises.rm(to, { recursive: true, force: true }) await fs.promises.cp(from, to, { recursive: true }) } + +export async function copySandboxResources(source: string, target: string): Promise { + const from = path.dirname(source) + const to = path.dirname(target) + const bwrap = path.join(from, "bwrap") + if (!fs.existsSync(bwrap)) return + + const helper = path.join(to, "bwrap") + await fs.promises.copyFile(bwrap, helper) + await fs.promises.chmod(helper, 0o755) + + const licenses = path.join(from, "licenses") + if (!fs.existsSync(licenses)) return + const destination = path.join(to, "licenses") + await fs.promises.rm(destination, { recursive: true, force: true }) + await fs.promises.cp(licenses, destination, { recursive: true }) +} diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index 9fb6aab88f1..094f85be475 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -7,6 +7,7 @@ import { toErrorMessage, } from "../../src/services/cli-backend/server-manager" import { + copySandboxResources, copyTreeSitterResources, resolveTreeSitterEnv, treeSitterDirForBinary, @@ -105,6 +106,34 @@ describe("cli tree-sitter resources", () => { await fs.rm(root, { recursive: true, force: true }) } }) + + it("copies the Linux sandbox helper and license resources", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-sandbox-")) + try { + const source = path.join(root, "dist", "bin", "kilo") + const target = path.join(root, "extension", "bin", "kilo") + const helper = path.join(path.dirname(source), "bwrap") + const license = path.join(path.dirname(source), "licenses", "bubblewrap", "COPYING") + + await fs.mkdir(path.dirname(license), { recursive: true }) + await fs.mkdir(path.dirname(target), { recursive: true }) + await fs.writeFile(source, "binary") + await fs.writeFile(target, "binary") + await fs.writeFile(helper, "helper") + await fs.writeFile(license, "LGPL") + + await copySandboxResources(source, target) + + const copied = path.join(path.dirname(target), "bwrap") + expect(await fs.readFile(copied, "utf8")).toBe("helper") + expect((await fs.stat(copied)).mode & 0o111).not.toBe(0) + expect(await fs.readFile(path.join(path.dirname(target), "licenses", "bubblewrap", "COPYING"), "utf8")).toBe( + "LGPL", + ) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) }) describe("toErrorMessage", () => { diff --git a/packages/opencode/Dockerfile b/packages/opencode/Dockerfile index 5f197528dd4..73ce096ea08 100644 --- a/packages/opencode/Dockerfile +++ b/packages/opencode/Dockerfile @@ -7,10 +7,16 @@ ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH} RUN apk add libgcc libstdc++ ripgrep FROM base AS build-amd64 -COPY dist/@kilocode/cli-linux-x64-baseline-musl/bin/kilo /usr/local/bin/kilo +# kilocode_change start +COPY dist/@kilocode/cli-linux-x64-baseline-musl/bin/kilo dist/@kilocode/cli-linux-x64-baseline-musl/bin/bwrap /usr/local/bin/ +COPY dist/@kilocode/cli-linux-x64-baseline-musl/bin/licenses /usr/local/share/licenses/kilo +# kilocode_change end FROM base AS build-arm64 -COPY dist/@kilocode/cli-linux-arm64-musl/bin/kilo /usr/local/bin/kilo +# kilocode_change start +COPY dist/@kilocode/cli-linux-arm64-musl/bin/kilo dist/@kilocode/cli-linux-arm64-musl/bin/bwrap /usr/local/bin/ +COPY dist/@kilocode/cli-linux-arm64-musl/bin/licenses /usr/local/share/licenses/kilo +# kilocode_change end ARG TARGETARCH FROM build-${TARGETARCH} diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index a3ac6301fdc..09f7e73e256 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -19,6 +19,7 @@ const generated = await import("./generate.ts") import { Script } from "@opencode-ai/script" import pkg from "../package.json" +import { stageBubblewrap } from "./kilocode/bubblewrap" // kilocode_change import { LanceDBRuntime } from "../src/kilocode/lancedb" // kilocode_change // Load migrations from migration directories @@ -271,6 +272,12 @@ for (const item of targets) { console.log(`building ${name}`) await $`mkdir -p dist/${name}/bin` + // kilocode_change start + const bwrap = + item.os === "linux" && process.env.KILO_SKIP_BUNDLED_BWRAP !== "1" + ? await stageBubblewrap(item.arch, path.resolve(dir, `dist/${name}/bin`)) + : undefined + // kilocode_change end const localPath = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js") const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") @@ -326,6 +333,7 @@ for (const item of targets) { KILO_INDEXING_WORKER_PATH: indexingWorkerPath, // kilocode_change KILO_CHANNEL: `'${Script.channel}'`, KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", + KILO_BWRAP_SHA256: bwrap ? `'${bwrap}'` : "undefined", // kilocode_change KILO_BUILD_KIND: Script.release ? `'release'` : `'source'`, // kilocode_change }, }) @@ -376,6 +384,7 @@ for (const item of targets) { { name, version: Script.version, + license: pkg.license, // kilocode_change preferUnplugged: true, os: [item.os], cpu: [item.arch], diff --git a/packages/opencode/script/kilocode/bubblewrap.ts b/packages/opencode/script/kilocode/bubblewrap.ts new file mode 100644 index 00000000000..fc8512c4bda --- /dev/null +++ b/packages/opencode/script/kilocode/bubblewrap.ts @@ -0,0 +1,163 @@ +import { createHash } from "node:crypto" +import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs" +import os from "node:os" +import path from "node:path" + +const version = "0.11.2" +const commit = "1b80120ef26a28e065e67f89bfef873f13bdd317" +const sourceUrl = `https://codeload.github.com/containers/bubblewrap/tar.gz/${commit}` +const sourceSha256 = "55a1f42de8f62f6cd8cc414229ce166ec6128ca4386b8c25dfca4229e44b56aa" +const cache = process.env.KILO_BWRAP_CACHE ?? path.join(os.tmpdir(), "kilo-bubblewrap", commit) + +const capability = `#pragma once +#include +#include +#include +#include + +typedef struct __user_cap_header_struct *cap_user_header_t; +typedef struct __user_cap_data_struct *cap_user_data_t; +typedef int cap_value_t; + +static inline int capget(cap_user_header_t header, cap_user_data_t data) { + return (int) syscall(SYS_capget, header, data); +} + +static inline int capset(cap_user_header_t header, const cap_user_data_t data) { + return (int) syscall(SYS_capset, header, data); +} + +static inline int cap_from_name(const char *name, cap_value_t *cap) { + (void) name; + (void) cap; + errno = EINVAL; + return -1; +} +` + +const config = `#pragma once +#define PACKAGE_STRING "bubblewrap ${version} for Kilo" +#define PACKAGE_VERSION "${version}" +` + +function sha256(file: string) { + return createHash("sha256").update(readFileSync(file)).digest("hex") +} + +async function source() { + const archive = path.join(cache, `bubblewrap-${commit}.tar.gz`) + if (!existsSync(archive) || sha256(archive) !== sourceSha256) { + mkdirSync(cache, { recursive: true }) + const response = await fetch(sourceUrl) + if (!response.ok) throw new Error(`Could not download Bubblewrap source: ${response.status}`) + await Bun.write(archive, response) + if (sha256(archive) !== sourceSha256) throw new Error("Bubblewrap source digest mismatch") + } + + const root = path.join(cache, `bubblewrap-${commit}`) + if (!existsSync(root)) { + const proc = Bun.spawn(["tar", "-xzf", archive, "-C", cache], { stdout: "inherit", stderr: "inherit" }) + if ((await proc.exited) !== 0) throw new Error("Could not extract Bubblewrap source") + } + return { archive, root } +} + +function target(arch: "x64" | "arm64") { + return arch === "x64" ? "x86_64-linux-musl" : "aarch64-linux-musl" +} + +function muslLicense(zig: string) { + const result = Bun.spawnSync([zig, "env"]) + if (result.exitCode !== 0) throw new Error("Could not inspect the Zig toolchain") + const match = result.stdout.toString().match(/(?:"lib_dir"\s*:\s*|\.lib_dir\s*=\s*)"([^"]+)"/) + if (!match) throw new Error("Could not locate Zig's bundled musl license") + const license = path.join(match[1], "libc", "musl", "COPYRIGHT") + if (!existsSync(license)) throw new Error(`Zig's bundled musl license is missing at ${license}`) + return license +} + +async function compile(arch: "x64" | "arm64") { + const sourceTree = await source() + const out = path.join(cache, `bwrap-${arch}`) + const include = path.join(cache, "include", "sys") + const generated = path.join(cache, "generated") + mkdirSync(include, { recursive: true }) + mkdirSync(generated, { recursive: true }) + await Bun.write(path.join(include, "capability.h"), capability) + await Bun.write(path.join(generated, "config.h"), config) + + const zig = process.env.ZIG ?? "zig" + const args = [ + zig, + "cc", + "-target", + target(arch), + "-static", + "-fPIE", + "-pie", + "-s", + "-O2", + "-D_GNU_SOURCE", + "-I", + path.join(cache, "include"), + "-I", + generated, + "-I", + sourceTree.root, + path.join(sourceTree.root, "bubblewrap.c"), + path.join(sourceTree.root, "bind-mount.c"), + path.join(sourceTree.root, "network.c"), + path.join(sourceTree.root, "utils.c"), + "-o", + out, + ] + const proc = Bun.spawn(args, { stdout: "inherit", stderr: "inherit" }) + if ((await proc.exited) !== 0) throw new Error(`Could not build Bubblewrap for Linux ${arch}`) + chmodSync(out, 0o755) + + return { + executable: out, + digest: sha256(out), + archive: sourceTree.archive, + license: path.join(sourceTree.root, "COPYING"), + musl: muslLicense(zig), + } +} + +const builds = new Map<"x64" | "arm64", ReturnType>() + +export function buildBubblewrap(arch: "x64" | "arm64") { + const cached = builds.get(arch) + if (cached) return cached + const built = compile(arch) + builds.set(arch, built) + return built +} + +export async function stageBubblewrap(arch: "x64" | "arm64", dir: string) { + const built = await buildBubblewrap(arch) + const licenses = path.join(dir, "licenses", "bubblewrap") + mkdirSync(dir, { recursive: true }) + rmSync(licenses, { recursive: true, force: true }) + mkdirSync(licenses, { recursive: true }) + copyFileSync(built.executable, path.join(dir, "bwrap")) + copyFileSync(built.license, path.join(licenses, "COPYING")) + copyFileSync(built.musl, path.join(licenses, "MUSL-COPYRIGHT")) + copyFileSync(built.archive, path.join(licenses, `bubblewrap-${commit}.tar.gz`)) + copyFileSync(import.meta.path, path.join(licenses, "build.ts")) + chmodSync(path.join(dir, "bwrap"), 0o755) + return built.digest +} + +if (import.meta.main) { + const arch = process.argv[process.argv.indexOf("--arch") + 1] + const output = process.argv[process.argv.indexOf("--output") + 1] + if ((arch !== "x64" && arch !== "arm64") || !output) { + throw new Error("Usage: bun bubblewrap.ts --arch --output ") + } + const built = await buildBubblewrap(arch) + mkdirSync(path.dirname(output), { recursive: true }) + copyFileSync(built.executable, output) + chmodSync(output, 0o755) + console.log(`${output} sha256:${built.digest}`) +} diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index d158c645259..03a15d0c183 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -137,6 +137,20 @@ function copyResources(source) { fs.rmSync(target, { recursive: true, force: true }) fs.cpSync(dir, target, { recursive: true }) } + + const bwrap = path.join(path.dirname(source), "bwrap") + if (fs.existsSync(bwrap)) { + const target = path.join(__dirname, "bin", "bwrap") + fs.copyFileSync(bwrap, target) + fs.chmodSync(target, 0o755) + } + + const licenses = path.join(path.dirname(source), "licenses") + if (fs.existsSync(licenses)) { + const target = path.join(__dirname, "bin", "licenses") + fs.rmSync(target, { recursive: true, force: true }) + fs.cpSync(licenses, target, { recursive: true }) + } } function copyBinary(source) { diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 0d9ab24e2d5..3079261e890 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -105,7 +105,7 @@ if (!Script.preview) { "pkgdesc='The AI coding agent built for the terminal.'", "url='https://github.com/Kilo-Org/kilocode'", "arch=('aarch64' 'x86_64')", - "license=('MIT')", + "license=('MIT' 'LGPL-2.0-or-later')", // kilocode_change "provides=('kilo')", "conflicts=('kilo')", "depends=('ripgrep')", @@ -118,8 +118,10 @@ if (!Script.preview) { "", "package() {", ' install -Dm755 ./kilo "${pkgdir}/usr/lib/kilo/kilo"', // kilocode_change - ' install -dm755 "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/kilo/tree-sitter"', // kilocode_change + ' install -Dm755 ./bwrap "${pkgdir}/usr/lib/kilo/bwrap"', // kilocode_change + ' install -dm755 "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/kilo/tree-sitter" "${pkgdir}/usr/share/licenses/kilo"', // kilocode_change ' cp -r ./tree-sitter/. "${pkgdir}/usr/lib/kilo/tree-sitter/"', // kilocode_change + ' cp -r ./licenses/. "${pkgdir}/usr/share/licenses/kilo/"', // kilocode_change " printf '%s\\n' '#!/bin/sh' 'export KILO_TREE_SITTER_WASM_DIR=/usr/lib/kilo/tree-sitter' 'exec /usr/lib/kilo/kilo \"$@\"' > \"${pkgdir}/usr/bin/kilo\"", // kilocode_change ' chmod 755 "${pkgdir}/usr/bin/kilo"', // kilocode_change "}", @@ -184,7 +186,7 @@ if (!Script.preview) { ` url "https://github.com/Kilo-Org/kilocode/releases/download/v${Script.version}/kilo-linux-x64.tar.gz"`, ` sha256 "${x64Sha}"`, " def install", - ' libexec.install "kilo", "tree-sitter"', // kilocode_change + ' libexec.install "kilo", "bwrap", "tree-sitter", "licenses"', // kilocode_change ' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change " end", " end", @@ -192,7 +194,7 @@ if (!Script.preview) { ` url "https://github.com/Kilo-Org/kilocode/releases/download/v${Script.version}/kilo-linux-arm64.tar.gz"`, ` sha256 "${arm64Sha}"`, " def install", - ' libexec.install "kilo", "tree-sitter"', // kilocode_change + ' libexec.install "kilo", "bwrap", "tree-sitter", "licenses"', // kilocode_change ' (bin/"kilo").write_env_script libexec/"kilo", KILO_TREE_SITTER_WASM_DIR: libexec/"tree-sitter"', // kilocode_change " end", " end", From 2772ed0434989378e4c199667d87d3e9bf53ff2b Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 18:02:31 +0200 Subject: [PATCH 02/10] chore(ci): annotate Linux sandbox workflows --- .github/workflows/publish.yml | 6 ++++-- .github/workflows/test.yml | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b9a76b6b74d..4ecf968219b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,11 +84,13 @@ jobs: - uses: ./.github/actions/setup-bun + # kilocode_change start - name: Setup Zig for Linux sandbox helpers uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: 0.14.0 use-cache: false + # kilocode_change end - name: Build id: build @@ -225,9 +227,9 @@ jobs: # as optional packages and must be installed for any Bun-compiled musl binary to run. apk add --no-cache libstdc++ libgcc # kilocode_change end - binary="/dist/$PACKAGE/bin/kilo" + binary="/dist/$PACKAGE/bin/kilo" # kilocode_change "$binary" --version - "/dist/$PACKAGE/bin/bwrap" --version + "/dist/$PACKAGE/bin/bwrap" --version # kilocode_change root="$(mktemp -d)" trap '\''rm -rf "$root"'\'' EXIT unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a37de39bfac..134002c3c63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -63,6 +63,7 @@ jobs: - name: Setup Bun uses: ./.github/actions/setup-bun + # kilocode_change start - name: Setup Zig for Linux sandbox helper if: runner.os == 'Linux' uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -75,6 +76,7 @@ jobs: run: | bun packages/opencode/script/kilocode/bubblewrap.ts --arch x64 --output "$RUNNER_TEMP/bwrap" echo "KILO_BWRAP_PATH=$RUNNER_TEMP/bwrap" >> "$GITHUB_ENV" + # kilocode_change end - name: Configure git identity run: | @@ -96,11 +98,13 @@ jobs: KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} KILO_TEST_PROFILE: ${{ runner.os == 'macOS' && github.event_name == 'pull_request' && 'darwin' || '' }} # kilocode_change + # kilocode_change start - name: Test nested mount rejection if: runner.os == 'Linux' run: | sudo env PATH="$PATH" KILO_BWRAP_PATH="$KILO_BWRAP_PATH" KILO_TEST_PRIVILEGED_MOUNTS=1 \ "$(command -v bun)" test packages/core/test/kilocode/linux-sandbox.test.ts -t "nested mount" + # kilocode_change end - name: Run HttpApi exerciser gates if: runner.os == 'Linux' # kilocode_change From 002cf96c2720bf8fa985f35672e275dc85923ab8 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 18:20:00 +0200 Subject: [PATCH 03/10] fix(vscode): remove stale sandbox resources --- .../src/services/cli-backend/cli-resources.ts | 11 +++--- .../tests/unit/server-manager-utils.test.ts | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts index a397b1e1943..ea5aa2912d2 100644 --- a/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts +++ b/packages/kilo-vscode/src/services/cli-backend/cli-resources.ts @@ -41,16 +41,17 @@ export async function copyTreeSitterResources(source: string, target: string): P export async function copySandboxResources(source: string, target: string): Promise { const from = path.dirname(source) const to = path.dirname(target) + const helper = path.join(to, "bwrap") + const destination = path.join(to, "licenses", "bubblewrap") + await fs.promises.rm(helper, { force: true }) + await fs.promises.rm(destination, { recursive: true, force: true }) + const bwrap = path.join(from, "bwrap") if (!fs.existsSync(bwrap)) return - - const helper = path.join(to, "bwrap") await fs.promises.copyFile(bwrap, helper) await fs.promises.chmod(helper, 0o755) - const licenses = path.join(from, "licenses") + const licenses = path.join(from, "licenses", "bubblewrap") if (!fs.existsSync(licenses)) return - const destination = path.join(to, "licenses") - await fs.promises.rm(destination, { recursive: true, force: true }) await fs.promises.cp(licenses, destination, { recursive: true }) } diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index 094f85be475..93659317621 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -134,6 +134,40 @@ describe("cli tree-sitter resources", () => { await fs.rm(root, { recursive: true, force: true }) } }) + + it("removes stale sandbox resources when the source has none", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-vscode-sandbox-stale-")) + try { + const source = path.join(root, "dist", "bin", "kilo") + const target = path.join(root, "extension", "bin", "kilo") + const helper = path.join(path.dirname(target), "bwrap") + const license = path.join(path.dirname(target), "licenses", "bubblewrap", "COPYING") + + await fs.mkdir(path.dirname(source), { recursive: true }) + await fs.mkdir(path.dirname(license), { recursive: true }) + await fs.writeFile(source, "binary") + await fs.writeFile(target, "binary") + await fs.writeFile(helper, "stale helper") + await fs.writeFile(license, "stale license") + + await copySandboxResources(source, target) + + expect( + await fs.stat(helper).then( + () => true, + () => false, + ), + ).toBe(false) + expect( + await fs.stat(path.dirname(license)).then( + () => true, + () => false, + ), + ).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } + }) }) describe("toErrorMessage", () => { From 265a21199d74dfbea208144b117d1f3b25edc4c0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 18:56:16 +0200 Subject: [PATCH 04/10] chore(cli): clarify Bubblewrap licensing --- .github/workflows/publish.yml | 4 +++- .../tests/unit/server-manager-utils.test.ts | 5 +++++ packages/opencode/script/build.ts | 14 +++++++++++++- packages/opencode/script/kilocode/bubblewrap.ts | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4ecf968219b..866cf143b85 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -190,6 +190,7 @@ jobs: helper="$(dirname "$binary")/bwrap" if [[ "${{ matrix.target }}" == linux-* ]]; then test -x "$helper" + grep -q '^SPDX-License-Identifier: LGPL-2.0-or-later$' "$(dirname "$binary")/licenses/bubblewrap/NOTICE" "$helper" --version "$helper" --unshare-user --disable-userns --unshare-pid --die-with-parent --new-session \ --ro-bind / / --dev /dev --proc /proc -- "$helper" --version @@ -228,8 +229,9 @@ jobs: apk add --no-cache libstdc++ libgcc # kilocode_change end binary="/dist/$PACKAGE/bin/kilo" # kilocode_change - "$binary" --version + "$binary" --version # kilocode_change "/dist/$PACKAGE/bin/bwrap" --version # kilocode_change + grep -q '^SPDX-License-Identifier: LGPL-2.0-or-later$' "/dist/$PACKAGE/bin/licenses/bubblewrap/NOTICE" # kilocode_change root="$(mktemp -d)" trap '\''rm -rf "$root"'\'' EXIT unset KILO_MODELS_PATH KILO_MODELS_URL KILO_CONFIG KILO_CONFIG_DIR diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index 93659317621..58efd7f0977 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -114,6 +114,7 @@ describe("cli tree-sitter resources", () => { const target = path.join(root, "extension", "bin", "kilo") const helper = path.join(path.dirname(source), "bwrap") const license = path.join(path.dirname(source), "licenses", "bubblewrap", "COPYING") + const notice = path.join(path.dirname(license), "NOTICE") await fs.mkdir(path.dirname(license), { recursive: true }) await fs.mkdir(path.dirname(target), { recursive: true }) @@ -121,6 +122,7 @@ describe("cli tree-sitter resources", () => { await fs.writeFile(target, "binary") await fs.writeFile(helper, "helper") await fs.writeFile(license, "LGPL") + await fs.writeFile(notice, "SPDX-License-Identifier: LGPL-2.0-or-later") await copySandboxResources(source, target) @@ -130,6 +132,9 @@ describe("cli tree-sitter resources", () => { expect(await fs.readFile(path.join(path.dirname(target), "licenses", "bubblewrap", "COPYING"), "utf8")).toBe( "LGPL", ) + expect(await fs.readFile(path.join(path.dirname(target), "licenses", "bubblewrap", "NOTICE"), "utf8")).toBe( + "SPDX-License-Identifier: LGPL-2.0-or-later", + ) } finally { await fs.rm(root, { recursive: true, force: true }) } diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 09f7e73e256..73afccba689 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -379,12 +379,24 @@ for (const item of targets) { } await $`rm -rf ./dist/${name}/bin/tui` + // kilocode_change start + if (bwrap) { + const licenses = path.resolve(dir, `dist/${name}/bin/licenses/bubblewrap`) + const content = await Promise.all([ + Bun.file(path.resolve(dir, "../../LICENSE")).text(), + Bun.file(path.join(licenses, "NOTICE")).text(), + Bun.file(path.join(licenses, "COPYING")).text(), + Bun.file(path.join(licenses, "MUSL-COPYRIGHT")).text(), + ]) + await Bun.write(`dist/${name}/LICENSE`, content.join("\n\n---\n\n")) + } + // kilocode_change end await Bun.file(`dist/${name}/package.json`).write( JSON.stringify( { name, version: Script.version, - license: pkg.license, // kilocode_change + license: bwrap ? "SEE LICENSE IN LICENSE" : pkg.license, // kilocode_change preferUnplugged: true, os: [item.os], cpu: [item.arch], diff --git a/packages/opencode/script/kilocode/bubblewrap.ts b/packages/opencode/script/kilocode/bubblewrap.ts index fc8512c4bda..d6f5aa779a7 100644 --- a/packages/opencode/script/kilocode/bubblewrap.ts +++ b/packages/opencode/script/kilocode/bubblewrap.ts @@ -40,6 +40,19 @@ const config = `#pragma once #define PACKAGE_VERSION "${version}" ` +const notice = `Bubblewrap ${version} + +SPDX-License-Identifier: LGPL-2.0-or-later +Source: https://github.com/containers/bubblewrap/tree/${commit} + +Kilo distributes Bubblewrap as a separate executable. The complete license text is +in COPYING. The exact corresponding source is in bubblewrap-${commit}.tar.gz, and +the build recipe and generated compatibility headers are in build.ts. + +The executable is statically linked with musl. Its copyright and license notices +are in MUSL-COPYRIGHT. +` + function sha256(file: string) { return createHash("sha256").update(readFileSync(file)).digest("hex") } @@ -141,6 +154,7 @@ export async function stageBubblewrap(arch: "x64" | "arm64", dir: string) { rmSync(licenses, { recursive: true, force: true }) mkdirSync(licenses, { recursive: true }) copyFileSync(built.executable, path.join(dir, "bwrap")) + await Bun.write(path.join(licenses, "NOTICE"), notice) copyFileSync(built.license, path.join(licenses, "COPYING")) copyFileSync(built.musl, path.join(licenses, "MUSL-COPYRIGHT")) copyFileSync(built.archive, path.join(licenses, `bubblewrap-${commit}.tar.gz`)) From 35a73627a28a21d0b5414c247eef9eb88822de20 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 19:06:12 +0200 Subject: [PATCH 05/10] fix(ci): use allowed Zig setup --- .github/workflows/publish.yml | 12 +++++++----- .github/workflows/test.yml | 13 +++++++------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 866cf143b85..ae13d11948a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -86,12 +86,14 @@ jobs: # kilocode_change start - name: Setup Zig for Linux sandbox helpers - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 - with: - version: 0.14.0 - use-cache: false + run: | + curl --fail --location --retry 3 \ + https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \ + --output "$RUNNER_TEMP/zig.tar.xz" + echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status + tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH" # kilocode_change end - - name: Build id: build run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 134002c3c63..e9392e3c78b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -66,10 +66,13 @@ jobs: # kilocode_change start - name: Setup Zig for Linux sandbox helper if: runner.os == 'Linux' - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 - with: - version: 0.14.0 - use-cache: false + run: | + curl --fail --location --retry 3 \ + https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz \ + --output "$RUNNER_TEMP/zig.tar.xz" + echo "473ec26806133cf4d1918caf1a410f8403a13d979726a9045b421b685031a982 $RUNNER_TEMP/zig.tar.xz" | sha256sum --check --status + tar -xJf "$RUNNER_TEMP/zig.tar.xz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/zig-linux-x86_64-0.14.0" >> "$GITHUB_PATH" - name: Build Linux sandbox helper if: runner.os == 'Linux' @@ -77,7 +80,6 @@ jobs: bun packages/opencode/script/kilocode/bubblewrap.ts --arch x64 --output "$RUNNER_TEMP/bwrap" echo "KILO_BWRAP_PATH=$RUNNER_TEMP/bwrap" >> "$GITHUB_ENV" # kilocode_change end - - name: Configure git identity run: | git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com" @@ -105,7 +107,6 @@ jobs: sudo env PATH="$PATH" KILO_BWRAP_PATH="$KILO_BWRAP_PATH" KILO_TEST_PRIVILEGED_MOUNTS=1 \ "$(command -v bun)" test packages/core/test/kilocode/linux-sandbox.test.ts -t "nested mount" # kilocode_change end - - name: Run HttpApi exerciser gates if: runner.os == 'Linux' # kilocode_change working-directory: packages/opencode From f16fd3d01fba68c65edfb20b526ff051e5b1ca55 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 19:24:04 +0200 Subject: [PATCH 06/10] fix(ci): run mount test from core package --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e9392e3c78b..607bb5b7b90 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -103,9 +103,10 @@ jobs: # kilocode_change start - name: Test nested mount rejection if: runner.os == 'Linux' + working-directory: packages/core run: | sudo env PATH="$PATH" KILO_BWRAP_PATH="$KILO_BWRAP_PATH" KILO_TEST_PRIVILEGED_MOUNTS=1 \ - "$(command -v bun)" test packages/core/test/kilocode/linux-sandbox.test.ts -t "nested mount" + "$(command -v bun)" test ./test/kilocode/linux-sandbox.test.ts -t "nested mount" # kilocode_change end - name: Run HttpApi exerciser gates if: runner.os == 'Linux' # kilocode_change From 3332e26607df04f29b2aab15cb5e1938684b197e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 19:44:27 +0200 Subject: [PATCH 07/10] test(cli): avoid privileged sandbox checks --- .github/workflows/test.yml | 12 ++----- .../core/test/kilocode/linux-sandbox.test.ts | 31 ------------------- packages/kilo-sandbox/src/bubblewrap.ts | 13 +++++--- packages/kilo-sandbox/test/backend.test.ts | 22 +++++++++++++ 4 files changed, 33 insertions(+), 45 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 607bb5b7b90..dbb18d8a4d7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -101,19 +101,13 @@ jobs: KILO_TEST_PROFILE: ${{ runner.os == 'macOS' && github.event_name == 'pull_request' && 'darwin' || '' }} # kilocode_change # kilocode_change start - - name: Test nested mount rejection - if: runner.os == 'Linux' - working-directory: packages/core - run: | - sudo env PATH="$PATH" KILO_BWRAP_PATH="$KILO_BWRAP_PATH" KILO_TEST_PRIVILEGED_MOUNTS=1 \ - "$(command -v bun)" test ./test/kilocode/linux-sandbox.test.ts -t "nested mount" - # kilocode_change end - name: Run HttpApi exerciser gates - if: runner.os == 'Linux' # kilocode_change + if: runner.os == 'Linux' working-directory: packages/opencode run: bun run test:httpapi + # kilocode_change end - - name: Publish unit reports + - name: Publish unit reports # kilocode_change if: always() uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: diff --git a/packages/core/test/kilocode/linux-sandbox.test.ts b/packages/core/test/kilocode/linux-sandbox.test.ts index 773145ded8e..6a0d1b02ca1 100644 --- a/packages/core/test/kilocode/linux-sandbox.test.ts +++ b/packages/core/test/kilocode/linux-sandbox.test.ts @@ -9,7 +9,6 @@ import { backendSupport, run, type Profile } from "@kilocode/sandbox" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" const linux = process.platform === "linux" ? test : test.skip -const privileged = process.platform === "linux" && process.env.KILO_TEST_PRIVILEGED_MOUNTS === "1" ? test : test.skip function profile(allow: ReadonlyArray, denyNames: ReadonlyArray = []): Profile { return { @@ -467,33 +466,3 @@ linux("fails closed when Bubblewrap is unavailable", () => { }) expect(result.status, result.stderr).toBe(0) }) - -privileged("allows a mounted writable root but rejects its nested mount points", async () => { - const root = await fixture() - const nested = path.join(root.project, "nested mount") - const mounted = spawnSync("mount", ["-t", "tmpfs", "tmpfs", root.project], { encoding: "utf8" }) - expect(mounted.status, mounted.stderr).toBe(0) - - try { - const allowed = path.join(root.project, "allowed.txt") - const script = `require("node:fs").writeFileSync(${JSON.stringify(allowed)}, "allowed")` - expect(Number(await Effect.runPromise(spawn(script, root.project, profile([root.project]))))).toBe(0) - expect(await fs.readFile(allowed, "utf8")).toBe("allowed") - - await fs.mkdir(nested) - const child = spawnSync("mount", ["-t", "tmpfs", "tmpfs", nested], { encoding: "utf8" }) - expect(child.status, child.stderr).toBe(0) - try { - await expect(Effect.runPromise(spawn("process.exit(0)", root.project, profile([root.project])))).rejects.toThrow( - "nested mount point", - ) - } finally { - const unmounted = spawnSync("umount", [nested], { encoding: "utf8" }) - expect(unmounted.status, unmounted.stderr).toBe(0) - } - } finally { - const unmounted = spawnSync("umount", [root.project], { encoding: "utf8" }) - expect(unmounted.status, unmounted.stderr).toBe(0) - await fs.rm(root.root, { recursive: true, force: true }) - } -}) diff --git a/packages/kilo-sandbox/src/bubblewrap.ts b/packages/kilo-sandbox/src/bubblewrap.ts index ab7e807fd25..8f60d393e5b 100644 --- a/packages/kilo-sandbox/src/bubblewrap.ts +++ b/packages/kilo-sandbox/src/bubblewrap.ts @@ -59,13 +59,11 @@ function mountpoints() { }) } -function validate(allow: ReadonlyArray, executable: string) { +function validate(allow: ReadonlyArray, executable: string, mounts: ReadonlyArray) { if (allow.some((rule) => beneath(rule.path, executable))) { throw new Error(`Bubblewrap executable is writable by the sandbox profile: ${executable}`) } - if (process.platform !== "linux") return - const mounts = mountpoints() for (const rule of allow) { if (rule.kind !== "subtree") continue const nested = mounts.find((mount) => mount !== rule.path && beneath(rule.path, mount)) @@ -106,9 +104,14 @@ function protectedPaths(profile: Profile, allow: ReadonlyArray) { return [...found].sort((a, b) => a.length - b.length) } -export function generate(profile: Profile, launch: Launch, executable: string): Launch { +export function generate( + profile: Profile, + launch: Launch, + executable: string, + mounts = process.platform === "linux" ? mountpoints() : [], +): Launch { const allow = writable(profile) - validate(allow, executable) + validate(allow, executable, mounts) const args = [ "--unshare-user", "--disable-userns", diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts index 44c8138c0de..fa345d460f3 100644 --- a/packages/kilo-sandbox/test/backend.test.ts +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -87,6 +87,28 @@ describe("sandbox launch preparation", () => { } }) + test("allows a mounted writable root but rejects nested mount points", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-mount-")) + const nested = path.join(root, "nested mount") + const profile: Profile = { + ...makeProfile(), + filesystem: { + allowWrite: [{ path: root, kind: "subtree" }], + denyWrite: [], + denyNames: [], + }, + } + + try { + expect(() => generateBubblewrap(profile, launch, "/opt/kilo/bwrap", [root])).not.toThrow() + expect(() => generateBubblewrap(profile, launch, "/opt/kilo/bwrap", [root, nested])).toThrow( + `Writable root contains a nested mount point: ${nested}`, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + test("rejects a Bubblewrap executable inside a writable root", () => { const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-helper-")) const helper = path.join(root, "bwrap") From 64c496c508d6d4c681c4af8fdcf173210d5ae6e4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 23 Jun 2026 20:25:37 +0200 Subject: [PATCH 08/10] test(cli): cover Linux mountinfo parsing --- packages/kilo-sandbox/src/bubblewrap.ts | 8 ++++++-- packages/kilo-sandbox/test/backend.test.ts | 14 +++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/kilo-sandbox/src/bubblewrap.ts b/packages/kilo-sandbox/src/bubblewrap.ts index 8f60d393e5b..5df35b1985a 100644 --- a/packages/kilo-sandbox/src/bubblewrap.ts +++ b/packages/kilo-sandbox/src/bubblewrap.ts @@ -48,8 +48,8 @@ function unescape(value: string) { return value.replace(/\\([0-7]{3})/g, (_match, code: string) => String.fromCharCode(Number.parseInt(code, 8))) } -function mountpoints() { - return readFileSync("/proc/self/mountinfo", "utf8") +export function parseMountinfo(content: string) { + return content .split("\n") .filter(Boolean) .map((line) => { @@ -59,6 +59,10 @@ function mountpoints() { }) } +function mountpoints() { + return parseMountinfo(readFileSync("/proc/self/mountinfo", "utf8")) +} + function validate(allow: ReadonlyArray, executable: string, mounts: ReadonlyArray) { if (allow.some((rule) => beneath(rule.path, executable))) { throw new Error(`Bubblewrap executable is writable by the sandbox profile: ${executable}`) diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts index fa345d460f3..1f19ebda70f 100644 --- a/packages/kilo-sandbox/test/backend.test.ts +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -4,7 +4,7 @@ import os from "node:os" import path from "node:path" import { Effect } from "effect" import { backendSupport, prepare, type Launch } from "../src/backend" -import { generate as generateBubblewrap } from "../src/bubblewrap" +import { generate as generateBubblewrap, parseMountinfo } from "../src/bubblewrap" import { run } from "../src/context" import type { Profile } from "../src/profile" import { generate } from "../src/seatbelt" @@ -87,6 +87,18 @@ describe("sandbox launch preparation", () => { } }) + test("parses escaped mount points from Linux mountinfo", () => { + const content = [ + String.raw`36 25 0:32 / / rw,relatime - overlay overlay rw`, + String.raw`37 36 0:33 / /tmp/kilo\040root rw - tmpfs tmpfs rw`, + String.raw`38 37 0:34 / /tmp/kilo\040root/nested\011mount rw - tmpfs tmpfs rw`, + String.raw`39 36 0:35 / /tmp/back\134slash rw - tmpfs tmpfs rw`, + "", + ].join("\n") + + expect(parseMountinfo(content)).toEqual(["/", "/tmp/kilo root", "/tmp/kilo root/nested\tmount", "/tmp/back\\slash"]) + }) + test("allows a mounted writable root but rejects nested mount points", () => { const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-mount-")) const nested = path.join(root, "nested mount") From ebb744ce2d231626c3ced1f2c16a7a0968ee7705 Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Wed, 24 Jun 2026 11:14:48 +0200 Subject: [PATCH 09/10] fix(cli): reconcile Linux sandbox network tests --- packages/kilo-sandbox/src/bubblewrap.ts | 3 --- packages/kilo-sandbox/test/backend.test.ts | 12 +++------ packages/kilo-sandbox/test/filesystem.test.ts | 27 ++++++++++--------- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/kilo-sandbox/src/bubblewrap.ts b/packages/kilo-sandbox/src/bubblewrap.ts index 69966f7d9c8..5df35b1985a 100644 --- a/packages/kilo-sandbox/src/bubblewrap.ts +++ b/packages/kilo-sandbox/src/bubblewrap.ts @@ -114,9 +114,6 @@ export function generate( executable: string, mounts = process.platform === "linux" ? mountpoints() : [], ): Launch { - if (profile.network.mode !== "allow" || profile.network.allowedHosts.length > 0) { - throw new Error("Linux process sandbox network restrictions are not supported") - } const allow = writable(profile) validate(allow, executable, mounts) const args = [ diff --git a/packages/kilo-sandbox/test/backend.test.ts b/packages/kilo-sandbox/test/backend.test.ts index fad44d1d8df..9f0e36e4b4c 100644 --- a/packages/kilo-sandbox/test/backend.test.ts +++ b/packages/kilo-sandbox/test/backend.test.ts @@ -75,12 +75,6 @@ describe("sandbox launch preparation", () => { expect(args.args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "printf '%s' 'hello world'"]) }) - test("fails Linux network restrictions closed without changing the network namespace", () => { - expect(() => generateBubblewrap(makeProfile("deny"), launch, "/opt/kilo/bwrap", [])).toThrow( - "Linux process sandbox network restrictions are not supported", - ) - }) - test("layers Linux writable roots before protected git metadata without changing the network namespace", () => { const root = mkdtempSync(path.join(os.tmpdir(), "kilo-bubblewrap-policy-")) const git = path.join(root, ".git") @@ -173,12 +167,12 @@ describe("sandbox launch preparation", () => { }) test("merges profile environment values and applies exact deny names", async () => { - const result = await Effect.runPromise(Effect.scoped(run(makeProfile(), prepare(launch)))) + const result = await Effect.runPromise(Effect.scoped(run(makeProfile("allow"), prepare(launch)))) expect(result.environment?.KEEP).toBe("profile") expect(result.environment?.DROP).toBeUndefined() expect(result.environment?.RESET).toBeUndefined() - expect(result.environment?.HTTPS_PROXY).toBeUndefined() - expect(result.environment?.no_proxy).toBeUndefined() + expect(result.environment?.HTTPS_PROXY).toBe("http://127.0.0.1:9000") + expect(result.environment?.no_proxy).toBe("*") expect(result.environment?.PATH).toBeUndefined() }) diff --git a/packages/kilo-sandbox/test/filesystem.test.ts b/packages/kilo-sandbox/test/filesystem.test.ts index 8b9e5ac4ac9..1d22fe8b3c4 100644 --- a/packages/kilo-sandbox/test/filesystem.test.ts +++ b/packages/kilo-sandbox/test/filesystem.test.ts @@ -321,18 +321,21 @@ describe("sandbox FileSystem", () => { }, ) - test.skipIf(process.platform === "darwin")("fails closed when the OS backend is unavailable", async () => { - await execute( - run( - makeProfile(allowed), - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const denied = yield* fs.writeFileString(path.join(allowed, "blocked.txt"), "blocked").pipe(Effect.flip) - expect(denied.reason._tag).toBe("PermissionDenied") - }), - ), - ) - }) + test.skipIf(process.platform === "darwin" || process.platform === "linux")( + "fails closed when the OS backend is unavailable", + async () => { + await execute( + run( + makeProfile(allowed), + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const denied = yield* fs.writeFileString(path.join(allowed, "blocked.txt"), "blocked").pipe(Effect.flip) + expect(denied.reason._tag).toBe("PermissionDenied") + }), + ), + ) + }, + ) test("passes through mutations when no profile is active", async () => { await execute( From f893ed2c90b4598da7b108e3c6f5a79a3213eaa9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Wed, 24 Jun 2026 14:38:26 +0200 Subject: [PATCH 10/10] fix(cli): integrate sandbox status backend support --- packages/opencode/src/kilocode/sandbox/policy.ts | 7 ++++--- .../opencode/test/kilocode/sandbox/config-network.test.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/kilocode/sandbox/policy.ts b/packages/opencode/src/kilocode/sandbox/policy.ts index 9783a891047..d3ad7188113 100644 --- a/packages/opencode/src/kilocode/sandbox/policy.ts +++ b/packages/opencode/src/kilocode/sandbox/policy.ts @@ -114,11 +114,12 @@ export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: Se const directory = yield* InstanceState.directory const override = overrides.get(key(directory, sessionID)) const enabled = override?.enabled ?? cfg.experimental?.sandbox ?? false + const support = backendSupport() return { directory, - enabled: enabled && backendSupport.available, - available: backendSupport.available, - reason: backendSupport.reason, + enabled: enabled && support.available, + available: support.available, + reason: support.reason, version: override?.version ?? 0, } }) diff --git a/packages/opencode/test/kilocode/sandbox/config-network.test.ts b/packages/opencode/test/kilocode/sandbox/config-network.test.ts index 0b5f1fb5881..d9011561587 100644 --- a/packages/opencode/test/kilocode/sandbox/config-network.test.ts +++ b/packages/opencode/test/kilocode/sandbox/config-network.test.ts @@ -63,7 +63,7 @@ restricted.live("keeps network restriction enabled by default when the sandbox i Effect.provideService(InstanceRef, ctx), Effect.exit, ) - if (!backendSupport.available) { + if (!backendSupport().available) { expect(Exit.isSuccess(exit)).toBe(true) expect(target.requests()).toBe(1) return