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 @@ -463,6 +463,10 @@ Examples:
- `javaAgentJarPath` - macOS/Linux: absolute path to `srt-proxy-agent.jar`, the JVM agent injected via `JAVA_TOOL_OPTIONS` (see "JVM tools" under Network Isolation). Only needed by consumers that bundle sandbox-runtime and ship the jar separately; a normal npm install finds it under `vendor/java-proxy-agent/`.
- `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 nothing else, 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 that rule 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 @@ -837,9 +841,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 @@ -494,6 +494,8 @@ async function main(): Promise<void> {
// we keep the existing shell-string path.
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)
// No slot to displace: libuv passes only the stdio array's
Expand All @@ -506,8 +508,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: sandboxedStdio(controlFd),
Expand Down
105 changes: 105 additions & 0 deletions 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 { buildJavaToolOptions } from './java-proxy-agent.js'
Expand Down Expand Up @@ -70,7 +72,20 @@ export interface MacOSSandboxParams {
*/
degradeToDenyPaths?: readonly string[]
ignoreViolations?: IgnoreViolationsConfig | undefined
/**
* Pseudo-terminal access. Unset and `false` grant `file-ioctl` on the
* terminals in `inheritedTtys`, which is what a TUI needs to enter raw mode.
* `true` grants every pty, for programs that allocate their own (tmux,
* script, node-pty).
*/
allowPty?: boolean
/**
* Terminals to grant `file-ioctl` on when `allowPty` is not `true`. The
* caller supplies them because it picks the child's stdio after this
* function returns; see {@link resolveInheritedStdioTtys}. Entries that are
* not pty slave devices are ignored.
*/
inheritedTtys?: string[]
allowGitConfig?: boolean
/**
* Directories to emit as `safe.directory` via `GIT_CONFIG_*` env
Expand Down Expand Up @@ -928,6 +943,80 @@ function generateWriteRules(
return rules
}

/**
* The probes {@link resolveInheritedStdioTtys} needs, injectable so a unit
* test can drive every path without a real pty on fd 0/1/2.
*/
export interface TtyProbes {
isatty: (fd: number) => boolean
/** Names under `/dev` that begin with `ttys`. */
listPtySlaves: () => string[]
rdevOfFd: (fd: number) => number
rdevOfPath: (devicePath: 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: devicePath => fs.statSync(devicePath).rdev,
}

/**
* Resolve every distinct pty this process holds on stdin, stdout or stderr,
* in that order. All three, because stdio can span two terminals and a
* program that reads keys from one while sizing the other needs both.
*
* Seatbelt matches ioctl rules on the device path, and the `/dev/tty` alias
* does not cover the pty slave (`/dev/ttysNNN`). Node has no `ttyname(3)` and
* `/dev/fd/N` does not resolve to the device on macOS, so each descriptor's
* device number is matched against the `/dev/ttys*` entries.
*/
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) {
// Log it: a silent failure leaves no rule and a TUI stuck out of 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 @@ -943,6 +1032,7 @@ function generateSandboxProfile({
allowLocalBinding,
allowMachLookup,
allowPty,
inheritedTtys,
allowGitConfig = false,
enableWeakerNetworkIsolation = false,
allowAppleEvents = false,
Expand All @@ -960,6 +1050,7 @@ function generateSandboxProfile({
allowLocalBinding?: boolean
allowMachLookup?: string[]
allowPty?: boolean
inheritedTtys?: string[]
allowGitConfig?: boolean
enableWeakerNetworkIsolation?: boolean
allowAppleEvents?: boolean
Expand Down Expand Up @@ -1266,6 +1357,10 @@ function generateSandboxProfile({
}

// Pseudo-terminal (pty) support
// Only pty slave paths may reach the (literal ...) rule below
const ttyGrants = (inheritedTtys ?? []).filter(device =>
/^\/dev\/ttys[0-9]+$/.test(device),
)
if (allowPty) {
profile.push('')
profile.push('; Pseudo-terminal (pty) support')
Expand All @@ -1278,6 +1373,14 @@ function generateSandboxProfile({
profile.push(' (literal "/dev/ptmx")')
profile.push(' (regex #"^/dev/ttys")')
profile.push(')')
} else if (ttyGrants.length > 0) {
// Without an ioctl rule on the inherited pty, TIOCSETA returns EPERM and no
// TUI can enter raw mode (#419, #391). ioctl is all raw mode needs.
profile.push('')
profile.push('; Pseudo-terminal (pty) support: inherited terminals only')
for (const device of ttyGrants) {
profile.push(`(allow file-ioctl (literal ${escapePath(device)}))`)
}
}

return profile.join('\n')
Expand Down Expand Up @@ -1316,6 +1419,7 @@ export function wrapCommandWithSandboxMacOS(
maskedFileBinds,
degradeToDenyPaths,
allowPty,
inheritedTtys,
allowGitConfig = false,
gitSafeDirectories,
enableWeakerNetworkIsolation = false,
Expand Down Expand Up @@ -1384,6 +1488,7 @@ export function wrapCommandWithSandboxMacOS(
allowLocalBinding,
allowMachLookup,
allowPty,
inheritedTtys,
allowGitConfig,
enableWeakerNetworkIsolation,
allowAppleEvents,
Expand Down
8 changes: 7 additions & 1 deletion src/sandbox/sandbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1159,7 +1159,13 @@ 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 inherited terminals so a TUI can enter raw mode, ' +
'when the caller spawns with inherited stdio (the CLI does). true ' +
'grants every pty, for programs that allocate their own (tmux, ' +
'script, node-pty).',
),
seccomp: SeccompConfigSchema.optional().describe(
'Custom seccomp binary paths (Linux only).',
),
Expand Down
13 changes: 13 additions & 0 deletions src/sandbox/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { expandReadDenyGlobLinux } from './read-deny-glob.js'
import {
wrapCommandWithSandboxMacOS,
startMacOSSandboxLogMonitor,
resolveInheritedStdioTtys,
} from './macos-sandbox-utils.js'
import {
startLinuxSandboxViolationMonitor,
Expand Down Expand Up @@ -1634,6 +1635,13 @@ export type WrapWithSandboxOptions = {
* reported as the violation's `command`. Defaults to `command`.
*/
commandText?: string
/**
* Set only when you spawn the wrapped command with `stdio: 'inherit'`. On
* macOS the profile then grants `file-ioctl` on this process's terminals,
* which a TUI needs to enter raw mode. The caller has to say so because it
* picks the child's stdio after wrapping. Ignored when `allowPty` is `true`.
*/
inheritsStdio?: boolean
}

async function wrapWithSandbox(
Expand Down Expand Up @@ -1807,6 +1815,11 @@ async function wrapWithSandbox(
allowMachLookup: getAllowMachLookup(),
ignoreViolations: getIgnoreViolations(),
allowPty,
// allowPty: true already grants every pty; false behaves like unset
inheritedTtys:
allowPty !== true && options?.inheritsStdio
? resolveInheritedStdioTtys()
: undefined,
allowGitConfig: getAllowGitConfig(),
gitSafeDirectories,
enableWeakerNetworkIsolation: getEnableWeakerNetworkIsolation(),
Expand Down
82 changes: 82 additions & 0 deletions test/helpers/pty-ctty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Run argv on a pty that is genuinely the child's CONTROLLING terminal.

Attaching a pty to stdio is not enough for terminal-permission tests. TIOCSTI
is permitted to an unprivileged caller when the fd is its *controlling*
terminal and returns EACCES when it is not, so a harness that skips setsid()
plus TIOCSCTTY measures the missing controlling terminal rather than the
sandbox policy under test.

Exits with the child's status, or 124 if the deadline killed it, so a crash or
hang is visible at the process level instead of only as an absent marker in
stdout.

Usage: pty-ctty.py <command> [args...]
"""
import fcntl
import os
import pty
import select
import subprocess
import sys
import time

TIOCSCTTY = 0x20007461 # macOS <sys/ttycom.h>
DEADLINE = float(os.environ.get("PTY_DEADLINE", "60"))
EXIT_TIMEOUT = 124 # timeout(1)'s convention


def main() -> int:
master, slave = pty.openpty()

def make_controlling() -> None:
os.setsid()
# The slave fd explicitly, rather than fd 0. Relying on fd 0 assumes
# CPython has already run its dup2 before preexec_fn, which is true
# today but is not a documented guarantee.
fcntl.ioctl(slave, TIOCSCTTY, 0)

proc = subprocess.Popen(
sys.argv[1:],
stdin=slave,
stdout=slave,
stderr=slave,
preexec_fn=make_controlling,
pass_fds=(slave,),
close_fds=True,
)
os.close(slave)

out = b""
timed_out = False
end = time.time() + DEADLINE
while True:
if time.time() >= end:
timed_out = True
break
ready, _, _ = select.select([master], [], [], 0.2)
if ready:
try:
chunk = os.read(master, 65536)
except OSError:
break
if not chunk:
break
out += chunk
elif proc.poll() is not None:
break

if proc.poll() is None:
proc.kill()
status = proc.wait()

sys.stdout.write(out.decode(errors="replace").replace("\r\n", "\n"))
if timed_out:
print(f"[harness] deadline of {DEADLINE}s expired", file=sys.stderr)
return EXIT_TIMEOUT
# A signalled child reports -N from wait(); report it the way a shell does.
return status if status >= 0 else 128 - status


if __name__ == "__main__":
raise SystemExit(main())
Loading