feat(sandbox): add multi-user and group management (#116)
Adds `createUser`, `asUser`, `createGroup`, `addUserToGroup`, and
`removeUserFromGroup` to the `Sandbox` class, plus a new `SandboxUser`
class that wraps sandbox operations to run in a specific user's context.
## Creating users and running commands
```ts
import { Sandbox } from '@vercel/sandbox';
const sandbox = await Sandbox.create();
// Create a user with isolated home directory at /home/alice
const alice = await sandbox.createUser("alice");
alice.username; // "alice"
alice.homeDir; // "/home/alice"
// Commands run as alice, cwd defaults to /home/alice
const whoami = await alice.runCommand("whoami");
await whoami.stdout(); // "alice\n"
const pwd = await alice.runCommand("pwd");
await pwd.stdout(); // "/home/alice\n"
// Pass environment variables
const cmd = await alice.runCommand({
cmd: "node",
args: ["-e", "console.log(process.env.SECRET)"],
env: { SECRET: "hunter2" },
});
// Override working directory
await alice.runCommand({ cmd: "ls", cwd: "/tmp" });
// Escalate to root when needed
await alice.runCommand({
cmd: "dnf",
args: ["install", "-y", "git"],
sudo: true,
});
// Detached mode for long-running processes
const server = await alice.runCommand({
cmd: "node",
args: ["server.js"],
detached: true,
});
// ... later
await server.kill("SIGTERM");
```
## File operations scoped to the user
```ts
const alice = await sandbox.createUser("alice");
// Relative paths resolve to /home/alice, files owned by alice:alice
await alice.writeFiles([
{ path: "app.js", content: Buffer.from('console.log("hi")') },
{ path: "data/config.json", content: Buffer.from("{}") },
]);
// Read files back
const buf = await alice.readFileToBuffer({ path: "app.js" });
buf?.toString(); // 'console.log("hi")'
// Stream reads
const stream = await alice.readFile({ path: "app.js" });
// Absolute paths work too
await alice.writeFiles([
{ path: "/opt/alice/data.bin", content: Buffer.from([0x00, 0xff]) },
]);
// Create directories owned by the user
await alice.mkDir("projects/my-app");
```
## File isolation between users
```ts
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 alice's files
const cat = await bob.runCommand({
cmd: "cat",
args: ["/home/alice/secret.txt"],
});
cat.exitCode; // non-zero, Permission denied
// Bob cannot list alice's home directory
const ls = await bob.runCommand({ cmd: "ls", args: ["/home/alice"] });
ls.exitCode; // non-zero, Permission denied
// Bob cannot write to alice's home directory
const touch = await bob.runCommand({
cmd: "touch",
args: ["/home/alice/hacked.txt"],
});
touch.exitCode; // non-zero
// Each user reads their own files normally
const content = await alice.readFileToBuffer({ path: "secret.txt" });
content?.toString(); // "alice only"
```
## Group management with shared directories
```ts
// Create a group with shared dir at /shared/devs (setgid 2770)
const devs = await sandbox.createGroup("devs");
devs.sharedDir; // "/shared/devs"
// Add users to the group
await sandbox.addUserToGroup("alice", "devs");
await sandbox.addUserToGroup("bob", "devs");
// Or use convenience methods on SandboxUser
await alice.addToGroup("devs");
// Files in the shared dir automatically inherit group ownership
await alice.runCommand({
cmd: "bash",
args: ["-c", 'echo "shared data" > /shared/devs/notes.txt'],
});
const notes = await bob.runCommand({
cmd: "cat",
args: ["/shared/devs/notes.txt"],
});
await notes.stdout(); // "shared data\n"
// Non-members are blocked
const charlie = await sandbox.createUser("charlie");
const blocked = await charlie.runCommand({
cmd: "ls",
args: ["/shared/devs"],
});
blocked.exitCode; // non-zero, Permission denied
// Remove from group revokes access
await sandbox.removeUserFromGroup("alice", "devs");
```
## Using asUser for pre-existing users
```ts
const existing = sandbox.asUser("bob");
await existing.runCommand("whoami");
```
## Multi-user AI agent example
```ts
const sandbox = await Sandbox.create();
const researcher = await sandbox.createUser("researcher");
const coder = await sandbox.createUser("coder");
const reviewer = await sandbox.createUser("reviewer");
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 and 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(`// Based on: ${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
```
## Implementation notes
- Purely SDK-side, no backend API changes. Uses `runCommand` with `sudo`
under the hood.
- Command wrapping: `sudo -u <user> -- bash -c 'cd <dir> && exec "$@"' _
<cmd> <args>`
- `writeFiles` stages to `/tmp` then `sudo mv` + `chown` (backend tar
extraction can't write to user home dirs).
- `readFile`/`readFileToBuffer` use `sudo cat` (backend read process
can't traverse user home dirs).
- `mkDir` uses `sudo mkdir` for the same reason.
- Username/group validation prevents command injection.
- Home dirs: `770`, `vercel-sandbox` added to each user's group.
- Shared group dirs: setgid `2770`, files inherit group ownership.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Marc Codina <marc.codina@vercel.com>
github.com-vercel-sandbox · c8678da4 · 2026-07-13