Commit d982542
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
- src
- utils
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
206 | 206 | | |
207 | 207 | | |
208 | 208 | | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
209 | 446 | | |
210 | 447 | | |
211 | 448 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
0 commit comments