From e3b1565cf2ac580d2da6790ba9da827a5c27e8fd Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 13 Aug 2026 12:00:17 +0700 Subject: [PATCH] feat(sandbox): run the guest server as an unprivileged user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codev-init and the WSL boot script now drop to the image's codev user (uid 1000) for the server itself; PID 1, the mounts, and the vsock/socat bridges stay root. WSL additionally gets /etc/wsl.conf at import so the Windows drives automount as that user. No host-uid remapping: measured on macOS 15 that Virtualization.framework's virtio-fs performs host operations as the process owning the VM and reports shared files as root in the guest, so guest ownership never gates workspace access. Verified on a rebuilt image — the server reports uid=1000(codev) and can still append to host files, create directories, write its home, and read the baked skills, with results landing on the host owned by the host user. vz takes effect with the next published image (codev-init is baked); the WSL half is host-generated and applies to the current image. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++++ packages/codev-sandbox/image/guest/codev-init | 11 +++++++- .../codev-sandbox/src/wsl/provider.test.ts | 28 ++++++++++++++++++- packages/codev-sandbox/src/wsl/provider.ts | 25 ++++++++++++++++- 4 files changed, 67 insertions(+), 3 deletions(-) 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(