Skip to content

Commit 1af1095

Browse files
Claude Botclaude
andcommitted
fix(process): support running Bun inside macOS App Sandbox
Fix two issues in `bun_initialize_process()` that prevent Bun from running inside a macOS App Sandbox (`com.apple.security.app-sandbox`): 1. Move `bun_stdio_tty[fd] = 1` inside the `tcgetattr` success check. In the macOS App Sandbox, `tcgetattr` fails with EPERM even though `isatty()` returns true. Previously, the TTY flag was set unconditionally, causing `bun_restore_stdio()` to call `tcsetattr` with uninitialized termios state at exit. This is the same class of bug Node.js fixed in nodejs/node#33944. 2. Fix `dup2` return value check: `dup2(oldfd, newfd)` returns `newfd` on success (not 0), so `err != 0` incorrectly triggered `abort()` for stdout/stderr. Changed to `err < 0` and replaced `abort()` with a graceful fallback. Also handle `open("/dev/null")` failure gracefully for restricted environments. Closes #15661 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 337a9f7 commit 1af1095

2 files changed

Lines changed: 205 additions & 4 deletions

File tree

src/bun.js/bindings/c-bindings.cpp

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -473,19 +473,25 @@ extern "C" void bun_initialize_process()
473473
} while (devNullFd_ < 0 and errno == EINTR);
474474
};
475475

476+
if (devNullFd_ < 0) {
477+
// open("/dev/null") failed (e.g., in macOS App Sandbox).
478+
// Continue without redirecting; this is best-effort.
479+
return;
480+
}
481+
476482
if (devNullFd_ == target_fd) {
477483
devNullFd_ = -1;
478484
return;
479485
}
480486

481-
ASSERT(devNullFd_ != -1);
482487
int err;
483488
do {
484489
err = dup2(devNullFd_, target_fd);
485490
} while (err < 0 && errno == EINTR);
486491

487-
if (err != 0) [[unlikely]] {
488-
abort();
492+
// dup2 returns the new fd on success (not 0), or -1 on error.
493+
if (err < 0) [[unlikely]] {
494+
bun_is_stdio_null[target_fd] = 0;
489495
}
490496
};
491497

@@ -497,14 +503,18 @@ extern "C" void bun_initialize_process()
497503
setDevNullFd(fd);
498504
}
499505
} else {
500-
bun_stdio_tty[fd] = 1;
501506
int err = 0;
502507

503508
do {
504509
err = tcgetattr(fd, &termios_to_restore_later[fd]);
505510
} while (err == -1 && errno == EINTR);
506511

507512
if (err == 0) [[likely]] {
513+
// Only mark as TTY if we successfully captured termios state.
514+
// In macOS App Sandbox, tcgetattr fails with EPERM even though
515+
// isatty() returns true. We must not try to restore state we
516+
// never captured.
517+
bun_stdio_tty[fd] = 1;
508518
anyTTYs = true;
509519
}
510520
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { copyFileSync, mkdirSync, writeFileSync } from "fs";
3+
import { bunEnv, bunExe, tempDir } from "harness";
4+
import { join } from "path";
5+
6+
const isMacOS = process.platform === "darwin";
7+
8+
// Modeled after Node.js's test/parallel/test-macos-app-sandbox.js
9+
describe.skipIf(!isMacOS)("macOS App Sandbox", () => {
10+
test("bun can execute JavaScript inside the app sandbox", () => {
11+
using dir = tempDir("macos-sandbox-test");
12+
13+
const appBundlePath = join(String(dir), "bun_sandboxed.app");
14+
const contentsPath = join(appBundlePath, "Contents");
15+
const macOSPath = join(contentsPath, "MacOS");
16+
const bunPath = join(macOSPath, "bun");
17+
18+
// Create app bundle structure:
19+
// bun_sandboxed.app/
20+
// └── Contents
21+
// ├── Info.plist
22+
// └── MacOS
23+
// └── bun
24+
mkdirSync(appBundlePath);
25+
mkdirSync(contentsPath);
26+
mkdirSync(macOSPath);
27+
28+
writeFileSync(
29+
join(contentsPath, "Info.plist"),
30+
`<?xml version="1.0" encoding="UTF-8"?>
31+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
32+
<plist version="1.0">
33+
<dict>
34+
<key>CFBundleExecutable</key>
35+
<string>bun</string>
36+
<key>CFBundleIdentifier</key>
37+
<string>dev.bun.test.bun_sandboxed</string>
38+
<key>CFBundleInfoDictionaryVersion</key>
39+
<string>6.0</string>
40+
<key>CFBundleName</key>
41+
<string>bun_sandboxed</string>
42+
<key>CFBundlePackageType</key>
43+
<string>APPL</string>
44+
<key>CFBundleShortVersionString</key>
45+
<string>1.0</string>
46+
<key>CFBundleSupportedPlatforms</key>
47+
<array>
48+
<string>MacOSX</string>
49+
</array>
50+
<key>CFBundleVersion</key>
51+
<string>1</string>
52+
</dict>
53+
</plist>`,
54+
);
55+
56+
const entitlementsPath = join(String(dir), "entitlements.plist");
57+
writeFileSync(
58+
entitlementsPath,
59+
`<?xml version="1.0" encoding="UTF-8"?>
60+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
61+
<plist version="1.0">
62+
<dict>
63+
<key>com.apple.security.app-sandbox</key>
64+
<true/>
65+
<key>com.apple.security.cs.allow-jit</key>
66+
<true/>
67+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
68+
<true/>
69+
<key>com.apple.security.cs.disable-executable-page-protection</key>
70+
<true/>
71+
<key>com.apple.security.cs.disable-library-validation</key>
72+
<true/>
73+
</dict>
74+
</plist>`,
75+
);
76+
77+
// Copy the bun binary into the app bundle
78+
copyFileSync(bunExe(), bunPath);
79+
80+
// Sign the app bundle with sandbox entitlements
81+
const codesignResult = Bun.spawnSync({
82+
cmd: ["/usr/bin/codesign", "--entitlements", entitlementsPath, "--force", "-s", "-", appBundlePath],
83+
env: bunEnv,
84+
stderr: "pipe",
85+
});
86+
expect(codesignResult.exitCode).toBe(0);
87+
88+
// Run bun inside the sandbox
89+
const result = Bun.spawnSync({
90+
cmd: [bunPath, "-e", "console.log('hello sandbox')"],
91+
env: bunEnv,
92+
stderr: "pipe",
93+
});
94+
95+
const stdout = result.stdout.toString().trim();
96+
const stderr = result.stderr.toString();
97+
98+
// Assert stdout before exit code for better error messages
99+
expect(stdout).toBe("hello sandbox");
100+
expect(result.exitCode).toBe(0);
101+
});
102+
103+
test("sandboxed bun cannot read the home directory", () => {
104+
using dir = tempDir("macos-sandbox-test-homedir");
105+
106+
const appBundlePath = join(String(dir), "bun_sandboxed.app");
107+
const contentsPath = join(appBundlePath, "Contents");
108+
const macOSPath = join(contentsPath, "MacOS");
109+
const bunPath = join(macOSPath, "bun");
110+
111+
mkdirSync(appBundlePath);
112+
mkdirSync(contentsPath);
113+
mkdirSync(macOSPath);
114+
115+
writeFileSync(
116+
join(contentsPath, "Info.plist"),
117+
`<?xml version="1.0" encoding="UTF-8"?>
118+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
119+
<plist version="1.0">
120+
<dict>
121+
<key>CFBundleExecutable</key>
122+
<string>bun</string>
123+
<key>CFBundleIdentifier</key>
124+
<string>dev.bun.test.bun_sandboxed</string>
125+
<key>CFBundleInfoDictionaryVersion</key>
126+
<string>6.0</string>
127+
<key>CFBundleName</key>
128+
<string>bun_sandboxed</string>
129+
<key>CFBundlePackageType</key>
130+
<string>APPL</string>
131+
<key>CFBundleShortVersionString</key>
132+
<string>1.0</string>
133+
<key>CFBundleSupportedPlatforms</key>
134+
<array>
135+
<string>MacOSX</string>
136+
</array>
137+
<key>CFBundleVersion</key>
138+
<string>1</string>
139+
</dict>
140+
</plist>`,
141+
);
142+
143+
const entitlementsPath = join(String(dir), "entitlements.plist");
144+
writeFileSync(
145+
entitlementsPath,
146+
`<?xml version="1.0" encoding="UTF-8"?>
147+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
148+
<plist version="1.0">
149+
<dict>
150+
<key>com.apple.security.app-sandbox</key>
151+
<true/>
152+
<key>com.apple.security.cs.allow-jit</key>
153+
<true/>
154+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
155+
<true/>
156+
<key>com.apple.security.cs.disable-executable-page-protection</key>
157+
<true/>
158+
<key>com.apple.security.cs.disable-library-validation</key>
159+
<true/>
160+
</dict>
161+
</plist>`,
162+
);
163+
164+
copyFileSync(bunExe(), bunPath);
165+
166+
const codesignResult = Bun.spawnSync({
167+
cmd: ["/usr/bin/codesign", "--entitlements", entitlementsPath, "--force", "-s", "-", appBundlePath],
168+
env: bunEnv,
169+
stderr: "pipe",
170+
});
171+
expect(codesignResult.exitCode).toBe(0);
172+
173+
// Sandboxed app should not be able to read the home directory.
174+
// Print a marker first to confirm the process started successfully
175+
// before the sandboxed filesystem access fails.
176+
const homedir = Bun.env.HOME ?? "/Users";
177+
const result = Bun.spawnSync({
178+
cmd: [
179+
bunPath,
180+
"-e",
181+
`process.stdout.write("SANDBOX_START\\n"); require('fs').readdirSync(${JSON.stringify(homedir)})`,
182+
],
183+
env: bunEnv,
184+
stderr: "pipe",
185+
});
186+
187+
const stdout = result.stdout.toString();
188+
expect(stdout).toContain("SANDBOX_START");
189+
expect(result.exitCode).not.toBe(0);
190+
});
191+
});

0 commit comments

Comments
 (0)