feat(sandbox): add multi-user and group management#116
feat(sandbox): add multi-user and group management#116
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| ...envArgs, | ||
| "bash", | ||
| "-c", | ||
| `cd ${cwd} && exec "$@"`, |
There was a problem hiding this comment.
@vercel can we change to sudo -u ${this.username} --chdir ${cwd}?
There was a problem hiding this comment.
Great call! Using sudo --chdir is a cleaner fix — it eliminates the bash -c wrapper entirely and avoids shell injection by design rather than relying on quoting. I've updated the suggestion to use this approach:
sudo --chdir <dir> -u <user> -- [env K=V...] <cmd> [args...]
This passes cwd as a separate argv element to sudo, so it's never shell-interpreted. It also simplifies the command structure since we no longer need the bash -c 'cd ... && exec "$@"' pattern.
| /** | ||
| * Create a new Linux user in this sandbox with an isolated home directory. | ||
| * | ||
| * The user gets a home directory at `/home/<username>` with `750` permissions. |
bcc951a to
d982542
Compare
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>
d982542 to
7e3e81f
Compare
| * | ||
| * @hideconstructor | ||
| */ | ||
| export class SandboxUser { |
There was a problem hiding this comment.
should we have implements Sandbox or something similar?
| asUser(username: string): SandboxUser { | ||
| validateName(username, "username"); | ||
| return new SandboxUser({ sandbox: this, username }); | ||
| } |
There was a problem hiding this comment.
we can suggest "root" if people want to
| asUser(username: string): SandboxUser { | |
| validateName(username, "username"); | |
| return new SandboxUser({ sandbox: this, username }); | |
| } | |
| asUser(username: "root" | (string & {})): SandboxUser { | |
| validateName(username, "username"); | |
| return new SandboxUser({ sandbox: this, username }); | |
| } |
Adds
createUser,asUser,createGroup,addUserToGroup, andremoveUserFromGroupto theSandboxclass, plus a newSandboxUserclass that wraps sandbox operations to run in a specific user's context.Creating users and running commands
File operations scoped to the user
File isolation between users
Group management with shared directories
Using asUser for pre-existing users
Multi-user AI agent example
Implementation notes
runCommandwithsudounder the hood.sudo -u <user> -- bash -c 'cd <dir> && exec "$@"' _ <cmd> <args>writeFilesstages to/tmpthensudo mv+chown(backend tar extraction can't write to user home dirs).readFile/readFileToBufferusesudo cat(backend read process can't traverse user home dirs).mkDirusessudo mkdirfor the same reason.770,vercel-sandboxadded to each user's group.2770, files inherit group ownership.