Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,10 @@ Examples:
- `enableWeakerNestedSandbox` - Enable weaker sandbox mode for Docker environments (boolean, default: false)
- `enableWeakerNetworkIsolation` - Allow access to `com.apple.trustd.agent` in the macOS sandbox (boolean, default: false). This is needed for Go programs (`gh`, `gcloud`, `terraform`, `kubectl`, etc.) to verify TLS certificates when using `httpProxyPort` with a MITM proxy and custom CA. **Security warning:** enabling this opens a potential data exfiltration vector through the trustd service.
- `allowAppleEvents` - Allow sending Apple Events and Launch Services open requests from the macOS sandbox (boolean, default: false). Without this, commands like `open`, `osascript`, and anything that opens URLs or scripts other apps via AppleScript fail with AppleScript error `-600` ("Application isn't running") or LaunchServices errors (`-10822`, `-54`). **Security warning:** enabling this means the sandbox no longer provides code-execution isolation. A sandboxed command can launch other applications via `open` with no user prompt, and anything it launches runs outside the sandbox's filesystem and network restrictions; scripting already-running apps via Apple Events is additionally gated by the user's per-app TCC automation consent. Embedders should only source this option from trusted user-level configuration — never from project-local files in a checked-out repository, which would let an attacker-authored project elevate its own sandbox permissions.
- `allowPty` - Pseudo-terminal access in the macOS sandbox (boolean, macOS only). **Leave it unset** for the default: the sandboxed process is granted `file-ioctl` — and only `file-ioctl` — on the terminals it inherited on stdin/stdout/stderr, which is what an interactive TUI needs to enter raw mode (`tcsetattr` / `process.stdin.setRawMode()`). Without it the terminal never leaves canonical mode and capability replies and mouse events echo as literal text. Each inherited terminal gets its own rule.
- `true` widens the grant to `(allow pseudo-tty)` plus read/write/ioctl on **every** pty. Programs that allocate their own pty need this: `tmux`, `script`, `expect`, anything built on `node-pty`.
- `false` behaves the same as unset — inherited-terminal `file-ioctl` only. To give the process no terminal at all, spawn it with piped stdio; the default then emits nothing.
- **Library callers must opt in.** The `srt` CLI gets this automatically (it spawns with `stdio: 'inherit'`). `wrapWithSandbox()` and `wrapWithSandboxArgv()` return a command you spawn yourself, so they emit no terminal rule unless you pass `{ inheritsStdio: true }`.

### Common Configuration Recipes

Expand Down Expand Up @@ -733,9 +737,11 @@ When a sandboxed process attempts to access a restricted resource:

```bash
# View sandbox violations in real-time
log stream --predicate 'process == "sandbox-exec"' --style syslog
log stream --predicate 'eventMessage CONTAINS "deny("' --style syslog
```

Match on the message, not on `process == "sandbox-exec"`: `sandbox-exec` execs into the target, so the kernel attributes each violation to the child that hit it (`node`, `bash`, the sandboxed binary), and a predicate on the `sandbox-exec` process name returns nothing.

**Linux**: Bubblewrap doesn't provide built-in violation reporting. Use `strace` to trace system calls and identify blocked operations:

```bash
Expand Down
11 changes: 9 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,8 @@ async function main(): Promise<void> {
let child
if (process.platform === 'win32') {
// env carries the proxy vars the sandboxed child must inherit.
// No inheritsStdio here: it only drives the macOS terminal grant,
// and this branch is Windows-only.
const { argv, env } =
await SandboxManager.wrapWithSandboxArgv(command)
child = spawn(argv[0], argv.slice(1), {
Expand All @@ -301,8 +303,13 @@ async function main(): Promise<void> {
env,
})
} else {
const sandboxedCommand =
await SandboxManager.wrapWithSandbox(command)
const sandboxedCommand = await SandboxManager.wrapWithSandbox(
command,
undefined,
undefined,
undefined,
{ inheritsStdio: true },
)
child = spawn(sandboxedCommand, {
shell: true,
stdio: 'inherit',
Expand Down
161 changes: 160 additions & 1 deletion src/sandbox/macos-sandbox-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { quote } from '../utils/shell-quote.js'
import { spawn } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
import * as tty from 'tty'
import { logForDebugging } from '../utils/debug.js'
import { whichSync } from '../utils/which.js'
import {
Expand Down Expand Up @@ -57,7 +59,32 @@ export interface MacOSSandboxParams {
*/
maskedFileBinds?: Array<{ realPath: string; fakePath: string }>
ignoreViolations?: IgnoreViolationsConfig | undefined
/**
* Pseudo-terminal access. Leave unset for the default: the terminals the
* child inherits are made ioctl-able and nothing else is, so an
* interactive TUI can enter raw mode. `true` widens that to every pty,
* which programs that allocate their own (tmux, script, node-pty) need.
* `false` behaves identically to unset — both grant only inherited-terminal
* ioctl; only `true` is special. (There is intentionally no "no pty rules"
* state: an inherited terminal a process never touches is harmless to make
* ioctl-able, and a distinct `false` was a footgun that reproduced the
* raw-mode bug the default fixes.)
*/
allowPty?: boolean
/**
* Terminals to grant `file-ioctl` on in the default (`allowPty` not `true`)
* mode. Always caller-supplied and never defaulted: this function builds a
* command string, and whoever spawns it decides the child's stdio
* afterwards, so a terminal detected here would be a guess. Callers that
* know the child inherits their stdio resolve them with
* {@link resolveInheritedStdioTtys} — `SandboxManager` does this for
* `inheritsStdio`, and tests inject paths directly.
*
* Entries that are not pty slave devices are ignored: the value reaches a
* `(literal ...)` rule, and the parameter is exported, so the shape is
* enforced here rather than trusted.
*/
inheritedTtys?: string[]
allowGitConfig?: boolean
/**
* Directories to emit as `safe.directory` via `GIT_CONFIG_*` env
Expand Down Expand Up @@ -632,6 +659,95 @@ function generateWriteRules(
return rules
}

/**
* Resolve every distinct pty this process holds on stdin, stdout or stderr,
* in that order. Empty when none of them is one.
*
* All three, not just the first: a process can be launched with its stdio
* split across different terminals, and a program that reads keys from one
* while sizing the other (TIOCGWINSZ) needs both. Emitting a rule per device
* costs one profile line each.
*
* Deliberately *not* named for the controlling terminal: a process can own a
* controlling terminal while its stdio is redirected, and can hold tty stdio
* that is not its controlling terminal. What matters here is the device a
* child would inherit, which is exactly this.
*
* Seatbelt matches ioctl rules on the device's path, and a terminal is a pty
* slave (`/dev/ttysNNN`) — the `/dev/tty` alias in the base profile does not
* cover it. Node exposes no `ttyname(3)`, and `/dev/fd/N` does not resolve to
* the device on macOS, so the device number of the inherited descriptor is
* matched against the `/dev/ttys*` entries.
*/
/**
* The filesystem/tty probes {@link resolveInheritedStdioTtys} needs, behind an
* interface so the rdev-matching logic can be unit-tested in-process with
* synthetic inputs. Exercising it for real requires a genuine pty on fd 0/1/2,
* which a test runner does not have; injecting these lets a unit test drive the
* matched, no-match, dedup and every error path without one, while production
* uses {@link REAL_TTY_PROBES}. The end-to-end tests still verify the real fs.
*/
export interface TtyProbes {
isatty: (fd: number) => boolean
/** Names under `/dev` that begin with `ttys`. */
listPtySlaves: () => string[]
rdevOfFd: (fd: number) => number
rdevOfPath: (path: string) => number
}

const REAL_TTY_PROBES: TtyProbes = {
isatty: fd => tty.isatty(fd),
listPtySlaves: () =>
fs.readdirSync('/dev').filter(name => name.startsWith('ttys')),
rdevOfFd: fd => fs.fstatSync(fd).rdev,
rdevOfPath: path => fs.statSync(path).rdev,
}

export function resolveInheritedStdioTtys(
probes: TtyProbes = REAL_TTY_PROBES,
): string[] {
const ttyFds = [0, 1, 2].filter(fd => probes.isatty(fd))
if (ttyFds.length === 0) return []

let slaves: string[]
try {
slaves = probes.listPtySlaves()
} catch (err) {
// Silence here would reproduce the very bug this exists to fix: no rule,
// no error, and a TUI that cannot enter raw mode.
logForDebugging(`[Sandbox macOS] cannot scan /dev for pty slaves: ${err}`)
return []
}

const found: string[] = []
for (const fd of ttyFds) {
let rdev: number
try {
rdev = probes.rdevOfFd(fd)
} catch (err) {
logForDebugging(`[Sandbox macOS] cannot fstat tty fd ${fd}: ${err}`)
continue
}
const match = slaves.find(name => {
try {
return probes.rdevOfPath(`/dev/${name}`) === rdev
} catch {
return false // racing device teardown; keep scanning
}
})
if (match === undefined) {
logForDebugging(
`[Sandbox macOS] fd ${fd} is a tty but no /dev/ttys* matches its ` +
`device number ${rdev}; it will get no ioctl rule`,
)
continue
}
const devicePath = `/dev/${match}`
if (!found.includes(devicePath)) found.push(devicePath)
}
return found
}

/**
* Generate complete sandbox profile
*/
Expand All @@ -646,6 +762,7 @@ function generateSandboxProfile({
allowLocalBinding,
allowMachLookup,
allowPty,
inheritedTtys,
allowGitConfig = false,
enableWeakerNetworkIsolation = false,
allowAppleEvents = false,
Expand All @@ -661,6 +778,7 @@ function generateSandboxProfile({
allowLocalBinding?: boolean
allowMachLookup?: string[]
allowPty?: boolean
inheritedTtys?: string[]
allowGitConfig?: boolean
enableWeakerNetworkIsolation?: boolean
allowAppleEvents?: boolean
Expand Down Expand Up @@ -961,8 +1079,18 @@ function generateSandboxProfile({
}
}

// Pseudo-terminal (pty) support
// Pseudo-terminal (pty) support.
// Filtered, not trusted: these paths reach a `(literal ...)` rule and
// MacOSSandboxParams is exported, so a caller (or a future config-sourced
// value) must not be able to grant ioctl on an arbitrary file.
const ttyGrants = (inheritedTtys ?? []).filter(device =>
/^\/dev\/ttys[0-9]+$/.test(device),
)
if (allowPty) {
// Explicit opt-in: every pty, which programs that allocate their own
// (tmux, script, node-pty) need. This also lets the sandboxed process
// read from and write to other terminals the same user owns, so it
// stays opt-in rather than becoming the default.
profile.push('')
profile.push('; Pseudo-terminal (pty) support')
profile.push('(allow pseudo-tty)')
Expand All @@ -974,6 +1102,31 @@ function generateSandboxProfile({
profile.push(' (literal "/dev/ptmx")')
profile.push(' (regex #"^/dev/ttys")')
profile.push(')')
} else if (ttyGrants.length > 0) {
// Default: the terminals the child inherits, and no others. This is the
// path for both an unset `allowPty` and an explicit `allowPty: false` —
// the two behave identically, so `false` is not a footgun that silently
// reproduces the raw-mode bug below; only `true` widens the grant. Without
// an ioctl rule covering these devices, TIOCSETA/TIOCSETAW return EPERM, no
// TUI can enter raw mode, and the terminal echoes capability replies, key
// encodings and mouse events back as literal text (issues #419, #391).
// Those descriptors are already open and inherited, so this grants no
// terminal the caller did not already have.
profile.push('')
profile.push('; Pseudo-terminal (pty) support: inherited terminals only')
// ioctl alone, deliberately: it is all raw mode needs (TIOCSETA/
// TIOCSETAW), and it is the only grant that stays harmless when the
// caller does not hand these terminals to the child. wrapWithSandbox()
// returns a string and library callers pick stdio afterwards, so a
// child spawned with piped stdio would otherwise be able to open the
// *parent's* terminals by path and read or write them. Measured: with a
// write config present — which every profile-generating path has, since
// an unrestricted one short-circuits before reaching here — reopening the
// device by path is refused while raw mode still works. Programs that
// legitimately reopen a terminal by name need `allowPty: true`.
for (const device of ttyGrants) {
profile.push(`(allow file-ioctl (literal ${escapePath(device)}))`)
}
}

return profile.join('\n')
Expand Down Expand Up @@ -1010,6 +1163,7 @@ export function wrapCommandWithSandboxMacOS(
setEnvVars,
maskedFileBinds,
allowPty,
inheritedTtys,
allowGitConfig = false,
gitSafeDirectories,
enableWeakerNetworkIsolation = false,
Expand Down Expand Up @@ -1075,6 +1229,11 @@ export function wrapCommandWithSandboxMacOS(
allowLocalBinding,
allowMachLookup,
allowPty,
// Never resolved here. This function returns a command string and the
// caller chooses the child's stdio afterwards, so a terminal detected
// now is a guess about a decision that has not been made yet. Only a
// caller that knows the child inherits its stdio may pass them.
inheritedTtys,
allowGitConfig,
enableWeakerNetworkIsolation,
allowAppleEvents,
Expand Down
9 changes: 8 additions & 1 deletion src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,7 +1107,14 @@ export const SandboxRuntimeConfigSchema = z
allowPty: z
.boolean()
.optional()
.describe('Allow pseudo-terminal (pty) operations (macOS only)'),
.describe(
'Pseudo-terminal (pty) access, macOS only. Unset or false grants ' +
'file-ioctl on the terminals the child inherits so an interactive ' +
'TUI can enter raw mode — but only when the caller asserts that ' +
'inheritance (the CLI does; library callers pass inheritsStdio to ' +
'wrapWithSandbox). true widens this to every pty, needed by ' +
'programs that allocate their own (tmux, script, node-pty).',
),
seccomp: SeccompConfigSchema.optional().describe(
'Custom seccomp binary paths (Linux only).',
),
Expand Down
27 changes: 27 additions & 0 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import {
wrapCommandWithSandboxMacOS,
startMacOSSandboxLogMonitor,
resolveInheritedStdioTtys,
} from './macos-sandbox-utils.js'
import {
startLinuxSandboxViolationMonitor,
Expand Down Expand Up @@ -1503,6 +1504,22 @@ export type WrapWithSandboxOptions = {
* reported as the violation's `command`. Defaults to `command`.
*/
commandText?: string
/**
* Declares that the wrapped command will be spawned with this process's
* stdio inherited, so the terminals on our fds are the terminals the child
* gets. On macOS this is what lets the profile grant `file-ioctl` on each
* of those devices, which an interactive TUI needs to enter raw mode.
*
* Off by default, and deliberately a caller's assertion rather than
* something detected here: wrapping returns a command string and the
* caller picks stdio afterwards, so detection at wrap time would guess at
* a decision not yet made — wrap under pipes then launch into a fresh
* pty, or wrap under terminal A then launch under terminal B, and the
* guess is wrong. Pass it only when you spawn with `stdio: 'inherit'`.
* Ignored when `allowPty` is `true` (that mode already grants every pty);
* honored for both unset and `false`, which share the inherited-only grant.
*/
inheritsStdio?: boolean
}

async function wrapWithSandbox(
Expand Down Expand Up @@ -1664,6 +1681,16 @@ async function wrapWithSandbox(
allowMachLookup: getAllowMachLookup(),
ignoreViolations: getIgnoreViolations(),
allowPty,
// Only when the caller asserts the child inherits our stdio, and only
// in the default mode. `allowPty: true` already covers every pty, so
// resolving the inherited set adds nothing; every other value (unset
// or an explicit `false`) takes the inherited-only default, which is
// why the guard is `!== true` rather than `=== undefined` — `false`
// must behave identically to unset, not suppress the grant.
inheritedTtys:
allowPty !== true && options?.inheritsStdio
? resolveInheritedStdioTtys()
: undefined,
allowGitConfig: getAllowGitConfig(),
gitSafeDirectories,
enableWeakerNetworkIsolation: getEnableWeakerNetworkIsolation(),
Expand Down
Loading