From 871637b4e426333f86be060a964bc771def8c9a3 Mon Sep 17 00:00:00 2001 From: pcontrerasp Date: Fri, 14 Aug 2026 04:08:01 -0700 Subject: [PATCH] fix(macos): grant the inherited terminals so sandboxed TUIs can enter raw mode Seatbelt matches ioctl rules by device path. The profile allowed file-ioctl on /dev/tty, but a terminal is a pty slave (/dev/ttysNNN) that the alias does not cover, so TIOCSETA returned EPERM and no TUI could enter raw mode. The terminal stayed in canonical+ECHO mode and echoed the application's own protocol traffic as text: capability replies (#419), and KKP key encodings and mouse reports (#391). The profile now grants file-ioctl on each terminal the child inherits on fds 0, 1 and 2. It does not emit pseudo-tty, so allocating new ptys still needs allowPty: true. Behaviour change: allowPty: false now behaves like unset. It used to emit no pty rule at all. Wrapping returns a command string and the caller picks stdio afterwards, so terminals are resolved only when the caller passes inheritsStdio. The CLI does; library callers opt in. Node has no ttyname(3), so devices are found by matching each descriptor's device number against /dev/ttys*. Tests run the CLI on a real controlling terminal: raw mode succeeds with no allowPty key, the KKP Ctrl+C sequence is no longer echoed, and TIOCSTI stays denied in both modes. Also documents allowPty in the README and fixes the violation-monitoring log predicate, which matched the sandbox-exec process name and returned nothing. Fixes #419, #391. Co-Authored-By: Claude Fable 5.1 --- README.md | 8 +- src/cli.ts | 11 +- src/sandbox/macos-sandbox-utils.ts | 105 ++++++ src/sandbox/sandbox-config.ts | 8 +- src/sandbox/sandbox-manager.ts | 13 + test/helpers/pty-ctty.py | 82 +++++ test/helpers/pty-kkp.py | 109 ++++++ test/helpers/pty-split.py | 71 ++++ test/sandbox/macos-pty-default.test.ts | 467 +++++++++++++++++++++++++ 9 files changed, 870 insertions(+), 4 deletions(-) create mode 100644 test/helpers/pty-ctty.py create mode 100644 test/helpers/pty-kkp.py create mode 100644 test/helpers/pty-split.py create mode 100644 test/sandbox/macos-pty-default.test.ts diff --git a/README.md b/README.md index b3df66930..51fea2380 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/cli.ts b/src/cli.ts index e1ab0fcc0..1acfd4dd9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -494,6 +494,8 @@ async function main(): Promise { // 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 @@ -506,8 +508,13 @@ async function main(): Promise { 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), diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 4c6a3a4df..e3a4410a1 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -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' @@ -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 @@ -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 */ @@ -943,6 +1032,7 @@ function generateSandboxProfile({ allowLocalBinding, allowMachLookup, allowPty, + inheritedTtys, allowGitConfig = false, enableWeakerNetworkIsolation = false, allowAppleEvents = false, @@ -960,6 +1050,7 @@ function generateSandboxProfile({ allowLocalBinding?: boolean allowMachLookup?: string[] allowPty?: boolean + inheritedTtys?: string[] allowGitConfig?: boolean enableWeakerNetworkIsolation?: boolean allowAppleEvents?: boolean @@ -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') @@ -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') @@ -1316,6 +1419,7 @@ export function wrapCommandWithSandboxMacOS( maskedFileBinds, degradeToDenyPaths, allowPty, + inheritedTtys, allowGitConfig = false, gitSafeDirectories, enableWeakerNetworkIsolation = false, @@ -1384,6 +1488,7 @@ export function wrapCommandWithSandboxMacOS( allowLocalBinding, allowMachLookup, allowPty, + inheritedTtys, allowGitConfig, enableWeakerNetworkIsolation, allowAppleEvents, diff --git a/src/sandbox/sandbox-config.ts b/src/sandbox/sandbox-config.ts index 5c45eb3c8..3848ba851 100644 --- a/src/sandbox/sandbox-config.ts +++ b/src/sandbox/sandbox-config.ts @@ -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).', ), diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index fedd50196..ea9e836a6 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -52,6 +52,7 @@ import { expandReadDenyGlobLinux } from './read-deny-glob.js' import { wrapCommandWithSandboxMacOS, startMacOSSandboxLogMonitor, + resolveInheritedStdioTtys, } from './macos-sandbox-utils.js' import { startLinuxSandboxViolationMonitor, @@ -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( @@ -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(), diff --git a/test/helpers/pty-ctty.py b/test/helpers/pty-ctty.py new file mode 100644 index 000000000..5dd7c85cd --- /dev/null +++ b/test/helpers/pty-ctty.py @@ -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 [args...] +""" +import fcntl +import os +import pty +import select +import subprocess +import sys +import time + +TIOCSCTTY = 0x20007461 # macOS +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()) diff --git a/test/helpers/pty-kkp.py b/test/helpers/pty-kkp.py new file mode 100644 index 000000000..ca8eedfb1 --- /dev/null +++ b/test/helpers/pty-kkp.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Reproduce issue #391 deterministically, with no keypress. + +#391 reports that pressing Ctrl+C under `srt claude` displays the literal text +`^[[99;5u` (the Kitty Keyboard Protocol encoding) instead of it being handled +as a key. That is the signature of a terminal still in canonical+ECHO mode: the +tty driver echoes whatever arrives on its input back to the display, so the +application's own protocol traffic becomes visible text. + +The symptom is therefore reproducible without a human: give the child a pty, +let it try to enter raw mode, then write the KKP sequence to the master and see +whether the tty echoes it back. + + raw mode entered -> ECHO off -> nothing echoed -> prints KKP-ECHOED=NO + raw mode refused -> ECHO on -> bytes come back -> prints KKP-ECHOED=YES + +The child must print READY_MARKER once it has tried to enter raw mode. That is +both the injection trigger and the liveness proof: without it, a child that +died at startup would echo nothing and look identical to a passing run. + +Usage: pty-kkp.py [args...] +""" +import fcntl +import os +import pty +import select +import subprocess +import sys +import time + +TIOCSCTTY = 0x20007461 # macOS +KKP_CTRL_C = b"\x1b[99;5u" # CSI 99 ; 5 u: 'c' with Ctrl, verbatim from #391 +READY_MARKER = b"KKP-CHILD-READY" +READY_TIMEOUT = 30.0 +COLLECT_SECONDS = 2.0 + + +def main() -> int: + master, slave = pty.openpty() + + def make_controlling() -> None: + os.setsid() + 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) + + # Wait for the child to say it has tried raw mode, rather than sleeping a + # fixed interval: a loaded machine would otherwise get the injection while + # the tty was still in canonical mode and fail spuriously. + preamble = b"" + ready_by = time.time() + READY_TIMEOUT + while READY_MARKER not in preamble and time.time() < ready_by: + r, _, _ = select.select([master], [], [], 0.2) + if not r: + continue + try: + preamble += os.read(master, 4096) + except OSError: + break + + if READY_MARKER not in preamble: + # Never inject blind: no marker means the child never got far enough, + # and "nothing was echoed" would then be a vacuous pass. + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + print("KKP-ECHOED=UNKNOWN") + print(f"child never signalled readiness; captured={preamble!r}") + return 1 + + os.write(master, KKP_CTRL_C) + + echoed = b"" + end = time.time() + COLLECT_SECONDS + while time.time() < end: + ready, _, _ = select.select([master], [], [], 0.2) + if not ready: + continue + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + echoed += chunk + + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + + leaked = KKP_CTRL_C in echoed or b"[99;5u" in echoed + print(f"KKP-ECHOED={'YES' if leaked else 'NO'}") + print(f"captured={echoed!r}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/helpers/pty-split.py b/test/helpers/pty-split.py new file mode 100644 index 000000000..58e0e5aed --- /dev/null +++ b/test/helpers/pty-split.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Run argv with stdin on one pty and stdout/stderr on a DIFFERENT pty. + +Exists to exercise the case a single-pty harness cannot reach: a process whose +inherited descriptors point at two distinct terminals. Granting only the first +one leaves operations on the other returning EPERM, and a harness that puts all +three fds on one device can never catch that. + +Child stdout (second pty) is echoed to our stdout. + +Usage: pty-split.py [args...] +""" +import os +import pty +import select +import subprocess +import sys +import time + +DEADLINE = float(os.environ.get("PTY_DEADLINE", "60")) +EXIT_TIMEOUT = 124 + + +def main() -> int: + in_master, in_slave = pty.openpty() + out_master, out_slave = pty.openpty() + + proc = subprocess.Popen( + sys.argv[1:], + stdin=in_slave, + stdout=out_slave, + stderr=out_slave, + close_fds=True, + ) + os.close(in_slave) + os.close(out_slave) + + out = b"" + timed_out = False + end = time.time() + DEADLINE + while True: + if time.time() >= end: + timed_out = True + break + ready, _, _ = select.select([out_master], [], [], 0.2) + if ready: + try: + chunk = os.read(out_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() + os.close(in_master) + os.close(out_master) + + 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 + return status if status >= 0 else 128 - status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/sandbox/macos-pty-default.test.ts b/test/sandbox/macos-pty-default.test.ts new file mode 100644 index 000000000..82db34e09 --- /dev/null +++ b/test/sandbox/macos-pty-default.test.ts @@ -0,0 +1,467 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + wrapCommandWithSandboxMacOS, + resolveInheritedStdioTtys, + type TtyProbes, +} from '../../src/sandbox/macos-sandbox-utils.js' +import { isMacOS } from '../helpers/platform.js' + +const CTTY = join(import.meta.dir, '../helpers/pty-ctty.py') +const UTILS = join(import.meta.dir, '../../src/sandbox/macos-sandbox-utils.ts') +const CLI = join(import.meta.dir, '../../src/cli.ts') + +/** The e2e cases drive the real CLI through a pty; both are hard deps. */ +const hasDeps = ['python3', 'bun'].every( + bin => spawnSync('command', ['-v', bin], { shell: true }).status === 0, +) + +/** + * Seatbelt matches ioctl rules by device path, and a terminal is a pty slave + * (`/dev/ttysNNN`) that the base profile's `/dev/tty` literal does not cover. + * Without a rule for it, TIOCSETA/TIOCSETAW return EPERM, no TUI can enter raw + * mode, and the terminal echoes capability replies, KKP key encodings and + * mouse events as literal text (issues #419 and #391). + */ +describe.if(isMacOS)('macOS pty rules: inherited-stdio terminal grant', () => { + const BROAD_REGEX = '(regex #"^/dev/ttys")' + const PSEUDO_TTY = '(allow pseudo-tty)' + const TTY_A = '/dev/ttys991' + const TTY_B = '/dev/ttys992' + + // A read restriction is required, otherwise the wrapper short-circuits and + // returns the bare command with no profile at all. + const baseParams = { + command: 'true', + needsNetworkRestriction: false, + readConfig: { denyOnly: ['/work/priv'] }, + writeConfig: undefined, + } + + it('grants ioctl on the given terminal, and nothing more', () => { + const profile = wrapCommandWithSandboxMacOS({ + ...baseParams, + inheritedTtys: [TTY_A], + }) + + expect(profile).toContain(`(allow file-ioctl (literal "${TTY_A}"))`) + // ioctl is all raw mode needs. A read/write grant would be policy surface + // that buys nothing here, and would matter if the child never receives + // this terminal. + expect(profile).not.toContain( + `(allow file-read* file-write* (literal "${TTY_A}"))`, + ) + expect(profile).not.toContain(BROAD_REGEX) + expect(profile).not.toContain(PSEUDO_TTY) + }) + + it('grants every distinct inherited terminal, not just the first', () => { + // stdio split across two terminals: a program reading keys from one while + // sizing the other needs both, and granting only the first leaves the + // second returning EPERM. + const profile = wrapCommandWithSandboxMacOS({ + ...baseParams, + inheritedTtys: [TTY_A, TTY_B], + }) + + expect(profile).toContain(`(allow file-ioctl (literal "${TTY_A}"))`) + expect(profile).toContain(`(allow file-ioctl (literal "${TTY_B}"))`) + }) + + it('ignores paths that are not pty slave devices', () => { + // The parameter is exported and reaches a (literal ...) rule, so the + // shape is enforced rather than trusted. + const profile = wrapCommandWithSandboxMacOS({ + ...baseParams, + inheritedTtys: ['/etc/passwd', '/dev/ttysNOPE', TTY_A], + }) + + expect(profile).not.toContain('/etc/passwd') + expect(profile).not.toContain('/dev/ttysNOPE') + expect(profile).toContain(`(allow file-ioctl (literal "${TTY_A}"))`) + }) + + it('emits no pty rules when no terminal is passed', () => { + // The wrapper never detects one on its own: the caller decides stdio + // after this returns, so detection here would be a guess. + const profile = wrapCommandWithSandboxMacOS({ ...baseParams }) + + expect(profile).not.toContain(PSEUDO_TTY) + expect(profile).not.toContain(BROAD_REGEX) + expect(profile).not.toContain('/dev/ttys') + }) + + it('grants every pty when allowPty is true', () => { + const profile = wrapCommandWithSandboxMacOS({ + ...baseParams, + allowPty: true, + inheritedTtys: [TTY_A], + }) + + expect(profile).toContain(PSEUDO_TTY) + expect(profile).toContain(BROAD_REGEX) + expect(profile).toContain('(literal "/dev/ptmx")') + expect(profile).not.toContain(`(allow file-ioctl (literal "${TTY_A}"))`) + }) + + it('treats allowPty:false identically to unset (inherited ioctl, no wide grant)', () => { + // `false` collapses into the default rather than emitting nothing: an + // explicit `false` and an absent flag must produce the same profile, so + // `false` cannot silently reproduce the raw-mode bug. Only `true` widens. + const asFalse = wrapCommandWithSandboxMacOS({ + ...baseParams, + allowPty: false, + inheritedTtys: [TTY_A], + }) + const asUnset = wrapCommandWithSandboxMacOS({ + ...baseParams, + inheritedTtys: [TTY_A], + }) + + expect(asFalse).toContain(`(allow file-ioctl (literal "${TTY_A}"))`) + expect(asFalse).not.toContain(PSEUDO_TTY) + expect(asFalse).not.toContain(BROAD_REGEX) + expect(asFalse).toBe(asUnset) + }) +}) + +describe('resolveInheritedStdioTtys: rdev matching (injected probes)', () => { + // Injected probes exercise the match/no-match/dedup and every error path in + // process. The real-fs path needs a genuine pty on fd 0/1/2 and is covered by + // the e2e resolver tests below. + const probes = ( + over: Partial & { ttys?: number[] }, + ): TtyProbes => ({ + isatty: fd => (over.ttys ?? []).includes(fd), + listPtySlaves: () => ['ttys001', 'ttys002', 'ttys003'], + rdevOfFd: () => 0, + rdevOfPath: () => -1, + ...over, + }) + + it('returns [] when no fd is a tty', () => { + expect(resolveInheritedStdioTtys(probes({ ttys: [] }))).toEqual([]) + }) + + it('resolves a tty fd to the device with the matching rdev', () => { + const p = probes({ + ttys: [1], + rdevOfFd: () => 42, + rdevOfPath: path => (path === '/dev/ttys002' ? 42 : 0), + }) + expect(resolveInheritedStdioTtys(p)).toEqual(['/dev/ttys002']) + }) + + it('deduplicates when two fds share one device', () => { + const p = probes({ + ttys: [1, 2], + rdevOfFd: () => 7, + rdevOfPath: path => (path === '/dev/ttys001' ? 7 : 0), + }) + expect(resolveInheritedStdioTtys(p)).toEqual(['/dev/ttys001']) + }) + + it('returns every distinct device across fds', () => { + const p = probes({ + ttys: [0, 1], + rdevOfFd: fd => (fd === 0 ? 7 : 9), + rdevOfPath: path => + path === '/dev/ttys001' ? 7 : path === '/dev/ttys003' ? 9 : 0, + }) + expect(resolveInheritedStdioTtys(p)).toEqual([ + '/dev/ttys001', + '/dev/ttys003', + ]) + }) + + it('returns [] when /dev cannot be scanned', () => { + const p = probes({ + ttys: [1], + listPtySlaves: () => { + throw new Error('EACCES') + }, + }) + expect(resolveInheritedStdioTtys(p)).toEqual([]) + }) + + it('skips an fd whose rdev cannot be read, keeps the others', () => { + const p = probes({ + ttys: [0, 1], + rdevOfFd: fd => { + if (fd === 0) throw new Error('EBADF') + return 9 + }, + rdevOfPath: path => (path === '/dev/ttys003' ? 9 : 0), + }) + expect(resolveInheritedStdioTtys(p)).toEqual(['/dev/ttys003']) + }) + + it('skips a slave whose rdev cannot be read (racing teardown)', () => { + const p = probes({ + ttys: [1], + rdevOfFd: () => 42, + rdevOfPath: () => { + throw new Error('ENOENT') + }, + }) + expect(resolveInheritedStdioTtys(p)).toEqual([]) + }) + + it('skips an fd with no matching device', () => { + const p = probes({ ttys: [1], rdevOfFd: () => 999, rdevOfPath: () => 0 }) + expect(resolveInheritedStdioTtys(p)).toEqual([]) + }) +}) + +describe.if(isMacOS)('macOS pty rules: harness dependencies', () => { + it('has python3 and bun available', () => { + // The Seatbelt-behaviour tests below are gated on these. Without this + // check a macOS runner missing either would quietly reduce the suite to + // string assertions and still report green. + expect(hasDeps).toBe(true) + }) +}) + +describe.if(isMacOS && hasDeps)('macOS pty rules: resolver', () => { + const runOnCtty = (...args: string[]) => + spawnSync('python3', [CTTY, ...args], { + encoding: 'utf8', + timeout: 120_000, + env: { ...process.env, PTY_DEADLINE: '60' }, + }) + + it('resolves the real device when a terminal is attached', () => { + // The rdev-to-/dev/ttysNNN matching is the one genuinely non-obvious piece + // of logic here, and the piped case below cannot reach it. + const probe = ` + import { resolveInheritedStdioTtys } from ${JSON.stringify(UTILS)} + console.log('RESOLVED:' + JSON.stringify(resolveInheritedStdioTtys())) + ` + const res = runOnCtty('bun', '--eval', probe) + const match = /RESOLVED:(\[.*\])/.exec(res.stdout ?? '') + + expect(match).not.toBeNull() + const devices = JSON.parse(match![1]) as string[] + expect(devices.length).toBeGreaterThan(0) + for (const device of devices) expect(device).toMatch(/^\/dev\/ttys\d+$/) + }) + + it('resolves BOTH devices when stdio spans two terminals', () => { + // The case a single-pty harness cannot reach. Returning on the first + // matching fd leaves the other terminal without a rule, so operations + // against it still fail with EPERM. + const probe = ` + import { resolveInheritedStdioTtys } from ${JSON.stringify(UTILS)} + console.log('RESOLVED:' + JSON.stringify(resolveInheritedStdioTtys())) + ` + const res = spawnSync( + 'python3', + [ + join(import.meta.dir, '../helpers/pty-split.py'), + 'bun', + '--eval', + probe, + ], + { encoding: 'utf8', timeout: 120_000 }, + ) + const match = /RESOLVED:(\[.*\])/.exec(res.stdout ?? '') + + expect(match).not.toBeNull() + const devices = JSON.parse(match![1]) as string[] + expect(devices.length).toBe(2) + expect(new Set(devices).size).toBe(2) + for (const device of devices) expect(device).toMatch(/^\/dev\/ttys\d+$/) + }) + + it('resolves nothing when stdio is piped', () => { + const probe = ` + import { resolveInheritedStdioTtys } from ${JSON.stringify(UTILS)} + console.log('RESOLVED:' + JSON.stringify(resolveInheritedStdioTtys())) + ` + const res = spawnSync('bun', ['--eval', probe], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + + expect(res.status).toBe(0) + expect(res.stdout).toContain('RESOLVED:[]') + }) + + it('emits a rule only when the caller asserts inheritsStdio', () => { + // The sandbox-manager gate: wrapping returns a string and the caller picks + // stdio afterwards, so a library consumer gets nothing unless it says so. + const probe = ` + import { SandboxManager } from ${JSON.stringify(join(import.meta.dir, '../../src/index.ts'))} + await SandboxManager.initialize({ + filesystem: { denyRead: ['/work/priv'], allowWrite: ['/tmp'], denyWrite: [] }, + network: { allowedDomains: [], deniedDomains: [] }, + }) + const re = /dev\\/ttys[0-9]+/ + const without = await SandboxManager.wrapWithSandbox('true') + const With = await SandboxManager.wrapWithSandbox('true', undefined, undefined, undefined, { inheritsStdio: true }) + console.log('GATE:' + JSON.stringify({ without: re.test(without), with: re.test(With) })) + await SandboxManager.reset() + ` + const res = runOnCtty('bun', '--eval', probe) + const match = /GATE:(\{.*\})/.exec(res.stdout ?? '') + + expect(match).not.toBeNull() + expect(JSON.parse(match![1])).toEqual({ without: false, with: true }) + }) + + it('allowPty:false resolves inherited ttys like unset (manager gate)', () => { + // Pins the `!== true` gate: an explicit `allowPty: false` still resolves + // and grants the inherited terminals, with ioctl only and no `pseudo-tty`. + const probe = ` + import { SandboxManager } from ${JSON.stringify(join(import.meta.dir, '../../src/index.ts'))} + await SandboxManager.initialize({ + filesystem: { denyRead: ['/work/priv'], allowWrite: ['/tmp'], denyWrite: [] }, + network: { allowedDomains: [], deniedDomains: [] }, + allowPty: false, + }) + const re = /dev\\/ttys[0-9]+/ + const asFalse = await SandboxManager.wrapWithSandbox('true', undefined, undefined, undefined, { inheritsStdio: true }) + console.log('FALSEGATE:' + JSON.stringify({ + hasInheritedDevice: re.test(asFalse), + hasPseudoTty: /pseudo-tty/.test(asFalse), + })) + await SandboxManager.reset() + ` + const res = runOnCtty('bun', '--eval', probe) + const match = /FALSEGATE:(\{.*\})/.exec(res.stdout ?? '') + + expect(match).not.toBeNull() + expect(JSON.parse(match![1])).toEqual({ + hasInheritedDevice: true, + hasPseudoTty: false, + }) + }) +}) + +/** + * End-to-end through the CLI on a pty that is genuinely the child's + * controlling terminal. These are the tests that would have caught #419: the + * unit tests above pin generated strings, which cannot tell you whether + * Seatbelt actually permits the ioctl. + */ +describe.if(isMacOS && hasDeps)('macOS pty rules: end to end', () => { + const DIR = join(tmpdir(), 'srt-pty-e2e-' + Date.now()) + const SETTINGS = join(DIR, 'settings.json') + const SETTINGS_BROAD = join(DIR, 'settings-allowpty.json') + + // The probe reports the errno so a refusal can be identified rather than + // merely counted: EPERM (1) is Seatbelt, EACCES (13) is the kernel saying + // this is not the caller's controlling terminal. + // + // Measured caveat: inside the sandbox Seatbelt refuses BEFORE the kernel's + // controlling-terminal check, so a harness that lost the controlling + // terminal also reports EPERM there. The errno assertion alone therefore + // cannot prove the harness is sound. The unsandboxed control test below + // does that, and it fails with EACCES if the prerequisite breaks. + const INJECTED = 'TIOCSTI-INJECTED-MARKER' + const TIOCSTI_PROBE = [ + 'import fcntl,sys', + 'TIOCSTI=0x80017472', + 'try:', + ` [fcntl.ioctl(sys.stdin.fileno(), TIOCSTI, c.encode()) for c in ${JSON.stringify(INJECTED)}]`, + ' print("TIOCSTI-ALLOWED")', + 'except OSError as e: print("TIOCSTI-DENIED errno=%d" % e.errno)', + ].join('\n') + + beforeAll(() => { + mkdirSync(DIR, { recursive: true }) + const base = { + filesystem: { denyRead: [], allowWrite: [DIR], denyWrite: [] }, + network: { allowedDomains: [], deniedDomains: [] }, + } + // No allowPty key at all: this is the default path users get. + writeFileSync(SETTINGS, JSON.stringify(base)) + writeFileSync(SETTINGS_BROAD, JSON.stringify({ ...base, allowPty: true })) + }) + + afterAll(() => { + if (existsSync(DIR)) rmSync(DIR, { recursive: true, force: true }) + }) + + const SPAWN_OPTS = { + encoding: 'utf8' as const, + timeout: 120_000, + env: { ...process.env, PTY_DEADLINE: '90' }, + } + + /** Through the sandbox: the real CLI, on a controlling terminal. */ + const runSandboxed = (shellCommand: string, settings = SETTINGS) => + spawnSync( + 'python3', + [CTTY, 'bun', CLI, '--settings', settings, '-c', shellCommand], + SPAWN_OPTS, + ) + + /** The same harness with no sandbox in between, for control cases. */ + const runUnsandboxed = (...args: string[]) => + spawnSync('python3', [CTTY, ...args], SPAWN_OPTS) + + it('lets a sandboxed program enter raw mode with no allowPty key', () => { + const res = runSandboxed('stty raw; echo "STTY-RC=$?"') + expect(res.stdout).toContain('STTY-RC=0') + }) + + it('does not echo an injected KKP key encoding back as text (#391)', () => { + // The byte-level reproduction of #391: write the Kitty Keyboard Protocol + // encoding of Ctrl+C to the pty master and read back. A terminal left in + // canonical+ECHO mode returns it verbatim, which is exactly the `^[[99;5u` + // the issue reports seeing on screen; in raw mode nothing comes back. + const res = spawnSync( + 'python3', + [ + join(import.meta.dir, '../helpers/pty-kkp.py'), + 'bun', + CLI, + '--settings', + SETTINGS, + '-c', + // The marker is the harness's injection trigger AND this test's + // liveness proof: a child that died at startup echoes nothing, which + // would otherwise be indistinguishable from a pass. + 'stty raw 2>/dev/null; echo KKP-CHILD-READY; sleep 3', + ], + { encoding: 'utf8', timeout: 120_000 }, + ) + + expect(res.stdout).toContain('KKP-ECHOED=NO') + expect(res.stdout).not.toContain('KKP-ECHOED=YES') + expect(res.stdout).not.toContain('KKP-ECHOED=UNKNOWN') + }) + + // Control. Every TIOCSTI assertion below is only meaningful if the harness + // really hands the child a CONTROLLING terminal, because macOS returns + // EACCES for a terminal that is merely attached. This test fails if that + // prerequisite ever breaks, so the denial tests cannot pass vacuously. + it('proves the harness grants a controlling terminal: TIOCSTI works unsandboxed', () => { + const res = runUnsandboxed('python3', '-c', TIOCSTI_PROBE) + + expect(res.stdout).toContain('TIOCSTI-ALLOWED') + // The injected text is echoed back by the terminal, which is the injection + // actually landing rather than merely being permitted. + expect(res.stdout).toContain(INJECTED) + }) + + it('refuses keystroke injection with EPERM in the default mode', () => { + const res = runSandboxed(`python3 -c '${TIOCSTI_PROBE}'`) + + expect(res.stdout).toContain('TIOCSTI-DENIED errno=1') + expect(res.stdout).not.toContain('TIOCSTI-ALLOWED') + }) + + it('refuses keystroke injection with EPERM under allowPty: true', () => { + // The broad mode grants ioctl over every pty, so this is where an + // injection primitive would appear first if the rules ever widened. + const res = runSandboxed(`python3 -c '${TIOCSTI_PROBE}'`, SETTINGS_BROAD) + + expect(res.stdout).toContain('TIOCSTI-DENIED errno=1') + expect(res.stdout).not.toContain('TIOCSTI-ALLOWED') + }) +})