diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ac273a26..8690ea6e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ file is the human-readable summary of what changed and why. ## Unreleased +### Sandbox + +- **Linux sandboxes now filter syscalls** — a Work session on Linux runs in a + bubblewrap container whose namespaces hide the host but do nothing to shrink + what the guest can ask the shared kernel; one kernel bug reachable from + inside was a full host escape. The container now installs a seccomp + allowlist before the agent starts: the syscalls real workloads use (derived + from Docker's default profile) are allowed, escalation primitives such as + bpf, perf_event_open, userfaultfd, module loading and the key syscalls kill + the caller, creating a new user namespace is fatal and any other new + namespace is refused, sockets are limited to unix, inet and route netlink, + and everything else fails with EPERM (or ENOSYS for syscalls newer than the + unified numbering, so libc fallbacks keep working). The filter is generated + on the host at every start and left in the sandbox's runtime directory as + `seccomp.bpf`. `CODEV_SANDBOX_SECCOMP=audit` switches it to logging what it + would have refused (kernel log) and `off` removes it, for a workload the + allowlist breaks; a `seccomp` field on a persisted sandbox does the same for + one sandbox. macOS sandboxes are VMs and need none; Windows sandboxes do not + get one yet. + ### Desktop - **Sign-in completes behind a corporate proxy** — on Windows machines that diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 9a596593e0..d1c38d2491 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -268,6 +268,7 @@ from the squash message first, or use a plain merge. | `AGENTS.md` | Default-branch note: `dev` → `main` (single merged line; upstream has two lines saying local `main` may not exist); added fork bullets requiring every upstream-file change to get a `DIVERGENCES.md` row (and matching en + es doc updates) in the same PR, and pointing agents at this file's Divergence policy (prefer config/plugins, then `packages/codev-*`, upstream edits last) and Syncing with upstream sections | The fork's default branch is `main` (see the workflow-triggers row); agents kept basing diffs on a nonexistent `dev`. Agents edit upstream files without recording the divergence — and don't apply the placement policy or sync runbook — unless the instructions file says to. | Keep our lines; take upstream's other guidance. | | `README.md` | Replaced with fork README | Fork identity, sync + divergence policy. | Keep ours entirely. | | `CONTRIBUTING.md`, `SECURITY.md` | Product-brand prose "OpenCode" → "CoDev Code"; built-CLI command examples `opencode …` → `codev …` | Docs people actually read should name the fork's product. Upstream issue/advisory links, `packages/opencode` paths, and build-output paths stay upstream; env-var mentions follow the env-var rename row. | Keep our strings; take upstream's structural changes. | +| `SECURITY.md` (sandbox section) | "No Sandbox" is scoped to the CLI and local desktop sessions; a new "Desktop sandbox (Work sessions)" section describes the fork-only `packages/codev-sandbox` guest (VM / WSL2 / bubblewrap, no network device, broker-only egress, seccomp allowlist on Linux) and puts escapes from it in scope; the out-of-scope row is reworded from "Sandbox escapes" to "Permission-system bypasses" | Upstream's statement that the product has no sandbox is false for this fork's desktop app, and a security policy that disclaims sandbox escapes while shipping one would turn real reports away. | Keep ours (both sections and the table row); take upstream's other changes to the file. | | `CONTEXT.md` | Product-brand prose "OpenCode" → "CoDev Code" (title, intro, and defined terms such as "CoDev Code Client" / "Embedded CoDev Code") | The V2 design glossary should name the fork's product; the renamed terms appear only in this file, so nothing desyncs. `@opencode-ai/*` package names stay upstream; env-var mentions follow the env-var rename row. | Keep our brand strings; take upstream's design changes (this file evolves with the V2 session core — merge content, re-apply the brand rename). | | `STATS.md` | Deleted | Upstream's download-stats log; meaningless for the fork. | Keep deleted. | | Test prose across `packages/*/test`, `packages/opencode/test/lib` | describe titles + comments naming the CLI command or product: `opencode `/"opencode CLI"/"OpenCode" → `codev …`/"CoDev Code" (incl. help-snapshot keys/labels — regenerate the `.snap`, see the scriptName row). Functional names stay: `@opencode-ai/*`, `~/.opencode/bin` install paths, provider id `opencode`, fixture APIs (`opencode.run`, `OpencodeCli`), auth method `opencode-login`, default username `opencode`, upstream issue refs, third-party names. Config-file and config-dir fixtures follow the rename rows (`codev.json`, `.codev/`). | Test names/comments are read by people; identifiers are load-bearing. | Keep our prose; take upstream's test-logic changes. | diff --git a/SECURITY.md b/SECURITY.md index 5ee1565891..4b4c42ba0d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,12 +12,16 @@ submit one that will be an automatic ban from the project. CoDev Code is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access. -### No Sandbox +### No Sandbox (CLI and local sessions) -CoDev Code does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation. +CoDev Code does **not** sandbox the agent in the CLI or in the desktop app's local sessions. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation. If you need true isolation, run CoDev Code inside a Docker container or VM. +### Desktop sandbox (Work sessions) + +The desktop app's Work sessions run the agent inside an isolated guest (`packages/codev-sandbox`): a Virtualization.framework VM on macOS, a WSL2 distro on Windows, and a bubblewrap container on Linux. The guest has no network device; its only egress is a host-side broker that forwards to an allowlist of upstreams. On Linux the container is additionally confined by a seccomp syscall allowlist. That backend still shares the host kernel, so it is defense in depth rather than a hypervisor boundary. Escapes from the desktop sandbox are in scope. + ### Server Mode Server mode is opt-in only. When enabled, set `CODEV_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability. @@ -27,7 +31,7 @@ Server mode is opt-in only. When enabled, set `CODEV_SERVER_PASSWORD` to require | Category | Rationale | | ------------------------------- | ----------------------------------------------------------------------- | | **Server access when opted-in** | If you enable server mode, API access is expected behavior | -| **Sandbox escapes** | The permission system is not a sandbox (see above) | +| **Permission-system bypasses** | The permission system is not a sandbox (see above) | | **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | | **MCP server behavior** | External MCP servers you configure are outside our trust boundary | | **Malicious config files** | Users control their own config; modifying it is not an attack vector | diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index b62b9f1cf3..e983c5c0f2 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -99,7 +99,27 @@ Electron main process. - `bwrap/args.ts` — the bubblewrap invocation: unshared user/pid/net/uts/ipc namespaces (loopback-only networking), read-only rootfs binds with merged-usr symlinks preserved, workspace rw at `/workspace`, server bundle ro at - `/opt/codev`, per-sandbox home, cleared env. + `/opt/codev`, per-sandbox home, cleared env, and `--seccomp` on an inherited + fd carrying the syscall filter. +- `seccomp/` — the syscall filter. Namespaces decide what the guest can see; + this decides what it can say to the shared host kernel, which is the wall a + container backend lacks and a VM has for free. `policy.ts` is the data: a + strict allowlist derived from Docker's default profile minus everything that + profile only grants to a capability the guest happens to hold inside its user + namespace, an escalation set that kills the caller (bpf, perf_event_open, + userfaultfd, module loading, kexec, the key syscalls), and the argument rules + — `clone`/`unshare` asking for a new user namespace is fatal, any other new + namespace is refused, and `socket` is limited to AF_UNIX, AF_INET, AF_INET6 + and route netlink. `bpf.ts` compiles that into classic BPF on the host at + every start (no libseccomp, no native dependency, no separately versioned + blob); the program is written to `runtime/seccomp.bpf` and handed to bwrap + as stdio slot 3. Unlisted syscalls get EPERM, or ENOSYS from the unified + numbering (424) up so libc fallbacks like clone3 → clone keep working. Three + modes: `enforce` (default), `audit` (the kernel logs what enforce would + refuse; nothing is blocked), `off`. The desktop's machine-wide default comes + from `CODEV_SANDBOX_SECCOMP`; a persisted per-sandbox `seccomp` field wins + over it. `syscalls-{x64,arm64}.ts` are generated from the kernel headers by + `script/gen-syscall-tables.ts`; `docs/seccomp.md` has the design history. - `bwrap/bridge.ts` — how TCP crosses the guest's network namespace: unix sockets in the shared `/run/codev` dir, with a host-side TCP→unix forwarder (also used by the vz backend) and a guest-side node forward script for the @@ -107,10 +127,11 @@ Electron main process. - `bwrap/rootfs.ts` — image unpack (tar) and rootfs layout probing. - `bwrap/provider.ts` — availability probe (detects the Ubuntu 23.10+ AppArmor userns restriction), create/start/destroy/reset, health-gated startup with - stderr capture. + stderr capture, seccomp program generation per start. -`bwrap.integration.test.ts` exercises real bubblewrap and auto-skips where -bwrap can't run (macOS, restricted CI runners). Packaging note: the .deb should +`bwrap.integration.test.ts` exercises real bubblewrap — including the seccomp +filter: ordinary work passes, `mount` is refused, `unshare -U` dies with +SIGSYS — and auto-skips where bwrap can't run (macOS, restricted CI runners). Packaging note: the .deb should ship an AppArmor profile permitting unprivileged user namespaces on Ubuntu 24.04+. @@ -191,3 +212,13 @@ This package is platform code only; the desktop app owns everything user-facing. - **Image upgrades are manual.** Reset re-provisions from the current manifest, but nothing surfaces that a newer image exists, so guests stay stale until the user happens to reset. +- **The seccomp allowlist has not been through an audit phase.** It ships in + enforce mode on the strength of Docker's profile and the workloads the + integration and smoke tests cover (node dev servers, child processes, + workers, file watching, git, python sockets). A tool that trips it fails with + EPERM/ENOSYS or SIGSYS; `CODEV_SANDBOX_SECCOMP=audit` turns those into kernel + log lines (`dmesg | grep SECCOMP`, or auditd) for widening the list, and `off` + removes the filter. No UI exposes the mode yet. +- **The WSL backend has no syscall filter.** It boots the same rootfs but not + through bwrap; installing the same program there needs a guest-side loader in + the boot script. diff --git a/packages/codev-sandbox/docs/seccomp.md b/packages/codev-sandbox/docs/seccomp.md index 50a3be69d0..2464797d5b 100644 --- a/packages/codev-sandbox/docs/seccomp.md +++ b/packages/codev-sandbox/docs/seccomp.md @@ -1,6 +1,9 @@ # Adding a seccomp BPF filter to the bubblewrap sandbox -Status: proposal / design +Status: implemented (`src/seccomp/`, `bwrap/args.ts`, `bwrap/provider.ts`) — see +"What shipped" at the end for where the implementation departs from the plan +below. The rest of this document is the original design, kept for the +reasoning. Scope: Linux `bwrap` backend (and, by reuse, the Windows WSL backend that boots the same rootfs) Owner: TBD @@ -222,3 +225,41 @@ So the allowlist is _reached_, not shipped cold: 4. **Does the WSL backend inherit this automatically?** It boots the same rootfs but is not spawned through `buildBwrapArgs`; if we want parity, the filter needs a separate install path inside the WSL boot. Out of scope for v1 unless required. + +## What shipped + +The implementation follows the decisions above with these departures, each +chosen to make the feature self-contained in one PR: + +- **The BPF program is generated in TypeScript at sandbox start**, not + precompiled with libseccomp in CI and shipped through the image manifest. The + arm64-correctness concern behind the libseccomp choice is met differently: + the syscall tables (`syscalls-{x64,arm64}.ts`) are generated straight from the + kernel uapi headers by `script/gen-syscall-tables.ts`, the program kills any + non-native `arch` and the x32 bit before looking at a number, and + `bpf.test.ts` runs the real program bytes through a cBPF interpreter for + every syscall number on both architectures. The trade: the policy versions + with the app, not with the rootfs image, so a rootfs that adds a tool needing + a new syscall needs an app release too. +- **`--seccomp`, not `--add-seccomp-fd`.** One program is all there is, and + the older flag exists in every bwrap release, which removes the + degradation question (open question 1) entirely. +- **ptrace returns EPERM rather than killing** (open question 3): strace and + gdb should print "Operation not permitted", not vanish with SIGSYS. The + syscall is refused either way. +- **AF_INET/AF_INET6 are allowed**, not only AF_UNIX: the guest's own server, + the broker stub, and any dev server the agent starts are loopback TCP, and + `--unshare-net` already leaves nothing else to reach. AF_NETLINK is limited to + NETLINK_ROUTE (getifaddrs). +- **Default is enforce, not audit.** With no fleet to collect audit logs from, + an audit default would have shipped no protection. The allowlist is Docker's + battle-tested profile minus capability-gated entries, verified by the + integration tests and a manual smoke run (node http/spawn/worker/watch/dns, + git, python sockets). `audit` and `off` remain, machine-wide via + `CODEV_SANDBOX_SECCOMP` and per sandbox via the persisted `seccomp` field + (open question 2: global default plus a hand-editable per-sandbox override, no + IPC channel or UI). +- **Unlisted syscalls at or above 424 return ENOSYS**, below it EPERM — the + Docker approach, so glibc's fallbacks (clone3 → clone, faccessat2 → + faccessat) trigger. `io_uring_*` and `clone3` are deliberately unlisted. +- **WSL is untouched** (open question 4). diff --git a/packages/codev-sandbox/script/gen-syscall-tables.ts b/packages/codev-sandbox/script/gen-syscall-tables.ts new file mode 100644 index 0000000000..68a3275015 --- /dev/null +++ b/packages/codev-sandbox/script/gen-syscall-tables.ts @@ -0,0 +1,101 @@ +// Regenerates src/seccomp/syscalls-{x64,arm64}.ts from the kernel uapi headers +// installed on a Linux host (linux-libc-dev). Run with bun from this package. +// +// The seccomp policy names syscalls; the filter needs numbers, and those are +// per-architecture below 424 (x86_64 keeps its historical table, arm64 uses +// the asm-generic one). Above 424 every architecture shares one numbering, so +// syscalls newer than the installed headers are appended from UNIFIED_TAIL +// rather than lost — a policy entry that names one of them must still resolve. +import { spawnSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const UNIFIED_TAIL: Record = { + futex_waitv: 449, + set_mempolicy_home_node: 450, + cachestat: 451, + fchmodat2: 452, + map_shadow_stack: 453, + futex_wake: 454, + futex_wait: 455, + futex_requeue: 456, + statmount: 457, + listmount: 458, + lsm_get_self_attr: 459, + lsm_set_self_attr: 460, + lsm_list_modules: 461, + mseal: 462, + setxattrat: 463, + getxattrat: 464, + listxattrat: 465, + removexattrat: 466, +} + +const ARCHES = { + x64: { header: "asm/unistd_64.h", defines: [] as string[] }, + arm64: { + header: "asm-generic/unistd.h", + // What arch/arm64/include/uapi/asm/unistd.h sets before including the + // generic table; the 32-bit-only ones are omitted since __BITS_PER_LONG=64. + defines: [ + "__BITS_PER_LONG=64", + "__ARCH_WANT_RENAMEAT", + "__ARCH_WANT_NEW_STAT", + "__ARCH_WANT_SET_GET_RLIMIT", + "__ARCH_WANT_SYS_CLONE3", + "__ARCH_WANT_MEMFD_SECRET", + ], + }, +} + +function generate(arch: keyof typeof ARCHES) { + const spec = ARCHES[arch] + const flags = spec.defines.map((define) => `-D${define}`) + const macros = spawnSync("cpp", ["-dM", ...flags, "-"], { input: `#include <${spec.header}>\n`, encoding: "utf8" }) + if (macros.status !== 0) throw new Error(`cpp failed for ${arch}: ${macros.stderr}`) + const names = [...macros.stdout.matchAll(/^#define __NR_(\w+)/gm)] + .map((match) => match[1]) + .filter((name) => name !== "syscalls" && name !== "arch_specific_syscall" && !name.startsWith("3264")) + // Numbers come out of the compiler, not a regex: the generic table defines + // many entries through __NR3264_* aliases that only the preprocessor resolves. + const dir = mkdtempSync(join(tmpdir(), "codev-syscalls-")) + const source = join(dir, "print.c") + writeFileSync( + source, + [ + `#include `, + `#include <${spec.header}>`, + `int main(void) {`, + ...names.map((name) => ` printf("${name} %d\\n", (int)__NR_${name});`), + ` return 0;`, + `}`, + ].join("\n"), + ) + const binary = join(dir, "print") + const compiled = spawnSync("cc", [...flags, "-o", binary, source], { encoding: "utf8" }) + if (compiled.status !== 0) throw new Error(`cc failed for ${arch}: ${compiled.stderr}`) + const printed = spawnSync(binary, [], { encoding: "utf8" }) + rmSync(dir, { recursive: true, force: true }) + const table = new Map( + printed.stdout + .trim() + .split("\n") + .map((line) => { + const [name, number] = line.split(" ") + return [name, Number(number)] as const + }), + ) + for (const [name, number] of Object.entries(UNIFIED_TAIL)) { + if (!table.has(name)) table.set(name, number) + } + const entries = [...table.entries()].sort((a, b) => a[1] - b[1]) + const body = entries.map(([name, number]) => ` ${name}: ${number},`).join("\n") + return `// Generated by script/gen-syscall-tables.ts from the ${spec.header} uapi header;\n// do not edit by hand. Syscall numbers for ${arch === "x64" ? "x86_64" : "aarch64"}.\nexport const SYSCALLS_${arch.toUpperCase()}: Record = {\n${body}\n}\n` +} + +for (const arch of Object.keys(ARCHES) as (keyof typeof ARCHES)[]) { + const target = join(import.meta.dirname, "..", "src", "seccomp", `syscalls-${arch}.ts`) + writeFileSync(target, generate(arch)) + console.log(`wrote ${target}`) +} diff --git a/packages/codev-sandbox/src/bwrap/args.test.ts b/packages/codev-sandbox/src/bwrap/args.test.ts index 16a825884a..c565140c49 100644 --- a/packages/codev-sandbox/src/bwrap/args.test.ts +++ b/packages/codev-sandbox/src/bwrap/args.test.ts @@ -89,3 +89,13 @@ test("clears the environment before setting the boot env", () => { expect(joined.indexOf("--setenv")).toBeGreaterThan(joined.indexOf("--clearenv")) expect(args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "exec node server"]) }) + +test("installs the seccomp program from the inherited fd, after the env is cleared", () => { + const args = buildBwrapArgs({ ...spec, seccompFd: 3 }) + const joined = args.join(" ") + expect(joined).toContain("--seccomp 3") + expect(joined.indexOf("--seccomp 3")).toBeGreaterThan(joined.indexOf("--clearenv")) + expect(joined.indexOf("--seccomp 3")).toBeLessThan(joined.indexOf("--setenv")) + expect(args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "exec node server"]) + expect(buildBwrapArgs(spec)).not.toContain("--seccomp") +}) diff --git a/packages/codev-sandbox/src/bwrap/args.ts b/packages/codev-sandbox/src/bwrap/args.ts index f6c208d15e..d6fa7a5170 100644 --- a/packages/codev-sandbox/src/bwrap/args.ts +++ b/packages/codev-sandbox/src/bwrap/args.ts @@ -32,6 +32,10 @@ export type BwrapSpec = { homeDir: string env: Record command: string[] + // A file descriptor the child inherits (see provider.ts: stdio slot 3) + // holding a compiled seccomp program; bwrap installs it just before exec. + // Absent means no syscall filter. + seccompFd?: number } // Mounts we manage ourselves; a rootfs copy of these must never shadow them. @@ -86,6 +90,9 @@ export function buildBwrapArgs(spec: BwrapSpec) { "--chdir", GUEST_WORKSPACE, "--clearenv", + // --seccomp, not --add-seccomp-fd: one program is all there is, and the + // older flag is accepted by every bwrap release rather than only 0.5+. + ...(spec.seccompFd === undefined ? [] : ["--seccomp", String(spec.seccompFd)]), ...Object.entries(spec.env).flatMap(([key, value]) => ["--setenv", key, value]), "--", ...spec.command, diff --git a/packages/codev-sandbox/src/bwrap/bwrap.integration.test.ts b/packages/codev-sandbox/src/bwrap/bwrap.integration.test.ts index 3806b4592c..efb328a4ee 100644 --- a/packages/codev-sandbox/src/bwrap/bwrap.integration.test.ts +++ b/packages/codev-sandbox/src/bwrap/bwrap.integration.test.ts @@ -1,9 +1,11 @@ import { expect, test } from "bun:test" -import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, open, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import { spawn } from "node:child_process" import { buildBwrapArgs, GUEST_HOME, GUEST_PATH } from "./args" import { readRootfsEntries, runCommand } from "./rootfs" +import { buildSeccompProgram, seccompArch } from "../seccomp/bpf" // Real-bwrap integration; runs only on Linux hosts where unprivileged user // namespaces work (skipped on macOS/CI runners with AppArmor restrictions). @@ -86,3 +88,56 @@ test.skipIf(!bwrapUsable)("workspace is writable while the rootfs stays read-onl await rm(dir, { recursive: true, force: true }) }) + +// Runs a shell script in a real bwrap guest under the compiled filter, the +// way provider.ts does: program written to a file, its fd inherited in slot 3. +async function runFiltered(spec: Awaited>, dir: string, script: string) { + const arch = seccompArch() + if (!arch) throw new Error(`no seccomp table for ${process.arch}`) + const path = join(dir, "seccomp.bpf") + await writeFile(path, buildSeccompProgram(arch, "enforce").bytes) + const file = await open(path, "r") + const child = spawn("bwrap", buildBwrapArgs({ ...spec, seccompFd: 3, command: ["/bin/sh", "-c", script] }), { + stdio: ["ignore", "pipe", "pipe", file.fd], + }) + await file.close() + let stdout = "" + let stderr = "" + child.stdout?.on("data", (chunk) => (stdout += chunk)) + child.stderr?.on("data", (chunk) => (stderr += chunk)) + const code = await new Promise((resolve) => child.once("exit", (code) => resolve(code))) + return { code, stdout, stderr } +} + +test.skipIf(!bwrapUsable)("the seccomp filter lets ordinary work through", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-int-")) + const spec = await hostSpec(dir) + const result = await runFiltered( + spec, + dir, + "cat /workspace/file.txt && echo && ls /workspace | wc -l && echo piped | cat && mkdir /workspace/sub && echo ok-$(id -u)", + ) + expect(result.stderr).toBe("") + expect(result.code).toBe(0) + expect(result.stdout).toContain("hello from host") + expect(result.stdout).toContain("piped") + expect(result.stdout).toMatch(/ok-\d+/) + await rm(dir, { recursive: true, force: true }) +}) + +test.skipIf(!bwrapUsable)("the seccomp filter refuses mounts and kills user-namespace creation", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-int-")) + const spec = await hostSpec(dir) + // Both would succeed on the namespaces alone: the guest holds CAP_SYS_ADMIN + // in its user namespace. 159 is 128 + SIGSYS, the shell's report of a + // child killed by the filter. + const result = await runFiltered( + spec, + dir, + 'mount -t tmpfs none /tmp 2>&1; echo "mount=$?"; unshare -U true 2>&1; echo "unshare=$?"', + ) + expect(result.stdout).toContain("mount=") + expect(result.stdout).not.toContain("mount=0") + expect(result.stdout).toContain("unshare=159") + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/bwrap/provider.test.ts b/packages/codev-sandbox/src/bwrap/provider.test.ts index 167a76a614..b507395414 100644 --- a/packages/codev-sandbox/src/bwrap/provider.test.ts +++ b/packages/codev-sandbox/src/bwrap/provider.test.ts @@ -25,7 +25,7 @@ function fakeChild(): FakeChild { const config: SandboxConfig = { id: "sandbox:default", name: "default", backend: "bwrap", projectDir: "/tmp" } async function startOptions(dir: string, child: FakeChild, overrides?: Partial) { - const spawnCalls: { command: string; args: string[] }[] = [] + const spawnCalls: { command: string; args: string[]; stdio: unknown[] }[] = [] const dirs = sandboxDirs(join(dir, "data"), config.name) await mkdir(join(dirs.rootfs, "usr"), { recursive: true }) const projectDir = join(dir, "project") @@ -38,8 +38,8 @@ async function startOptions(dir: string, child: FakeChild, overrides?: Partial { - spawnCalls.push({ command, args }) + spawnImpl: ((command: string, args: string[], spawnOptions: { stdio: unknown[] }) => { + spawnCalls.push({ command, args, stdio: spawnOptions.stdio }) return child }) as unknown as typeof spawn, fetchImpl: (async () => new Response("ok")) as unknown as typeof fetch, @@ -221,3 +221,52 @@ test("create/reset/destroy manage the sandbox dirs from a staged image", async ( server.stop(true) await rm(dir, { recursive: true, force: true }) }) + +test("start installs the seccomp filter through an inherited fd by default", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const child = fakeChild() + const prepared = await startOptions(dir, child) + const provider = createBwrapProvider(prepared.options) + const sandbox = await provider.start({ ...config, projectDir: prepared.projectDir }) + const call = prepared.spawnCalls[0]! + expect(call.args.join(" ")).toContain("--seccomp 3") + // Slot 3 is an open descriptor on the compiled program, which is also left + // in the runtime dir for inspection; the parent's handle is closed after + // spawn, so the fd number alone is what remains. + expect(typeof call.stdio[3]).toBe("number") + const program = await Bun.file( + join(sandboxDirs(prepared.options.dataDir, config.name).runtime, "seccomp.bpf"), + ).bytes() + expect(program.length % 8).toBe(0) + expect(program.length).toBeGreaterThan(8 * 100) + sandbox.stop() + await rm(dir, { recursive: true, force: true }) +}) + +test("start skips the filter when the sandbox or the provider turns it off", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const child = fakeChild() + const prepared = await startOptions(dir, child, { seccomp: "off" }) + const provider = createBwrapProvider(prepared.options) + const sandbox = await provider.start({ ...config, projectDir: prepared.projectDir }) + expect(prepared.spawnCalls[0]!.args).not.toContain("--seccomp") + expect(prepared.spawnCalls[0]!.stdio.length).toBe(3) + sandbox.stop() + + // The per-sandbox setting wins over the provider default, in both directions. + const enforced = await provider.start({ ...config, projectDir: prepared.projectDir, seccomp: "audit" }) + expect(prepared.spawnCalls[1]!.args.join(" ")).toContain("--seccomp 3") + enforced.stop() + await rm(dir, { recursive: true, force: true }) +}) + +test("start refuses to filter on an architecture without a syscall table", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-bwrap-")) + const child = fakeChild() + const prepared = await startOptions(dir, child, { arch: "ia32" }) + const provider = createBwrapProvider(prepared.options) + expect(provider.start({ ...config, projectDir: prepared.projectDir })).rejects.toThrow( + "seccomp filtering is not available on ia32", + ) + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index e5f64b97fe..8f51df4936 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process" -import { mkdir, rm, stat, writeFile } from "node:fs/promises" +import { mkdir, open, rm, stat, writeFile } from "node:fs/promises" import { join } from "node:path" import { SANDBOX_GUEST_LAUNCHER, @@ -13,6 +13,8 @@ import { localImageAsset, readImageMarker, stageAsset, writeImageMarker } from " import type { SandboxAsset } from "../manifest" import { resolveGuestMounts } from "../mounts" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" +import { buildSeccompProgram, seccompArch } from "../seccomp/bpf" +import type { SeccompMode } from "../seccomp/policy" import { GUEST_BRIDGE, GUEST_BROKER_PORT, @@ -39,9 +41,15 @@ export type BwrapProviderOptions = { // fixed: the bwrap guest's network namespace (and thus loopback) is private, // so the stub port cannot collide with another sandbox's. resolveEgress?: (opts: { guestBrokerPort: number }) => Promise + // Syscall filter for sandboxes whose config does not choose one. enforce + // is the default; audit only logs what enforce would refuse (kernel log), + // and off installs no filter — the escape hatch for a workload the + // allowlist breaks. + seccomp?: SeccompMode bwrapCommand?: string guestNodeCommand?: string platform?: NodeJS.Platform + arch?: string spawnImpl?: typeof spawn fetchImpl?: typeof fetch startTimeoutMs?: number @@ -62,6 +70,7 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi const bwrap = options.bwrapCommand ?? "bwrap" const guestNode = options.guestNodeCommand ?? "node" const platform = options.platform ?? process.platform + const arch = seccompArch(options.arch) const spawnImpl = options.spawnImpl ?? spawn const fetchImpl = options.fetchImpl ?? fetch const startTimeoutMs = options.startTimeoutMs ?? 30_000 @@ -140,6 +149,18 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi if (broker) await broker.listenUnix(join(dirs.runtime, "broker.sock")) if (egress?.guestConfig) await writeFile(join(dirs.bridge, "codev.json"), egress.guestConfig) + // The filter is compiled per start and handed to bwrap as an inherited + // fd (stdio slot 3), which it reads in full before exec — so the file + // is closed here the moment spawn returns. It stays in the runtime dir + // as the record of what this instance runs under. + const seccomp = config.seccomp ?? options.seccomp ?? "enforce" + const seccompPath = join(dirs.runtime, "seccomp.bpf") + if (seccomp !== "off") { + if (!arch) throw new Error(`seccomp filtering is not available on ${options.arch ?? process.arch}`) + await writeFile(seccompPath, buildSeccompProgram(arch, seccomp).bytes) + } + const seccompFile = seccomp === "off" ? null : await open(seccompPath, "r") + const credentials = generateSandboxCredentials() const bootScript = [ // Fresh homes get the image's skel (skills config); images may also @@ -169,9 +190,12 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi ...(egress?.guestConfig ? { CODEV_CONFIG: `${GUEST_BRIDGE}/codev.json` } : {}), }, command: ["/bin/sh", "-c", bootScript], + ...(seccompFile ? { seccompFd: 3 } : {}), }), - { stdio: ["ignore", "ignore", "pipe"] }, + { stdio: seccompFile ? ["ignore", "ignore", "pipe", seccompFile.fd] : ["ignore", "ignore", "pipe"] }, ) + // spawn has already dup'd the fd into the child; the parent's copy is done. + await seccompFile?.close() let stderrTail = "" child.stderr?.on("data", (chunk) => { stderrTail = `${stderrTail}${chunk}`.slice(-8000) diff --git a/packages/codev-sandbox/src/index.ts b/packages/codev-sandbox/src/index.ts index 7c10a724db..ee1d15fa5e 100644 --- a/packages/codev-sandbox/src/index.ts +++ b/packages/codev-sandbox/src/index.ts @@ -11,6 +11,8 @@ export * from "./bwrap/args" export * from "./bwrap/bridge" export * from "./bwrap/rootfs" export * from "./bwrap/provider" +export * from "./seccomp/policy" +export * from "./seccomp/bpf" export * from "./vz/protocol" export * from "./vz/provider" export * from "./wsl/cli" diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts index d90bf4d294..abe140a622 100644 --- a/packages/codev-sandbox/src/provider.ts +++ b/packages/codev-sandbox/src/provider.ts @@ -1,3 +1,5 @@ +import type { SeccompMode } from "./seccomp/policy" + export type SandboxBackend = "vz" | "wsl" | "bwrap" export type SandboxArch = "x64" | "arm64" @@ -33,6 +35,11 @@ export type SandboxConfig = { // here yet — so the UI only offers them where they take effect. cpus?: number memoryMiB?: number + // Syscall filter mode, applied by the bubblewrap backend only (vz is a VM + // and needs none; WSL boots the same rootfs but not through bwrap). Absent + // means the provider default, enforce. No UI sets this yet: it is the + // per-sandbox escape hatch, editable in the persisted store. + seccomp?: SeccompMode } export type SandboxMount = { diff --git a/packages/codev-sandbox/src/seccomp/bpf.test.ts b/packages/codev-sandbox/src/seccomp/bpf.test.ts new file mode 100644 index 0000000000..578b4b32d8 --- /dev/null +++ b/packages/codev-sandbox/src/seccomp/bpf.test.ts @@ -0,0 +1,180 @@ +import { expect, test } from "bun:test" +import { + SECCOMP_RET_ALLOW, + SECCOMP_RET_ERRNO, + SECCOMP_RET_KILL_PROCESS, + SECCOMP_RET_LOG, + buildSeccompProgram, + seccompArch, +} from "./bpf" +import type { SeccompArch } from "./bpf" +import { CLONE_NEWUSER, SECCOMP_ALLOW, SECCOMP_DENIED, SECCOMP_KILL, SOCKET_FAMILY } from "./policy" +import { SYSCALLS_ARM64 } from "./syscalls-arm64" +import { SYSCALLS_X64 } from "./syscalls-x64" + +const TABLES: Record> = { x64: SYSCALLS_X64, arm64: SYSCALLS_ARM64 } +const AUDIT_ARCH: Record = { x64: 0xc000003e, arm64: 0xc00000b7 } +const EPERM = SECCOMP_RET_ERRNO | 1 +const ENOSYS = SECCOMP_RET_ERRNO | 38 +const EAFNOSUPPORT = SECCOMP_RET_ERRNO | 97 + +// A classic-BPF interpreter for the seccomp subset the generator emits, so +// the tests exercise the real program bytes the kernel would run rather than +// the generator's intermediate form. +function run(bytes: Buffer, data: { nr: number; arch: number; args?: number[] }) { + const input = Buffer.alloc(64) + input.writeUInt32LE(data.nr >>> 0, 0) + input.writeUInt32LE(data.arch >>> 0, 4) + ;(data.args ?? []).forEach((arg, index) => input.writeBigUInt64LE(BigInt(arg), 16 + index * 8)) + let acc = 0 + let pc = 0 + for (let steps = 0; steps < 10_000; steps++) { + if (pc * 8 >= bytes.length) throw new Error("fell off the end of the program") + const code = bytes.readUInt16LE(pc * 8) + const jt = bytes.readUInt8(pc * 8 + 2) + const jf = bytes.readUInt8(pc * 8 + 3) + const k = bytes.readUInt32LE(pc * 8 + 4) + if (code === 0x20) { + acc = input.readUInt32LE(k) + pc += 1 + continue + } + if (code === 0x06) return k + if (code === 0x15) { + pc += 1 + (acc === k ? jt : jf) + continue + } + if (code === 0x35) { + pc += 1 + (acc >= k ? jt : jf) + continue + } + if (code === 0x45) { + pc += 1 + ((acc & k) !== 0 ? jt : jf) + continue + } + throw new Error(`unsupported opcode 0x${code.toString(16)} at ${pc}`) + } + throw new Error("program did not terminate") +} + +function verdict(arch: SeccompArch, mode: "audit" | "enforce", name: string, args?: number[]) { + const nr = TABLES[arch][name] + if (nr === undefined) throw new Error(`${name} is not on ${arch}`) + return run(buildSeccompProgram(arch, mode).bytes, { nr, arch: AUDIT_ARCH[arch], args }) +} + +for (const arch of ["x64", "arm64"] as const) { + test(`${arch}: allows the workload syscalls and denies the rest`, () => { + expect(verdict(arch, "enforce", "read")).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "openat")).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "execve")).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "mount")).toBe(EPERM) + expect(verdict(arch, "enforce", "ptrace")).toBe(EPERM) + expect(verdict(arch, "enforce", "setns")).toBe(EPERM) + // Newer syscalls libc probes for must read as absent, not forbidden. + expect(verdict(arch, "enforce", "clone3")).toBe(ENOSYS) + expect(verdict(arch, "enforce", "io_uring_setup")).toBe(ENOSYS) + }) + + test(`${arch}: every allowlisted syscall passes, every kill/deny entry does not`, () => { + const program = buildSeccompProgram(arch, "enforce").bytes + for (const name of SECCOMP_ALLOW) { + const nr = TABLES[arch][name] + if (nr === undefined) continue + // clone and socket are argument-filtered; benign arguments here. + const args = name === "socket" || name === "socketpair" ? [SOCKET_FAMILY.AF_UNIX] : [0] + expect([name, run(program, { nr, arch: AUDIT_ARCH[arch], args })]).toEqual([name, SECCOMP_RET_ALLOW]) + } + for (const name of SECCOMP_KILL) { + const nr = TABLES[arch][name] + if (nr === undefined) continue + expect([name, run(program, { nr, arch: AUDIT_ARCH[arch] })]).toEqual([name, SECCOMP_RET_KILL_PROCESS]) + } + for (const name of SECCOMP_DENIED) { + const nr = TABLES[arch][name] + if (nr === undefined) continue + const action = run(program, { nr, arch: AUDIT_ARCH[arch] }) + expect([name, action]).toEqual([name, nr >= 424 ? ENOSYS : EPERM]) + } + }) + + test(`${arch}: kills the wrong architecture`, () => { + const program = buildSeccompProgram(arch, "enforce").bytes + const other = arch === "x64" ? AUDIT_ARCH.arm64 : AUDIT_ARCH.x64 + expect(run(program, { nr: TABLES[arch].read, arch: other })).toBe(SECCOMP_RET_KILL_PROCESS) + // AUDIT_ARCH_I386, the compat ABI on x86_64 hosts. + expect(run(program, { nr: 3, arch: 0x40000003 })).toBe(SECCOMP_RET_KILL_PROCESS) + }) + + test(`${arch}: refuses namespace creation and kills user-namespace creation`, () => { + const SIGCHLD = 17 + expect(verdict(arch, "enforce", "clone", [SIGCHLD])).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "clone", [SIGCHLD | CLONE_NEWUSER])).toBe(SECCOMP_RET_KILL_PROCESS) + expect(verdict(arch, "enforce", "clone", [SIGCHLD | 0x20000000 /* NEWPID */])).toBe(EPERM) + expect(verdict(arch, "enforce", "unshare", [0x00000200 /* CLONE_FS */])).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "unshare", [0x00020000 /* NEWNS */])).toBe(EPERM) + expect(verdict(arch, "enforce", "unshare", [CLONE_NEWUSER])).toBe(SECCOMP_RET_KILL_PROCESS) + // Only the low word is inspected; a flag word with high bits set but no + // namespace bit is still a plain clone. + expect(verdict(arch, "enforce", "clone", [0x1_0000_0000 + SIGCHLD])).toBe(SECCOMP_RET_ALLOW) + }) + + test(`${arch}: limits socket domains to unix, inet and route netlink`, () => { + const SOCK_STREAM = 1 + expect(verdict(arch, "enforce", "socket", [SOCKET_FAMILY.AF_UNIX, SOCK_STREAM, 0])).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "socket", [SOCKET_FAMILY.AF_INET, SOCK_STREAM, 0])).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "socket", [SOCKET_FAMILY.AF_INET6, SOCK_STREAM, 0])).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "socketpair", [SOCKET_FAMILY.AF_UNIX, SOCK_STREAM, 0])).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "enforce", "socket", [SOCKET_FAMILY.AF_NETLINK, 3, 0 /* NETLINK_ROUTE */])).toBe( + SECCOMP_RET_ALLOW, + ) + expect(verdict(arch, "enforce", "socket", [SOCKET_FAMILY.AF_NETLINK, 3, 9 /* NETLINK_AUDIT */])).toBe(EAFNOSUPPORT) + expect(verdict(arch, "enforce", "socket", [17 /* AF_PACKET */, 3, 0])).toBe(EAFNOSUPPORT) + expect(verdict(arch, "enforce", "socketpair", [17, 3, 0])).toBe(EAFNOSUPPORT) + // The domain check must not leak into the syscalls checked after it: + // the accumulator still holds nr on the miss path. + expect(verdict(arch, "enforce", "bind", [SOCKET_FAMILY.AF_UNIX])).toBe(SECCOMP_RET_ALLOW) + }) + + test(`${arch}: audit mode logs instead of refusing`, () => { + expect(verdict(arch, "audit", "read")).toBe(SECCOMP_RET_ALLOW) + expect(verdict(arch, "audit", "mount")).toBe(SECCOMP_RET_LOG) + expect(verdict(arch, "audit", "clone3")).toBe(SECCOMP_RET_LOG) + expect(verdict(arch, "audit", "bpf")).toBe(SECCOMP_RET_LOG) + expect(verdict(arch, "audit", "unshare", [CLONE_NEWUSER])).toBe(SECCOMP_RET_LOG) + expect(verdict(arch, "audit", "socket", [17, 3, 0])).toBe(SECCOMP_RET_LOG) + expect(run(buildSeccompProgram(arch, "audit").bytes, { nr: 0, arch: 0x40000003 })).toBe(SECCOMP_RET_LOG) + }) + + test(`${arch}: every syscall number gets a verdict and the program fits the kernel limit`, () => { + const program = buildSeccompProgram(arch, "enforce") + expect(program.instructions).toBeLessThanOrEqual(4096) + expect(program.bytes.length).toBe(program.instructions * 8) + for (let nr = 0; nr < 1024; nr++) { + const action = run(program.bytes, { nr, arch: AUDIT_ARCH[arch], args: [0, 0, 0] }) + // EAFNOSUPPORT: the sweep's zero args make socket(AF_UNSPEC) a refusal. + expect([SECCOMP_RET_ALLOW, SECCOMP_RET_KILL_PROCESS, EPERM, ENOSYS, EAFNOSUPPORT]).toContain(action) + } + }) +} + +test("x64: kills the x32 ABI by syscall number", () => { + const program = buildSeccompProgram("x64", "enforce").bytes + expect(run(program, { nr: SYSCALLS_X64.read | 0x40000000, arch: AUDIT_ARCH.x64 })).toBe(SECCOMP_RET_KILL_PROCESS) +}) + +test("the policy names only real syscalls and never lists one twice", () => { + const known = (name: string) => name in SYSCALLS_X64 || name in SYSCALLS_ARM64 + for (const name of [...SECCOMP_ALLOW, ...SECCOMP_KILL, ...SECCOMP_DENIED]) + expect([name, known(name)]).toEqual([name, true]) + const lists = [new Set(SECCOMP_ALLOW), new Set(SECCOMP_KILL), new Set(SECCOMP_DENIED)] + expect(lists.map((set) => set.size)).toEqual([SECCOMP_ALLOW.length, SECCOMP_KILL.length, SECCOMP_DENIED.length]) + for (const name of SECCOMP_ALLOW) expect([name, lists[1].has(name) || lists[2].has(name)]).toEqual([name, false]) + for (const name of SECCOMP_KILL) expect([name, lists[2].has(name)]).toEqual([name, false]) +}) + +test("seccompArch maps the supported node archs and nothing else", () => { + expect(seccompArch("x64")).toBe("x64") + expect(seccompArch("arm64")).toBe("arm64") + expect(seccompArch("ia32")).toBe(null) +}) diff --git a/packages/codev-sandbox/src/seccomp/bpf.ts b/packages/codev-sandbox/src/seccomp/bpf.ts new file mode 100644 index 0000000000..c523618824 --- /dev/null +++ b/packages/codev-sandbox/src/seccomp/bpf.ts @@ -0,0 +1,213 @@ +// Compiles the seccomp policy into the classic-BPF program bwrap's --seccomp +// flag installs on the guest. Generated on the host at every start, so no +// native dependency, no build step, and no separately versioned blob: the +// policy ships with the app that runs the guest. +// +// A seccomp filter sees `struct seccomp_data` — nr, arch, instruction pointer +// and six 64-bit args — and returns an action. This program: +// 1. kills anything whose arch is not the native one (and, on x86_64, any +// x32-ABI number), so a compat ABI cannot reach a syscall by another +// number; +// 2. kills the escalation set outright; +// 3. inspects the flags of clone/unshare and the domain of socket/socketpair; +// 4. allows the allowlist; +// 5. denies the rest with EPERM, or ENOSYS above the unified numbering. +// In audit mode every non-allow verdict becomes SECCOMP_RET_LOG: the kernel +// logs what enforce would have blocked and the workload proceeds unchanged. +import { + CLONE_NAMESPACE_FLAGS, + CLONE_NEWUSER, + NETLINK_ROUTE, + SECCOMP_ALLOW, + SECCOMP_KILL, + SOCKET_FAMILY, + UNIFIED_SYSCALL_BASE, +} from "./policy" +import type { SeccompMode } from "./policy" +import { SYSCALLS_ARM64 } from "./syscalls-arm64" +import { SYSCALLS_X64 } from "./syscalls-x64" + +export type SeccompArch = "x64" | "arm64" + +const SYSCALL_TABLES: Record> = { x64: SYSCALLS_X64, arm64: SYSCALLS_ARM64 } + +// AUDIT_ARCH_* from : EM_* | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE. +const AUDIT_ARCH: Record = { x64: 0xc000003e, arm64: 0xc00000b7 } +const X32_SYSCALL_BIT = 0x40000000 + +// struct seccomp_data offsets. Both supported arches are little-endian, so +// the low 32 bits of an argument sit at its base offset. +const DATA_NR = 0 +const DATA_ARCH = 4 +const argOffset = (index: number) => 16 + index * 8 + +// Instruction classes, from and . +const LD_W_ABS = 0x20 +const JMP_JEQ_K = 0x15 +const JMP_JGE_K = 0x35 +const JMP_JSET_K = 0x45 +const RET_K = 0x06 + +// return actions. +export const SECCOMP_RET_KILL_PROCESS = 0x80000000 +export const SECCOMP_RET_ERRNO = 0x00050000 +export const SECCOMP_RET_LOG = 0x7ffc0000 +export const SECCOMP_RET_ALLOW = 0x7fff0000 + +const EPERM = 1 +const ENOSYS = 38 +const EAFNOSUPPORT = 97 + +// The kernel refuses programs longer than this (BPF_MAXINSNS). +const BPF_MAXINSNS = 4096 + +// Jump targets are unsigned 8-bit offsets from the next instruction, so a +// label may only be referenced from within 255 instructions of it; assemble() +// checks. The allowlist is emitted in chunks with their own `ret ALLOW` so no +// reference has to reach further than a chunk. +const ALLOW_CHUNK = 200 + +type Target = number | string +type Instruction = { code: number; k: number; jt: Target; jf: Target; label?: string } + +export type SeccompProgram = { + arch: SeccompArch + mode: Exclude + instructions: number + bytes: Buffer +} + +export function buildSeccompProgram(arch: SeccompArch, mode: Exclude): SeccompProgram { + const table = SYSCALL_TABLES[arch] + const nr = (name: string) => { + const number = table[name] + if (number === undefined) throw new Error(`syscall ${name} is not in the ${arch} table`) + return number + } + const present = (names: readonly string[]) => names.filter((name) => requireKnown(name) in table) + // In audit mode nothing is refused; the verdict that would have been + // returned is logged instead and the syscall proceeds. + const verdict = (action: number) => (mode === "audit" ? SECCOMP_RET_LOG : action) + const errno = (code: number) => verdict(SECCOMP_RET_ERRNO | code) + const kill = verdict(SECCOMP_RET_KILL_PROCESS) + + const program: Instruction[] = [] + const load = (offset: number) => program.push({ code: LD_W_ABS, k: offset, jt: 0, jf: 0 }) + const jeq = (k: number, jt: Target, jf: Target) => program.push({ code: JMP_JEQ_K, k, jt, jf }) + const jge = (k: number, jt: Target, jf: Target) => program.push({ code: JMP_JGE_K, k, jt, jf }) + const jset = (k: number, jt: Target, jf: Target) => program.push({ code: JMP_JSET_K, k, jt, jf }) + const ret = (action: number, label?: string) => program.push({ code: RET_K, k: action, jt: 0, jf: 0, label }) + + // 1. architecture gate + load(DATA_ARCH) + jeq(AUDIT_ARCH[arch], 1, 0) + ret(kill) + load(DATA_NR) + if (arch === "x64") { + jge(X32_SYSCALL_BIT, 0, 1) + ret(kill) + } + + // 2. the escalation set + for (const name of present(SECCOMP_KILL)) jeq(nr(name), "kill", 0) + jeq(nr("clone"), "namespace-flags", 0) + jeq(nr("unshare"), "namespace-flags", "socket-domain-check") + ret(kill, "kill") + + // 3a. clone/unshare: fatal for a new user namespace, refused for any other + // namespace, allowed otherwise. The flags are the first argument on both + // arches, and every CLONE_NEW* bit lives in the low word. + program.push({ code: LD_W_ABS, k: argOffset(0), jt: 0, jf: 0, label: "namespace-flags" }) + jset(CLONE_NEWUSER, "kill-namespace", 0) + jset(CLONE_NAMESPACE_FLAGS, "refuse-namespace", 0) + ret(SECCOMP_RET_ALLOW) + ret(kill, "kill-namespace") + ret(errno(EPERM), "refuse-namespace") + + // 3b. socket/socketpair: domain in the first argument, protocol in the + // third. The accumulator still holds nr on the skip path, so the checks + // that follow need no reload. + program.push({ code: JMP_JEQ_K, k: nr("socket"), jt: "socket-domain", jf: 0, label: "socket-domain-check" }) + jeq(nr("socketpair"), "socket-domain", "allowlist") + program.push({ code: LD_W_ABS, k: argOffset(0), jt: 0, jf: 0, label: "socket-domain" }) + jeq(SOCKET_FAMILY.AF_UNIX, "socket-allow", 0) + jeq(SOCKET_FAMILY.AF_INET, "socket-allow", 0) + jeq(SOCKET_FAMILY.AF_INET6, "socket-allow", 0) + jeq(SOCKET_FAMILY.AF_NETLINK, 0, "socket-refuse") + load(argOffset(2)) + jeq(NETLINK_ROUTE, 0, "socket-refuse") + ret(SECCOMP_RET_ALLOW, "socket-allow") + ret(errno(EAFNOSUPPORT), "socket-refuse") + + // 4. the allowlist, in chunks short enough for 8-bit jumps + const allowed = present(SECCOMP_ALLOW) + .map(nr) + .sort((a, b) => a - b) + if (allowed.length === 0) throw new Error(`seccomp allowlist resolves to nothing on ${arch}`) + const chunks = Array.from({ length: Math.ceil(allowed.length / ALLOW_CHUNK) }, (_, i) => + allowed.slice(i * ALLOW_CHUNK, (i + 1) * ALLOW_CHUNK), + ) + chunks.forEach((chunk, index) => { + // Each chunk: jeq → its own ret ALLOW; the last jeq's miss goes to the + // next chunk (or the deny tail), skipping over that ret. + const allow = `allow-${index}` + const next = index + 1 < chunks.length ? `chunk-${index + 1}` : "deny" + chunk.forEach((number, position) => { + program.push({ + code: JMP_JEQ_K, + k: number, + jt: allow, + jf: position === chunk.length - 1 ? next : 0, + ...(position === 0 ? { label: index === 0 ? "allowlist" : `chunk-${index}` } : {}), + }) + }) + ret(SECCOMP_RET_ALLOW, allow) + }) + + // 5. default deny + program.push({ code: JMP_JGE_K, k: UNIFIED_SYSCALL_BASE, jt: "enosys", jf: 0, label: "deny" }) + ret(errno(EPERM)) + ret(errno(ENOSYS), "enosys") + + return { arch, mode, instructions: program.length, bytes: assemble(program) } +} + +function requireKnown(name: string) { + if (name in SYSCALLS_X64 || name in SYSCALLS_ARM64) return name + throw new Error(`seccomp policy names an unknown syscall: ${name}`) +} + +// Resolves labels to relative offsets and packs `struct sock_filter` +// (u16 code, u8 jt, u8 jf, u32 k; little-endian on both supported hosts). +function assemble(program: Instruction[]) { + if (program.length > BPF_MAXINSNS) throw new Error(`seccomp program too long: ${program.length} instructions`) + const labels = new Map() + program.forEach((instruction, index) => { + if (instruction.label === undefined) return + if (labels.has(instruction.label)) throw new Error(`duplicate seccomp label ${instruction.label}`) + labels.set(instruction.label, index) + }) + const offset = (target: Target, from: number) => { + if (typeof target === "number") return target + const index = labels.get(target) + if (index === undefined) throw new Error(`unknown seccomp label ${target}`) + const distance = index - (from + 1) + if (distance < 0 || distance > 255) throw new Error(`seccomp label ${target} is out of jump range (${distance})`) + return distance + } + const bytes = Buffer.alloc(program.length * 8) + program.forEach((instruction, index) => { + bytes.writeUInt16LE(instruction.code, index * 8) + bytes.writeUInt8(offset(instruction.jt, index), index * 8 + 2) + bytes.writeUInt8(offset(instruction.jf, index), index * 8 + 3) + bytes.writeUInt32LE(instruction.k >>> 0, index * 8 + 4) + }) + return bytes +} + +// The host's own architecture, which is the guest's too: bwrap runs the +// rootfs on the host kernel. +export function seccompArch(arch: string = process.arch): SeccompArch | null { + if (arch === "x64" || arch === "arm64") return arch + return null +} diff --git a/packages/codev-sandbox/src/seccomp/policy.ts b/packages/codev-sandbox/src/seccomp/policy.ts new file mode 100644 index 0000000000..7c1ee84002 --- /dev/null +++ b/packages/codev-sandbox/src/seccomp/policy.ts @@ -0,0 +1,150 @@ +// The syscall policy for the bubblewrap guest. Data only: bpf.ts compiles it +// into the classic-BPF program bwrap installs before exec. +// +// Posture: strict allowlist. A syscall not named here is denied — with EPERM +// below the unified numbering (424), and ENOSYS from there up so libc +// fallbacks for newer syscalls (clone3 → clone, faccessat2 → faccessat) keep +// working instead of failing with a permission error they never expect. +// +// The namespaces (--unshare-user/pid/net/uts/ipc) decide what the guest can +// see; this decides what it can say to the shared host kernel. The names are +// resolved per architecture: a name missing on one arch (x86_64's open, stat, +// arch_prctl; arm64 has neither) is simply skipped there, and a name missing on +// both is a typo the build refuses. +// +// The list is derived from Docker's default seccomp profile — the most widely +// exercised allowlist there is — minus everything that profile only grants to +// containers holding an extra capability. Inside bwrap's user namespace the +// guest does hold CAP_SYS_ADMIN over its own namespaces, so those syscalls +// (mount, setns, unshare, keyctl, bpf, ...) are exactly the ones this filter +// has to take back. +export type SeccompMode = "off" | "audit" | "enforce" + +export const SECCOMP_MODES: readonly SeccompMode[] = ["off", "audit", "enforce"] + +export function isSeccompMode(value: unknown): value is SeccompMode { + return SECCOMP_MODES.includes(value as SeccompMode) +} + +// prettier-ignore +export const SECCOMP_ALLOW = [ + // process, threads, scheduling + "clone", "fork", "vfork", "execve", "execveat", "exit", "exit_group", "wait4", "waitid", + "kill", "tkill", "tgkill", "getpid", "getppid", "gettid", "set_tid_address", "set_robust_list", + "get_robust_list", "rseq", "futex", "futex_waitv", "futex_wait", "futex_wake", "futex_requeue", + "sched_yield", "sched_getaffinity", "sched_setaffinity", "sched_getparam", "sched_setparam", + "sched_getscheduler", "sched_setscheduler", "sched_get_priority_max", "sched_get_priority_min", + "sched_rr_get_interval", "sched_getattr", "sched_setattr", "getpriority", "setpriority", + "nanosleep", "clock_nanosleep", "pause", "restart_syscall", "prctl", "arch_prctl", "capget", + "capset", "seccomp", "pidfd_open", "pidfd_send_signal", "getcpu", "ioprio_get", "ioprio_set", + "membarrier", "prlimit64", "getrlimit", "setrlimit", "getrusage", "times", "uname", "sysinfo", + "getrandom", + // identity + "getuid", "geteuid", "getgid", "getegid", "getgroups", "setuid", "setgid", "setreuid", + "setregid", "setresuid", "setresgid", "getresuid", "getresgid", "setgroups", "setfsuid", + "setfsgid", "getpgid", "getpgrp", "setpgid", "getsid", "setsid", + // signals and timers + "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "rt_sigpending", "rt_sigtimedwait", + "rt_sigqueueinfo", "rt_tgsigqueueinfo", "rt_sigsuspend", "sigaltstack", "alarm", "getitimer", + "setitimer", "timer_create", "timer_settime", "timer_gettime", "timer_getoverrun", + "timer_delete", "signalfd", "signalfd4", "timerfd_create", "timerfd_settime", "timerfd_gettime", + // memory + "brk", "mmap", "munmap", "mprotect", "mremap", "madvise", "mincore", "msync", "mlock", "mlock2", + "munlock", "mlockall", "munlockall", "memfd_create", "remap_file_pages", "pkey_alloc", + "pkey_free", "pkey_mprotect", "cachestat", "map_shadow_stack", "mseal", + // files and directories + "read", "write", "readv", "writev", "pread64", "pwrite64", "preadv", "pwritev", "preadv2", + "pwritev2", "open", "openat", "openat2", "creat", "close", "close_range", "lseek", "stat", + "fstat", "lstat", "newfstatat", "statx", "access", "faccessat", "faccessat2", "readlink", + "readlinkat", "symlink", "symlinkat", "link", "linkat", "unlink", "unlinkat", "rename", + "renameat", "renameat2", "mkdir", "mkdirat", "rmdir", "chdir", "fchdir", "getcwd", "chmod", + "fchmod", "fchmodat", "fchmodat2", "chown", "fchown", "lchown", "fchownat", "truncate", + "ftruncate", "fallocate", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "dup", + "dup2", "dup3", "fcntl", "flock", "ioctl", "getdents", "getdents64", "utime", "utimes", + "utimensat", "futimesat", "statfs", "fstatfs", "copy_file_range", "sendfile", "splice", "tee", + "vmsplice", "fadvise64", "readahead", "umask", "getxattr", "lgetxattr", "fgetxattr", "setxattr", + "lsetxattr", "fsetxattr", "listxattr", "llistxattr", "flistxattr", "removexattr", + "lremovexattr", "fremovexattr", "setxattrat", "getxattrat", "listxattrat", "removexattrat", + "inotify_init", "inotify_init1", "inotify_add_watch", "inotify_rm_watch", "pipe", "pipe2", + "mknod", "mknodat", "eventfd", "eventfd2", "epoll_create", "epoll_create1", "epoll_ctl", + "epoll_wait", "epoll_pwait", "epoll_pwait2", "poll", "ppoll", "select", "pselect6", + "io_setup", "io_destroy", "io_submit", "io_cancel", "io_getevents", "io_pgetevents", + // self-restriction only: a tool may confine itself further, never widen + "landlock_create_ruleset", "landlock_add_rule", "landlock_restrict_self", + // sockets — socket/socketpair are additionally family-filtered (below) + "socket", "socketpair", "bind", "listen", "accept", "accept4", "connect", "shutdown", + "getsockname", "getpeername", "getsockopt", "setsockopt", "sendto", "recvfrom", "sendmsg", + "recvmsg", "sendmmsg", "recvmmsg", + // System V and POSIX IPC (the ipc namespace is private to the guest) + "shmget", "shmat", "shmdt", "shmctl", "semget", "semop", "semtimedop", "semctl", "msgget", + "msgsnd", "msgrcv", "msgctl", "mq_open", "mq_unlink", "mq_timedsend", "mq_timedreceive", + "mq_notify", "mq_getsetattr", + // clocks (read only; setting them is denied) + "clock_gettime", "clock_getres", "gettimeofday", "time", +] as const + +// Escalation primitives with no place in an agent workload. These kill the +// calling process (SIGSYS) rather than return an errno: a program that hits +// one is either an exploit or badly confused, and a loud death is the better +// diagnostic for both. ptrace is deliberately not here — strace and gdb should +// fail with "Operation not permitted", not vanish — so it falls to the default +// deny below. +// prettier-ignore +export const SECCOMP_KILL = [ + "bpf", "perf_event_open", "userfaultfd", + "init_module", "finit_module", "delete_module", "kexec_load", "kexec_file_load", + "keyctl", "add_key", "request_key", +] as const + +// Not in the filter at all — they fall to the default deny like any unknown +// syscall — but named so the reason is on record and a test can pin them. +// prettier-ignore +export const SECCOMP_DENIED = [ + // cross-process introspection and fd theft + "ptrace", "process_vm_readv", "process_vm_writev", "kcmp", "pidfd_getfd", + // namespace and mount manipulation: the guest holds CAP_SYS_ADMIN in its + // user namespace, so the kernel alone would let it reshape its own view + "mount", "umount2", "pivot_root", "setns", "chroot", "fsopen", "fsconfig", "fsmount", "fspick", + "move_mount", "open_tree", "mount_setattr", "name_to_handle_at", "open_by_handle_at", + // host-wide state a namespace does not isolate + "reboot", "swapon", "swapoff", "acct", "syslog", "quotactl", "sethostname", "setdomainname", + "clock_settime", "settimeofday", "adjtimex", "clock_adjtime", "vhangup", "nfsservctl", + "lookup_dcookie", "iopl", "ioperm", "modify_ldt", "personality", + // NUMA policy (CAP_SYS_NICE in Docker's profile) and notification fan-out + "mbind", "set_mempolicy", "get_mempolicy", "migrate_pages", "move_pages", "fanotify_init", + "fanotify_mark", + // io_uring: a large, fast-moving kernel surface; libuv treats a failed + // io_uring_setup as "unavailable" and falls back to its thread pool + "io_uring_setup", "io_uring_enter", "io_uring_register", + // clone3 takes its flags in a struct the filter cannot read; ENOSYS makes + // glibc retry through clone, which the flag check below does cover + "clone3", "memfd_secret", +] as const + +// socket(2)/socketpair(2) domains the guest may open. --unshare-net already +// leaves it a loopback-only network, so AF_INET/AF_INET6 give it the local +// dev-server workflow (the codev server, the broker stub and anything the +// agent starts and dials) and nothing more. AF_NETLINK is allowed only for +// NETLINK_ROUTE, which is what getifaddrs(3) — and so every tool that prints +// "listening on" addresses — needs; the other netlink families are kernel +// attack surface with no workload behind them. +export const SOCKET_FAMILY = { AF_UNIX: 1, AF_INET: 2, AF_INET6: 10, AF_NETLINK: 16 } as const +export const NETLINK_ROUTE = 0 + +// clone(2)/unshare(2) flags. A new user namespace is the primitive most +// kernel privilege escalations start from (and bwrap already gave the guest +// the one it gets), so asking for one is fatal; the other namespace flags are +// merely refused. +export const CLONE_NEWUSER = 0x10000000 +export const CLONE_NAMESPACE_FLAGS = + 0x00020000 /* CLONE_NEWNS */ | + 0x02000000 /* CLONE_NEWCGROUP */ | + 0x04000000 /* CLONE_NEWUTS */ | + 0x08000000 /* CLONE_NEWIPC */ | + 0x20000000 /* CLONE_NEWPID */ | + 0x40000000 /* CLONE_NEWNET */ + +// First number of the arch-independent syscall range. Below it, an unlisted +// syscall is an old one the guest has no business calling (EPERM); from it up, +// it is a newer one libc may probe for and fall back from (ENOSYS). +export const UNIFIED_SYSCALL_BASE = 424 diff --git a/packages/codev-sandbox/src/seccomp/syscalls-arm64.ts b/packages/codev-sandbox/src/seccomp/syscalls-arm64.ts new file mode 100644 index 0000000000..33d561fd76 --- /dev/null +++ b/packages/codev-sandbox/src/seccomp/syscalls-arm64.ts @@ -0,0 +1,326 @@ +// Generated by script/gen-syscall-tables.ts from the asm-generic/unistd.h uapi header; +// do not edit by hand. Syscall numbers for aarch64. +export const SYSCALLS_ARM64: Record = { + io_setup: 0, + io_destroy: 1, + io_submit: 2, + io_cancel: 3, + io_getevents: 4, + setxattr: 5, + lsetxattr: 6, + fsetxattr: 7, + getxattr: 8, + lgetxattr: 9, + fgetxattr: 10, + listxattr: 11, + llistxattr: 12, + flistxattr: 13, + removexattr: 14, + lremovexattr: 15, + fremovexattr: 16, + getcwd: 17, + lookup_dcookie: 18, + eventfd2: 19, + epoll_create1: 20, + epoll_ctl: 21, + epoll_pwait: 22, + dup: 23, + dup3: 24, + fcntl: 25, + inotify_init1: 26, + inotify_add_watch: 27, + inotify_rm_watch: 28, + ioctl: 29, + ioprio_set: 30, + ioprio_get: 31, + flock: 32, + mknodat: 33, + mkdirat: 34, + unlinkat: 35, + symlinkat: 36, + linkat: 37, + renameat: 38, + umount2: 39, + mount: 40, + pivot_root: 41, + nfsservctl: 42, + statfs: 43, + fstatfs: 44, + truncate: 45, + ftruncate: 46, + fallocate: 47, + faccessat: 48, + chdir: 49, + fchdir: 50, + chroot: 51, + fchmod: 52, + fchmodat: 53, + fchownat: 54, + fchown: 55, + openat: 56, + close: 57, + vhangup: 58, + pipe2: 59, + quotactl: 60, + getdents64: 61, + lseek: 62, + read: 63, + write: 64, + readv: 65, + writev: 66, + pread64: 67, + pwrite64: 68, + preadv: 69, + pwritev: 70, + sendfile: 71, + pselect6: 72, + ppoll: 73, + signalfd4: 74, + vmsplice: 75, + splice: 76, + tee: 77, + readlinkat: 78, + newfstatat: 79, + fstat: 80, + sync: 81, + fsync: 82, + fdatasync: 83, + sync_file_range: 84, + timerfd_create: 85, + timerfd_settime: 86, + timerfd_gettime: 87, + utimensat: 88, + acct: 89, + capget: 90, + capset: 91, + personality: 92, + exit: 93, + exit_group: 94, + waitid: 95, + set_tid_address: 96, + unshare: 97, + futex: 98, + set_robust_list: 99, + get_robust_list: 100, + nanosleep: 101, + getitimer: 102, + setitimer: 103, + kexec_load: 104, + init_module: 105, + delete_module: 106, + timer_create: 107, + timer_gettime: 108, + timer_getoverrun: 109, + timer_settime: 110, + timer_delete: 111, + clock_settime: 112, + clock_gettime: 113, + clock_getres: 114, + clock_nanosleep: 115, + syslog: 116, + ptrace: 117, + sched_setparam: 118, + sched_setscheduler: 119, + sched_getscheduler: 120, + sched_getparam: 121, + sched_setaffinity: 122, + sched_getaffinity: 123, + sched_yield: 124, + sched_get_priority_max: 125, + sched_get_priority_min: 126, + sched_rr_get_interval: 127, + restart_syscall: 128, + kill: 129, + tkill: 130, + tgkill: 131, + sigaltstack: 132, + rt_sigsuspend: 133, + rt_sigaction: 134, + rt_sigprocmask: 135, + rt_sigpending: 136, + rt_sigtimedwait: 137, + rt_sigqueueinfo: 138, + rt_sigreturn: 139, + setpriority: 140, + getpriority: 141, + reboot: 142, + setregid: 143, + setgid: 144, + setreuid: 145, + setuid: 146, + setresuid: 147, + getresuid: 148, + setresgid: 149, + getresgid: 150, + setfsuid: 151, + setfsgid: 152, + times: 153, + setpgid: 154, + getpgid: 155, + getsid: 156, + setsid: 157, + getgroups: 158, + setgroups: 159, + uname: 160, + sethostname: 161, + setdomainname: 162, + getrlimit: 163, + setrlimit: 164, + getrusage: 165, + umask: 166, + prctl: 167, + getcpu: 168, + gettimeofday: 169, + settimeofday: 170, + adjtimex: 171, + getpid: 172, + getppid: 173, + getuid: 174, + geteuid: 175, + getgid: 176, + getegid: 177, + gettid: 178, + sysinfo: 179, + mq_open: 180, + mq_unlink: 181, + mq_timedsend: 182, + mq_timedreceive: 183, + mq_notify: 184, + mq_getsetattr: 185, + msgget: 186, + msgctl: 187, + msgrcv: 188, + msgsnd: 189, + semget: 190, + semctl: 191, + semtimedop: 192, + semop: 193, + shmget: 194, + shmctl: 195, + shmat: 196, + shmdt: 197, + socket: 198, + socketpair: 199, + bind: 200, + listen: 201, + accept: 202, + connect: 203, + getsockname: 204, + getpeername: 205, + sendto: 206, + recvfrom: 207, + setsockopt: 208, + getsockopt: 209, + shutdown: 210, + sendmsg: 211, + recvmsg: 212, + readahead: 213, + brk: 214, + munmap: 215, + mremap: 216, + add_key: 217, + request_key: 218, + keyctl: 219, + clone: 220, + execve: 221, + mmap: 222, + fadvise64: 223, + swapon: 224, + swapoff: 225, + mprotect: 226, + msync: 227, + mlock: 228, + munlock: 229, + mlockall: 230, + munlockall: 231, + mincore: 232, + madvise: 233, + remap_file_pages: 234, + mbind: 235, + get_mempolicy: 236, + set_mempolicy: 237, + migrate_pages: 238, + move_pages: 239, + rt_tgsigqueueinfo: 240, + perf_event_open: 241, + accept4: 242, + recvmmsg: 243, + wait4: 260, + prlimit64: 261, + fanotify_init: 262, + fanotify_mark: 263, + name_to_handle_at: 264, + open_by_handle_at: 265, + clock_adjtime: 266, + syncfs: 267, + setns: 268, + sendmmsg: 269, + process_vm_readv: 270, + process_vm_writev: 271, + kcmp: 272, + finit_module: 273, + sched_setattr: 274, + sched_getattr: 275, + renameat2: 276, + seccomp: 277, + getrandom: 278, + memfd_create: 279, + bpf: 280, + execveat: 281, + userfaultfd: 282, + membarrier: 283, + mlock2: 284, + copy_file_range: 285, + preadv2: 286, + pwritev2: 287, + pkey_mprotect: 288, + pkey_alloc: 289, + pkey_free: 290, + statx: 291, + io_pgetevents: 292, + rseq: 293, + kexec_file_load: 294, + pidfd_send_signal: 424, + io_uring_setup: 425, + io_uring_enter: 426, + io_uring_register: 427, + open_tree: 428, + move_mount: 429, + fsopen: 430, + fsconfig: 431, + fsmount: 432, + fspick: 433, + pidfd_open: 434, + clone3: 435, + close_range: 436, + openat2: 437, + pidfd_getfd: 438, + faccessat2: 439, + process_madvise: 440, + epoll_pwait2: 441, + mount_setattr: 442, + quotactl_fd: 443, + landlock_create_ruleset: 444, + landlock_add_rule: 445, + landlock_restrict_self: 446, + memfd_secret: 447, + process_mrelease: 448, + futex_waitv: 449, + set_mempolicy_home_node: 450, + cachestat: 451, + fchmodat2: 452, + map_shadow_stack: 453, + futex_wake: 454, + futex_wait: 455, + futex_requeue: 456, + statmount: 457, + listmount: 458, + lsm_get_self_attr: 459, + lsm_set_self_attr: 460, + lsm_list_modules: 461, + mseal: 462, + setxattrat: 463, + getxattrat: 464, + listxattrat: 465, + removexattrat: 466, +} diff --git a/packages/codev-sandbox/src/seccomp/syscalls-x64.ts b/packages/codev-sandbox/src/seccomp/syscalls-x64.ts new file mode 100644 index 0000000000..6578a5bf62 --- /dev/null +++ b/packages/codev-sandbox/src/seccomp/syscalls-x64.ts @@ -0,0 +1,382 @@ +// Generated by script/gen-syscall-tables.ts from the asm/unistd_64.h uapi header; +// do not edit by hand. Syscall numbers for x86_64. +export const SYSCALLS_X64: Record = { + read: 0, + write: 1, + open: 2, + close: 3, + stat: 4, + fstat: 5, + lstat: 6, + poll: 7, + lseek: 8, + mmap: 9, + mprotect: 10, + munmap: 11, + brk: 12, + rt_sigaction: 13, + rt_sigprocmask: 14, + rt_sigreturn: 15, + ioctl: 16, + pread64: 17, + pwrite64: 18, + readv: 19, + writev: 20, + access: 21, + pipe: 22, + select: 23, + sched_yield: 24, + mremap: 25, + msync: 26, + mincore: 27, + madvise: 28, + shmget: 29, + shmat: 30, + shmctl: 31, + dup: 32, + dup2: 33, + pause: 34, + nanosleep: 35, + getitimer: 36, + alarm: 37, + setitimer: 38, + getpid: 39, + sendfile: 40, + socket: 41, + connect: 42, + accept: 43, + sendto: 44, + recvfrom: 45, + sendmsg: 46, + recvmsg: 47, + shutdown: 48, + bind: 49, + listen: 50, + getsockname: 51, + getpeername: 52, + socketpair: 53, + setsockopt: 54, + getsockopt: 55, + clone: 56, + fork: 57, + vfork: 58, + execve: 59, + exit: 60, + wait4: 61, + kill: 62, + uname: 63, + semget: 64, + semop: 65, + semctl: 66, + shmdt: 67, + msgget: 68, + msgsnd: 69, + msgrcv: 70, + msgctl: 71, + fcntl: 72, + flock: 73, + fsync: 74, + fdatasync: 75, + truncate: 76, + ftruncate: 77, + getdents: 78, + getcwd: 79, + chdir: 80, + fchdir: 81, + rename: 82, + mkdir: 83, + rmdir: 84, + creat: 85, + link: 86, + unlink: 87, + symlink: 88, + readlink: 89, + chmod: 90, + fchmod: 91, + chown: 92, + fchown: 93, + lchown: 94, + umask: 95, + gettimeofday: 96, + getrlimit: 97, + getrusage: 98, + sysinfo: 99, + times: 100, + ptrace: 101, + getuid: 102, + syslog: 103, + getgid: 104, + setuid: 105, + setgid: 106, + geteuid: 107, + getegid: 108, + setpgid: 109, + getppid: 110, + getpgrp: 111, + setsid: 112, + setreuid: 113, + setregid: 114, + getgroups: 115, + setgroups: 116, + setresuid: 117, + getresuid: 118, + setresgid: 119, + getresgid: 120, + getpgid: 121, + setfsuid: 122, + setfsgid: 123, + getsid: 124, + capget: 125, + capset: 126, + rt_sigpending: 127, + rt_sigtimedwait: 128, + rt_sigqueueinfo: 129, + rt_sigsuspend: 130, + sigaltstack: 131, + utime: 132, + mknod: 133, + uselib: 134, + personality: 135, + ustat: 136, + statfs: 137, + fstatfs: 138, + sysfs: 139, + getpriority: 140, + setpriority: 141, + sched_setparam: 142, + sched_getparam: 143, + sched_setscheduler: 144, + sched_getscheduler: 145, + sched_get_priority_max: 146, + sched_get_priority_min: 147, + sched_rr_get_interval: 148, + mlock: 149, + munlock: 150, + mlockall: 151, + munlockall: 152, + vhangup: 153, + modify_ldt: 154, + pivot_root: 155, + _sysctl: 156, + prctl: 157, + arch_prctl: 158, + adjtimex: 159, + setrlimit: 160, + chroot: 161, + sync: 162, + acct: 163, + settimeofday: 164, + mount: 165, + umount2: 166, + swapon: 167, + swapoff: 168, + reboot: 169, + sethostname: 170, + setdomainname: 171, + iopl: 172, + ioperm: 173, + create_module: 174, + init_module: 175, + delete_module: 176, + get_kernel_syms: 177, + query_module: 178, + quotactl: 179, + nfsservctl: 180, + getpmsg: 181, + putpmsg: 182, + afs_syscall: 183, + tuxcall: 184, + security: 185, + gettid: 186, + readahead: 187, + setxattr: 188, + lsetxattr: 189, + fsetxattr: 190, + getxattr: 191, + lgetxattr: 192, + fgetxattr: 193, + listxattr: 194, + llistxattr: 195, + flistxattr: 196, + removexattr: 197, + lremovexattr: 198, + fremovexattr: 199, + tkill: 200, + time: 201, + futex: 202, + sched_setaffinity: 203, + sched_getaffinity: 204, + set_thread_area: 205, + io_setup: 206, + io_destroy: 207, + io_getevents: 208, + io_submit: 209, + io_cancel: 210, + get_thread_area: 211, + lookup_dcookie: 212, + epoll_create: 213, + epoll_ctl_old: 214, + epoll_wait_old: 215, + remap_file_pages: 216, + getdents64: 217, + set_tid_address: 218, + restart_syscall: 219, + semtimedop: 220, + fadvise64: 221, + timer_create: 222, + timer_settime: 223, + timer_gettime: 224, + timer_getoverrun: 225, + timer_delete: 226, + clock_settime: 227, + clock_gettime: 228, + clock_getres: 229, + clock_nanosleep: 230, + exit_group: 231, + epoll_wait: 232, + epoll_ctl: 233, + tgkill: 234, + utimes: 235, + vserver: 236, + mbind: 237, + set_mempolicy: 238, + get_mempolicy: 239, + mq_open: 240, + mq_unlink: 241, + mq_timedsend: 242, + mq_timedreceive: 243, + mq_notify: 244, + mq_getsetattr: 245, + kexec_load: 246, + waitid: 247, + add_key: 248, + request_key: 249, + keyctl: 250, + ioprio_set: 251, + ioprio_get: 252, + inotify_init: 253, + inotify_add_watch: 254, + inotify_rm_watch: 255, + migrate_pages: 256, + openat: 257, + mkdirat: 258, + mknodat: 259, + fchownat: 260, + futimesat: 261, + newfstatat: 262, + unlinkat: 263, + renameat: 264, + linkat: 265, + symlinkat: 266, + readlinkat: 267, + fchmodat: 268, + faccessat: 269, + pselect6: 270, + ppoll: 271, + unshare: 272, + set_robust_list: 273, + get_robust_list: 274, + splice: 275, + tee: 276, + sync_file_range: 277, + vmsplice: 278, + move_pages: 279, + utimensat: 280, + epoll_pwait: 281, + signalfd: 282, + timerfd_create: 283, + eventfd: 284, + fallocate: 285, + timerfd_settime: 286, + timerfd_gettime: 287, + accept4: 288, + signalfd4: 289, + eventfd2: 290, + epoll_create1: 291, + dup3: 292, + pipe2: 293, + inotify_init1: 294, + preadv: 295, + pwritev: 296, + rt_tgsigqueueinfo: 297, + perf_event_open: 298, + recvmmsg: 299, + fanotify_init: 300, + fanotify_mark: 301, + prlimit64: 302, + name_to_handle_at: 303, + open_by_handle_at: 304, + clock_adjtime: 305, + syncfs: 306, + sendmmsg: 307, + setns: 308, + getcpu: 309, + process_vm_readv: 310, + process_vm_writev: 311, + kcmp: 312, + finit_module: 313, + sched_setattr: 314, + sched_getattr: 315, + renameat2: 316, + seccomp: 317, + getrandom: 318, + memfd_create: 319, + kexec_file_load: 320, + bpf: 321, + execveat: 322, + userfaultfd: 323, + membarrier: 324, + mlock2: 325, + copy_file_range: 326, + preadv2: 327, + pwritev2: 328, + pkey_mprotect: 329, + pkey_alloc: 330, + pkey_free: 331, + statx: 332, + io_pgetevents: 333, + rseq: 334, + pidfd_send_signal: 424, + io_uring_setup: 425, + io_uring_enter: 426, + io_uring_register: 427, + open_tree: 428, + move_mount: 429, + fsopen: 430, + fsconfig: 431, + fsmount: 432, + fspick: 433, + pidfd_open: 434, + clone3: 435, + close_range: 436, + openat2: 437, + pidfd_getfd: 438, + faccessat2: 439, + process_madvise: 440, + epoll_pwait2: 441, + mount_setattr: 442, + quotactl_fd: 443, + landlock_create_ruleset: 444, + landlock_add_rule: 445, + landlock_restrict_self: 446, + memfd_secret: 447, + process_mrelease: 448, + futex_waitv: 449, + set_mempolicy_home_node: 450, + cachestat: 451, + fchmodat2: 452, + map_shadow_stack: 453, + futex_wake: 454, + futex_wait: 455, + futex_requeue: 456, + statmount: 457, + listmount: 458, + lsm_get_self_attr: 459, + lsm_set_self_attr: 460, + lsm_list_modules: 461, + mseal: 462, + setxattrat: 463, + getxattrat: 464, + listxattrat: 465, + removexattrat: 466, +} diff --git a/packages/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts index 83c5ffa811..4d0f7f3fa4 100644 --- a/packages/desktop/src/main/sandbox/controller.ts +++ b/packages/desktop/src/main/sandbox/controller.ts @@ -10,6 +10,7 @@ import { createWslProvider, fetchSandboxManifest, fetchWslInstallerManifest, + isSeccompMode, requireImageAsset, sandboxResourcesValid, } from "@codev/sandbox" @@ -70,10 +71,21 @@ function platformProvider(): SandboxProvider | undefined { if (process.platform === "darwin") return createVzProvider({ ...options("vz"), helperPath: vmmHelperPath() }) if (process.platform === "win32") return createWslProvider({ ...options("bwrap"), resolveInstallerMinBuild: cachedInstallerMinBuild() }) - if (process.platform === "linux") return createBwrapProvider(options("bwrap")) + if (process.platform === "linux") return createBwrapProvider({ ...options("bwrap"), seccomp: seccompDefault() }) return undefined } +// The machine-wide syscall-filter default for bubblewrap sandboxes. enforce +// unless CODEV_SANDBOX_SECCOMP says audit or off — the escape hatch for a +// workload the allowlist breaks, and the way to collect audit logs before +// widening it. A persisted per-sandbox `seccomp` still wins over this. +function seccompDefault() { + const mode = process.env.CODEV_SANDBOX_SECCOMP + if (mode === undefined || mode === "") return "enforce" as const + if (isSeccompMode(mode)) return mode + throw new Error(`CODEV_SANDBOX_SECCOMP must be one of off, audit, enforce (got ${JSON.stringify(mode)})`) +} + // The Windows build the published WSL installer needs, for the availability // warning. Availability is asked on every launch and its verdict is cached for // the app's lifetime, so this is bounded and cached too — and only successes @@ -161,6 +173,8 @@ function normalizePersistedSandbox(value: unknown): SandboxConfig[] { // sandbox persisted before this existed has a folder the user chose. ...(record.placeholderProject === true ? { placeholderProject: true as const } : {}), ...(sandboxResourcesValid(resources) ? resources : {}), + // Hand-edited escape hatch; anything but a known mode means the default. + ...(isSeccompMode(record.seccomp) ? { seccomp: record.seccomp } : {}), }, ] }