diff --git a/packages/vercel-sandbox/README.md b/packages/vercel-sandbox/README.md index 0732622..236470f 100644 --- a/packages/vercel-sandbox/README.md +++ b/packages/vercel-sandbox/README.md @@ -206,6 +206,243 @@ Sandbox runs sudo in the following configuration: Both these images are based on Amazon Linux 2023. The full package list is available [here](https://docs.aws.amazon.com/linux/al2023/release-notes/all-packages-AL2023.7.html). +## Multi-user + +Sandboxes support creating isolated Linux users with their own home directories, +file permissions, and process ownership. This is useful for multi-agent workflows +where each agent needs its own workspace, or for simulating multi-user +environments. + +### Creating users + +```typescript +import { Sandbox } from "@vercel/sandbox"; + +const sandbox = await Sandbox.create(); + +// Creates /home/alice with isolated permissions +const alice = await sandbox.createUser("alice"); + +alice.username; // "alice" +alice.homeDir; // "/home/alice" +``` + +`createUser` sets up: + +- A Linux user with `/bin/bash` as the default shell +- A home directory at `/home/` group-owned by `vercel-sandbox` with `770` permissions + +### Running commands as a user + +All commands run as the user by default, with the working directory set to their +home: + +```typescript +const alice = await sandbox.createUser("alice"); + +const whoami = await alice.runCommand("whoami"); +await whoami.stdout(); // "alice\n" + +const pwd = await alice.runCommand("pwd"); +await pwd.stdout(); // "/home/alice\n" +``` + +You can pass environment variables, override the working directory, or use the +full `RunCommandParams` interface: + +```typescript +// Environment variables +await alice.runCommand({ + cmd: "node", + args: ["-e", "console.log(process.env.API_KEY)"], + env: { API_KEY: "secret" }, +}); + +// Custom working directory +await alice.runCommand({ cmd: "ls", cwd: "/tmp" }); + +// Detached mode for long-running processes +const server = await alice.runCommand({ + cmd: "node", + args: ["server.js"], + detached: true, +}); +``` + +To escalate to root, pass `sudo: true`: + +```typescript +await alice.runCommand({ + cmd: "dnf", + args: ["install", "-y", "git"], + sudo: true, +}); +``` + +### File operations + +`writeFiles`, `readFile`, `readFileToBuffer`, and `mkDir` all resolve relative +paths against the user's home directory. Written files are owned by the user: + +```typescript +const alice = await sandbox.createUser("alice"); + +// Writes to /home/alice/app.js, owned by alice:alice +await alice.writeFiles([ + { path: "app.js", content: Buffer.from('console.log("hi")') }, +]); + +// Read it back +const buf = await alice.readFileToBuffer({ path: "app.js" }); +buf?.toString(); // 'console.log("hi")' + +// Stream reads +const stream = await alice.readFile({ path: "app.js" }); + +// Create directories owned by the user +await alice.mkDir("projects/my-app"); + +// Absolute paths also work +await alice.writeFiles([ + { path: "/tmp/output.txt", content: Buffer.from("data") }, +]); +``` + +### File isolation + +Users cannot access each other's home directories: + +```typescript +const alice = await sandbox.createUser("alice"); +const bob = await sandbox.createUser("bob"); + +await alice.writeFiles([ + { path: "secret.txt", content: Buffer.from("alice only") }, +]); + +// Bob cannot read, list, or write to alice's home +const cat = await bob.runCommand({ + cmd: "cat", + args: ["/home/alice/secret.txt"], +}); +cat.exitCode; // non-zero — Permission denied +``` + +**The SDK can read all users' files** because home directories are group-owned +by `vercel-sandbox`. Both `SandboxUser` methods and direct `sandbox` methods +work: + +```typescript +// Via SandboxUser (relative paths resolve to home dir) +const buf = await alice.readFileToBuffer({ path: "secret.txt" }); +buf?.toString(); // "alice only" + +// Via sandbox directly (absolute path required) +const buf2 = await sandbox.readFileToBuffer({ path: "/home/alice/secret.txt" }); +buf2?.toString(); // "alice only" +``` + +### Groups and shared directories + +Create groups to let users collaborate through a shared directory: + +```typescript +const devs = await sandbox.createGroup("devs"); +devs.sharedDir; // "/shared/devs" + +await sandbox.addUserToGroup("alice", "devs"); +await sandbox.addUserToGroup("bob", "devs"); + +// Alice writes to the shared directory +await alice.runCommand({ + cmd: "bash", + args: ["-c", 'echo "spec v2" > /shared/devs/spec.txt'], +}); + +// Bob can read it — files inherit group ownership via setgid +const spec = await bob.runCommand({ + cmd: "cat", + args: ["/shared/devs/spec.txt"], +}); +await spec.stdout(); // "spec v2\n" + +// Non-members are blocked +const charlie = await sandbox.createUser("charlie"); +const ls = await charlie.runCommand({ cmd: "ls", args: ["/shared/devs"] }); +ls.exitCode; // non-zero — Permission denied +``` + +Shared directories use setgid (`2770`), so files created inside them +automatically inherit the group. All group members get read/write access. + +Convenience methods are available on `SandboxUser`: + +```typescript +await alice.addToGroup("devs"); +await alice.removeFromGroup("devs"); +``` + +### Using `asUser` for existing users + +If a user already exists (e.g., from a snapshot or manual creation), use +`asUser` to get a handle without re-creating: + +```typescript +const existing = sandbox.asUser("bob"); +await existing.runCommand("whoami"); // "bob" +``` + +### Username validation + +Usernames and group names must match `/^[a-z_][a-z0-9_-]*$/` and be at most 32 +characters. Invalid names throw an error immediately: + +```typescript +sandbox.asUser("Alice"); // throws — uppercase +sandbox.asUser("user name"); // throws — space +sandbox.asUser("$(whoami)"); // throws — special characters +sandbox.asUser("a".repeat(33)); // throws — too long +``` + +### Multi-agent example + +```typescript +const sandbox = await Sandbox.create(); + +// Each agent gets its own isolated workspace +const researcher = await sandbox.createUser("researcher"); +const coder = await sandbox.createUser("coder"); +const reviewer = await sandbox.createUser("reviewer"); + +// Shared workspace for collaboration +await sandbox.createGroup("project"); +await sandbox.addUserToGroup("researcher", "project"); +await sandbox.addUserToGroup("coder", "project"); +await sandbox.addUserToGroup("reviewer", "project"); + +// Researcher writes findings to shared dir +await researcher.runCommand({ + cmd: "bash", + args: ["-c", 'echo "API spec v2" > /shared/project/spec.txt'], +}); + +// Coder reads spec, writes code in their own home +const spec = await coder.runCommand({ + cmd: "cat", + args: ["/shared/project/spec.txt"], +}); +await coder.writeFiles([ + { path: "app.js", content: Buffer.from(`// ${await spec.stdout()}`) }, +]); + +// Reviewer can read the shared spec but not coder's private files +const blocked = await reviewer.runCommand({ + cmd: "cat", + args: ["/home/coder/app.js"], +}); +blocked.exitCode; // non-zero — isolation enforced +``` + [create-token]: https://vercel.com/account/settings/tokens [hive]: https://vercel.com/blog/a-deep-dive-into-hive-vercels-builds-infrastructure [al-2023-packages]: https://docs.aws.amazon.com/linux/al2023/release-notes/all-packages-AL2023.7.html diff --git a/packages/vercel-sandbox/src/index.ts b/packages/vercel-sandbox/src/index.ts index c5af466..00fcaf7 100644 --- a/packages/vercel-sandbox/src/index.ts +++ b/packages/vercel-sandbox/src/index.ts @@ -4,6 +4,7 @@ export { type NetworkPolicyRule, type NetworkTransformer, } from "./sandbox.js"; +export { SandboxUser } from "./sandbox-user.js"; export { Snapshot } from "./snapshot.js"; export { Command, CommandFinished } from "./command.js"; export { StreamError } from "./api-client/api-error.js"; diff --git a/packages/vercel-sandbox/src/sandbox-user.test.ts b/packages/vercel-sandbox/src/sandbox-user.test.ts new file mode 100644 index 0000000..af50fae --- /dev/null +++ b/packages/vercel-sandbox/src/sandbox-user.test.ts @@ -0,0 +1,642 @@ +import { expect, it, beforeEach, afterEach, describe } from "vitest"; +import { Sandbox } from "./sandbox.js"; +import { SandboxUser } from "./sandbox-user.js"; + +describe("validateName (unit)", () => { + it("asUser rejects invalid usernames synchronously", () => { + const sandbox = new Sandbox({ + client: {} as any, + routes: [], + sandbox: { id: "test" } as any, + }); + expect(() => sandbox.asUser("Alice")).toThrow("Invalid username"); + expect(() => sandbox.asUser("user name")).toThrow("Invalid username"); + expect(() => sandbox.asUser("")).toThrow("Invalid username"); + expect(() => sandbox.asUser("a".repeat(33))).toThrow("Invalid username"); + expect(() => sandbox.asUser("root; rm -rf /")).toThrow("Invalid username"); + expect(() => sandbox.asUser("$(whoami)")).toThrow("Invalid username"); + }); + + it("asUser accepts valid usernames", () => { + const sandbox = new Sandbox({ + client: {} as any, + routes: [], + sandbox: { id: "test" } as any, + }); + expect(sandbox.asUser("alice").username).toBe("alice"); + expect(sandbox.asUser("_user").username).toBe("_user"); + expect(sandbox.asUser("user-name").username).toBe("user-name"); + expect(sandbox.asUser("user_123").username).toBe("user_123"); + }); +}); + +describe.skipIf(process.env.RUN_INTEGRATION_TESTS !== "1")( + "SandboxUser integration", + () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await Sandbox.create(); + }); + + afterEach(async () => { + await sandbox.stop(); + }); + + // ─── User Creation ─────────────────────────────────────────────── + + describe("createUser", () => { + it("creates a user with a home directory", async () => { + const alice = await sandbox.createUser("alice"); + + expect(alice).toBeInstanceOf(SandboxUser); + expect(alice.username).toBe("alice"); + expect(alice.homeDir).toBe("/home/alice"); + + const result = await sandbox.runCommand({ + cmd: "test", + args: ["-d", "/home/alice"], + sudo: true, + }); + expect(result.exitCode).toBe(0); + }); + + it("sets 750 permissions on home directory", async () => { + await sandbox.createUser("alice"); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%a", "/home/alice"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("770"); + }); + + it("sets home directory group to vercel-sandbox", async () => { + await sandbox.createUser("alice"); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%U:%G", "/home/alice"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("alice:vercel-sandbox"); + }); + + it("creates multiple users", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + const aliceWho = await alice.runCommand("whoami"); + const bobWho = await bob.runCommand("whoami"); + expect((await aliceWho.stdout()).trim()).toBe("alice"); + expect((await bobWho.stdout()).trim()).toBe("bob"); + }); + + it("throws on duplicate username", async () => { + await sandbox.createUser("alice"); + await expect(sandbox.createUser("alice")).rejects.toThrow( + 'Failed to create user "alice"', + ); + }); + }); + + // ─── Command Execution as User ────────────────────────────────── + + describe("runCommand as user", () => { + it("runs as the correct user", async () => { + const alice = await sandbox.createUser("alice"); + const whoami = await alice.runCommand("whoami"); + expect((await whoami.stdout()).trim()).toBe("alice"); + }); + + it("runs with correct uid/gid", async () => { + const alice = await sandbox.createUser("alice"); + const id = await alice.runCommand("id"); + const output = (await id.stdout()).trim(); + expect(output).toContain("(alice)"); + }); + + it("defaults cwd to home directory", async () => { + const alice = await sandbox.createUser("alice"); + const pwd = await alice.runCommand("pwd"); + expect((await pwd.stdout()).trim()).toBe("/home/alice"); + }); + + it("allows overriding cwd", async () => { + const alice = await sandbox.createUser("alice"); + const pwd = await alice.runCommand({ cmd: "pwd", cwd: "/tmp" }); + expect((await pwd.stdout()).trim()).toBe("/tmp"); + }); + + it("passes environment variables through sudo -u", async () => { + const alice = await sandbox.createUser("alice"); + const cmd = await alice.runCommand({ + cmd: "env", + env: { MY_VAR: "hello", ANOTHER: "world" }, + }); + const output = await cmd.stdout(); + expect(output).toContain("MY_VAR=hello"); + expect(output).toContain("ANOTHER=world"); + }); + + it("delegates to root when sudo: true", async () => { + const alice = await sandbox.createUser("alice"); + const whoami = await alice.runCommand({ cmd: "whoami", sudo: true }); + expect((await whoami.stdout()).trim()).toBe("root"); + }); + + it("supports detached mode", async () => { + const alice = await sandbox.createUser("alice"); + const cmd = await alice.runCommand({ + cmd: "sleep", + args: ["100"], + detached: true, + }); + await cmd.kill("SIGTERM"); + const result = await cmd.wait(); + // The command runs inside a bash -c wrapper, so the exit code + // may differ from a direct kill (e.g., 255 from bash vs 143 for SIGTERM). + expect(result.exitCode).not.toBe(0); + }); + }); + + // ─── File Operations as User ──────────────────────────────────── + + describe("writeFiles + readFile as user", () => { + it("writes files owned by the user in home dir", async () => { + const alice = await sandbox.createUser("alice"); + + await alice.writeFiles([ + { path: "hello.txt", content: Buffer.from("hello world") }, + ]); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%U:%G", "/home/alice/hello.txt"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("alice:alice"); + }); + + it("reads files back via readFileToBuffer", async () => { + const alice = await sandbox.createUser("alice"); + + await alice.writeFiles([ + { path: "data.txt", content: Buffer.from("read me") }, + ]); + + const content = await alice.readFileToBuffer({ path: "data.txt" }); + expect(content?.toString()).toBe("read me"); + }); + + it("reads files back via readFile (stream)", async () => { + const alice = await sandbox.createUser("alice"); + + await alice.writeFiles([ + { path: "stream.txt", content: Buffer.from("streamed") }, + ]); + + const stream = await alice.readFile({ path: "stream.txt" }); + expect(stream).not.toBeNull(); + + const chunks: Buffer[] = []; + for await (const chunk of stream!) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + expect(Buffer.concat(chunks).toString()).toBe("streamed"); + }); + + it("resolves relative paths to home directory", async () => { + const alice = await sandbox.createUser("alice"); + + await alice.writeFiles([ + { path: "subdir/file.txt", content: Buffer.from("nested") }, + ]); + + const exists = await sandbox.runCommand({ + cmd: "test", + args: ["-f", "/home/alice/subdir/file.txt"], + sudo: true, + }); + expect(exists.exitCode).toBe(0); + }); + + it("handles absolute paths", async () => { + const alice = await sandbox.createUser("alice"); + + await alice.writeFiles([ + { path: "/tmp/alice-file.txt", content: Buffer.from("abs path") }, + ]); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%U", "/tmp/alice-file.txt"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("alice"); + }); + + it("reads files created by user commands (umask makes them group-readable)", async () => { + const alice = await sandbox.createUser("alice"); + + // User creates a file via runCommand — inherits alice:alice ownership + default umask + await alice.runCommand({ + cmd: "bash", + args: ["-c", 'echo "from command" > /home/alice/cmd-file.txt'], + }); + + // The HTTP file API (running as vercel-sandbox) should be able to read it + // because vercel-sandbox is in alice's group and default umask is 0022 (644) + const content = await alice.readFileToBuffer({ + path: "cmd-file.txt", + }); + expect(content?.toString()).toBe("from command\n"); + }); + }); + + describe("mkDir as user", () => { + it("creates a directory owned by the user with 750 permissions", async () => { + const alice = await sandbox.createUser("alice"); + + await alice.mkDir("projects"); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%U:%G %a", "/home/alice/projects"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("alice:alice 770"); + }); + }); + + // ─── File Isolation Between Users ─────────────────────────────── + + describe("file isolation", () => { + it("user A cannot read user B's files via runCommand", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + await alice.writeFiles([ + { path: "secret.txt", content: Buffer.from("alice's secret") }, + ]); + + const cat = await bob.runCommand({ + cmd: "cat", + args: ["/home/alice/secret.txt"], + }); + expect(cat.exitCode).not.toBe(0); + expect(await cat.stderr()).toContain("Permission denied"); + }); + + it("user A cannot list user B's home directory", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + const ls = await bob.runCommand({ + cmd: "ls", + args: ["/home/alice"], + }); + expect(ls.exitCode).not.toBe(0); + expect(await ls.stderr()).toContain("Permission denied"); + }); + + it("user A cannot write to user B's home directory", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + const touch = await bob.runCommand({ + cmd: "touch", + args: ["/home/alice/hacked.txt"], + }); + expect(touch.exitCode).not.toBe(0); + }); + + it("user A cannot execute in user B's home directory", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + await alice.writeFiles([ + { + path: "script.sh", + content: Buffer.from("#!/bin/bash\necho pwned"), + mode: 0o755, + }, + ]); + + const exec = await bob.runCommand({ + cmd: "/home/alice/script.sh", + }); + expect(exec.exitCode).not.toBe(0); + }); + + it("SandboxUser can read its own files but not other users' files", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + await alice.writeFiles([ + { path: "alice.txt", content: Buffer.from("alice data") }, + ]); + await bob.writeFiles([ + { path: "bob.txt", content: Buffer.from("bob data") }, + ]); + + // Each user can read their own files via SandboxUser.readFileToBuffer + const aliceContent = await alice.readFileToBuffer({ + path: "alice.txt", + }); + const bobContent = await bob.readFileToBuffer({ + path: "bob.txt", + }); + + expect(aliceContent?.toString()).toBe("alice data"); + expect(bobContent?.toString()).toBe("bob data"); + + // The sandbox HTTP API can also read all users' files (home dirs + // are group-owned by vercel-sandbox) + const aliceViaSandbox = await sandbox.readFileToBuffer({ + path: "/home/alice/alice.txt", + }); + expect(aliceViaSandbox?.toString()).toBe("alice data"); + + // Bob cannot read alice's file via runCommand (inter-user isolation) + const cat = await bob.runCommand({ + cmd: "cat", + args: ["/home/alice/alice.txt"], + }); + expect(cat.exitCode).not.toBe(0); + }); + }); + + // ─── Group Management ─────────────────────────────────────────── + + describe("createGroup", () => { + it("creates a group with a shared directory", async () => { + const devs = await sandbox.createGroup("devs"); + + expect(devs.groupname).toBe("devs"); + expect(devs.sharedDir).toBe("/shared/devs"); + + const exists = await sandbox.runCommand({ + cmd: "test", + args: ["-d", "/shared/devs"], + sudo: true, + }); + expect(exists.exitCode).toBe(0); + }); + + it("sets setgid (2770) on the shared directory", async () => { + await sandbox.createGroup("devs"); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%a", "/shared/devs"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("2770"); + }); + + it("shared dir is owned by vercel-sandbox:", async () => { + await sandbox.createGroup("devs"); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%U:%G", "/shared/devs"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("vercel-sandbox:devs"); + }); + }); + + // ─── Group Access Control ─────────────────────────────────────── + + describe("group file sharing", () => { + it("group member can write to shared directory", async () => { + const alice = await sandbox.createUser("alice"); + await sandbox.createGroup("devs"); + await sandbox.addUserToGroup("alice", "devs"); + + const touch = await alice.runCommand({ + cmd: "touch", + args: ["/shared/devs/from-alice.txt"], + }); + expect(touch.exitCode).toBe(0); + }); + + it("files in shared dir inherit group ownership (setgid)", async () => { + const alice = await sandbox.createUser("alice"); + await sandbox.createGroup("devs"); + await sandbox.addUserToGroup("alice", "devs"); + + await alice.runCommand({ + cmd: "touch", + args: ["/shared/devs/file.txt"], + }); + + const stat = await sandbox.runCommand({ + cmd: "stat", + args: ["-c", "%G", "/shared/devs/file.txt"], + sudo: true, + }); + expect((await stat.stdout()).trim()).toBe("devs"); + }); + + it("non-member cannot access shared directory", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + await sandbox.createGroup("devs"); + await sandbox.addUserToGroup("alice", "devs"); + // bob is NOT in devs + + const ls = await bob.runCommand({ + cmd: "ls", + args: ["/shared/devs"], + }); + expect(ls.exitCode).not.toBe(0); + expect(await ls.stderr()).toContain("Permission denied"); + }); + + it("two group members can read each other's files in shared dir", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + await sandbox.createGroup("devs"); + await sandbox.addUserToGroup("alice", "devs"); + await sandbox.addUserToGroup("bob", "devs"); + + // Alice creates a file + await alice.runCommand({ + cmd: "bash", + args: [ + "-c", + 'echo "shared data" > /shared/devs/collab.txt', + ], + }); + + // Bob reads it + const cat = await bob.runCommand({ + cmd: "cat", + args: ["/shared/devs/collab.txt"], + }); + expect(cat.exitCode).toBe(0); + expect((await cat.stdout()).trim()).toBe("shared data"); + }); + + it("removed member loses access to shared directory", async () => { + const alice = await sandbox.createUser("alice"); + await sandbox.createGroup("devs"); + await sandbox.addUserToGroup("alice", "devs"); + + // Verify access works + const touch = await alice.runCommand({ + cmd: "touch", + args: ["/shared/devs/test.txt"], + }); + expect(touch.exitCode).toBe(0); + + // Remove from group + await sandbox.removeUserFromGroup("alice", "devs"); + + // Access should fail now + const ls = await alice.runCommand({ + cmd: "ls", + args: ["/shared/devs"], + }); + expect(ls.exitCode).not.toBe(0); + }); + }); + + // ─── Convenience Methods ──────────────────────────────────────── + + describe("SandboxUser convenience methods", () => { + it("addToGroup adds the user to a group", async () => { + const alice = await sandbox.createUser("alice"); + await sandbox.createGroup("devs"); + + await alice.addToGroup("devs"); + + const groups = await alice.runCommand("groups"); + expect(await groups.stdout()).toContain("devs"); + }); + + it("removeFromGroup removes the user from a group", async () => { + const alice = await sandbox.createUser("alice"); + await sandbox.createGroup("devs"); + await alice.addToGroup("devs"); + await alice.removeFromGroup("devs"); + + const groups = await sandbox.runCommand({ + cmd: "groups", + args: ["alice"], + sudo: true, + }); + expect(await groups.stdout()).not.toContain("devs"); + }); + }); + + // ─── Multi-Group Isolation ────────────────────────────────────── + + describe("multi-group isolation", () => { + it("user in group A but not group B cannot access group B's shared dir", async () => { + const alice = await sandbox.createUser("alice"); + await sandbox.createGroup("frontend"); + await sandbox.createGroup("backend"); + await sandbox.addUserToGroup("alice", "frontend"); + // alice is NOT in backend + + const touchFE = await alice.runCommand({ + cmd: "touch", + args: ["/shared/frontend/fe-file.txt"], + }); + expect(touchFE.exitCode).toBe(0); + + const touchBE = await alice.runCommand({ + cmd: "touch", + args: ["/shared/backend/be-file.txt"], + }); + expect(touchBE.exitCode).not.toBe(0); + }); + + it("user in multiple groups can access all their shared dirs", async () => { + const alice = await sandbox.createUser("alice"); + await sandbox.createGroup("frontend"); + await sandbox.createGroup("backend"); + await sandbox.addUserToGroup("alice", "frontend"); + await sandbox.addUserToGroup("alice", "backend"); + + const touchFE = await alice.runCommand({ + cmd: "touch", + args: ["/shared/frontend/fe-file.txt"], + }); + const touchBE = await alice.runCommand({ + cmd: "touch", + args: ["/shared/backend/be-file.txt"], + }); + expect(touchFE.exitCode).toBe(0); + expect(touchBE.exitCode).toBe(0); + }); + }); + + // ─── Process Isolation ────────────────────────────────────────── + + describe("process isolation", () => { + it("user commands run with correct user identity", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + const aliceId = await alice.runCommand("id"); + const bobId = await bob.runCommand("id"); + + const aliceOutput = await aliceId.stdout(); + const bobOutput = await bobId.stdout(); + + expect(aliceOutput).toContain("(alice)"); + expect(aliceOutput).not.toContain("(bob)"); + expect(bobOutput).toContain("(bob)"); + expect(bobOutput).not.toContain("(alice)"); + }); + + it("user cannot kill another user's process", async () => { + const alice = await sandbox.createUser("alice"); + const bob = await sandbox.createUser("bob"); + + // Alice starts a long-running process + const aliceProc = await alice.runCommand({ + cmd: "sleep", + args: ["300"], + detached: true, + }); + + // Get the PID of alice's sleep process + const pgrep = await sandbox.runCommand({ + cmd: "pgrep", + args: ["-u", "alice", "sleep"], + sudo: true, + }); + const pid = (await pgrep.stdout()).trim(); + + // Bob tries to kill it + const kill = await bob.runCommand({ + cmd: "kill", + args: [pid], + }); + expect(kill.exitCode).not.toBe(0); + + // Clean up + await aliceProc.kill("SIGTERM"); + await aliceProc.wait(); + }); + + it("created user does not have in-guest sudo access", async () => { + const alice = await sandbox.createUser("alice"); + + // alice should not be able to run sudo inside the sandbox + const sudoAttempt = await alice.runCommand({ + cmd: "sudo", + args: ["whoami"], + }); + // sudo should fail — only vercel-sandbox has passwordless sudo + expect(sudoAttempt.exitCode).not.toBe(0); + }); + }); + }, +); diff --git a/packages/vercel-sandbox/src/sandbox-user.ts b/packages/vercel-sandbox/src/sandbox-user.ts new file mode 100644 index 0000000..5acf376 --- /dev/null +++ b/packages/vercel-sandbox/src/sandbox-user.ts @@ -0,0 +1,324 @@ +import type { Writable } from "stream"; +import type { Sandbox } from "./sandbox.js"; +import type { Command, CommandFinished } from "./command.js"; +import { validateName } from "./utils/validate-name.js"; + +/** @inline */ +interface RunCommandParams { + cmd: string; + args?: string[]; + cwd?: string; + env?: Record; + sudo?: boolean; + detached?: boolean; + stdout?: Writable; + stderr?: Writable; + signal?: AbortSignal; +} + +/** + * A user context within a sandbox. + * + * All file and command operations default to running as this user. + * Created via {@link Sandbox.createUser} or {@link Sandbox.asUser}. + * + * @hideconstructor + */ +export class SandboxUser { + /** + * The Linux username. + */ + readonly username: string; + + /** + * The user's home directory (e.g., `/home/alice`). + */ + readonly homeDir: string; + + private readonly sandbox: Sandbox; + + constructor({ + sandbox, + username, + }: { + sandbox: Sandbox; + username: string; + }) { + this.sandbox = sandbox; + this.username = username; + this.homeDir = `/home/${username}`; + } + + /** + * Build the wrapped command args to run as this user via `sudo -u`. + * + * When `env` is provided, injects `env KEY=VAL ...` so that environment + * variables survive the `sudo -u` transition. + */ + private buildUserCommand(params: { + cmd: string; + args?: string[]; + env?: Record; + cwd?: string; + }): { cmd: string; args: string[] } { + const envEntries = Object.entries(params.env ?? {}); + const envArgs = + envEntries.length > 0 + ? ["env", ...envEntries.map(([k, v]) => `${k}=${v}`)] + : []; + + // We cannot use the API's `cwd` parameter for user home dirs because + // the backend cd's to `cwd` before exec, and SUID binaries (like sudo) + // cannot be executed from directories with restricted permissions (770). + // + // Instead, we use: sudo -u -- [env K=V...] bash -c 'cd && exec "$@"' _ + // This pattern: + // - Uses bash's "$@" to properly handle arguments with spaces + // - Sets the working directory inside the user's context + // - Passes env vars via the `env` command before bash + const cwd = params.cwd ?? this.homeDir; + return { + cmd: "sudo", + args: [ + "-u", + this.username, + "--", + ...envArgs, + "bash", + "-c", + `cd ${cwd} && exec "$@"`, + "_", + params.cmd, + ...(params.args ?? []), + ], + }; + } + + /** + * Resolve a path relative to this user's home directory. + * Absolute paths are returned as-is. + */ + private resolvePath(path: string): string { + return path.startsWith("/") ? path : `${this.homeDir}/${path}`; + } + + /** + * Start executing a command as this user. + * + * @param command - The command to execute. + * @param args - Arguments to pass to the command. + * @param opts - Optional parameters. + * @returns A {@link CommandFinished} result once execution is done. + */ + async runCommand( + command: string, + args?: string[], + opts?: { signal?: AbortSignal }, + ): Promise; + + /** + * Start executing a command as this user in detached mode. + * + * @param params - The command parameters. + * @returns A {@link Command} instance for the running command. + */ + async runCommand( + params: RunCommandParams & { detached: true }, + ): Promise; + + /** + * Start executing a command as this user. + * + * @param params - The command parameters. + * @returns A {@link CommandFinished} result once execution is done. + */ + async runCommand(params: RunCommandParams): Promise; + + async runCommand( + commandOrParams: string | RunCommandParams, + args?: string[], + opts?: { signal?: AbortSignal }, + ): Promise { + if (typeof commandOrParams === "string") { + const wrapped = this.buildUserCommand({ + cmd: commandOrParams, + args, + }); + // Don't pass cwd to the sandbox API — the bash -c wrapper handles it. + // Don't pass sudo: true — vercel-sandbox already has sudo privileges. + return this.sandbox.runCommand({ + ...wrapped, + signal: opts?.signal, + }); + } + + const params = commandOrParams; + + // When sudo: true is passed, delegate directly to root (skip user wrapping). + // Don't default cwd to homeDir — the backend can't exec SUID binaries + // from directories with restricted permissions. + if (params.sudo) { + return this.sandbox.runCommand({ + ...params, + } as RunCommandParams & { detached: true }); + } + + const wrapped = this.buildUserCommand({ + cmd: params.cmd, + args: params.args, + env: params.env, + cwd: params.cwd, + }); + + return this.sandbox.runCommand({ + cmd: wrapped.cmd, + args: wrapped.args, + // Don't pass cwd — bash -c wrapper handles it (see buildUserCommand) + // Don't pass sudo: true — vercel-sandbox already has sudo privileges + // env is already baked into the wrapped command via `env KEY=VAL` + detached: params.detached, + stdout: params.stdout, + stderr: params.stderr, + signal: params.signal, + } as RunCommandParams & { detached: true }); + } + + /** + * Write files to this user's home directory (or absolute paths). + * Files are written via the sandbox HTTP API then chowned to this user. + * + * The HTTP API can write to user home dirs because they are group-owned + * by `vercel-sandbox` with `770` permissions. + * + * @param files - Array of files with path, content, and optional mode + * @param opts - Optional parameters. + */ + async writeFiles( + files: { path: string; content: Buffer; mode?: number }[], + opts?: { signal?: AbortSignal }, + ) { + // Resolve relative paths to user's home directory + const absoluteFiles = files.map((f) => ({ + ...f, + path: this.resolvePath(f.path), + })); + + // Write via the HTTP API (works because home dirs are group-owned + // by vercel-sandbox) + await this.sandbox.writeFiles(absoluteFiles, opts); + + // Chown all written files to this user + const paths = absoluteFiles.map((f) => f.path); + await this.sandbox.runCommand({ + cmd: "chown", + args: [`${this.username}:${this.username}`, ...paths], + sudo: true, + signal: opts?.signal, + }); + } + + /** + * Read a file from this user's context as a stream. + * + * @param file - File to read, with path and optional cwd + * @param opts - Optional parameters. + * @returns A ReadableStream of the file contents, or null if not found + */ + async readFile( + file: { path: string; cwd?: string }, + opts?: { signal?: AbortSignal }, + ): Promise { + return this.sandbox.readFile( + { path: file.path, cwd: file.cwd ?? this.homeDir }, + opts, + ); + } + + /** + * Read a file from this user's context as a Buffer. + * + * @param file - File to read, with path and optional cwd + * @param opts - Optional parameters. + * @returns The file contents as a Buffer, or null if not found + */ + async readFileToBuffer( + file: { path: string; cwd?: string }, + opts?: { signal?: AbortSignal }, + ): Promise { + return this.sandbox.readFileToBuffer( + { path: file.path, cwd: file.cwd ?? this.homeDir }, + opts, + ); + } + + /** + * Download a file from this user's context to the local filesystem. + * + * @param src - Source file in the sandbox + * @param dst - Destination on the local machine + * @param opts - Optional parameters. + * @returns The absolute path to the written file, or null if not found + */ + async downloadFile( + src: { path: string; cwd?: string }, + dst: { path: string; cwd?: string }, + opts?: { mkdirRecursive?: boolean; signal?: AbortSignal }, + ): Promise { + return this.sandbox.downloadFile( + { path: src.path, cwd: src.cwd ?? this.homeDir }, + dst, + opts, + ); + } + + /** + * Create a directory owned by this user. + * + * @param path - Path of the directory to create + * @param opts - Optional parameters. + */ + async mkDir(path: string, opts?: { signal?: AbortSignal }): Promise { + const absPath = this.resolvePath(path); + await this.sandbox.mkDir(absPath, opts); + await this.sandbox.runCommand({ + cmd: "chown", + args: [`${this.username}:${this.username}`, absPath], + sudo: true, + signal: opts?.signal, + }); + await this.sandbox.runCommand({ + cmd: "chmod", + args: ["770", absPath], + sudo: true, + signal: opts?.signal, + }); + } + + /** + * Add this user to a group. + * + * @param groupname - Name of the group to join + * @param opts - Optional parameters. + */ + async addToGroup( + groupname: string, + opts?: { signal?: AbortSignal }, + ): Promise { + validateName(groupname, "group name"); + await this.sandbox.addUserToGroup(this.username, groupname, opts); + } + + /** + * Remove this user from a group. + * + * @param groupname - Name of the group to leave + * @param opts - Optional parameters. + */ + async removeFromGroup( + groupname: string, + opts?: { signal?: AbortSignal }, + ): Promise { + validateName(groupname, "group name"); + await this.sandbox.removeUserFromGroup(this.username, groupname, opts); + } +} diff --git a/packages/vercel-sandbox/src/sandbox.ts b/packages/vercel-sandbox/src/sandbox.ts index ed1cd1e..5239ff4 100644 --- a/packages/vercel-sandbox/src/sandbox.ts +++ b/packages/vercel-sandbox/src/sandbox.ts @@ -21,6 +21,8 @@ import { convertSandbox, type ConvertedSandbox, } from "./utils/convert-sandbox.js"; +import { SandboxUser } from "./sandbox-user.js"; +import { validateName } from "./utils/validate-name.js"; export type { NetworkPolicy, NetworkPolicyRule, NetworkTransformer }; @@ -758,6 +760,238 @@ export class Sandbox { this.sandbox = convertSandbox(response.json.sandbox); } + /** + * Create a new Linux user in this sandbox with an isolated home directory. + * + * The home directory is group-owned by `vercel-sandbox` with `770` permissions, + * so the SDK's HTTP file API can read/write directly. Other users cannot access + * this user's home directory since they are not in the `vercel-sandbox` group. + * + * @param username - Linux username (lowercase letters, digits, hyphens, underscores) + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns A {@link SandboxUser} instance for the created user. + * + * @example + * const alice = await sandbox.createUser("alice"); + * await alice.runCommand("whoami"); // "alice" + * await alice.writeFiles([{ path: "hello.txt", content: Buffer.from("hi") }]); + */ + async createUser( + username: string, + opts?: { signal?: AbortSignal }, + ): Promise { + validateName(username, "username"); + + // Create user with home directory and default shell + const useradd = await this._runCommand({ + cmd: "useradd", + args: ["-m", "-s", "/bin/bash", username], + sudo: true, + signal: opts?.signal, + }); + if (useradd.exitCode !== 0) { + const stderr = await useradd.stderr(); + throw new Error(`Failed to create user "${username}": ${stderr}`); + } + + // Set home directory group to vercel-sandbox so the HTTP file API + // (which runs as the vercel-sandbox process) can read/write directly. + // This avoids the stale-group problem that occurs with usermod -aG, + // since vercel-sandbox already has gid=1000 in its process credentials. + const chown = await this._runCommand({ + cmd: "chown", + args: [`${username}:vercel-sandbox`, `/home/${username}`], + sudo: true, + signal: opts?.signal, + }); + if (chown.exitCode !== 0) { + const stderr = await chown.stderr(); + throw new Error( + `Failed to set ownership on /home/${username}: ${stderr}`, + ); + } + + // Set home directory permissions: owner full, group (vercel-sandbox) + // read+write+execute, others none. Other users can't access because + // they are not in the vercel-sandbox group. + const chmod = await this._runCommand({ + cmd: "chmod", + args: ["770", `/home/${username}`], + sudo: true, + signal: opts?.signal, + }); + if (chmod.exitCode !== 0) { + const stderr = await chmod.stderr(); + throw new Error( + `Failed to set permissions on /home/${username}: ${stderr}`, + ); + } + + return new SandboxUser({ sandbox: this, username }); + } + + /** + * Get a user handle without creating the user. + * Assumes the user already exists in the sandbox. + * + * @param username - Linux username + * @returns A {@link SandboxUser} instance. + * + * @example + * const root = sandbox.asUser("root"); + * await root.runCommand("whoami"); // "root" + */ + asUser(username: string): SandboxUser { + validateName(username, "username"); + return new SandboxUser({ sandbox: this, username }); + } + + /** + * Create a new Linux group with a shared directory. + * + * Creates a shared directory at `/shared/` with setgid permissions + * (`2770`), so files created inside it automatically inherit the group. + * All group members can read and write files in the shared directory. + * + * @param groupname - Group name (lowercase letters, digits, hyphens, underscores) + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * @returns An object with the group name and shared directory path. + * + * @example + * const devs = await sandbox.createGroup("devs"); + * console.log(devs.sharedDir); // "/shared/devs" + * await sandbox.addUserToGroup("alice", "devs"); + */ + async createGroup( + groupname: string, + opts?: { signal?: AbortSignal }, + ): Promise<{ groupname: string; sharedDir: string }> { + validateName(groupname, "group name"); + + const sharedDir = `/shared/${groupname}`; + + // Create the group + const groupadd = await this._runCommand({ + cmd: "groupadd", + args: [groupname], + sudo: true, + signal: opts?.signal, + }); + if (groupadd.exitCode !== 0) { + const stderr = await groupadd.stderr(); + throw new Error(`Failed to create group "${groupname}": ${stderr}`); + } + + // Create shared directory + const mkdirResult = await this._runCommand({ + cmd: "mkdir", + args: ["-p", sharedDir], + sudo: true, + signal: opts?.signal, + }); + if (mkdirResult.exitCode !== 0) { + const stderr = await mkdirResult.stderr(); + throw new Error(`Failed to create shared directory ${sharedDir}: ${stderr}`); + } + + // Set ownership: vercel-sandbox user, group-owned by the new group + const chown = await this._runCommand({ + cmd: "chown", + args: [`vercel-sandbox:${groupname}`, sharedDir], + sudo: true, + signal: opts?.signal, + }); + if (chown.exitCode !== 0) { + const stderr = await chown.stderr(); + throw new Error(`Failed to set ownership on ${sharedDir}: ${stderr}`); + } + + // Set permissions: setgid (2) + rwx for owner and group, none for others + const chmod = await this._runCommand({ + cmd: "chmod", + args: ["2770", sharedDir], + sudo: true, + signal: opts?.signal, + }); + if (chmod.exitCode !== 0) { + const stderr = await chmod.stderr(); + throw new Error(`Failed to set permissions on ${sharedDir}: ${stderr}`); + } + + return { groupname, sharedDir }; + } + + /** + * Add a user to a group. + * + * After joining, the user can read and write files in the group's + * shared directory at `/shared/`. + * + * @param username - The user to add + * @param groupname - The group to add the user to + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * + * @example + * await sandbox.addUserToGroup("alice", "devs"); + */ + async addUserToGroup( + username: string, + groupname: string, + opts?: { signal?: AbortSignal }, + ): Promise { + validateName(username, "username"); + validateName(groupname, "group name"); + + const result = await this._runCommand({ + cmd: "usermod", + args: ["-aG", groupname, username], + sudo: true, + signal: opts?.signal, + }); + if (result.exitCode !== 0) { + const stderr = await result.stderr(); + throw new Error( + `Failed to add "${username}" to group "${groupname}": ${stderr}`, + ); + } + } + + /** + * Remove a user from a group. + * + * @param username - The user to remove + * @param groupname - The group to remove the user from + * @param opts - Optional parameters. + * @param opts.signal - An AbortSignal to cancel the operation. + * + * @example + * await sandbox.removeUserFromGroup("alice", "devs"); + */ + async removeUserFromGroup( + username: string, + groupname: string, + opts?: { signal?: AbortSignal }, + ): Promise { + validateName(username, "username"); + validateName(groupname, "group name"); + + const result = await this._runCommand({ + cmd: "gpasswd", + args: ["-d", username, groupname], + sudo: true, + signal: opts?.signal, + }); + if (result.exitCode !== 0) { + const stderr = await result.stderr(); + throw new Error( + `Failed to remove "${username}" from group "${groupname}": ${stderr}`, + ); + } + } + /** * Create a snapshot from this currently running sandbox. New sandboxes can * then be created from this snapshot using {@link Sandbox.createFromSnapshot}. diff --git a/packages/vercel-sandbox/src/utils/validate-name.ts b/packages/vercel-sandbox/src/utils/validate-name.ts new file mode 100644 index 0000000..9209842 --- /dev/null +++ b/packages/vercel-sandbox/src/utils/validate-name.ts @@ -0,0 +1,22 @@ +const VALID_NAME_RE = /^[a-z_][a-z0-9_-]*$/; +const MAX_NAME_LENGTH = 32; + +/** + * Validate a Linux username or group name. + * Throws if the name is invalid or could be used for command injection. + */ +export function validateName(name: string, kind: "username" | "group name") { + if (!name) { + throw new Error(`Invalid ${kind}: must not be empty`); + } + if (name.length > MAX_NAME_LENGTH) { + throw new Error( + `Invalid ${kind} "${name}": must be at most ${MAX_NAME_LENGTH} characters`, + ); + } + if (!VALID_NAME_RE.test(name)) { + throw new Error( + `Invalid ${kind} "${name}": must match ${VALID_NAME_RE} (lowercase letters, digits, hyphens, underscores)`, + ); + } +}