Compare commits

...

15 Commits

Author SHA1 Message Date
Dylan Conway
1bf145fe38 Merge branch 'main' into dylan/spawn-sandbox 2026-01-28 08:52:55 +00:00
Dylan Conway
0a9b852a64 Merge branch 'main' into dylan/spawn-sandbox 2026-01-25 01:17:22 +00:00
Dylan Conway
05be0f7117 Merge branch 'main' into dylan/spawn-sandbox 2026-01-20 07:11:38 +00:00
Dylan Conway
e2d1d9f899 Merge branch 'main' into dylan/spawn-sandbox 2026-01-12 14:56:34 -08:00
Dylan Conway
9db1d80e50 fix(test): use ulimit to disable core dumps on non-ASAN Linux
Alpine (musl) builds don't have ASAN, so ASAN_OPTIONS=disable_coredump
has no effect. For non-ASAN Linux builds, wrap the test command with
`ulimit -c 0` to disable core dumps for tests that spawn processes
expected to crash (e.g., seccomp sandbox tests).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 22:03:20 +00:00
Dylan Conway
8228f0d459 fix(test): pass bunEnv to KILL_ALL_FILTER tests to inherit RLIMIT_CORE
The runner sets ASAN_OPTIONS with disable_coredump=1 for this test,
which causes ASAN to set RLIMIT_CORE=0 at startup. By passing bunEnv
to the spawn calls, the child process inherits this setting and won't
generate core dumps when killed by SIGSYS.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:55:14 +00:00
Dylan Conway
572a96ce0d fix(spawn): move Sandbox type to SpawnOptions for cross-platform support
Move the Sandbox struct from PosixSpawn to SpawnOptions (aliased as
PosixSpawnOptions) so it can be referenced consistently across the
codebase. Add an empty Sandbox stub to WindowsSpawnOptions for
cross-platform compatibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:21:42 -08:00
Dylan Conway
3ac8b1ff85 fix(test): add aarch64 support for seccomp write-blocking filter
Add BLOCK_WRITE_FILTER_AARCH64 for arm64 architecture which uses
syscall number 64 for write (vs 1 on x86_64). Select the appropriate
filter based on process.arch at runtime.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:15:17 +00:00
Dylan Conway
96292141b3 fix(test): disable core dumps for spawn-sandbox test
The spawn-sandbox test spawns child processes with seccomp filters
that intentionally kill them with SIGSYS. These expected crashes
were generating core dumps that caused CI to report failures even
though all tests passed.

Add expectsSpawnedProcessCrash() helper to detect tests that expect
spawned processes to crash, and set ASAN_OPTIONS with disable_coredump=1
for those tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:14:19 +00:00
Jarred Sumner
783fcfaa81 Merge branch 'main' into dylan/spawn-sandbox 2026-01-12 00:52:03 -08:00
autofix-ci[bot]
6b3df67dce [autofix.ci] apply automated fixes 2026-01-12 04:45:19 +00:00
Dylan Conway
4d5ceecd0f feat(spawn): add seccomp filter flags support (LOG, SPEC_ALLOW)
Add support for passing flags to seccomp via an object format:

```js
Bun.spawn({
  cmd: ["program"],
  sandbox: {
    seccomp: {
      filter: bpfFilter,
      flags: ["LOG", "SPEC_ALLOW"]
    }
  }
});
```

- LOG: log filter actions to audit log
- SPEC_ALLOW: disable Spectre mitigations for performance

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 04:36:49 +00:00
Dylan Conway
b58b9f15b5 refactor(spawn): use flat sandbox API with technology names
Change sandbox option from nested platform structure to flat technology names:
- sandbox.linux.seccomp -> sandbox.seccomp
- sandbox.darwin -> sandbox.seatbelt

This makes the API cleaner since developers using these low-level security
APIs already know what platform they are targeting. The technology names
(seccomp, seatbelt) are more descriptive than platform names.

Windows will remain nested (sandbox.windows) when implemented because
it requires multiple interrelated mechanisms (AppContainer, Job Objects,
Mitigations, Integrity Levels).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-11 18:07:22 -08:00
Dylan Conway
c387e08cfe feat(spawn): add sandbox.darwin option for macOS Seatbelt sandboxing
Add macOS sandbox support for Bun.spawn and Bun.spawnSync using
Apple Seatbelt/SBPL. The sandbox is applied after fork but before
exec using sandbox_init_with_parameters.

Supports:
- Inline SBPL profile strings
- SBPL profiles with parameters via param function
- Named system profiles from /usr/share/sandbox/

The sandbox.darwin option is silently ignored on non-macOS platforms,
allowing cross-platform code to specify sandboxes for multiple platforms.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-11 17:31:44 -08:00
Dylan Conway
51967f2889 feat(spawn): add sandbox.linux option for seccomp-BPF filtering
Add a new `sandbox.linux` option to `Bun.spawn()` and `Bun.spawnSync()`
that accepts a TypedArray or ArrayBuffer containing compiled seccomp-BPF
bytecode. The filter is applied to the child process after fork() but
before execve(), providing syscall sandboxing capabilities.

Key behaviors:
- Filter must be a multiple of 8 bytes (each BPF instruction is 8 bytes)
- Bun automatically calls prctl(PR_SET_NO_NEW_PRIVS, 1) before applying
- If the filter kills the process, exitCode is null and signalCode is "SIGSYS"
- Silently ignored on non-Linux platforms (allows cross-platform code)

This is part of a unified cross-platform sandbox API where platform-specific
options can be specified together and are silently ignored on other platforms.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:00:22 +00:00
7 changed files with 1402 additions and 5 deletions

View File

@@ -6326,6 +6326,177 @@ declare module "bun" {
* @default undefined (no limit)
*/
maxBuffer?: number;
/**
* Process sandboxing options.
*
* You can specify options for multiple sandbox technologies - options
* for unavailable technologies are silently ignored. This allows writing
* cross-platform code that sandboxes on all supported platforms.
*
* Currently supports:
* - **seccomp**: Linux syscall filtering via BPF
* - **seatbelt**: macOS sandbox via SBPL profiles
*
* @example
* ```ts
* // Cross-platform sandboxing
* const proc = Bun.spawn({
* cmd: ["./untrusted-program"],
* sandbox: {
* seccomp: bpfFilter, // Linux
* seatbelt: ` // macOS
* (version 1)
* (allow default)
* (deny network*)
* `,
* },
* });
* ```
*/
sandbox?: {
/**
* seccomp-BPF filter (Linux only).
*
* A compiled BPF program that restricts which system calls the
* subprocess can make. Silently ignored on non-Linux platforms.
*
* The buffer must contain valid BPF bytecode. Each BPF instruction
* is 8 bytes:
* - 2 bytes: opcode
* - 1 byte: jump-true offset
* - 1 byte: jump-false offset
* - 4 bytes: k value
*
* **Important**: Your BPF program MUST check the architecture
* (`seccomp_data.arch`) before checking syscall numbers, as syscall
* numbers differ between architectures (x86_64, arm64, etc.).
*
* The filter is applied after `fork()` but before `execve()`, and
* persists across `execve()`. Child processes inherit the filter.
*
* Bun automatically calls `prctl(PR_SET_NO_NEW_PRIVS, 1)` before
* applying the filter.
*
* If the filter kills the process (via `SECCOMP_RET_KILL`), the
* subprocess will have `exitCode: null` and `signalCode: "SIGSYS"`.
*
* @see https://man7.org/linux/man-pages/man2/seccomp.2.html
* @platform linux
*
* @example
* ```ts
* // Simple format: raw BPF filter
* Bun.spawn({
* cmd: ["program"],
* sandbox: { seccomp: bpfFilter }
* });
*
* // Object format with flags
* Bun.spawn({
* cmd: ["program"],
* sandbox: {
* seccomp: {
* filter: bpfFilter,
* flags: ["LOG", "SPEC_ALLOW"]
* }
* }
* });
* ```
*/
seccomp?:
| ArrayBufferView
| ArrayBuffer
| {
/** BPF filter bytecode. Each instruction is 8 bytes. */
filter: ArrayBufferView | ArrayBuffer;
/**
* Seccomp filter flags. Can be a single flag or array of flags.
*
* - `"LOG"` - Log all filter actions except ALLOW to audit log
* - `"SPEC_ALLOW"` - Disable Speculative Store Bypass mitigation
*/
flags?: "LOG" | "SPEC_ALLOW" | ("LOG" | "SPEC_ALLOW")[];
};
/**
* macOS Seatbelt sandbox profile (SBPL format).
*
* Silently ignored on non-macOS platforms.
*
* The sandbox is applied after `fork()` but before `exec()` using
* Apple's `sandbox_init()` API. Once applied, the sandbox cannot
* be removed or weakened. Child processes inherit the sandbox.
*
* Common operations to allow/deny:
* - `file-read*`, `file-write*` - File system access
* - `network-outbound`, `network-inbound` - Network access
* - `process-exec`, `process-fork` - Process creation
* - `sysctl-read`, `sysctl-write` - System information
*
* Can be specified as:
* - A string containing an inline SBPL profile
* - An object with `profile` (SBPL string) and optional `parameters`
* - An object with `namedProfile` referencing a system profile
*
* @example
* ```ts
* // Inline SBPL profile (string)
* const proc = Bun.spawn({
* cmd: ["./my-script.sh"],
* sandbox: {
* seatbelt: `
* (version 1)
* (deny default)
* (allow file-read*)
* (allow process-exec)
* (allow process-fork)
* `
* }
* });
*
* // SBPL profile with parameters
* const proc2 = Bun.spawn({
* cmd: ["./my-script.sh"],
* sandbox: {
* seatbelt: {
* profile: `
* (version 1)
* (deny default)
* (allow file-read* (subpath (param "ALLOWED_PATH")))
* `,
* parameters: {
* ALLOWED_PATH: "/tmp/safe-directory"
* }
* }
* }
* });
*
* // Named system profile
* const proc3 = Bun.spawn({
* cmd: ["./my-script.sh"],
* sandbox: {
* seatbelt: { namedProfile: "quicklookd" }
* }
* });
* ```
*
* @see https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf
* @platform darwin
*/
seatbelt?:
| string
| {
/** Inline SBPL profile string */
profile: string;
/** Optional parameters passed to the SBPL profile via the `param()` function */
parameters?: Record<string, string>;
}
| {
/** Named profile (e.g., "quicklookd", "pfd") */
namedProfile: string;
};
};
}
interface SpawnSyncOptions<In extends Writable, Out extends Readable, Err extends Readable>

View File

@@ -340,6 +340,16 @@ const shouldValidateLeakSan = test => {
return !(skipsForLeaksan.includes(test) || skipsForLeaksan.includes("test/" + test));
};
/**
* Returns whether a test expects spawned child processes to crash (e.g., seccomp tests).
* For these tests, we disable core dumps to avoid CI failures from expected crashes.
* @param {string} test
* @returns {boolean}
*/
const expectsSpawnedProcessCrash = test => {
return test.endsWith("spawn-sandbox.test.ts");
};
/**
* @param {string} testPath
* @returns {string[]}
@@ -603,6 +613,11 @@ async function runTests() {
// prettier-ignore
env.LSAN_OPTIONS = `malloc_context_size=100:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
}
if (expectsSpawnedProcessCrash(testPath)) {
// Tests that spawn processes expected to crash (e.g., seccomp sandbox tests)
// should not generate core dumps
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=1";
}
return runTest(title, async () => {
const { ok, error, stdout, crashes } = await spawnBun(execPath, {
cwd: cwd,
@@ -1350,9 +1365,27 @@ async function spawnBunTest(execPath, testPath, opts = { cwd }) {
// prettier-ignore
env.LSAN_OPTIONS = `malloc_context_size=100:print_suppressions=0:suppressions=${process.cwd()}/test/leaksan.supp`;
}
// For tests that spawn processes expected to crash, disable core dumps
const disableCoreDumps = expectsSpawnedProcessCrash(relative(cwd, absPath));
const isAsanBuild = basename(execPath).includes("asan");
if (disableCoreDumps && isAsanBuild) {
// ASAN builds: use ASAN_OPTIONS to disable core dumps
env.ASAN_OPTIONS = "allow_user_segv_handler=1:disable_coredump=1";
}
const { ok, error, stdout, crashes } = await spawnBun(execPath, {
args: isReallyTest ? testArgs : [...args, absPath],
// Build the command args
let spawnArgs = isReallyTest ? testArgs : [...args, absPath];
let spawnExecPath = execPath;
// Non-ASAN Linux builds: wrap with ulimit -c 0 to disable core dumps
if (disableCoreDumps && isLinux && !isAsanBuild) {
const originalCmd = [execPath, ...spawnArgs].map(a => `"${a.replace(/"/g, '\\"')}"`).join(" ");
spawnExecPath = "/bin/sh";
spawnArgs = ["-c", `ulimit -c 0 && exec ${originalCmd}`];
}
const { ok, error, stdout, crashes } = await spawnBun(spawnExecPath, {
args: spawnArgs,
cwd: opts["cwd"],
timeout: isReallyTest ? timeout : 30_000,
env,

View File

@@ -157,6 +157,7 @@ pub fn spawnMaybeSync(
var terminal_info: ?Terminal.CreateResult = null;
var existing_terminal: ?*Terminal = null; // Existing terminal passed by user
var terminal_js_value: jsc.JSValue = .zero;
var sandbox: bun.spawn.SpawnOptions.Sandbox = .{};
defer {
if (abort_signal) |signal| {
signal.unref();
@@ -420,6 +421,166 @@ pub fn spawnMaybeSync(
stdio[2] = .{ .fd = slave_fd };
}
}
// Parse sandbox option
if (try args.getTruthy(globalThis, "sandbox")) |sandbox_val| {
// Parse sandbox.seccomp (seccomp BPF filter) - only on Linux, ignored on other platforms
if (comptime Environment.isLinux) {
if (try sandbox_val.getTruthy(globalThis, "seccomp")) |seccomp_val| {
// Check if it's an ArrayBuffer/TypedArray (simple format) or object (extended format)
if (seccomp_val.asArrayBuffer(globalThis)) |array_buffer| {
// Simple format: just the filter bytes
const slice = array_buffer.slice();
if (slice.len > 0) {
if (slice.len % 8 != 0) {
return globalThis.throwInvalidArguments(
"sandbox.seccomp filter must be a multiple of 8 bytes (each BPF instruction is 8 bytes)",
.{},
);
}
sandbox.linux.seccomp_filter = try allocator.dupe(u8, slice);
}
} else if (seccomp_val.isObject()) {
// Extended format: { filter: ArrayBuffer, flags?: string | string[] }
if (try seccomp_val.getTruthy(globalThis, "filter")) |filter_val| {
if (filter_val.asArrayBuffer(globalThis)) |array_buffer| {
const slice = array_buffer.slice();
if (slice.len > 0) {
if (slice.len % 8 != 0) {
return globalThis.throwInvalidArguments(
"sandbox.seccomp.filter must be a multiple of 8 bytes (each BPF instruction is 8 bytes)",
.{},
);
}
sandbox.linux.seccomp_filter = try allocator.dupe(u8, slice);
}
} else {
return globalThis.throwInvalidArgumentType(
"spawn",
"sandbox.seccomp.filter",
"ArrayBuffer or TypedArray",
);
}
} else {
return globalThis.throwInvalidArguments(
"sandbox.seccomp object must have 'filter' property",
.{},
);
}
// Parse optional flags (string or array of strings)
if (try seccomp_val.get(globalThis, "flags")) |flags_val| {
if (!flags_val.isUndefinedOrNull()) {
var flags: u32 = 0;
if (flags_val.isString()) {
// Single flag: "LOG"
const flag = try SeccompFlag.fromJS(globalThis, flags_val) orelse {
return globalThis.throwInvalidArguments(
"Unknown seccomp flag. Expected: LOG or SPEC_ALLOW",
.{},
);
};
flags = @intFromEnum(flag);
} else if (flags_val.jsType().isArray()) {
// Array of flags: ["LOG", "SPEC_ALLOW"]
var iter = try flags_val.arrayIterator(globalThis);
while (try iter.next()) |item| {
const flag = try SeccompFlag.fromJS(globalThis, item) orelse {
return globalThis.throwInvalidArguments(
"Unknown seccomp flag. Expected: LOG or SPEC_ALLOW",
.{},
);
};
flags |= @intFromEnum(flag);
}
} else {
return globalThis.throwInvalidArgumentType(
"spawn",
"sandbox.seccomp.flags",
"string or array of strings",
);
}
sandbox.linux.seccomp_flags = flags;
}
}
} else {
return globalThis.throwInvalidArgumentType(
"spawn",
"sandbox.seccomp",
"ArrayBuffer, TypedArray, or object",
);
}
}
}
// Parse sandbox.seatbelt (macOS SBPL profile) - only on macOS, ignored on other platforms
if (comptime Environment.isMac) {
if (try sandbox_val.getTruthy(globalThis, "seatbelt")) |seatbelt_val| {
if (seatbelt_val.isString()) {
const slice = seatbelt_val.toSlice(globalThis, allocator) catch {
return globalThis.throwInvalidArgumentType("spawn", "sandbox.seatbelt", "string");
};
if (slice.len > 0) {
sandbox.darwin.profile = try allocator.dupeZ(u8, slice.slice());
}
} else if (seatbelt_val.isObject()) {
// Object format: { profile: "...", parameters?: {...} } or { namedProfile: "..." }
if (try seatbelt_val.get(globalThis, "namedProfile")) |named_val| {
if ((try seatbelt_val.get(globalThis, "profile")) != null) {
return globalThis.throwInvalidArguments("sandbox.seatbelt cannot have both 'profile' and 'namedProfile'", .{});
}
const slice = named_val.toSlice(globalThis, allocator) catch {
return globalThis.throwInvalidArgumentType("spawn", "sandbox.seatbelt.namedProfile", "string");
};
if (slice.len > 0) {
sandbox.darwin.profile = try allocator.dupeZ(u8, slice.slice());
sandbox.darwin.flags = 1; // SANDBOX_NAMED
}
} else if (try seatbelt_val.get(globalThis, "profile")) |profile_val| {
const slice = profile_val.toSlice(globalThis, allocator) catch {
return globalThis.throwInvalidArgumentType("spawn", "sandbox.seatbelt.profile", "string");
};
if (slice.len > 0) {
sandbox.darwin.profile = try allocator.dupeZ(u8, slice.slice());
}
// Parse optional parameters
if (try seatbelt_val.getTruthy(globalThis, "parameters")) |params_val| {
const params_obj = params_val.getObject() orelse {
return globalThis.throwInvalidArgumentType("spawn", "sandbox.seatbelt.parameters", "object");
};
var params_iter = try jsc.JSPropertyIterator(.{ .skip_empty_name = false, .include_value = true }).init(globalThis, params_obj);
defer params_iter.deinit();
// Format: ["KEY1", "VALUE1", "KEY2", "VALUE2", ..., null]
var params_list = try std.ArrayList(?[*:0]const u8).initCapacity(allocator, params_iter.len * 2 + 1);
while (try params_iter.next()) |key| {
const value = params_iter.value;
if (value.isUndefined()) continue;
const value_str = try value.toBunString(globalThis);
defer value_str.deref();
params_list.appendAssumeCapacity((try key.toOwnedSliceZ(allocator)).ptr);
params_list.appendAssumeCapacity((try value_str.toOwnedSliceZ(allocator)).ptr);
}
params_list.appendAssumeCapacity(null);
sandbox.darwin.parameters = @ptrCast(params_list.items.ptr);
}
} else {
// Object doesn't have either 'profile' or 'namedProfile'
return globalThis.throwInvalidArgumentType("spawn", "sandbox.seatbelt", "object with 'profile' or 'namedProfile'");
}
} else {
return globalThis.throwInvalidArgumentType("spawn", "sandbox.seatbelt", "string or object");
}
}
}
// TODO: Parse sandbox.appContainer (Windows sandbox options)
}
} else {
try getArgv(globalThis, cmd_value, PATH, cwd, &argv0, allocator, &argv);
}
@@ -578,6 +739,9 @@ pub fn spawnMaybeSync(
break :blk -1;
} else {},
// Platform-specific sandbox configuration
.sandbox = sandbox,
.windows = if (Environment.isWindows) .{
.hide_window = windows_hide,
.verbatim_arguments = windows_verbatim_arguments,
@@ -1099,6 +1263,22 @@ pub fn appendEnvpFromJS(globalThis: *jsc.JSGlobalObject, object: *jsc.JSObject,
const log = Output.scoped(.Subprocess, .hidden);
extern "C" const BUN_DEFAULT_PATH_FOR_SPAWN: [*:0]const u8;
/// Seccomp filter flags for Linux sandboxing.
/// These correspond to SECCOMP_FILTER_FLAG_* constants.
pub const SeccompFlag = enum(u32) {
LOG = 0x2, // SECCOMP_FILTER_FLAG_LOG
SPEC_ALLOW = 0x4, // SECCOMP_FILTER_FLAG_SPEC_ALLOW
// NEW_LISTENER = 0x8, // SECCOMP_FILTER_FLAG_NEW_LISTENER
const Map = bun.ComptimeStringMap(SeccompFlag, .{
.{ "LOG", .LOG },
.{ "SPEC_ALLOW", .SPEC_ALLOW },
// .{ "NEW_LISTENER", .NEW_LISTENER },
});
pub const fromJS = Map.fromJS;
};
const IPC = @import("../../ipc.zig");
const Terminal = @import("./Terminal.zig");
const std = @import("std");

View File

@@ -996,6 +996,8 @@ pub const PosixSpawnOptions = struct {
no_sigpipe: bool = true,
/// PTY slave fd for controlling terminal setup (-1 if not using PTY).
pty_slave_fd: i32 = -1,
/// Platform-specific sandbox configuration.
sandbox: Sandbox = .{},
pub const Stdio = union(enum) {
path: []const u8,
@@ -1011,6 +1013,56 @@ pub const PosixSpawnOptions = struct {
pub fn deinit(_: *const PosixSpawnOptions) void {
// no-op
}
/// Sandbox configuration for process spawning.
/// Contains platform-specific sandboxing options for both Linux (seccomp) and macOS (seatbelt).
pub const Sandbox = struct {
/// Linux sandbox options.
linux: Linux = .{},
/// macOS sandbox options.
darwin: Darwin = .{},
pub const Linux = struct {
/// Seccomp BPF filter bytecode.
/// Must be a multiple of 8 bytes (sizeof(struct sock_filter)).
seccomp_filter: ?[]const u8 = null,
/// Seccomp filter flags (SECCOMP_FILTER_FLAG_*).
/// Only used when seccomp_filter is set.
seccomp_flags: u32 = 0,
};
pub const Darwin = struct {
/// Sandbox profile (SBPL string or named profile).
/// Applied after fork() but before exec() using sandbox_init().
profile: ?[:0]const u8 = null,
/// Sandbox flags: 0 = inline SBPL string, 1 = named profile from /usr/share/sandbox
flags: u64 = 0,
/// Sandbox parameters: array of key-value pairs for the param() function in SBPL.
/// Format: ["KEY1", "VALUE1", "KEY2", "VALUE2", ..., null]
parameters: ?[*:null]const ?[*:0]const u8 = null,
};
/// C-compatible layout for FFI with posix_spawn_bun.
pub const Extern = extern struct {
linux_seccomp_filter: ?[*]const u8 = null,
linux_seccomp_filter_len: usize = 0,
linux_seccomp_flags: u32 = 0,
darwin_profile: ?[*:0]const u8 = null,
darwin_flags: u64 = 0,
darwin_parameters: ?[*:null]const ?[*:0]const u8 = null,
};
pub fn toExtern(self: Sandbox) Extern {
return .{
.linux_seccomp_filter = if (self.linux.seccomp_filter) |s| s.ptr else null,
.linux_seccomp_filter_len = if (self.linux.seccomp_filter) |s| s.len else 0,
.linux_seccomp_flags = self.linux.seccomp_flags,
.darwin_profile = if (self.darwin.profile) |s| s.ptr else null,
.darwin_flags = self.darwin.flags,
.darwin_parameters = self.darwin.parameters,
};
}
};
};
pub const WindowsSpawnResult = struct {
@@ -1066,6 +1118,8 @@ pub const WindowsSpawnOptions = struct {
can_block_entire_thread_to_reduce_cpu_usage_in_fast_path: bool = false,
/// PTY not supported on Windows - this is a void placeholder for struct compatibility
pty_slave_fd: void = {},
sandbox: Sandbox = .{},
pub const WindowsOptions = struct {
verbatim_arguments: bool = false,
hide_window: bool = true,
@@ -1096,6 +1150,8 @@ pub const WindowsSpawnOptions = struct {
stdio.deinit();
}
}
pub const Sandbox = struct {};
};
pub const PosixSpawnResult = struct {
@@ -1264,6 +1320,9 @@ pub fn spawnProcessPosix(
// Pass PTY slave fd to attr for controlling terminal setup
attr.pty_slave_fd = options.pty_slave_fd;
// Pass platform-specific sandbox settings to attr
attr.sandbox = options.sandbox;
if (options.cwd.len > 0) {
try actions.chdir(options.cwd);
}

View File

@@ -95,6 +95,7 @@ pub const BunSpawn = struct {
pty_slave_fd: i32 = -1,
flags: u16 = 0,
reset_signals: bool = false,
sandbox: bun.spawn.SpawnOptions.Sandbox = .{},
pub fn init() !Attr {
return Attr{};
@@ -270,6 +271,7 @@ pub const PosixSpawn = struct {
detached: bool = false,
actions: ActionsList = .{},
pty_slave_fd: i32 = -1,
sandbox: SpawnOptions.Sandbox.Extern = .{},
const ActionsList = extern struct {
ptr: ?[*]const BunSpawn.Action = null,
@@ -324,14 +326,17 @@ pub const PosixSpawn = struct {
) Maybe(pid_t) {
const pty_slave_fd = if (attr) |a| a.pty_slave_fd else -1;
const detached = if (attr) |a| a.detached else false;
const sandbox = if (attr) |a| a.sandbox else SpawnOptions.Sandbox{};
// Use posix_spawn_bun when:
// - Linux: always (uses vfork which is fast and safe)
// - macOS: only for PTY spawns (pty_slave_fd >= 0) because PTY setup requires
// - macOS: for PTY spawns (pty_slave_fd >= 0) because PTY setup requires
// setsid() + ioctl(TIOCSCTTY) before exec, which system posix_spawn can't do.
// For non-PTY spawns on macOS, we use system posix_spawn which is safer
// - macOS: for sandbox spawns (sandbox.darwin_profile != null) because sandbox_init()
// must be called after fork() but before exec(), which system posix_spawn can't do.
// For non-PTY, non-sandbox spawns on macOS, we use system posix_spawn which is safer
// (Apple's posix_spawn uses a kernel fast-path that avoids fork() entirely).
const use_bun_spawn = Environment.isLinux or (Environment.isMac and pty_slave_fd >= 0);
const use_bun_spawn = Environment.isLinux or (Environment.isMac and (pty_slave_fd >= 0 or sandbox.darwin.profile != null));
if (use_bun_spawn) {
return BunSpawnRequest.spawn(
@@ -347,6 +352,7 @@ pub const PosixSpawn = struct {
.chdir_buf = if (actions) |a| a.chdir_buf else null,
.detached = detached,
.pty_slave_fd = pty_slave_fd,
.sandbox = sandbox.toExtern(),
},
argv,
envp,

View File

@@ -15,6 +15,24 @@
#if OS(LINUX)
#include <sys/syscall.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <linux/filter.h>
#endif
#if OS(DARWIN)
#include <sandbox.h>
// Private function used by Chrome, Firefox, Nix, etc.
// Apple officially deprecates sandbox_init() but continues to support it.
extern "C" int sandbox_init_with_parameters(
const char* profile,
uint64_t flags,
const char* const parameters[],
char** errorbuf);
// Sandbox flags (from sandbox.h, but not always exposed)
#define BUN_SANDBOX_NAMED 0x0001
#endif
extern char** environ;
@@ -99,6 +117,17 @@ typedef struct bun_spawn_request_t {
bool detached;
bun_spawn_file_action_list_t actions;
int pty_slave_fd; // -1 if not using PTY, otherwise the slave fd to set as controlling terminal
// Linux seccomp BPF filter (Linux only)
const void* linux_seccomp_filter; // Pointer to BPF bytecode (array of sock_filter structs)
size_t linux_seccomp_filter_len; // Length in bytes (must be multiple of 8)
uint32_t linux_seccomp_flags; // SECCOMP_FILTER_FLAG_* flags
// macOS sandbox profile (SBPL string or named profile)
const char* darwin_profile;
// macOS sandbox flags: 0 = SANDBOX_STRING (inline SBPL), 1 = SANDBOX_NAMED
uint64_t darwin_flags;
// macOS sandbox parameters: null-terminated array of "KEY\0VALUE\0" pairs, ending with empty string
// Format: ["KEY1", "VALUE1", "KEY2", "VALUE2", ..., NULL]
const char* const* darwin_parameters;
} bun_spawn_request_t;
// Raw exit syscall that doesn't go through libc.
@@ -284,6 +313,54 @@ extern "C" ssize_t posix_spawn_bun(
// Close all fds > current_max_fd, preferring cloexec if available
closeRangeOrLoop(current_max_fd + 1, INT_MAX, true);
#if OS(LINUX)
// Apply seccomp filter if provided (Linux only)
if (request->linux_seccomp_filter && request->linux_seccomp_filter_len > 0) {
// Validate filter length (must be multiple of sizeof(sock_filter) = 8)
if (request->linux_seccomp_filter_len % sizeof(struct sock_filter) != 0) {
errno = EINVAL;
return childFailed();
}
// Set no_new_privs (required for seccomp without CAP_SYS_ADMIN)
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) == -1) {
return childFailed();
}
// Construct sock_fprog
struct sock_fprog prog = {
.len = static_cast<unsigned short>(request->linux_seccomp_filter_len / sizeof(struct sock_filter)),
.filter = static_cast<struct sock_filter*>(const_cast<void*>(request->linux_seccomp_filter)),
};
if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, request->linux_seccomp_flags, &prog) == -1) {
return childFailed();
}
}
#endif
#if OS(DARWIN)
// Apply macOS sandbox profile if provided
// The sandbox is applied before execve and persists across exec.
// Child processes inherit the sandbox - they cannot remove or weaken it.
if (request->darwin_profile && request->darwin_profile[0] != '\0') {
char* errorbuf = nullptr;
// darwin_flags: 0 = inline SBPL string, BUN_SANDBOX_NAMED = named profile from /usr/share/sandbox
// darwin_parameters: array of key-value pairs for the param() function in SBPL
if (sandbox_init_with_parameters(
request->darwin_profile,
request->darwin_flags,
request->darwin_parameters,
&errorbuf)
!= 0) {
// sandbox_init failed - child process will exit immediately via childFailed()
// No need to free errorbuf since we're about to _exit()
errno = EINVAL;
return childFailed();
}
}
#endif
if (execve(path, argv, envp) == -1) {
return childFailed();
}

View File

@@ -0,0 +1,871 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, tempDir } from "harness";
const isLinux = process.platform === "linux";
const isMac = process.platform === "darwin";
// BPF instruction that returns SECCOMP_RET_ALLOW (0x7fff0000)
// struct sock_filter { __u16 code; __u8 jt; __u8 jf; __u32 k; }
// BPF_RET | BPF_K = 0x0006, jt=0, jf=0, k=0x7fff0000
// Little-endian bytes: [0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f]
const ALLOW_ALL_FILTER = new Uint8Array([0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f]);
// BPF instruction that returns SECCOMP_RET_KILL_PROCESS (0x80000000)
// This will kill the process on any syscall
const KILL_ALL_FILTER = new Uint8Array([0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80]);
// BPF filter that blocks write() syscall with EPERM
// This allows the process to continue but write() calls will fail with "Operation not permitted"
//
// BPF program logic:
// 0: Load arch from seccomp_data (offset 4)
// 1: If arch matches expected, continue; else jump to kill
// 2: Load syscall number (offset 0)
// 3: If syscall == write, jump to block with EPERM
// 4: Allow all other syscalls
// 5: Return ERRNO | EPERM (0x00050001)
// 6: Kill process (for wrong architecture)
//
// Architecture-specific values:
// x86_64: AUDIT_ARCH = 0xc000003e, write syscall = 1
// aarch64: AUDIT_ARCH = 0xc00000b7, write syscall = 64
//
// prettier-ignore
const BLOCK_WRITE_FILTER_X86_64 = new Uint8Array([
// struct sock_filter { __u16 code; __u8 jt; __u8 jf; __u32 k; }
// Byte order: [code_lo, code_hi, jt, jf, k_0, k_1, k_2, k_3]
//
// Instruction 0: Load architecture (BPF_LD | BPF_W | BPF_ABS, offset=4)
0x20, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00,
// Instruction 1: If arch == x86_64 (0xc000003e), continue (jt=0); else jump to instruction 6 (jf=4)
0x15, 0x00, 0x00, 0x04, 0x3e, 0x00, 0x00, 0xc0,
// Instruction 2: Load syscall number (offset=0)
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// Instruction 3: If syscall == 1 (write), jump to instruction 5 (jt=1); else continue (jf=0)
0x15, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00,
// Instruction 4: Allow (SECCOMP_RET_ALLOW = 0x7fff0000)
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f,
// Instruction 5: Block with EPERM (SECCOMP_RET_ERRNO | 1 = 0x00050001)
0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x05, 0x00,
// Instruction 6: Kill (SECCOMP_RET_KILL_PROCESS = 0x80000000) for wrong arch
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
]);
// prettier-ignore
const BLOCK_WRITE_FILTER_AARCH64 = new Uint8Array([
// Instruction 0: Load architecture (BPF_LD | BPF_W | BPF_ABS, offset=4)
0x20, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00,
// Instruction 1: If arch == aarch64 (0xc00000b7), continue (jt=0); else jump to instruction 6 (jf=4)
0x15, 0x00, 0x00, 0x04, 0xb7, 0x00, 0x00, 0xc0,
// Instruction 2: Load syscall number (offset=0)
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// Instruction 3: If syscall == 64 (write on aarch64), jump to instruction 5 (jt=1); else continue (jf=0)
0x15, 0x00, 0x01, 0x00, 0x40, 0x00, 0x00, 0x00,
// Instruction 4: Allow (SECCOMP_RET_ALLOW = 0x7fff0000)
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0x7f,
// Instruction 5: Block with EPERM (SECCOMP_RET_ERRNO | 1 = 0x00050001)
0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x05, 0x00,
// Instruction 6: Kill (SECCOMP_RET_KILL_PROCESS = 0x80000000) for wrong arch
0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
]);
// Select the appropriate filter based on current architecture
const BLOCK_WRITE_FILTER = process.arch === "arm64" ? BLOCK_WRITE_FILTER_AARCH64 : BLOCK_WRITE_FILTER_X86_64;
// SBPL profile that allows everything (proper SBPL syntax)
const ALLOW_ALL_PROFILE = `(version 1)
(allow default)`;
// SBPL profile that denies network access
const DENY_NETWORK_PROFILE = `(version 1)
(allow default)
(deny network*)`;
// SBPL profile that denies file writes
const DENY_FILE_WRITE_PROFILE = `(version 1)
(allow default)
(deny file-write*)`;
describe("spawn sandbox (Linux)", () => {
test.if(!isLinux)("sandbox.seccomp is silently ignored on non-Linux", async () => {
// On non-Linux, sandbox.seccomp should be silently ignored
// This allows users to specify all platform options at once
await using proc = Bun.spawn({
cmd: ["echo", "test"],
sandbox: { seccomp: ALLOW_ALL_FILTER },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("test\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("rejects filter with invalid length (not multiple of 8)", () => {
expect(() => {
Bun.spawn({
cmd: ["echo", "test"],
sandbox: { seccomp: new Uint8Array([0x06, 0x00, 0x00]) }, // 3 bytes, not multiple of 8
});
}).toThrow("multiple of 8 bytes");
});
test.if(isLinux)("rejects non-ArrayBuffer sandbox.seccomp value", () => {
expect(() => {
Bun.spawn({
cmd: ["echo", "test"],
sandbox: { seccomp: "not a buffer" as unknown as Uint8Array },
});
}).toThrow("ArrayBuffer, TypedArray, or object");
});
test.if(isLinux)("accepts empty filter (no-op)", async () => {
// Empty filter should be treated as no filter
await using proc = Bun.spawn({
cmd: ["echo", "hello"],
sandbox: { seccomp: new Uint8Array(0) },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("hello\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("spawn with allow-all filter works with echo", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "sandboxed"],
sandbox: { seccomp: ALLOW_ALL_FILTER },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("sandboxed\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("spawnSync with allow-all filter works with echo", () => {
const result = Bun.spawnSync({
cmd: ["echo", "sync-sandboxed"],
sandbox: { seccomp: ALLOW_ALL_FILTER },
});
expect(result.stdout.toString()).toBe("sync-sandboxed\n");
expect(result.exitCode).toBe(0);
});
test.if(isLinux)("spawnSync with kill-all filter terminates process with SIGSYS", () => {
const result = Bun.spawnSync({
cmd: ["/bin/true"],
env: bunEnv,
sandbox: { seccomp: KILL_ALL_FILTER },
});
// Process is killed before it can do anything
expect(result.stdout.toString()).toBe("");
// When killed by signal, exitCode is null and signalCode contains the signal name
expect(result.exitCode).toBeNull();
expect(result.signalCode).toBe("SIGSYS");
expect(result.success).toBe(false);
});
test.if(isLinux)("spawn with kill-all filter terminates process with SIGSYS", async () => {
// The process should be killed immediately when it tries to make any syscall.
// seccomp sends SIGSYS (signal 31) to terminate the process.
await using proc = Bun.spawn({
cmd: ["/bin/true"],
env: bunEnv,
sandbox: { seccomp: KILL_ALL_FILTER },
stderr: "pipe",
});
await proc.exited;
const stdout = await proc.stdout.text();
const stderr = await proc.stderr.text();
// Process is killed before it can output anything
expect(stdout).toBe("");
expect(stderr).toBe("");
// When killed by signal, exitCode is null and signalCode contains the signal name.
// SIGSYS indicates seccomp blocked a syscall.
expect(proc.exitCode).toBeNull();
expect(proc.signalCode).toBe("SIGSYS");
});
test.if(isLinux)("accepts ArrayBuffer", async () => {
const buffer = ALLOW_ALL_FILTER.buffer.slice(
ALLOW_ALL_FILTER.byteOffset,
ALLOW_ALL_FILTER.byteOffset + ALLOW_ALL_FILTER.byteLength,
);
await using proc = Bun.spawn({
cmd: ["echo", "arraybuffer"],
sandbox: { seccomp: buffer },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("arraybuffer\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("accepts different TypedArray types", async () => {
// Test with Uint16Array (same bytes, different view)
const uint16View = new Uint16Array(ALLOW_ALL_FILTER.buffer);
await using proc = Bun.spawn({
cmd: ["echo", "uint16"],
sandbox: { seccomp: uint16View },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("uint16\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("filter that blocks write() causes echo to fail", async () => {
// echo uses write() to output to stdout
// With write() blocked, echo should fail
await using proc = Bun.spawn({
cmd: ["echo", "this should not appear"],
sandbox: { seccomp: BLOCK_WRITE_FILTER },
stderr: "pipe",
});
const exitCode = await proc.exited;
const stdout = await proc.stdout.text();
// echo should fail to write and exit with error, or produce no output
// echo exits with 1 when write fails
expect(stdout).toBe("");
expect(exitCode).toBe(1);
});
test.if(isLinux)("filter that blocks write() allows /bin/true to succeed", async () => {
// /bin/true doesn't write anything, it just exits with 0
// So blocking write() shouldn't affect it
await using proc = Bun.spawn({
cmd: ["/bin/true"],
sandbox: { seccomp: BLOCK_WRITE_FILTER },
});
const exitCode = await proc.exited;
expect(exitCode).toBe(0);
});
test.if(isLinux)("filter that blocks write() causes /bin/false to exit non-zero", async () => {
// /bin/false doesn't write anything, it just exits with 1
// So blocking write() shouldn't affect it - it should still exit 1
await using proc = Bun.spawn({
cmd: ["/bin/false"],
sandbox: { seccomp: BLOCK_WRITE_FILTER },
});
const exitCode = await proc.exited;
expect(exitCode).toBe(1);
});
test.if(isLinux)("spawnSync filter that blocks write() causes echo to fail", () => {
const result = Bun.spawnSync({
cmd: ["echo", "this should not appear"],
sandbox: { seccomp: BLOCK_WRITE_FILTER },
});
// echo should fail to write and exit with 1
expect(result.stdout.toString()).toBe("");
expect(result.exitCode).toBe(1);
});
// Seccomp flags tests
test.if(isLinux)("seccomp object format with filter property works", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "object-format"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
},
},
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("object-format\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("seccomp single flag as string", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "with-log-flag"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
flags: "LOG",
},
},
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("with-log-flag\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("seccomp single flag in array", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "spec-allow"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
flags: ["SPEC_ALLOW"],
},
},
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("spec-allow\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("seccomp multiple flags", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "multiple-flags"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
flags: ["LOG", "SPEC_ALLOW"],
},
},
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("multiple-flags\n");
expect(await proc.exited).toBe(0);
});
test.if(isLinux)("seccomp spawnSync with flags", () => {
const result = Bun.spawnSync({
cmd: ["echo", "sync-with-flags"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
flags: "LOG",
},
},
});
expect(result.stdout.toString()).toBe("sync-with-flags\n");
expect(result.exitCode).toBe(0);
});
test.if(isLinux)("seccomp object format requires filter property", () => {
expect(() => {
Bun.spawn({
cmd: ["echo", "test"],
sandbox: {
seccomp: { flags: "LOG" } as unknown as Uint8Array,
},
});
}).toThrow("filter");
});
test.if(isLinux)("seccomp rejects unknown flag", () => {
expect(() => {
Bun.spawn({
cmd: ["echo", "test"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
flags: "INVALID_FLAG" as "LOG",
},
},
});
}).toThrow("Unknown seccomp flag");
});
test.if(isLinux)("seccomp rejects non-string flag in array", () => {
expect(() => {
Bun.spawn({
cmd: ["echo", "test"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
flags: ["LOG", 123] as unknown as ("LOG" | "SPEC_ALLOW")[],
},
},
});
}).toThrow("Unknown seccomp flag");
});
test.if(isLinux)("seccomp object format filter must be ArrayBuffer or TypedArray", () => {
expect(() => {
Bun.spawn({
cmd: ["echo", "test"],
sandbox: {
seccomp: {
filter: "not a buffer" as unknown as Uint8Array,
},
},
});
}).toThrow("ArrayBuffer or TypedArray");
});
test.if(isLinux)("seccomp object format with empty flags array", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "empty-flags"],
sandbox: {
seccomp: {
filter: ALLOW_ALL_FILTER,
flags: [],
},
},
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("empty-flags\n");
expect(await proc.exited).toBe(0);
});
});
describe("spawn sandbox (macOS)", () => {
test.if(!isMac)("sandbox.seatbelt is silently ignored on non-macOS", async () => {
// On non-macOS, sandbox.seatbelt should be silently ignored
await using proc = Bun.spawn({
cmd: ["echo", "test"],
sandbox: { seatbelt: ALLOW_ALL_PROFILE },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("test\n");
expect(await proc.exited).toBe(0);
});
test.if(isMac)("non-string sandbox.seatbelt value throws", () => {
// When sandbox.seatbelt is not a string or object, the spawn itself throws
expect(() => {
Bun.spawn({
cmd: ["echo", "test"],
sandbox: { seatbelt: 12345 as unknown as string },
});
}).toThrow("Expected sandbox.seatbelt to be a string or object");
});
test.if(isMac)("spawn with allow-all profile works with echo", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "sandboxed"],
sandbox: { seatbelt: ALLOW_ALL_PROFILE },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("sandboxed\n");
expect(await proc.exited).toBe(0);
});
test.if(isMac)("spawnSync with allow-all profile works with echo", () => {
const result = Bun.spawnSync({
cmd: ["echo", "sync-sandboxed"],
sandbox: { seatbelt: ALLOW_ALL_PROFILE },
});
expect(result.stdout.toString()).toBe("sync-sandboxed\n");
expect(result.exitCode).toBe(0);
});
test.if(isMac)("spawn with deny-network profile allows echo", async () => {
// echo doesn't use network, so it should work
await using proc = Bun.spawn({
cmd: ["echo", "no-network"],
sandbox: { seatbelt: DENY_NETWORK_PROFILE },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("no-network\n");
expect(await proc.exited).toBe(0);
});
test.if(isMac)("spawn with deny-file-write profile allows /usr/bin/true", async () => {
// /usr/bin/true doesn't write files, so it should work
await using proc = Bun.spawn({
cmd: ["/usr/bin/true"],
sandbox: { seatbelt: DENY_FILE_WRITE_PROFILE },
});
expect(await proc.exited).toBe(0);
});
test.if(isMac)("spawnSync with deny-file-write profile allows /usr/bin/false", () => {
// /usr/bin/false doesn't write files, it just exits with 1
const result = Bun.spawnSync({
cmd: ["/usr/bin/false"],
sandbox: { seatbelt: DENY_FILE_WRITE_PROFILE },
});
expect(result.exitCode).toBe(1);
});
test("can specify both seccomp and seatbelt sandbox options", async () => {
// This should work on all platforms - each platform uses its own option
await using proc = Bun.spawn({
cmd: ["echo", "cross-platform"],
sandbox: {
seccomp: ALLOW_ALL_FILTER,
seatbelt: ALLOW_ALL_PROFILE,
},
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("cross-platform\n");
expect(await proc.exited).toBe(0);
});
test.if(isMac)("invalid SBPL profile causes spawn to fail", () => {
// An invalid sandbox profile causes the child to fail immediately with EINVAL
expect(() => {
Bun.spawn({
cmd: ["/usr/bin/true"],
sandbox: { seatbelt: "(invalid sbpl garbage)" },
});
}).toThrow("EINVAL: invalid argument, posix_spawn");
});
test.if(isMac)("object format with profile property works", async () => {
// Test the object format { profile: "..." }
await using proc = Bun.spawn({
cmd: ["echo", "object-format"],
sandbox: { seatbelt: { profile: ALLOW_ALL_PROFILE } },
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("object-format\n");
expect(await proc.exited).toBe(0);
});
test.if(isMac)("object format with profile and parameters works", async () => {
// SBPL profile that uses param() function to get a value
// This profile allows everything but demonstrates parameter passing
const profileWithParams = `(version 1)
(allow default)
(deny file-write* (subpath (param "BLOCKED_PATH")))`;
// Test the object format with parameters
// We block writes to /nonexistent which shouldn't affect /usr/bin/true
await using proc = Bun.spawn({
cmd: ["/usr/bin/true"],
sandbox: {
seatbelt: {
profile: profileWithParams,
parameters: { BLOCKED_PATH: "/nonexistent" },
},
},
});
expect(await proc.exited).toBe(0);
});
test.if(isMac)("spawnSync with profile and parameters works", () => {
const profileWithParams = `(version 1)
(allow default)
(deny file-write* (subpath (param "BLOCKED_PATH")))`;
const result = Bun.spawnSync({
cmd: ["/usr/bin/true"],
sandbox: {
seatbelt: {
profile: profileWithParams,
parameters: { BLOCKED_PATH: "/nonexistent" },
},
},
});
expect(result.exitCode).toBe(0);
});
test.if(isMac)("specifying both profile and namedProfile throws error", () => {
expect(() => {
Bun.spawn({
cmd: ["/usr/bin/true"],
sandbox: {
seatbelt: {
profile: ALLOW_ALL_PROFILE,
namedProfile: "pfd",
} as unknown as string,
},
});
}).toThrow("sandbox.seatbelt cannot have both 'profile' and 'namedProfile'");
});
test.if(isMac)("empty parameters object is allowed", async () => {
await using proc = Bun.spawn({
cmd: ["echo", "empty-params"],
sandbox: {
seatbelt: {
profile: ALLOW_ALL_PROFILE,
parameters: {},
},
},
});
const stdout = await proc.stdout.text();
expect(stdout).toBe("empty-params\n");
expect(await proc.exited).toBe(0);
});
test.if(isMac)("seatbelt object without profile or namedProfile throws error", () => {
expect(() => {
Bun.spawn({
cmd: ["/usr/bin/true"],
sandbox: {
seatbelt: { parameters: { KEY: "VALUE" } } as unknown as string,
},
});
}).toThrow("Expected sandbox.seatbelt to be a object with 'profile' or 'namedProfile'");
});
test.if(isMac)("namedProfile with non-existent profile throws EINVAL", () => {
// A non-existent named profile should fail with EINVAL
expect(() => {
Bun.spawn({
cmd: ["/usr/bin/true"],
sandbox: {
seatbelt: { namedProfile: "this-profile-does-not-exist-12345" },
},
});
}).toThrow("EINVAL: invalid argument, posix_spawn");
});
test.if(isMac)("namedProfile restricts process execution", () => {
// First verify ls works without sandbox
const withoutSandbox = Bun.spawnSync({
cmd: ["/bin/ls", "/"],
});
expect(withoutSandbox.stdout.toString()).toContain("usr");
expect(withoutSandbox.exitCode).toBe(0);
// The "pfd" (packet filter daemon) profile only allows executing /usr/libexec/pfd
// So /bin/ls should fail with EPERM (Operation not permitted)
// The sandbox blocks execve, which causes spawn to throw
expect(() => {
Bun.spawnSync({
cmd: ["/bin/ls", "/"],
sandbox: {
seatbelt: { namedProfile: "pfd" },
},
});
}).toThrow("EPERM: operation not permitted, posix_spawn");
});
test.if(isMac)("namedProfile restricts file writes during execution", () => {
using dir = tempDir("named-profile-test", {});
const testFile = `${dir}/test.txt`;
// First verify file write works without sandbox
const withoutSandbox = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile}" && rm "${testFile}"`],
});
expect(withoutSandbox.exitCode).toBe(0);
// The "quicklookd" profile allows exec and file reads, but blocks file writes
// The process spawns successfully, but the write operation fails
const withSandbox = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile}"`],
sandbox: {
seatbelt: { namedProfile: "quicklookd" },
},
stderr: "pipe",
});
// The shell runs but write fails with "Operation not permitted", exit code 1
expect(withSandbox.stderr.toString()).toContain("Operation not permitted");
expect(withSandbox.exitCode).toBe(1);
});
test.if(isMac)("SBPL parameters successfully block specified path", () => {
// Create a temporary directory
using dir = tempDir("sbpl-param-test", {});
const testFile = `${dir}/test.txt`;
// SBPL profile that blocks writes to the path specified by parameter
const profileWithParams = `(version 1)
(allow default)
(deny file-write* (subpath (param "BLOCKED_PATH")))`;
// First verify write works without sandbox
const withoutSandbox = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile}" && rm "${testFile}"`],
});
expect(withoutSandbox.exitCode).toBe(0);
// With sandbox blocking the temp directory - should fail
const withSandboxBlocking = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile}"`],
sandbox: {
seatbelt: {
profile: profileWithParams,
parameters: { BLOCKED_PATH: String(dir) },
},
},
stderr: "pipe",
});
expect(withSandboxBlocking.stderr.toString()).toContain("Operation not permitted");
expect(withSandboxBlocking.exitCode).toBe(1);
// With sandbox blocking a different path - should succeed
const withSandboxAllowing = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile}" && rm "${testFile}"`],
sandbox: {
seatbelt: {
profile: profileWithParams,
parameters: { BLOCKED_PATH: "/nonexistent" },
},
},
stderr: "pipe",
});
expect(withSandboxAllowing.exitCode).toBe(0);
});
test.if(isMac)("SBPL profile with multiple parameters", () => {
using dir1 = tempDir("sbpl-multi-param-1", {});
using dir2 = tempDir("sbpl-multi-param-2", {});
const testFile1 = `${dir1}/test.txt`;
const testFile2 = `${dir2}/test.txt`;
// SBPL profile that blocks writes to two different paths via parameters
const profileWithMultipleParams = `(version 1)
(allow default)
(deny file-write* (subpath (param "BLOCKED_PATH_1")))
(deny file-write* (subpath (param "BLOCKED_PATH_2")))`;
// Both paths blocked - writes to dir1 should fail
const result1 = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile1}"`],
sandbox: {
seatbelt: {
profile: profileWithMultipleParams,
parameters: {
BLOCKED_PATH_1: String(dir1),
BLOCKED_PATH_2: String(dir2),
},
},
},
stderr: "pipe",
});
expect(result1.stderr.toString()).toContain("Operation not permitted");
expect(result1.exitCode).toBe(1);
// Both paths blocked - writes to dir2 should also fail
const result2 = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile2}"`],
sandbox: {
seatbelt: {
profile: profileWithMultipleParams,
parameters: {
BLOCKED_PATH_1: String(dir1),
BLOCKED_PATH_2: String(dir2),
},
},
},
stderr: "pipe",
});
expect(result2.stderr.toString()).toContain("Operation not permitted");
expect(result2.exitCode).toBe(1);
// Only dir1 blocked - writes to dir2 should succeed
const result3 = Bun.spawnSync({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile2}" && cat "${testFile2}" && rm "${testFile2}"`],
sandbox: {
seatbelt: {
profile: profileWithMultipleParams,
parameters: {
BLOCKED_PATH_1: String(dir1),
BLOCKED_PATH_2: "/nonexistent",
},
},
},
});
expect(result3.stdout.toString()).toBe("hello\n");
expect(result3.exitCode).toBe(0);
});
test.if(isMac)("async spawn with namedProfile blocking file writes", async () => {
using dir = tempDir("async-named-profile-test", {});
const testFile = `${dir}/test.txt`;
// First verify write works without sandbox
await using procWithout = Bun.spawn({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile}" && rm "${testFile}"`],
});
expect(await procWithout.exited).toBe(0);
// The "quicklookd" profile blocks file writes
await using procWith = Bun.spawn({
cmd: ["/bin/sh", "-c", `echo hello > "${testFile}"`],
sandbox: {
seatbelt: { namedProfile: "quicklookd" },
},
stderr: "pipe",
});
const stderr = await procWith.stderr.text();
const exitCode = await procWith.exited;
expect(stderr).toContain("Operation not permitted");
expect(exitCode).toBe(1);
});
test.if(isMac)("async spawn with SBPL profile blocking writes", async () => {
using dir = tempDir("async-sbpl-test", {});
const testFile = `${dir}/test.txt`;
const profileWithParams = `(version 1)
(allow default)
(deny file-write* (subpath (param "BLOCKED_PATH")))`;
// Blocked write
await using procBlocked = Bun.spawn({
cmd: ["/bin/sh", "-c", `echo blocked > "${testFile}"`],
sandbox: {
seatbelt: {
profile: profileWithParams,
parameters: { BLOCKED_PATH: String(dir) },
},
},
stderr: "pipe",
});
const stderrBlocked = await procBlocked.stderr.text();
const exitCodeBlocked = await procBlocked.exited;
expect(stderrBlocked).toContain("Operation not permitted");
expect(exitCodeBlocked).toBe(1);
// Allowed write (different path blocked)
await using procAllowed = Bun.spawn({
cmd: ["/bin/sh", "-c", `echo allowed > "${testFile}" && cat "${testFile}" && rm "${testFile}"`],
sandbox: {
seatbelt: {
profile: profileWithParams,
parameters: { BLOCKED_PATH: "/nonexistent" },
},
},
stderr: "pipe",
});
const stdoutAllowed = await procAllowed.stdout.text();
const exitCodeAllowed = await procAllowed.exited;
expect(stdoutAllowed).toBe("allowed\n");
expect(exitCodeAllowed).toBe(0);
});
test.if(isMac)("async spawn with deny-file-write profile blocks writes", async () => {
using dir = tempDir("async-deny-write-test", {});
const testFile = `${dir}/test.txt`;
await using proc = Bun.spawn({
cmd: ["/bin/sh", "-c", `echo test > "${testFile}"`],
sandbox: { seatbelt: DENY_FILE_WRITE_PROFILE },
stderr: "pipe",
});
const stderr = await proc.stderr.text();
const exitCode = await proc.exited;
expect(stderr).toContain("Operation not permitted");
expect(exitCode).toBe(1);
});
});