diff --git a/CHANGELOG.md b/CHANGELOG.md index 69145ac034..a4bdade49f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Sandboxes run the agent as an unprivileged user** — the server inside a + sandbox no longer runs as root: it drops to the image's ordinary `codev` + account once the guest has finished mounting, so a sandbox escape has one + less privilege to work with. Project files, the guest home, and the bundled + skills all remain fully usable. On macOS this takes effect with the next + published sandbox image; Windows sandboxes pick it up immediately. - **Per-sandbox CPU and memory limits (macOS)** — the Add sandbox dialog gains optional CPUs and Memory fields, applied when the sandbox VM boots; leaving them empty keeps the previous defaults (4 CPUs, 4 GB). The fields appear diff --git a/packages/codev-sandbox/image/guest/codev-init b/packages/codev-sandbox/image/guest/codev-init index 8c76b7b0b2..3f9d76338a 100644 --- a/packages/codev-sandbox/image/guest/codev-init +++ b/packages/codev-sandbox/image/guest/codev-init @@ -34,8 +34,17 @@ for (const [key, value] of Object.entries(boot.env)) console.log("export " + key export HOME=/home/codev cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true + +# The server runs as the image's unprivileged codev user; PID 1 and the vsock +# bridges stay root. No uid remapping is needed: Virtualization.framework's +# virtio-fs performs host operations as the process that owns the VM and +# reports every shared file as root inside the guest, so guest ownership never +# gates workspace access — verified on macOS 15 (append, mkdir, create all +# succeed as uid 1000 and land on the host owned by the host user). +chown -R codev:codev "$HOME" 2>/dev/null + cd /workspace -node /opt/codev-guest/server-launch.mjs & +setpriv --reuid=codev --regid=codev --init-groups node /opt/codev-guest/server-launch.mjs & SERVER=$! # Stay PID 1: reap orphans, power off when the server dies so the helper's diff --git a/packages/codev-sandbox/src/wsl/provider.test.ts b/packages/codev-sandbox/src/wsl/provider.test.ts index 3db2a8554a..4337c693e0 100644 --- a/packages/codev-sandbox/src/wsl/provider.test.ts +++ b/packages/codev-sandbox/src/wsl/provider.test.ts @@ -35,8 +35,14 @@ if (args[0] === "--unregister") { } if (args.includes("wslpath")) { process.stdout.write("/mnt/hostpath\\n"); process.exit(0) } -// Boot command: assert the script the provider built, then answer health checks. const script = args[args.length - 1] +// Import-time distro configuration runs through the same -- /bin/sh -c shape. +if (script.includes("/etc/wsl.conf")) { + const value = read(); value.wslConf = script; write(value) + process.exit(0) +} + +// Boot command: assert the script the provider built, then answer health checks. if (!script.includes("mount --bind") || !script.includes("server-launch.mjs")) { process.stderr.write("unexpected boot script\\n"); process.exit(2) } @@ -208,6 +214,26 @@ test("egress wires the broker bridge, guest config, and CODEV_CONFIG", async () await rm(dir, { recursive: true, force: true }) }) +test("configures automount at import and runs the server unprivileged", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) + const provider = createWslProvider(await preparedOptions(dir)) + await provider.create(config) + const imported = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + // Windows has no uid to match, so the guest's own codev user owns the + // automounted drives — otherwise the dropped server cannot write them. + expect(imported.wslConf).toContain("uid=1000,gid=1000") + + const sandbox = await provider.start(config) + const state = JSON.parse(await Bun.file(join(dir, "state.json")).text()) + expect(state.bootScript).toContain("setpriv --reuid=codev --regid=codev --init-groups node") + // Root still does the mounts and the bridge before dropping. + expect(state.bootScript.indexOf("mount --bind")).toBeLessThan(state.bootScript.indexOf("setpriv")) + + sandbox.stop() + await provider.destroy(config) + await rm(dir, { recursive: true, force: true }) +}) + test("routeless egress still injects the guest config but starts no broker", async () => { const dir = await mkdtemp(join(tmpdir(), "wsl-test-")) const provider = createWslProvider( diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 70bf19d55d..8c64f38105 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -129,6 +129,25 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider }) if (imported.code !== 0) throw new Error(`wsl --import failed for ${distro}: ${imported.stderr.trim() || `exited ${imported.code}`}`) + // Windows has no uid to match, so the guest keeps its own codev user and + // the Windows drives are automounted as that user — without this the + // unprivileged server could not write the project directory. wsl.conf is + // read at distro boot, so write it and terminate before the first start. + const configured = await wsl([ + "-d", + distro, + "-u", + "root", + "--", + "/bin/sh", + "-c", + `printf '[automount]\\noptions = "metadata,uid=1000,gid=1000"\\n' > /etc/wsl.conf`, + ]) + if (configured.code !== 0) + throw new Error( + `could not write /etc/wsl.conf in ${distro}: ${configured.stderr.trim() || `exited ${configured.code}`}`, + ) + await wsl(["--terminate", distro]) await writeImageMarker(dirs.base, asset) } @@ -191,6 +210,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider ? [`mount --bind ${shellQuote(await guestPath(distro, configDir))} /codev-config`] : []), `cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true`, + `chown -R codev:codev "$HOME" 2>/dev/null || true`, `if [ -f /etc/codev-guest.env ]; then . /etc/codev-guest.env; fi`, // Guest loopback 8788 is the broker stub on every backend; on WSL it // relays to the host's TCP broker at the NAT gateway. @@ -201,7 +221,10 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider ] : []), `cd ${shellQuote(await guestPath(distro, config.projectDir))}`, - `exec node ${SANDBOX_GUEST_LAUNCHER}`, + // Root did the bind mounts and the socat bridge; the agent itself runs + // as the image's unprivileged codev user, which owns the automounted + // Windows drives per the wsl.conf written at import. + `exec setpriv --reuid=codev --regid=codev --init-groups node ${SANDBOX_GUEST_LAUNCHER}`, ].join("\n") const child = spawnImpl(