Skip to content

Commit d982542

Browse files
cramforceclaude
andcommitted
feat(sandbox): add multi-user and group management
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>
1 parent cf13a34 commit d982542

6 files changed

Lines changed: 1448 additions & 0 deletions

File tree

packages/vercel-sandbox/README.md

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,243 @@ Sandbox runs sudo in the following configuration:
206206
Both these images are based on Amazon Linux 2023. The full package list is
207207
available [here](https://docs.aws.amazon.com/linux/al2023/release-notes/all-packages-AL2023.7.html).
208208

209+
## Multi-user
210+
211+
Sandboxes support creating isolated Linux users with their own home directories,
212+
file permissions, and process ownership. This is useful for multi-agent workflows
213+
where each agent needs its own workspace, or for simulating multi-user
214+
environments.
215+
216+
### Creating users
217+
218+
```typescript
219+
import { Sandbox } from "@vercel/sandbox";
220+
221+
const sandbox = await Sandbox.create();
222+
223+
// Creates /home/alice with isolated permissions
224+
const alice = await sandbox.createUser("alice");
225+
226+
alice.username; // "alice"
227+
alice.homeDir; // "/home/alice"
228+
```
229+
230+
`createUser` sets up:
231+
232+
- A Linux user with `/bin/bash` as the default shell
233+
- A home directory at `/home/<username>` group-owned by `vercel-sandbox` with `770` permissions
234+
235+
### Running commands as a user
236+
237+
All commands run as the user by default, with the working directory set to their
238+
home:
239+
240+
```typescript
241+
const alice = await sandbox.createUser("alice");
242+
243+
const whoami = await alice.runCommand("whoami");
244+
await whoami.stdout(); // "alice\n"
245+
246+
const pwd = await alice.runCommand("pwd");
247+
await pwd.stdout(); // "/home/alice\n"
248+
```
249+
250+
You can pass environment variables, override the working directory, or use the
251+
full `RunCommandParams` interface:
252+
253+
```typescript
254+
// Environment variables
255+
await alice.runCommand({
256+
cmd: "node",
257+
args: ["-e", "console.log(process.env.API_KEY)"],
258+
env: { API_KEY: "secret" },
259+
});
260+
261+
// Custom working directory
262+
await alice.runCommand({ cmd: "ls", cwd: "/tmp" });
263+
264+
// Detached mode for long-running processes
265+
const server = await alice.runCommand({
266+
cmd: "node",
267+
args: ["server.js"],
268+
detached: true,
269+
});
270+
```
271+
272+
To escalate to root, pass `sudo: true`:
273+
274+
```typescript
275+
await alice.runCommand({
276+
cmd: "dnf",
277+
args: ["install", "-y", "git"],
278+
sudo: true,
279+
});
280+
```
281+
282+
### File operations
283+
284+
`writeFiles`, `readFile`, `readFileToBuffer`, and `mkDir` all resolve relative
285+
paths against the user's home directory. Written files are owned by the user:
286+
287+
```typescript
288+
const alice = await sandbox.createUser("alice");
289+
290+
// Writes to /home/alice/app.js, owned by alice:alice
291+
await alice.writeFiles([
292+
{ path: "app.js", content: Buffer.from('console.log("hi")') },
293+
]);
294+
295+
// Read it back
296+
const buf = await alice.readFileToBuffer({ path: "app.js" });
297+
buf?.toString(); // 'console.log("hi")'
298+
299+
// Stream reads
300+
const stream = await alice.readFile({ path: "app.js" });
301+
302+
// Create directories owned by the user
303+
await alice.mkDir("projects/my-app");
304+
305+
// Absolute paths also work
306+
await alice.writeFiles([
307+
{ path: "/tmp/output.txt", content: Buffer.from("data") },
308+
]);
309+
```
310+
311+
### File isolation
312+
313+
Users cannot access each other's home directories:
314+
315+
```typescript
316+
const alice = await sandbox.createUser("alice");
317+
const bob = await sandbox.createUser("bob");
318+
319+
await alice.writeFiles([
320+
{ path: "secret.txt", content: Buffer.from("alice only") },
321+
]);
322+
323+
// Bob cannot read, list, or write to alice's home
324+
const cat = await bob.runCommand({
325+
cmd: "cat",
326+
args: ["/home/alice/secret.txt"],
327+
});
328+
cat.exitCode; // non-zero — Permission denied
329+
```
330+
331+
**The SDK can read all users' files** because home directories are group-owned
332+
by `vercel-sandbox`. Both `SandboxUser` methods and direct `sandbox` methods
333+
work:
334+
335+
```typescript
336+
// Via SandboxUser (relative paths resolve to home dir)
337+
const buf = await alice.readFileToBuffer({ path: "secret.txt" });
338+
buf?.toString(); // "alice only"
339+
340+
// Via sandbox directly (absolute path required)
341+
const buf2 = await sandbox.readFileToBuffer({ path: "/home/alice/secret.txt" });
342+
buf2?.toString(); // "alice only"
343+
```
344+
345+
### Groups and shared directories
346+
347+
Create groups to let users collaborate through a shared directory:
348+
349+
```typescript
350+
const devs = await sandbox.createGroup("devs");
351+
devs.sharedDir; // "/shared/devs"
352+
353+
await sandbox.addUserToGroup("alice", "devs");
354+
await sandbox.addUserToGroup("bob", "devs");
355+
356+
// Alice writes to the shared directory
357+
await alice.runCommand({
358+
cmd: "bash",
359+
args: ["-c", 'echo "spec v2" > /shared/devs/spec.txt'],
360+
});
361+
362+
// Bob can read it — files inherit group ownership via setgid
363+
const spec = await bob.runCommand({
364+
cmd: "cat",
365+
args: ["/shared/devs/spec.txt"],
366+
});
367+
await spec.stdout(); // "spec v2\n"
368+
369+
// Non-members are blocked
370+
const charlie = await sandbox.createUser("charlie");
371+
const ls = await charlie.runCommand({ cmd: "ls", args: ["/shared/devs"] });
372+
ls.exitCode; // non-zero — Permission denied
373+
```
374+
375+
Shared directories use setgid (`2770`), so files created inside them
376+
automatically inherit the group. All group members get read/write access.
377+
378+
Convenience methods are available on `SandboxUser`:
379+
380+
```typescript
381+
await alice.addToGroup("devs");
382+
await alice.removeFromGroup("devs");
383+
```
384+
385+
### Using `asUser` for existing users
386+
387+
If a user already exists (e.g., from a snapshot or manual creation), use
388+
`asUser` to get a handle without re-creating:
389+
390+
```typescript
391+
const existing = sandbox.asUser("bob");
392+
await existing.runCommand("whoami"); // "bob"
393+
```
394+
395+
### Username validation
396+
397+
Usernames and group names must match `/^[a-z_][a-z0-9_-]*$/` and be at most 32
398+
characters. Invalid names throw an error immediately:
399+
400+
```typescript
401+
sandbox.asUser("Alice"); // throws — uppercase
402+
sandbox.asUser("user name"); // throws — space
403+
sandbox.asUser("$(whoami)"); // throws — special characters
404+
sandbox.asUser("a".repeat(33)); // throws — too long
405+
```
406+
407+
### Multi-agent example
408+
409+
```typescript
410+
const sandbox = await Sandbox.create();
411+
412+
// Each agent gets its own isolated workspace
413+
const researcher = await sandbox.createUser("researcher");
414+
const coder = await sandbox.createUser("coder");
415+
const reviewer = await sandbox.createUser("reviewer");
416+
417+
// Shared workspace for collaboration
418+
await sandbox.createGroup("project");
419+
await sandbox.addUserToGroup("researcher", "project");
420+
await sandbox.addUserToGroup("coder", "project");
421+
await sandbox.addUserToGroup("reviewer", "project");
422+
423+
// Researcher writes findings to shared dir
424+
await researcher.runCommand({
425+
cmd: "bash",
426+
args: ["-c", 'echo "API spec v2" > /shared/project/spec.txt'],
427+
});
428+
429+
// Coder reads spec, writes code in their own home
430+
const spec = await coder.runCommand({
431+
cmd: "cat",
432+
args: ["/shared/project/spec.txt"],
433+
});
434+
await coder.writeFiles([
435+
{ path: "app.js", content: Buffer.from(`// ${await spec.stdout()}`) },
436+
]);
437+
438+
// Reviewer can read the shared spec but not coder's private files
439+
const blocked = await reviewer.runCommand({
440+
cmd: "cat",
441+
args: ["/home/coder/app.js"],
442+
});
443+
blocked.exitCode; // non-zero — isolation enforced
444+
```
445+
209446
[create-token]: https://vercel.com/account/settings/tokens
210447
[hive]: https://vercel.com/blog/a-deep-dive-into-hive-vercels-builds-infrastructure
211448
[al-2023-packages]: https://docs.aws.amazon.com/linux/al2023/release-notes/all-packages-AL2023.7.html

packages/vercel-sandbox/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export {
44
type NetworkPolicyRule,
55
type NetworkTransformer,
66
} from "./sandbox.js";
7+
export { SandboxUser } from "./sandbox-user.js";
78
export { Snapshot } from "./snapshot.js";
89
export { Command, CommandFinished } from "./command.js";
910
export { StreamError } from "./api-client/api-error.js";

0 commit comments

Comments
 (0)