Skip to content

feat(sandbox): add multi-user and group management#116

Open
cramforce wants to merge 1 commit intomainfrom
malte/multi-user
Open

feat(sandbox): add multi-user and group management#116
cramforce wants to merge 1 commit intomainfrom
malte/multi-user

Conversation

@cramforce
Copy link
Copy Markdown
Contributor

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

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

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

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

// 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

const existing = sandbox.asUser("bob");
await existing.runCommand("whoami");

Multi-user AI agent example

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.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 27, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sandbox Ready Ready Preview, Comment, Open in v0 Mar 27, 2026 6:20pm
sandbox-cli Ready Ready Preview, Comment, Open in v0 Mar 27, 2026 6:20pm
sandbox-sdk Ready Ready Preview, Comment, Open in v0 Mar 27, 2026 6:20pm
sandbox-sdk-ai-example Ready Ready Preview, Comment, Open in v0 Mar 27, 2026 6:20pm
workflow-code-runner Error Error Open in v0 Mar 27, 2026 6:20pm

Request Review

...envArgs,
"bash",
"-c",
`cd ${cwd} && exec "$@"`,
Copy link
Copy Markdown

@vercel vercel bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell injection and command breakage via unquoted cwd interpolation in bash -c string in buildUserCommand.

Fix on Vercel

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel can we change to sudo -u ${this.username} --chdir ${cwd}?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown

@vercel vercel bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test descriptions incorrectly state 750 permissions when the assertions and code actually use 770 on user home directories.

Fix on Vercel

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>
*
* @hideconstructor
*/
export class SandboxUser {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we have implements Sandbox or something similar?

Comment on lines +845 to +848
asUser(username: string): SandboxUser {
validateName(username, "username");
return new SandboxUser({ sandbox: this, username });
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can suggest "root" if people want to

Suggested change
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 });
}

🔗 typescript playground

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants