Skip to content

Add experimental sandboxed execution mode to Bun Shell ($.sandbox)#31906

Draft
robobun wants to merge 2 commits into
mainfrom
farm/a91c5779/shell-sandbox
Draft

Add experimental sandboxed execution mode to Bun Shell ($.sandbox)#31906
robobun wants to merge 2 commits into
mainfrom
farm/a91c5779/shell-sandbox

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Jun 5, 2026

What

$.sandbox(options) creates a restricted Bun Shell for safely running untrusted shell commands. Because Bun Shell implements its commands natively, the policy is enforced inside the interpreter, before any command or filesystem operation executes.

import { $ } from "bun";

const box = $.sandbox({
  commands: { deny: ["rm"] },
  fs: { read: ["/work/data"], write: ["/work/data/out"] },
  limits: { timeout: 5_000, maxOutputBytes: 1024 * 1024 },
});

await box`ls /work/data`;   // ok
await box`cat /etc/passwd`; // exit 1: "read access not permitted in sandbox"
await box`git status`;      // exit 1: "external commands are not permitted in sandbox"

The returned function is $-compatible (interpolation, .cwd(), .env(), .quiet(), .text(), ...) and inherits cwd/env/throws from the shell it derives from. Marked experimental; the option surface is validated strictly (unknown keys throw) so it can grow toward overlay filesystems and network allowlists without breaking existing callers.

Policy surface

  • commands: external binaries never run, blocked before any PATH lookup. commands.allow / commands.deny filter the builtin set; unknown names throw with the list of valid builtins. Deny wins over allow.
  • fs: no filesystem access by default. fs.read / fs.write grant absolute path prefixes; a write prefix implies read, and omitting write gives a read-only sandbox. Every checked path is resolved against the shell cwd and through symlinks (realpath of the deepest existing ancestor) before the prefix comparison, and policy prefixes get the same canonicalization at parse time.
  • network: structurally off. No builtin performs network I/O and external binaries cannot run, so the option is reserved: only false is accepted, true throws instead of silently doing nothing.
  • limits.timeout: an EventLoopTimer plus a wall-clock deadline checked at interpreter step boundaries, so synchronous write bursts that starve the event loop still observe the timeout. Rejects with Shell command timed out after <n>ms (sandbox limits.timeout).
  • limits.maxOutputBytes: counted at the two output choke points (Builtin::write_no_io and IOWriter::enqueue), covering stdout, stderr, and file redirects. Rejects with Shell command output exceeded <n> bytes (sandbox limits.maxOutputBytes). Both rejections carry the output captured so far.

Enforcement points

  • Cmd::transition_to_exec is the single command gate (builtin allow/deny, external block).
  • Path checks sit at every place the interpreter touches the filesystem: cat/ls/rm/mv/cp/touch/mkdir operands, redirect opens (<, >, >>), cd targets, glob walk roots, and [[ -f ... ]] probes (which answer "does not exist" outside the grants).
  • which never probes PATH in a sandbox; it resolves policy-permitted builtins only, so scripts cannot enumerate host binaries.
  • Traversal stays under checked roots: ls -R descends by dirent kind, rm -r deletes with NOFOLLOW semantics, the glob walker runs with follow_symlinks = false, and cp rejects -H/-L at parse time. Subshells, pipelines, and command substitution share the same Interpreter, so the policy applies to them with no extra plumbing.
  • On a limit fault the promise settles immediately and the state machine unwinds at step boundaries (Script::next, Binary::next, Cmd::next, the builtin IO-writer callback) via a synthetic ECANCELED write error, so builtins release their readers/writers through existing error paths instead of being torn down mid-state.

Blocked operations exit 1 with a ... not permitted in sandbox message on stderr, so they compose with &&/||/.nothrow() like any failing command.

Also fixed

run_task_cold had no dispatch arm for ShellYesTask, so the quiet-mode yes builtin panicked the event loop with panic: Unexpected Task tag: 12 as soon as its write loop bounced through the task queue. The sandbox limit tests exercise exactly that path; the arm now routes to YesTask::run_from_main_thread.

Out of scope, by design (documented)

  • The available builtin set matches the regular shell: cat/cp are POSIX-disabled natively today, so inside a sandbox they are blocked like external commands on those platforms.
  • JS objects interpolated into the command (Bun.file(), buffers, Response) come from the trusted caller, not the untrusted script, and are not policy-checked.
  • A builtin blocked forever on a pipe that never receives data keeps its read poll active after a timeout; the promise still rejects and the caller regains control.
  • Overlay/copy-on-write writes and host/domain network allowlists are follow-ups; the option shapes leave room for both.

Verification

  • New test/js/bun/shell/bunshell-sandbox.test.ts: 38 tests / 142 assertions covering option validation, allow/deny, the external-command block (including bun itself and PATH= tricks), read/write prefix matrices for every filesystem builtin and redirect form, and escape attempts: .. traversal, symlinked directories, files, and write targets, subshells, command substitution, pipelines, glob enumeration, cd, and [[ -f ]] probes, plus timeout and output-limit behavior with exact error messages.
  • Existing shell suite passes: bunshell.test.ts (376 pass), instance/default/throw/shelloutput/yield/lazy/exec/file-io/brace, and commands/. The only failures in this container are pre-existing and reproduce identically on a clean tree (ls permission-denied tests under root, shell-hang's 700ms spawn budget in a debug build).
  • bun run rust:check-all: 10/10 target configurations compile.
  • Docs: new "Sandboxed shells (experimental)" section in docs/runtime/shell.mdx; TypeScript declarations in packages/bun-types/shell.d.ts (bun-types integration test passes).

$.sandbox(options) returns a $-compatible shell that enforces a policy
inside the interpreter, before any command or filesystem operation
executes: builtin allow/deny lists with external binaries always
blocked, filesystem access restricted to symlink-resolved absolute path
prefixes (fs.read/fs.write, write implies read), a reserved network
toggle that only accepts false, and limits.timeout /
limits.maxOutputBytes that reject the promise and unwind the state
machine at step boundaries.

Path checks cover builtin operands, redirect opens, cd, glob walk
roots, and conditional file tests, all canonicalized through realpath
of the deepest existing ancestor so dot-dot segments and symlinks
cannot escape the granted prefixes. Recursive traversal stays under
checked roots because ls -R, rm -r, and the glob walker never follow
symlinks, and cp rejects its dereference flags at parse time.

Also adds the missing run_task_cold dispatch arm for ShellYesTask,
which panicked the event loop (Unexpected Task tag: 12) whenever the
quiet-mode yes builtin bounced its write loop.
@github-actions github-actions Bot added the claude label Jun 5, 2026
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Jun 5, 2026

@mintlify
Copy link
Copy Markdown

mintlify Bot commented Jun 5, 2026

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 5, 2026, 10:54 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant