diff --git a/backend/cli/src/cli/cmd/sandbox.ts b/backend/cli/src/cli/cmd/sandbox.ts new file mode 100644 index 00000000..f36b9a3a --- /dev/null +++ b/backend/cli/src/cli/cmd/sandbox.ts @@ -0,0 +1,157 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import { UI } from "../ui" +import { Instance } from "../../project/instance" +import { Config } from "../../config/config" +import { Sandbox } from "../../sandbox/sandbox" + +const S = UI.Style + +/** The trusted (global + managed) sandbox policy that actually gets enforced. */ +async function effectiveSandbox(): Promise { + return Config.trustedSandbox() +} + +function printStatus(config?: Config.Sandbox) { + const d = Sandbox.describe() + const enabled = config?.enabled === true + + UI.println(`${S.TEXT_NORMAL_BOLD}Execution sandbox${S.TEXT_NORMAL}`) + UI.println( + ` status ${enabled ? `${S.TEXT_SUCCESS_BOLD}enabled` : `${S.TEXT_DIM}disabled`}${S.TEXT_NORMAL}` + + `${S.TEXT_DIM} (agent shell commands${enabled ? " are confined to the workspace" : " run with full user authority"})${S.TEXT_NORMAL}`, + ) + UI.println(` platform ${d.platform}`) + UI.println( + ` backend ${ + d.available + ? `${S.TEXT_SUCCESS}${d.backend}${S.TEXT_NORMAL} ${S.TEXT_DIM}(${d.tool})${S.TEXT_NORMAL}` + : `${S.TEXT_WARNING}unavailable${S.TEXT_NORMAL} ${S.TEXT_DIM}— ${d.reason}${S.TEXT_NORMAL}` + }`, + ) + if (enabled) { + UI.println(` network ${config?.network ?? "allow"}`) + UI.println(` on missing backend ${config?.onUnavailable ?? "warn"}`) + if (config?.allowWrite?.length) UI.println(` extra writable ${config.allowWrite.join(", ")}`) + } + if (enabled && !d.available) { + UI.println("") + UI.println( + ` ${S.TEXT_WARNING_BOLD}Note:${S.TEXT_NORMAL} sandbox is on but no backend exists here — ` + + `commands run per "${config?.onUnavailable ?? "warn"}". It takes effect on machines with a backend.`, + ) + } +} + +async function showStatus() { + await Instance.provide({ + directory: process.cwd(), + async fn() { + printStatus(await effectiveSandbox()) + }, + }) +} + +const StatusCommand = cmd({ + command: ["status", "$0"], + describe: "show sandbox status (backend + current config)", + handler: async () => { + UI.empty() + await showStatus() + }, +}) + +const EnableCommand = cmd({ + command: "enable", + describe: "turn the execution sandbox on", + builder: (yargs: Argv) => + yargs + .option("network", { + choices: ["allow", "deny"] as const, + describe: "allow or deny network egress from sandboxed commands (default: allow)", + }) + .option("allow", { + type: "string", + array: true, + describe: "extra absolute path the sandbox may write to (repeatable)", + }) + .option("on-unavailable", { + choices: ["warn", "error", "allow"] as const, + describe: "what to do when no backend exists on a machine (default: warn)", + }), + handler: async (args) => { + await Instance.provide({ + directory: process.cwd(), + async fn() { + const patch: Partial = { enabled: true } + if (args.network) patch.network = args.network as "allow" | "deny" + if (args["on-unavailable"]) patch.onUnavailable = args["on-unavailable"] as "warn" | "error" | "allow" + const allow = args.allow as string[] | undefined + if (allow?.length) patch.allowWrite = allow + await Config.setSandbox(patch) + UI.empty() + UI.println(`${S.TEXT_SUCCESS_BOLD}Sandbox enabled${S.TEXT_NORMAL} ${S.TEXT_DIM}(global config)${S.TEXT_NORMAL}`) + const d = Sandbox.describe() + if (!d.available) { + UI.println( + `${S.TEXT_WARNING}No sandbox backend on this machine (${d.reason}).${S.TEXT_NORMAL} ` + + `It will apply where one is available.`, + ) + } + UI.empty() + }, + }) + await showStatus() + UI.empty() + UI.println(`${S.TEXT_DIM}Verify it holds: openscience sandbox test${S.TEXT_NORMAL}`) + }, +}) + +const DisableCommand = cmd({ + command: "disable", + describe: "turn the execution sandbox off", + handler: async () => { + await Config.setSandbox({ enabled: false }) + UI.empty() + UI.println(`${S.TEXT_NORMAL_BOLD}Sandbox disabled${S.TEXT_NORMAL} ${S.TEXT_DIM}(global config)${S.TEXT_NORMAL}`) + }, +}) + +const TestCommand = cmd({ + command: "test", + describe: "prove the sandbox actually confines writes (and network) on this machine", + handler: async () => { + UI.empty() + const result = await Sandbox.selfTest() + if (!result.available) { + const d = Sandbox.describe() + UI.println(`${S.TEXT_WARNING}No sandbox backend available${S.TEXT_NORMAL} — ${d.reason}.`) + UI.println(`${S.TEXT_DIM}Nothing to test here.${S.TEXT_NORMAL}`) + return + } + UI.println( + `${S.TEXT_NORMAL_BOLD}Sandbox self-test${S.TEXT_NORMAL} ${S.TEXT_DIM}(${result.backend})${S.TEXT_NORMAL}`, + ) + for (const c of result.checks) { + const mark = c.skipped ? `${S.TEXT_DIM}– skip` : c.pass ? `${S.TEXT_SUCCESS}✓ pass` : `${S.TEXT_DANGER}✗ FAIL` + UI.println(` ${mark}${S.TEXT_NORMAL} ${c.name}${c.detail ? ` ${S.TEXT_DIM}(${c.detail})${S.TEXT_NORMAL}` : ""}`) + } + UI.empty() + UI.println( + result.ok + ? `${S.TEXT_SUCCESS_BOLD}Containment verified.${S.TEXT_NORMAL}` + : `${S.TEXT_DANGER_BOLD}Containment FAILED — do not rely on the sandbox until this passes.${S.TEXT_NORMAL}`, + ) + }, +}) + +export const SandboxCommand = cmd({ + command: "sandbox", + describe: "manage the agent execution sandbox (confine shell commands to the workspace)", + builder: (yargs: Argv) => + yargs.command(StatusCommand).command(EnableCommand).command(DisableCommand).command(TestCommand).demandCommand(0), + handler: async () => { + UI.empty() + await showStatus() + }, +}) diff --git a/backend/cli/src/cli/onboard.ts b/backend/cli/src/cli/onboard.ts index f91695d0..7dde8058 100644 --- a/backend/cli/src/cli/onboard.ts +++ b/backend/cli/src/cli/onboard.ts @@ -6,6 +6,7 @@ import { OpenScience } from "../openscience" import { Auth } from "../auth" import { Config } from "../config/config" import { Provider } from "../provider/provider" +import { Sandbox } from "../sandbox/sandbox" import { Global } from "../global" import { openUrl } from "../util/open-url" import { runAtlasLogin } from "./cmd/connect" @@ -252,6 +253,20 @@ export const DoctorCommand = cmd({ prompts.log.success(`Local models: ${locals.map(([id]) => id).join(", ")} (run \`openscience local list\`)`) } prompts.log.info(`Default model: ${config.model ?? "auto (chosen from available providers)"}`) + + const sandbox = Sandbox.describe() + const sandboxOn = (await Config.trustedSandbox())?.enabled === true + const sandboxLine = sandboxOn + ? sandbox.available + ? { level: "success" as const, msg: `Sandbox: on (${sandbox.backend}) (run \`openscience sandbox test\`)` } + : { level: "warn" as const, msg: `Sandbox: on but no backend here — ${sandbox.reason}` } + : { + level: "info" as const, + msg: sandbox.available + ? `Sandbox: off (${sandbox.backend} available — \`openscience sandbox enable\`)` + : "Sandbox: off", + } + prompts.log[sandboxLine.level](sandboxLine.msg) } catch {} if (!(await isConfigured())) { diff --git a/backend/cli/src/config/config.ts b/backend/cli/src/config/config.ts index 74a42987..9d354465 100644 --- a/backend/cli/src/config/config.ts +++ b/backend/cli/src/config/config.ts @@ -622,6 +622,34 @@ export namespace Config { }) export type Permission = z.infer + export const Sandbox = z + .object({ + enabled: z + .boolean() + .optional() + .describe( + "Run the agent's shell commands inside an OS sandbox (macOS Seatbelt / Linux bubblewrap) that confines writes to the workspace. Off by default.", + ), + network: z + .enum(["allow", "deny"]) + .optional() + .describe("Whether sandboxed commands may reach the network. Default: allow."), + allowWrite: z + .array(z.string()) + .optional() + .describe("Extra absolute paths — beyond the workspace and temp dirs — the sandbox may write to."), + onUnavailable: z + .enum(["warn", "error", "allow"]) + .optional() + .describe( + "Behaviour when no sandbox backend exists on this platform: 'warn' (default) runs unsandboxed with a notice, 'error' refuses to run the command, 'allow' runs unsandboxed silently.", + ), + }) + .meta({ + ref: "SandboxConfig", + }) + export type Sandbox = z.infer + export const Command = z.object({ template: z.string(), description: z.string().optional(), @@ -1104,6 +1132,7 @@ export namespace Config { instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), layout: Layout.optional().describe("@deprecated Always uses stretch layout."), permission: Permission.optional(), + sandbox: Sandbox.optional().describe("OS-level execution sandbox for the agent's shell commands."), tools: z.record(z.string(), z.boolean()).optional(), enterprise: z .object({ @@ -1460,6 +1489,34 @@ export namespace Config { return patchConfigPath(scope, ["provider", id], undefined) } + /** + * Execution-sandbox policy resolved from GLOBAL + MANAGED (admin) config only. + * Project config is deliberately excluded: the sandbox is a machine-wide safety + * boundary, so an untrusted repo's `openscience.json` must not be able to weaken + * or disable it. Managed (enterprise) config wins over the user's global config. + */ + export async function trustedSandbox(): Promise { + const base = (await global()).sandbox + let managed: Sandbox | undefined + if (existsSync(managedConfigDir)) { + let acc: Info = {} + for (const file of CONFIG_FILES) acc = mergeDeep(acc, await loadFile(path.join(managedConfigDir, file))) + managed = acc.sandbox + } + if (!base && !managed) return undefined + return { ...(base ?? {}), ...(managed ?? {}) } + } + + /** Merge a patch into the GLOBAL `sandbox` config block, JSONC-preserving. The + * execution sandbox is a machine-wide safety setting and is only ever read + * from global + managed config (see {@link trustedSandbox}), so it is always + * written globally — a project-scoped value would be silently ignored. */ + export async function setSandbox(patch: Partial) { + const current = (await getGlobal()).sandbox + const next: Sandbox = { ...(current ?? {}), ...patch } + return patchConfigPath("global", ["sandbox"], next) + } + /** Remove a key path from the global config (deep-merge can't unset). */ export async function unsetGlobal(target: string[]) { return patchConfigPath("global", target, undefined) diff --git a/backend/cli/src/index.ts b/backend/cli/src/index.ts index 94128dd6..d6fbe4ac 100644 --- a/backend/cli/src/index.ts +++ b/backend/cli/src/index.ts @@ -37,6 +37,7 @@ import { ProjectCommand } from "./cli/cmd/project" import { WalletCommand } from "./cli/cmd/billing" import { KeysCommand, ConnectCommand, DisconnectCommand } from "./cli/cmd/auth" import { LocalCommand } from "./cli/cmd/local" +import { SandboxCommand } from "./cli/cmd/sandbox" import { InitCommand, DoctorCommand } from "./cli/onboard" import { OpenScience } from "./openscience" @@ -122,6 +123,7 @@ const cli = yargs(hideBin(process.argv)) .command(WebCommand) .command(ModelsCommand) .command(LocalCommand) + .command(SandboxCommand) .command(SkillCommand) .command(StatsCommand) .command(ExportCommand) diff --git a/backend/cli/src/sandbox/sandbox.ts b/backend/cli/src/sandbox/sandbox.ts new file mode 100644 index 00000000..2f261ffc --- /dev/null +++ b/backend/cli/src/sandbox/sandbox.ts @@ -0,0 +1,509 @@ +import path from "path" +import os from "os" +import fs from "fs" +import { spawn, spawnSync } from "child_process" +import { lazy } from "@/util/lazy" +import { Log } from "@/util/log" +import { Shell } from "@/shell/shell" + +const log = Log.create({ service: "sandbox" }) + +/** + * OS-level execution sandbox for the agent's shell commands. + * + * The permission system decides *whether* a command runs; it is an approval + * layer, not an isolation boundary — an approved (or auto-approved) command + * otherwise executes with the full authority of the user running OpenScience. + * This module adds the missing boundary: it wraps the command in a real OS + * sandbox so that, regardless of what the command tries to do, it cannot write + * outside the workspace (plus temp dirs) and — optionally — cannot reach the + * network. + * + * - macOS → Seatbelt via `sandbox-exec` (an SBPL profile). + * - Linux → `bubblewrap` (bwrap) mount namespaces. + * - other → no backend; the caller decides whether to warn, error, or run. + * + * The model is deliberately *write-containment* (allow-by-default, deny writes + * outside an allowlist, optionally deny network) rather than a deny-by-default + * syscall jail: research workflows run arbitrary compilers, package managers and + * interpreters, and a strict jail would break far more than it protects. Reads + * stay open; the threat this stops is tampering with files outside the workspace + * (`~/.ssh`, `~/.bashrc`, other projects) and, in network-deny mode, silent + * exfiltration. + */ +export namespace Sandbox { + export type Backend = "seatbelt" | "bubblewrap" | "none" + + export interface Policy { + /** Absolute paths the sandboxed process may write to. */ + writable: string[] + /** Whether the sandboxed process may reach the network. */ + network: boolean + } + + /** A ready-to-spawn argv: `spawn(file, args)` with no shell wrapping. */ + export interface Spec { + file: string + args: string[] + } + + /** User-facing config knobs (mirrors Config.Sandbox, kept dependency-free). */ + export interface Options { + enabled?: boolean + network?: "allow" | "deny" + allowWrite?: string[] + onUnavailable?: "warn" | "error" | "allow" + } + + export interface Plan { + /** Program to spawn. */ + file: string + /** Args when running sandboxed; undefined when running the raw command. */ + args?: string[] + /** `shell` option to pass to spawn (a shell path for the raw command, else false). */ + useShell: string | false + /** True when the command is wrapped in an OS sandbox. */ + sandboxed: boolean + backend: Backend + /** One-time human-readable note (e.g. sandbox requested but unavailable). */ + warning?: string + } + + /** Result of wrapping a raw argv (used by the notebook/R kernels). */ + export interface Wrapped { + /** Program to spawn — the backend wrapper when sandboxed, else the original file. */ + file: string + /** Args to spawn — the original argv is preserved at the tail when sandboxed. */ + args: string[] + sandboxed: boolean + backend: Backend + warning?: string + } + + export class UnavailableError extends Error { + constructor(message: string) { + super(message) + this.name = "SandboxUnavailableError" + } + } + + // ── backend detection ─────────────────────────────────────────────────────── + + function probeBubblewrap(bin: string): boolean { + // bwrap can exist yet fail at runtime when unprivileged user namespaces are + // disabled (kernel.unprivileged_userns_clone=0, some hardened distros), and + // --unshare-pid needs a usable PID namespace. Probe with the same namespace + // ops the real sandbox uses so detection matches enforcement. + try { + const res = spawnSync( + bin, + ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--unshare-pid", "--", "true"], + { stdio: "ignore", timeout: 5000 }, + ) + return res.status === 0 + } catch { + return false + } + } + + const detected = lazy(() => { + if (process.platform === "darwin") { + return Bun.which("sandbox-exec") ? "seatbelt" : "none" + } + if (process.platform === "linux") { + const bin = Bun.which("bwrap") + if (!bin) return "none" + return probeBubblewrap(bin) ? "bubblewrap" : "none" + } + return "none" + }) + + /** The sandbox backend usable on this machine right now, or "none". */ + export function backend(): Backend { + return detected() + } + + export function available(): boolean { + return backend() !== "none" + } + + /** Backend + platform summary for status output (CLI `doctor`, GUI panel). */ + export function describe(): { + platform: NodeJS.Platform + backend: Backend + available: boolean + tool?: string + reason?: string + } { + const b = backend() + if (b === "seatbelt") return { platform: process.platform, backend: b, available: true, tool: "sandbox-exec" } + if (b === "bubblewrap") return { platform: process.platform, backend: b, available: true, tool: "bwrap" } + const reason = + process.platform === "darwin" + ? "sandbox-exec not found on PATH" + : process.platform === "linux" + ? "bubblewrap (bwrap) is not installed, or unprivileged user namespaces are disabled" + : `no sandbox backend for platform "${process.platform}"` + return { platform: process.platform, backend: "none", available: false, reason } + } + + // ── writable-path assembly ────────────────────────────────────────────────── + + /** Temp dirs a sandboxed command legitimately needs to write to. */ + export function tempDirs(): string[] { + const dirs = new Set() + const add = (d?: string | null) => { + if (d) dirs.add(d) + } + add(process.env.TMPDIR) + add(process.env.TMP) + add(process.env.TEMP) + add(os.tmpdir()) + add("/tmp") + if (process.platform === "darwin") add("/private/tmp") + return [...dirs] + } + + function dedupe(paths: string[]): string[] { + const out = new Set() + for (const p of paths) { + if (p) out.add(path.resolve(p)) + } + return [...out] + } + + /** + * A path too broad to ever be a sandbox writable root: granting write here + * would hand back most of the filesystem and defeat containment. Guards + * against a project/worktree opened at "/" and against `TMPDIR`/`allowWrite` + * pointing at `$HOME`, `/etc`, etc. Subdirectories of these (e.g. a real + * project under `$HOME/code/foo`) are fine — only the roots themselves are + * refused. + */ + function tooBroadToConfine(p: string): boolean { + if (p === "/" || p === path.parse(p).root) return true + const home = os.homedir() + if (p === home) return true + if (home.startsWith(p + path.sep)) return true // ancestor of home, e.g. "/home", "/Users" + const roots = [ + "/etc", + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib64", + "/boot", + "/root", + "/var", + "/opt", + "/dev", + "/proc", + "/sys", + ] + return roots.includes(p) + } + + /** Assemble the writable allowlist for a policy, dropping over-broad roots. */ + function buildPolicy(input: { workspace: string[]; extraWritable?: string[]; options: Options }): Policy { + const candidates = dedupe([ + ...input.workspace, + ...tempDirs(), + ...(input.options.allowWrite ?? []), + ...(input.extraWritable ?? []), + ]) + const writable = candidates.filter((p) => { + if (tooBroadToConfine(p)) { + log.warn("refusing to grant sandbox write access to an over-broad path", { path: p }) + return false + } + return true + }) + return { writable, network: (input.options.network ?? "allow") !== "deny" } + } + + // ── macOS: Seatbelt (sandbox-exec) ────────────────────────────────────────── + + /** Escape a path for an SBPL double-quoted string literal. */ + function sbpl(p: string): string { + return p.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + } + + /** Add the macOS `/private/...` firmlink alias for /tmp,/var,/etc paths. */ + function withPrivateAliases(paths: string[]): string[] { + const out = new Set(paths) + for (const p of paths) { + for (const root of ["/tmp", "/var", "/etc"]) { + if (p === root || p.startsWith(root + "/")) out.add("/private" + p) + } + } + return [...out] + } + + export function seatbeltProfile(policy: Policy): string { + const lines = ["(version 1)", "(allow default)"] + if (!policy.network) lines.push("(deny network*)") + lines.push("(deny file-write*)") + + const writable = withPrivateAliases(dedupe(policy.writable)) + if (writable.length) { + lines.push(`(allow file-write* ${writable.map((p) => `(subpath "${sbpl(p)}")`).join(" ")})`) + } + // Character devices tools legitimately write (null, tty, ptys, urandom, …). + lines.push('(allow file-write* (subpath "/dev"))') + return lines.join("\n") + } + + // ── Linux: bubblewrap (bwrap) ─────────────────────────────────────────────── + + export function bubblewrapArgs(policy: Policy): string[] { + // Whole fs read-only, a fresh /dev and /proc, and a throwaway writable /tmp; + // then re-mount the bits that must be writable on top. + const args = ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp"] + for (const p of dedupe(policy.writable)) { + // Skip only the /tmp mount root itself — it is provided as a fresh tmpfs and + // re-binding host /tmp would defeat it. A workspace that lives *under* /tmp + // still needs binding on top of the tmpfs, or its writes vanish. + if (p === "/tmp") continue + // --bind-try: don't abort if the source path doesn't exist. + args.push("--bind-try", p, p) + } + if (!policy.network) args.push("--unshare-net") + // --unshare-pid: don't share the host PID namespace, so /proc//root of a + // same-uid host process can't be used to write through the read-only bind. + args.push("--unshare-pid", "--die-with-parent") + return args + } + + /** Wrap an arbitrary argv under the active backend, or null when unavailable. */ + function specForArgv(argv: string[], policy: Policy): Spec | null { + switch (backend()) { + case "seatbelt": + return { file: "sandbox-exec", args: ["-p", seatbeltProfile(policy), ...argv] } + case "bubblewrap": + return { file: "bwrap", args: [...bubblewrapArgs(policy), "--", ...argv] } + default: + return null + } + } + + // ── planning (consumed by the bash tool and the kernels) ──────────────────── + + // Warn only once per process so every command doesn't repeat the same notice. + const warned = { unavailable: false } + + function unavailableMessage(): string { + return `Sandbox is enabled but unavailable on this machine (${describe().reason}). Running the command WITHOUT isolation. Install the backend, or set sandbox.onUnavailable to "error" to refuse instead.` + } + + /** + * Resolve which backend a command should use given the config. Returns + * backend "none" (run unsandboxed) with an optional one-time warning, or the + * active backend. Throws UnavailableError only when `onUnavailable: "error"` + * and no backend exists. + */ + function decide(options?: Options): { backend: Backend; warning?: string } { + if (options?.enabled !== true) return { backend: "none" } + const b = backend() + if (b !== "none") return { backend: b } + const mode = options.onUnavailable ?? "warn" + if (mode === "error") throw new UnavailableError(unavailableMessage()) + const warning = mode === "warn" && !warned.unavailable ? unavailableMessage() : undefined + if (warning) { + warned.unavailable = true + log.warn("sandbox enabled but unavailable", { platform: process.platform }) + } + return { backend: "none", warning } + } + + /** + * Decide how to run a shell command given the sandbox config and the + * workspace. Never throws unless `onUnavailable: "error"` and no backend + * exists. The `cwd` is *not* granted write access unless it lies within the + * workspace — an approved external working directory is a permission decision, + * not a reason to widen the write boundary to the escape target. + */ + export function plan(input: { + command: string + shell: string + cwd: string + /** Workspace roots (Instance.directory + worktree) that stay writable. */ + workspace: string[] + options?: Options + }): Plan { + const { backend: b, warning } = decide(input.options) + if (b === "none") { + return { file: input.command, useShell: input.shell, sandboxed: false, backend: "none", warning } + } + const policy = buildPolicy({ workspace: input.workspace, options: input.options! }) + const s = specForArgv([input.shell, "-c", input.command], policy)! + log.info("sandboxing command", { backend: b, network: policy.network, writable: policy.writable.length }) + return { file: s.file, args: s.args, useShell: false, sandboxed: true, backend: b, warning } + } + + /** + * Wrap a raw argv (program + args, no shell) — used by the notebook/R kernels + * which spawn an interpreter directly. When the sandbox is off or unavailable + * the original `file`/`args` are returned unchanged, so callers can spawn the + * result verbatim. + */ + export function wrapArgv(input: { + file: string + args: string[] + /** Workspace roots that stay writable. */ + workspace: string[] + /** Extra paths (e.g. a generated kernel script under /tmp) to keep writable/visible. */ + extraWritable?: string[] + options?: Options + }): Wrapped { + const { backend: b, warning } = decide(input.options) + if (b === "none") { + return { file: input.file, args: input.args, sandboxed: false, backend: "none", warning } + } + const policy = buildPolicy({ + workspace: input.workspace, + extraWritable: input.extraWritable, + options: input.options!, + }) + const s = specForArgv([input.file, ...input.args], policy)! + log.info("sandboxing process", { backend: b, network: policy.network, writable: policy.writable.length }) + return { file: s.file, args: s.args, sandboxed: true, backend: b, warning } + } + + // ── self-test (proves the boundary actually holds on this machine) ────────── + + export interface Check { + name: string + pass: boolean + skipped?: boolean + detail?: string + } + + export interface SelfTest { + backend: Backend + available: boolean + checks: Check[] + ok: boolean + } + + function firstLine(s?: string): string | undefined { + const line = s?.trim().split("\n")[0] + return line || undefined + } + + function runAsync(file: string, args: string[], cwd: string): Promise<{ status: number; stderr: string }> { + return new Promise((resolve) => { + const proc = spawn(file, args, { cwd, stdio: ["ignore", "ignore", "pipe"] }) + let stderr = "" + proc.stderr?.on("data", (d) => { + stderr += d.toString() + }) + const timer = setTimeout(() => proc.kill("SIGKILL"), 15000) + proc.once("exit", (code) => { + clearTimeout(timer) + resolve({ status: code ?? 1, stderr }) + }) + proc.once("error", (err) => { + clearTimeout(timer) + resolve({ status: 1, stderr: String(err) }) + }) + }) + } + + /** + * Empirically verify the sandbox on this machine: write inside a scratch + * workspace (must succeed), write outside it (must be attempted-and-blocked), + * and — when connectivity allows — confirm network-deny mode blocks egress. + * Spawns real sandboxed commands; safe to run anytime. Async so it never + * blocks the server event loop. + */ + export async function selfTest(): Promise { + const b = backend() + if (b === "none") return { backend: b, available: false, checks: [], ok: false } + + const shell = Shell.acceptable() + const work = fs.mkdtempSync(path.join(os.tmpdir(), "openscience-sbx-")) + const outside = path.join(os.homedir(), `.openscience-sbx-escape-${process.pid}`) + const checks: Check[] = [] + + const run = (command: string, network: "allow" | "deny") => { + const p = plan({ command, shell, cwd: work, workspace: [work], options: { enabled: true, network } }) + return runAsync(p.file, p.args ?? [], work) + } + + try { + const inside = await run(`printf hi > "${work}/probe" && cat "${work}/probe"`, "allow") + const insideOk = inside.status === 0 + checks.push({ + name: "write inside the workspace succeeds", + pass: insideOk, + detail: insideOk ? undefined : firstLine(inside.stderr), + }) + if (!insideOk) { + // The sandbox isn't running commands correctly here; the remaining checks + // would false-pass (an escape file simply never gets created), so don't + // assert containment we can't stand behind. + checks.push({ + name: "write outside the workspace is blocked", + pass: false, + skipped: true, + detail: "inconclusive — inside-write failed, sandbox not functioning here", + }) + return { backend: b, available: true, checks, ok: false } + } + + fs.rmSync(outside, { force: true }) + const escape = await run(`printf x > "${outside}"`, "allow") + const escaped = fs.existsSync(outside) + checks.push({ + name: "write outside the workspace is blocked", + // Require both: no file escaped AND the write was actually refused (not + // silently succeeding). A missing file with exit 0 means the write went + // somewhere unexpected, not that it was denied. + pass: !escaped && escape.status !== 0, + detail: escaped + ? `a file escaped to ${outside}` + : escape.status === 0 + ? "write outside reported success — not denied" + : undefined, + }) + + const curlCmd = `curl -m 5 -s -o /dev/null https://example.com` + if (Bun.which("curl")) { + // Distinguish "sandbox blocked it" from "machine is offline" by checking + // that egress works in allow-mode before asserting deny-mode blocks it. + const allow = await run(curlCmd, "allow") + if (allow.status !== 0) { + checks.push({ + name: "network egress blocked in deny mode", + pass: true, + skipped: true, + detail: "no outbound connectivity — inconclusive", + }) + } else { + const deny = await run(curlCmd, "deny") + checks.push({ + name: "network egress blocked in deny mode", + pass: deny.status !== 0, + detail: deny.status === 0 ? "egress succeeded despite deny" : undefined, + }) + } + } else { + checks.push({ + name: "network egress blocked in deny mode", + pass: true, + skipped: true, + detail: "curl not available — skipped", + }) + } + } finally { + try { + fs.rmSync(outside, { force: true }) + } catch {} + try { + fs.rmSync(work, { recursive: true, force: true }) + } catch {} + } + + return { backend: b, available: true, checks, ok: checks.filter((c) => !c.skipped).every((c) => c.pass) } + } +} diff --git a/backend/cli/src/server/routes/settings/sandbox.ts b/backend/cli/src/server/routes/settings/sandbox.ts new file mode 100644 index 00000000..83b175d7 --- /dev/null +++ b/backend/cli/src/server/routes/settings/sandbox.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono" +import { validator } from "hono-openapi" +import z from "zod" +import { lazy } from "../../../util/lazy" +import { Log } from "../../../util/log" +import { Config } from "../../../config/config" +import { Sandbox } from "../../../sandbox/sandbox" + +const log = Log.create({ service: "settings-sandbox" }) + +const PatchSchema = z.object({ + enabled: z.boolean().optional(), + network: z.enum(["allow", "deny"]).optional(), + allowWrite: z.array(z.string()).optional(), + onUnavailable: z.enum(["warn", "error", "allow"]).optional(), +}) + +async function currentConfig() { + // Read the trusted (global + managed) policy directly — the same source the + // bash tool enforces — rather than Config.get(), which needs an Instance + // context this route is mounted before and would otherwise always throw → {}. + return (await Config.trustedSandbox().catch(() => undefined)) ?? {} +} + +/** + * Execution-sandbox settings for the workspace GUI. The SPA can neither detect + * the OS backend nor spawn a probe itself, so the server — which runs the + * commands — reports availability, persists the config, and runs the empirical + * self-test on its behalf. Mirrors the `openscience sandbox` CLI. + */ +export const SandboxSettingsRoutes = lazy(() => + new Hono() + // Current config + backend availability. + .get("/", async (c) => c.json({ config: await currentConfig(), status: Sandbox.describe() })) + + // Persist a partial config patch (machine-wide / global). + .put("/", validator("json", PatchSchema), async (c) => { + const patch = c.req.valid("json") + log.info("updating sandbox config", { keys: Object.keys(patch) }) + await Config.setSandbox(patch) + return c.json({ config: await currentConfig(), status: Sandbox.describe() }) + }) + + // Run the empirical containment self-test. + .post("/test", async (c) => c.json(await Sandbox.selfTest())), +) diff --git a/backend/cli/src/server/server.ts b/backend/cli/src/server/server.ts index a617a899..b26b9e34 100644 --- a/backend/cli/src/server/server.ts +++ b/backend/cli/src/server/server.ts @@ -51,6 +51,7 @@ import { ComputeSettingsRoutes } from "./routes/settings/compute" import { RegistryPermissionsRoutes } from "./routes/settings/registry-permissions" import { SettingsPreferencesRoutes } from "./routes/settings/preferences" import { LocalModelsRoutes } from "./routes/settings/local" +import { SandboxSettingsRoutes } from "./routes/settings/sandbox" import { BillingSettingsRoutes } from "./routes/settings/billing" import { WalletSettingsRoutes } from "./routes/settings/wallet" import { SettingsUsageRoutes } from "./routes/settings/usage" @@ -161,6 +162,7 @@ export namespace Server { .route("/settings/permissions", RegistryPermissionsRoutes()) .route("/settings/preferences", SettingsPreferencesRoutes()) .route("/settings/local", LocalModelsRoutes()) + .route("/settings/sandbox", SandboxSettingsRoutes()) .route("/settings/billing", BillingSettingsRoutes()) .route("/settings/wallet", WalletSettingsRoutes()) .put( diff --git a/backend/cli/src/tool/bash.ts b/backend/cli/src/tool/bash.ts index 65731f3c..57869fda 100644 --- a/backend/cli/src/tool/bash.ts +++ b/backend/cli/src/tool/bash.ts @@ -17,6 +17,8 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" import { OpenScience } from "@/openscience" +import { Sandbox } from "@/sandbox/sandbox" +import { Config } from "@/config/config" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENSCIENCE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 0 @@ -165,14 +167,34 @@ export const BashTool = Tool.define("bash", async () => { // provider keys (auth.json + shell env), not just synced managed ones. await OpenScience.refreshByokSecrets(process.env).catch(() => {}) - const proc = spawn(params.command, { + const env = await OpenScience.subprocessEnv(process.env) + + // Wrap the command in an OS sandbox when configured. The permission checks + // above decide *whether* to run; this decides *with what authority*. When + // sandbox is off (default) `plan` returns the raw command unchanged. + const sandbox = Sandbox.plan({ + command: params.command, shell, cwd, - env: await OpenScience.subprocessEnv(process.env), - stdio: ["ignore", "pipe", "pipe"], - detached: process.platform !== "win32", + workspace: [Instance.directory, Instance.worktree], + options: await Config.trustedSandbox(), }) + const proc = sandbox.sandboxed + ? spawn(sandbox.file, sandbox.args ?? [], { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + : spawn(sandbox.file, { + shell: sandbox.useShell, + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + }) + let output = "" // Initialize metadata with empty output @@ -253,6 +275,10 @@ export const BashTool = Tool.define("bash", async () => { const resultMetadata: string[] = [] + if (sandbox.warning) { + resultMetadata.push(sandbox.warning) + } + if (timedOut) { resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) } diff --git a/backend/cli/src/tool/biology/notebook.ts b/backend/cli/src/tool/biology/notebook.ts index bd14c903..30f3d9d7 100644 --- a/backend/cli/src/tool/biology/notebook.ts +++ b/backend/cli/src/tool/biology/notebook.ts @@ -5,6 +5,8 @@ import path from "path" import os from "os" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" +import { Config } from "@/config/config" +import { Sandbox } from "@/sandbox/sandbox" const KERNEL_SCRIPT = ` import sys, json, io, traceback, os @@ -134,7 +136,16 @@ async function getKernel(sessionID: string): Promise { await Bun.write(scriptPath, KERNEL_SCRIPT) const pythonBin = await findPython() - const proc = spawn(pythonBin, ["-u", scriptPath], { + // Confine the kernel to the workspace when the execution sandbox is on: it runs + // arbitrary agent-authored code — the same threat model as the bash tool. + const sandboxed = Sandbox.wrapArgv({ + file: pythonBin, + args: ["-u", scriptPath], + workspace: [Instance.directory, Instance.worktree], + extraWritable: [scriptPath], + options: await Config.trustedSandbox(), + }) + const proc = spawn(sandboxed.file, sandboxed.args, { cwd: Instance.directory, env: { ...(await OpenScience.subprocessEnv(process.env)), PYTHONUNBUFFERED: "1" }, stdio: ["pipe", "pipe", "pipe"], diff --git a/backend/cli/src/tool/notebook.ts b/backend/cli/src/tool/notebook.ts index 0b642b4e..4bf0036a 100644 --- a/backend/cli/src/tool/notebook.ts +++ b/backend/cli/src/tool/notebook.ts @@ -6,6 +6,8 @@ import os from "os" import { unlinkSync } from "fs" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" +import { Config } from "@/config/config" +import { Sandbox } from "@/sandbox/sandbox" import type { Kernel, KernelManager, @@ -234,7 +236,17 @@ class PythonKernel implements Kernel { this.scriptPath = scriptPath const bin = await findPython(opts?.binary) - const proc = spawn(bin, ["-u", scriptPath], { + // Confine the kernel to the workspace when the execution sandbox is on: the + // notebook runs arbitrary agent-authored code — the same threat model as the + // bash tool — so it must not be able to escape the boundary bash respects. + const sandboxed = Sandbox.wrapArgv({ + file: bin, + args: ["-u", scriptPath], + workspace: [Instance.directory, Instance.worktree], + extraWritable: [scriptPath], + options: await Config.trustedSandbox(), + }) + const proc = spawn(sandboxed.file, sandboxed.args, { cwd: opts?.cwd ?? Instance.directory, env: { ...(await OpenScience.subprocessEnv(process.env)), ...(opts?.env ?? {}), PYTHONUNBUFFERED: "1" }, stdio: ["pipe", "pipe", "pipe"], diff --git a/backend/cli/src/tool/rkernel.ts b/backend/cli/src/tool/rkernel.ts index cbd3f083..bc002aa2 100644 --- a/backend/cli/src/tool/rkernel.ts +++ b/backend/cli/src/tool/rkernel.ts @@ -6,6 +6,8 @@ import os from "os" import { unlinkSync } from "fs" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" +import { Config } from "@/config/config" +import { Sandbox } from "@/sandbox/sandbox" import type { Kernel, KernelManager, @@ -219,7 +221,17 @@ class RKernel implements Kernel { await Bun.write(scriptPath, KERNEL_SCRIPT) this.scriptPath = scriptPath - const proc = spawn(bin, ["--vanilla", scriptPath], { + // Confine the kernel to the workspace when the execution sandbox is on: the R + // kernel runs arbitrary agent-authored code — the same threat model as the + // bash tool — so it must respect the same boundary. + const sandboxed = Sandbox.wrapArgv({ + file: bin, + args: ["--vanilla", scriptPath], + workspace: [Instance.directory, Instance.worktree], + extraWritable: [scriptPath], + options: await Config.trustedSandbox(), + }) + const proc = spawn(sandboxed.file, sandboxed.args, { cwd: opts?.cwd ?? Instance.directory, env: { ...(await OpenScience.subprocessEnv(process.env)), ...(opts?.env ?? {}) }, stdio: ["pipe", "pipe", "pipe"], diff --git a/backend/cli/test/sandbox/sandbox.test.ts b/backend/cli/test/sandbox/sandbox.test.ts new file mode 100644 index 00000000..503f7c6f --- /dev/null +++ b/backend/cli/test/sandbox/sandbox.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test" +import os from "os" +import { Sandbox } from "../../src/sandbox/sandbox" + +const shell = "/bin/sh" + +describe("Sandbox.seatbeltProfile", () => { + test("denies writes by default and re-allows the workspace", () => { + const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], network: true }) + expect(profile).toContain("(version 1)") + expect(profile).toContain("(allow default)") + expect(profile).toContain("(deny file-write*)") + expect(profile).toContain('(subpath "/work/project")') + }) + + test("network:false adds a network deny; network:true does not", () => { + expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: false })).toContain("(deny network*)") + expect(Sandbox.seatbeltProfile({ writable: ["/w"], network: true })).not.toContain("(deny network*)") + }) + + test("a path outside the allowlist is not granted write access", () => { + const profile = Sandbox.seatbeltProfile({ writable: ["/work/project"], network: true }) + expect(profile).not.toContain('(subpath "/etc/passwd")') + expect(profile).not.toContain(process.env.HOME + "/.ssh") + }) + + test("adds the macOS /private firmlink alias for /tmp", () => { + const profile = Sandbox.seatbeltProfile({ writable: ["/tmp"], network: true }) + expect(profile).toContain('(subpath "/tmp")') + expect(profile).toContain('(subpath "/private/tmp")') + }) + + test("escapes quotes in paths so the profile cannot be broken out of", () => { + const profile = Sandbox.seatbeltProfile({ writable: ['/weird/pa"th'], network: true }) + expect(profile).toContain('/weird/pa\\"th') + }) +}) + +describe("Sandbox.bubblewrapArgs", () => { + test("mounts the fs read-only then re-binds the workspace writable", () => { + const args = Sandbox.bubblewrapArgs({ writable: ["/work/project"], network: true }) + expect(args.slice(0, 3)).toEqual(["--ro-bind", "/", "/"]) // whole fs read-only first + expect(args).toContain("--die-with-parent") + const i = args.indexOf("--bind-try") + expect(i).toBeGreaterThan(-1) + expect(args[i + 1]).toBe("/work/project") + expect(args[i + 2]).toBe("/work/project") + }) + + test("network:false unshares the network namespace", () => { + expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: false })).toContain("--unshare-net") + expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: true })).not.toContain("--unshare-net") + }) + + test("skips the /tmp tmpfs root but binds workspace paths under it", () => { + const args = Sandbox.bubblewrapArgs({ writable: ["/tmp", "/tmp/sub"], network: true }) + expect(args).toContain("--tmpfs") + const binds = args.flatMap((a, n) => (a === "--bind-try" ? [args[n + 1]!] : [])) + // the /tmp mount root itself is never bound from the host (the tmpfs provides it) + expect(binds).not.toContain("/tmp") + // ...but a workspace living under /tmp must still be bound on top of the tmpfs, + // otherwise its writes vanish into the throwaway tmpfs + expect(binds).toContain("/tmp/sub") + }) + + test("unshares the PID namespace so /proc escape vectors are closed", () => { + expect(Sandbox.bubblewrapArgs({ writable: ["/w"], network: true })).toContain("--unshare-pid") + }) +}) + +describe("Sandbox.backend/describe", () => { + test("describe() is internally consistent with backend()", () => { + const d = Sandbox.describe() + expect(d.backend).toBe(Sandbox.backend()) + expect(d.available).toBe(Sandbox.available()) + expect(d.platform).toBe(process.platform) + if (d.available) expect(d.tool).toBeTruthy() + else expect(d.reason).toBeTruthy() + }) +}) + +describe("Sandbox.plan", () => { + const base = { command: "echo hi", shell, cwd: "/work/project", workspace: ["/work/project"] } + + test("disabled → runs the raw command unchanged", () => { + const p = Sandbox.plan({ ...base, options: { enabled: false } }) + expect(p.sandboxed).toBe(false) + expect(p.file).toBe("echo hi") + expect(p.useShell).toBe(shell) + expect(p.args).toBeUndefined() + }) + + test("no options → runs the raw command unchanged", () => { + const p = Sandbox.plan(base) + expect(p.sandboxed).toBe(false) + }) + + test("enabled → sandboxed when a backend exists, else degrades", () => { + const p = Sandbox.plan({ ...base, options: { enabled: true } }) + if (Sandbox.available()) { + expect(p.sandboxed).toBe(true) + expect(["sandbox-exec", "bwrap"]).toContain(p.file) + expect(p.useShell).toBe(false) + // the actual shell command lives at the tail of the argv + expect(p.args).toContain("echo hi") + expect(p.args).toContain(shell) + } else { + expect(p.sandboxed).toBe(false) + } + }) + + test("onUnavailable:error throws when no backend is available", () => { + if (Sandbox.available()) return // only meaningful without a backend + expect(() => Sandbox.plan({ ...base, options: { enabled: true, onUnavailable: "error" } })).toThrow() + }) + + test("makes the workspace writable but not an out-of-workspace cwd", () => { + if (!Sandbox.available()) return + const p = Sandbox.plan({ + command: "true", + shell, + cwd: "/work/elsewhere", + workspace: ["/work/project"], + options: { enabled: true }, + }) + const argv = (p.args ?? []).join(" ") + expect(argv).toContain("/work/project") + // an approved external cwd is a permission decision, not a reason to widen + // the sandbox's write boundary to the escape target + expect(argv).not.toContain("/work/elsewhere") + }) + + test("drops over-broad writable roots (worktree='/', $HOME) from the policy", () => { + if (!Sandbox.available()) return + const p = Sandbox.plan({ + command: "true", + shell, + cwd: "/work/project", + workspace: ["/work/project", "/"], + options: { enabled: true, allowWrite: [os.homedir()] }, + }) + const argv = (p.args ?? []).join(" ") + expect(argv).toContain("/work/project") + // "/" must never become a writable root (seatbelt subpath / bwrap bind) + expect(argv).not.toContain('(subpath "/")') + expect(argv).not.toContain("--bind-try / /") + // nor $HOME itself + expect(argv).not.toContain(`(subpath "${os.homedir()}")`) + }) +}) diff --git a/backend/cli/test/server/settings-sandbox.test.ts b/backend/cli/test/server/settings-sandbox.test.ts new file mode 100644 index 00000000..3ac2b5de --- /dev/null +++ b/backend/cli/test/server/settings-sandbox.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { SandboxSettingsRoutes } from "../../src/server/routes/settings/sandbox" +import { Sandbox } from "../../src/sandbox/sandbox" + +const app = SandboxSettingsRoutes() + +// GET / and POST /test are read-only / write-to-temp-only, so these never touch +// the real global config (PUT does — that path is covered by the CLI e2e). +describe("/settings/sandbox routes", () => { + test("GET / reports backend availability and a config object", async () => { + const res = await app.request("/") + expect(res.status).toBe(200) + const body = (await res.json()) as { + config: object + status: { platform: string; backend: string; available: boolean } + } + expect(typeof body.config).toBe("object") + expect(body.status.platform).toBe(process.platform) + expect(body.status.backend).toBe(Sandbox.backend()) + expect(typeof body.status.available).toBe("boolean") + }) + + test("POST /test runs the self-test and returns per-check results", async () => { + const res = await app.request("/test", { method: "POST" }) + expect(res.status).toBe(200) + const body = (await res.json()) as { + backend: string + available: boolean + ok: boolean + checks: { name: string; pass: boolean }[] + } + expect(body.available).toBe(Sandbox.available()) + expect(typeof body.ok).toBe("boolean") + if (body.available) { + // Containment must actually hold on a machine that has a backend. + expect(body.checks.length).toBeGreaterThanOrEqual(2) + expect(body.checks.some((c) => /inside/.test(c.name))).toBe(true) + expect(body.checks.some((c) => /outside/.test(c.name))).toBe(true) + expect(body.ok).toBe(true) + } + }) +}) diff --git a/backend/cli/test/tool/bash-sandbox.test.ts b/backend/cli/test/tool/bash-sandbox.test.ts new file mode 100644 index 00000000..ff55e8fa --- /dev/null +++ b/backend/cli/test/tool/bash-sandbox.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import { BashTool } from "../../src/tool/bash" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { Sandbox } from "../../src/sandbox/sandbox" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "research", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +// End-to-end through the real bash tool (not the Sandbox module in isolation): +// with the sandbox enabled, a command that writes outside the workspace must be +// blocked, while one that writes inside must succeed. The sandbox policy is read +// only from trusted (global + managed) config — never project config — so we +// enable it via the test-isolated managed config dir, not a project file. +describe("tool.bash sandbox integration", () => { + test("confines the bash tool's writes to the workspace", async () => { + if (!Sandbox.available()) return // no OS backend on this platform — nothing to enforce + + await using tmp = await tmpdir({ git: true }) + const managedDir = process.env.OPENSCIENCE_TEST_MANAGED_CONFIG_DIR! + const managedFile = path.join(managedDir, "openscience.json") + fs.mkdirSync(managedDir, { recursive: true }) + fs.writeFileSync(managedFile, JSON.stringify({ sandbox: { enabled: true, network: "deny" } })) + + const outside = path.join(os.homedir(), `.openscience-bash-escape-${process.pid}`) + fs.rmSync(outside, { force: true }) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + + const inside = await bash.execute( + { command: `printf hi > inside.txt && cat inside.txt`, description: "write inside workspace" }, + ctx, + ) + expect(inside.metadata.exit).toBe(0) + expect(fs.existsSync(path.join(tmp.path, "inside.txt"))).toBe(true) + + const escape = await bash.execute( + { command: `printf x > "${outside}"`, description: "write outside workspace" }, + ctx, + ) + expect(escape.metadata.exit).not.toBe(0) + expect(fs.existsSync(outside)).toBe(false) + }, + }) + } finally { + fs.rmSync(outside, { force: true }) + // don't leak "sandbox on" into any test that runs after this one + fs.rmSync(managedFile, { force: true }) + } + }) +}) diff --git a/backend/cli/test/tool/kernel-sandbox.test.ts b/backend/cli/test/tool/kernel-sandbox.test.ts new file mode 100644 index 00000000..2b9dcb6c --- /dev/null +++ b/backend/cli/test/tool/kernel-sandbox.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs" +import os from "os" +import path from "path" +import { spawn } from "child_process" +import { Sandbox } from "../../src/sandbox/sandbox" + +function run(file: string, args: string[], cwd: string): Promise { + return new Promise((resolve) => { + const p = spawn(file, args, { cwd, stdio: "ignore" }) + p.once("exit", (code) => resolve(code ?? 1)) + p.once("error", () => resolve(1)) + }) +} + +// The notebook (Python) and R kernels spawn an interpreter on a generated script +// that lives under os.tmpdir() (=/tmp on Linux). Under bwrap /tmp is a fresh +// tmpfs, so the script must be bound back via extraWritable or the interpreter +// can't even read it. This exercises that exact wrapArgv path the kernels use. +describe("Sandbox.wrapArgv — kernel confinement", () => { + test("wrapped interpreter reads its /tmp script and writes inside, but not outside, the workspace", async () => { + if (!Sandbox.available()) return // no OS backend — nothing to enforce + + const work = fs.mkdtempSync(path.join(os.tmpdir(), "openscience-kernel-test-")) + const script = path.join(os.tmpdir(), `openscience-kernel-probe-${process.pid}.sh`) + const outside = path.join(os.homedir(), `.openscience-kernel-escape-${process.pid}`) + fs.writeFileSync(script, `printf hi > "${work}/inside" && printf x > "${outside}"\n`) + fs.rmSync(outside, { force: true }) + + try { + const wrapped = Sandbox.wrapArgv({ + file: "/bin/sh", + args: [script], + workspace: [work], + extraWritable: [script], + options: { enabled: true, network: "deny" }, + }) + expect(wrapped.sandboxed).toBe(true) + + await run(wrapped.file, wrapped.args, work) + + // the script (under /tmp) was bound back and ran → the inside write landed + expect(fs.existsSync(path.join(work, "inside"))).toBe(true) + // ...but the write outside the workspace was contained + expect(fs.existsSync(outside)).toBe(false) + } finally { + fs.rmSync(script, { force: true }) + fs.rmSync(outside, { force: true }) + fs.rmSync(work, { recursive: true, force: true }) + } + }) + + test("returns the argv unchanged when the sandbox is disabled", () => { + const wrapped = Sandbox.wrapArgv({ + file: "python3", + args: ["-u", "/tmp/kernel.py"], + workspace: ["/work"], + options: { enabled: false }, + }) + expect(wrapped.sandboxed).toBe(false) + expect(wrapped.file).toBe("python3") + expect(wrapped.args).toEqual(["-u", "/tmp/kernel.py"]) + }) +}) diff --git a/frontend/docs/src/content/openscience/commands.mdx b/frontend/docs/src/content/openscience/commands.mdx index 65c22870..5e1ddbab 100644 --- a/frontend/docs/src/content/openscience/commands.mdx +++ b/frontend/docs/src/content/openscience/commands.mdx @@ -61,6 +61,17 @@ Connecting an Atlas account is optional — see [Atlas](/openscience/atlas). Ope | `openscience wallet` | Wallet balance and key routing (alias `billing`). | | `openscience wallet topup` | Open the app to add credit. | +## Sandbox + +| Command | Use | +| --- | --- | +| `openscience sandbox` | Show sandbox status: OS backend + current policy. | +| `openscience sandbox enable` | Confine the agent's shell + notebook execution to the workspace (`--network deny`, `--allow `, `--on-unavailable `). | +| `openscience sandbox disable` | Turn the sandbox off. | +| `openscience sandbox test` | Prove containment on this machine (writes inside/outside the workspace, network egress). | + +Off by default. See [Execution sandbox](/openscience/sandbox). + ## Agent profiles | Command | Use | diff --git a/frontend/docs/src/content/openscience/docs.json b/frontend/docs/src/content/openscience/docs.json index bcc7b215..25566c25 100644 --- a/frontend/docs/src/content/openscience/docs.json +++ b/frontend/docs/src/content/openscience/docs.json @@ -21,7 +21,7 @@ "groups": [ { "group": "CLI", - "pages": ["commands", "security"] + "pages": ["commands", "security", "sandbox"] } ] } diff --git a/frontend/docs/src/content/openscience/sandbox.mdx b/frontend/docs/src/content/openscience/sandbox.mdx new file mode 100644 index 00000000..870a9493 --- /dev/null +++ b/frontend/docs/src/content/openscience/sandbox.mdx @@ -0,0 +1,91 @@ +--- +title: "Execution sandbox" +description: "Confine the agent's shell commands to the workspace with a real OS sandbox — macOS Seatbelt, Linux bubblewrap. Opt-in, off by default." +icon: "lock" +--- + +The permission system decides **whether** the agent runs a command; it does not decide **what that command can reach** once it runs. An approved (or auto-approved) command executes with your full user authority — it can touch `~/.ssh`, rewrite `~/.bashrc`, or delete another project. The permission prompt keeps you *aware*; it is not an isolation boundary. + +The execution sandbox adds the missing boundary. When enabled, everything the agent executes — shell commands via the `bash` tool, and code run by the Python and R notebook kernels — is wrapped in a real OS sandbox that **confines writes to the workspace** and can optionally **deny network egress**. It is **off by default** — turn it on when you want the agent's blast radius contained. + +## Quick start + +```bash +openscience sandbox enable # turn it on (global) +openscience sandbox test # prove it actually confines writes + network +openscience sandbox # show status: backend + current policy +``` + +`sandbox test` runs real sandboxed commands — it writes inside a scratch workspace (must succeed), writes outside it (must be blocked), and checks that network-deny mode blocks egress — then prints a pass/fail for each. If it doesn't say **Containment verified**, don't rely on the sandbox. + +## What it does + +- **Writes** are denied everywhere except the workspace (your working directory and its worktree), the system temp dirs, and any extra paths you allow. Everything else on disk is read-only to the agent. +- **Reads** stay open. The threat model is *tampering and exfiltration*, not hiding your files from a tool that needs to read them. +- **Network** is allowed by default; set it to deny to stop sandboxed commands from reaching the network at all. + +## Backends + +| Platform | Backend | Requirement | +| --- | --- | --- | +| macOS | Seatbelt (`sandbox-exec`) | Built in. | +| Linux | bubblewrap (`bwrap`) | Install `bubblewrap`; unprivileged user namespaces must be enabled. | +| Windows | — | No backend; commands run per `onUnavailable` (see below). | + +Check what's available on your machine with `openscience sandbox` or `openscience doctor`. + +## The `sandbox` command + +```bash +openscience sandbox # status (backend + config) +openscience sandbox enable # turn on +openscience sandbox enable --network deny # also block network egress +openscience sandbox enable --allow /data/shared # extra writable path (repeatable) +openscience sandbox enable --on-unavailable error # refuse to run where no backend exists +openscience sandbox disable # turn off +openscience sandbox test # empirical containment self-test +``` + +The sandbox is a machine-wide safety setting, so it is always written to your **global** config. + +| Flag | Meaning | +| --- | --- | +| `--network` | `allow` (default) or `deny` network egress from sandboxed commands. | +| `--allow` | An absolute path, beyond the workspace and temp dirs, the sandbox may write to. Repeatable. | +| `--on-unavailable` | Behaviour where no backend exists: `warn` (default, runs unsandboxed with a notice), `error` (refuses to run), `allow` (runs unsandboxed silently). | + +## From the workspace GUI + +Open **Settings → Sandbox**. The panel shows whether a backend is available on this machine, an on/off toggle, the network and fallback policies, an editor for extra writable paths, and a **run self-test** button that shows the same containment checks the CLI does. + +## By hand (config) + +The sandbox is a `sandbox` block in your **global** `openscience.json` (or an enterprise **managed** config). `openscience sandbox enable` writes it for you. The policy is read **only** from global and managed config — never from a project's `openscience.json` — so opening an untrusted repo cannot weaken or disable your sandbox: + +```jsonc +{ + "sandbox": { + "enabled": true, + "network": "deny", + "allowWrite": ["/data/shared"], + "onUnavailable": "error" + } +} +``` + +- `enabled` — master switch. Off by default. +- `network` — `allow` (default) or `deny`. +- `allowWrite` — extra absolute paths the sandbox may write to. +- `onUnavailable` — `warn` (default) · `error` · `allow`, for machines with no backend. + +## Limitations + +This is deliberately a **write-containment** sandbox, not a deny-by-default syscall jail. Research workflows run arbitrary compilers, package managers, and interpreters, and a strict jail would break far more than it protects. Concretely: + +- **Reads are not restricted.** A sandboxed command can still read files it has permission to read. +- **Local IPC is not blocked.** Network-deny stops IP egress, but a command can still reach host UNIX-domain sockets (e.g. a Docker daemon socket). Treat access to such a socket as equivalent to the privileges it exposes. +- **It is not a security boundary against a determined local attacker** the way a container or VM is. For hostile code, run OpenScience inside a container or VM as well. +- **Linux needs bubblewrap** and unprivileged user namespaces; where they're unavailable the sandbox can't engage (`onUnavailable` decides what happens). +- **Windows has no backend** yet. + +What it *does* reliably stop: an agent command writing outside your workspace, and — in deny mode — a command phoning home. Verify it on your machine with `openscience sandbox test`. diff --git a/frontend/docs/src/content/openscience/security.mdx b/frontend/docs/src/content/openscience/security.mdx index b0a7fd8c..64d0e8d7 100644 --- a/frontend/docs/src/content/openscience/security.mdx +++ b/frontend/docs/src/content/openscience/security.mdx @@ -6,9 +6,11 @@ icon: "shield-check" OpenScience is open source under Apache-2.0, so the whole security model below is auditable in the [repository](https://github.com/synthetic-sciences/openscience). The agent runs locally with the same filesystem and shell access you have. Two principles anchor the design: keep credentials out of subprocesses that do not need them, and make the trust boundary explicit instead of pretending the agent is a sandbox. -## The agent is not a sandbox +## The agent is not a sandbox by default -The permission system prompts you before the agent runs a command or writes a file. That keeps you aware of what it is doing — it is not an isolation boundary. The agent can read, write, and execute anywhere your user can. For real isolation, run OpenScience inside a container or a VM. +The permission system prompts you before the agent runs a command or writes a file. That keeps you aware of what it is doing — it is not, on its own, an isolation boundary. By default the agent can read, write, and execute anywhere your user can. + +You can add a real boundary: the opt-in [execution sandbox](/openscience/sandbox) wraps the agent's shell commands in an OS sandbox (macOS Seatbelt, Linux bubblewrap) that confines writes to the workspace and can deny network egress. Turn it on with `openscience sandbox enable` and verify it with `openscience sandbox test`. It is write-containment, not a full jail — for hostile code, still run OpenScience inside a container or a VM. ## Credential storage diff --git a/frontend/workspace/src/components/settings/Sandbox.tsx b/frontend/workspace/src/components/settings/Sandbox.tsx new file mode 100644 index 00000000..6d1e0d3f --- /dev/null +++ b/frontend/workspace/src/components/settings/Sandbox.tsx @@ -0,0 +1,292 @@ +// Execution sandbox settings — the permission system decides *whether* the agent +// runs a shell command; it is not an isolation boundary. This panel turns on a +// real one: macOS Seatbelt / Linux bubblewrap that confines the agent's writes +// to the workspace and can deny network egress. The server +// (routes/settings/sandbox.ts) reports backend availability, persists the +// config, and runs the empirical self-test the browser can't. Mirrors the +// `openscience sandbox` CLI. +import { Component, For, Show, createResource, createSignal } from "solid-js" +import { Select } from "@synsci/ui/select" +import { Button } from "@synsci/ui/button" +import { Switch } from "@synsci/ui/switch" +import { Icon } from "@synsci/ui/icon" +import { showToast } from "@synsci/ui/toast" +import { useGlobalSDK } from "@/context/global-sdk" +import { usePlatform } from "@/context/platform" +import { settingsApi } from "./api" + +interface SandboxConfig { + enabled?: boolean + network?: "allow" | "deny" + allowWrite?: string[] + onUnavailable?: "warn" | "error" | "allow" +} +interface Status { + platform: string + backend: "seatbelt" | "bubblewrap" | "none" + available: boolean + tool?: string + reason?: string +} +interface Payload { + config: SandboxConfig + status: Status +} +interface Check { + name: string + pass: boolean + skipped?: boolean + detail?: string +} +interface SelfTest { + backend: string + available: boolean + checks: Check[] + ok: boolean +} + +const NETWORK_OPTS = [ + { value: "allow" as const, label: "Allow" }, + { value: "deny" as const, label: "Deny" }, +] +const UNAVAILABLE_OPTS = [ + { value: "warn" as const, label: "Warn & run" }, + { value: "error" as const, label: "Refuse" }, + { value: "allow" as const, label: "Run silently" }, +] + +const Sandbox: Component = () => { + const sdk = useGlobalSDK() + const platform = usePlatform() + const fetchFn = platform.fetch ?? fetch + const call = (path: string, init?: RequestInit) => + settingsApi(sdk.url, fetchFn, `/settings/sandbox${path}`, init) + + const [data, { mutate, refetch }] = createResource(() => call("")) + const [busy, setBusy] = createSignal(false) + const [test, setTest] = createSignal() + const [testing, setTesting] = createSignal(false) + const [newPath, setNewPath] = createSignal("") + + const config = () => data()?.config ?? {} + const status = () => data()?.status + + const patch = async (body: SandboxConfig, failure: string) => { + setBusy(true) + try { + mutate(await call("", { method: "PUT", body: JSON.stringify(body) })) + } catch (err) { + showToast({ title: failure, description: err instanceof Error ? err.message : String(err) }) + refetch() + } + setBusy(false) + } + + const runTest = async () => { + setTesting(true) + try { + setTest(await call("/test", { method: "POST" })) + } catch (err) { + showToast({ title: "Self-test failed to run", description: err instanceof Error ? err.message : String(err) }) + } + setTesting(false) + } + + const addPath = () => { + const p = newPath().trim() + if (!p) return + if (!p.startsWith("/")) { + showToast({ title: "Use an absolute path", description: "Extra writable paths must start with /." }) + return + } + const next = [...(config().allowWrite ?? [])] + if (!next.includes(p)) next.push(p) + setNewPath("") + patch({ allowWrite: next }, "Couldn't add the path") + } + const removePath = (p: string) => + patch({ allowWrite: (config().allowWrite ?? []).filter((x) => x !== p) }, "Couldn't remove the path") + + return ( +
+
+
+

Execution sandbox

+

+ Permissions decide whether the agent runs a shell command — not what it can reach once it does. + Turn this on to confine the agent's commands inside an OS sandbox: writes are limited to the workspace, and + network egress can be denied. +

+
+
+ +
+ {/* ── Backend availability ── */} + + {(s) => ( +
+ + + No sandbox backend on this machine ({s().platform}) — {s().reason}. + + } + > + + Backend ready: {s().backend} via {s().tool} ({s().platform}). + + +
+ )} +
+ + {/* ── Enable ── */} +
+
+
+ Sandbox agent commands + + {config().enabled + ? "On — commands run confined to the workspace." + : "Off — commands run with your full user authority."} + +
+ patch({ enabled: checked }, "Couldn't update the sandbox setting")} + /> +
+
+ + {/* ── Options (only when enabled) ── */} + +
+

Policy

+ +
+
+
+ Network egress + + Deny to stop sandboxed commands reaching the network. + +
+ o.value === (config().onUnavailable ?? "warn"))} + value={(o) => o.value} + label={(o) => o.label} + onSelect={(o) => o && patch({ onUnavailable: o.value }, "Couldn't update fallback behavior")} + variant="secondary" + size="small" + triggerVariant="settings" + /> +
+
+ + {/* extra writable paths */} +
+ Extra writable paths + + Absolute paths — beyond the workspace and temp dirs — the sandbox may write to. + + + {(p) => ( +
+ {p} + +
+ )} +
+
+ setNewPath(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && addPath()} + /> + +
+
+ + {/* self-test */} +
+
+
+ Verify containment + + Runs real sandboxed commands to prove writes and network are actually confined. + +
+ +
+ + {(t) => ( +
+ + {(c) => ( +
+ + {c.name} + + — {c.detail} + +
+ )} +
+ + {t().ok ? "Containment verified." : "Containment FAILED — do not rely on the sandbox."} + +
+ )} +
+
+
+
+
+
+ ) +} + +export default Sandbox diff --git a/frontend/workspace/src/components/settings/registry.ts b/frontend/workspace/src/components/settings/registry.ts index 1fb8d54a..3f13f690 100644 --- a/frontend/workspace/src/components/settings/registry.ts +++ b/frontend/workspace/src/components/settings/registry.ts @@ -31,6 +31,7 @@ export type SettingsPanelId = | "local-models" | "network" | "permissions" + | "sandbox" | "credentials" | "spend" | "wallet" @@ -105,6 +106,13 @@ export const SETTINGS_PANELS: SettingsPanel[] = [ section: "workspace", component: lazy(() => import("./Permissions")), }, + { + id: "sandbox", + title: "Sandbox", + icon: "console", + section: "workspace", + component: lazy(() => import("./Sandbox")), + }, { id: "credentials", title: "Credentials",