Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>`/"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. |
Expand Down
10 changes: 7 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 |
Expand Down
39 changes: 35 additions & 4 deletions packages/codev-sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,39 @@ 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
server and broker paths.
- `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+.

Expand Down Expand Up @@ -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.
43 changes: 42 additions & 1 deletion packages/codev-sandbox/docs/seccomp.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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).
101 changes: 101 additions & 0 deletions packages/codev-sandbox/script/gen-syscall-tables.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {
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 <stdio.h>`,
`#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<string, number>(
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<string, number> = {\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}`)
}
10 changes: 10 additions & 0 deletions packages/codev-sandbox/src/bwrap/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Loading
Loading