Skip to content

fix(macos): grant the inherited terminals so sandboxed TUIs can enter raw mode - #480

Open
pcontrerasp wants to merge 1 commit into
anthropics:mainfrom
pcontrerasp:fix/macos-pty-default-inherited-ttys
Open

fix(macos): grant the inherited terminals so sandboxed TUIs can enter raw mode#480
pcontrerasp wants to merge 1 commit into
anthropics:mainfrom
pcontrerasp:fix/macos-pty-default-inherited-ttys

Conversation

@pcontrerasp

@pcontrerasp pcontrerasp commented Aug 16, 2026

Copy link
Copy Markdown

Fixes the macOS Seatbelt profile so interactive TUIs can enter raw mode by default. Reported as #419 and #391 — both are the same root cause.

Root cause

Seatbelt matches file-ioctl rules by device path. The generated profile allows file-ioctl on the literals /dev/tty, /dev/null, /dev/zero, /dev/random, /dev/urandom and /dev/dtracehelper — but a terminal is a pty slave (/dev/ttysNNN), which the /dev/tty alias does not cover. TIOCSETA / TIOCSETAW therefore return EPERM, and nothing under srt can leave canonical mode.

Both filed symptoms follow from that. A terminal stuck in canonical + ECHO echoes whatever arrives on its input back to the display, so the application's own protocol traffic becomes visible text: capability replies such as XTVERSION and DA1 (#419), and Kitty Keyboard Protocol encodings and SGR mouse reports on every keypress (#391). Input stays line-buffered in both.

allowPty: true already fixed this, but it is documented nowhere.

What changed

The default now grants file-ioctl and only file-ioctl on the terminals the child inherits on stdin/stdout/stderr, which is all raw mode needs.

allowPty grant
unset (default) / false ioctl on the inherited terminals only (default)
true (allow pseudo-tty) + read/write/ioctl on every pty, for programs that allocate their own (tmux, script, node-pty)

false behaves identically to unset; there is no separate "no rules" state, since an inherited terminal a process never touches is harmless to make ioctl-able, and a distinct false only ever broke raw mode by surprise.

Why the terminals aren't detected while wrapping

wrapWithSandbox returns a command string; the caller chooses stdio afterwards. A terminal detected at wrap time is a guess about a decision not yet made — wrap under pipes and launch into a fresh pty and the rule is missing; wrap under terminal A and launch under B and the rule names the wrong device.

So WrapWithSandboxOptions gains inheritsStdio, an assertion by the caller that it spawns with this process's stdio inherited. Only that triggers resolution. The CLI passes it; library consumers opt in.

Paths are re-validated against /^\/dev\/ttys[0-9]+$/ at the profile seam rather than trusted, since inheritedTtys is an exported parameter whose value reaches a (literal ...) rule.

Resolving the devices

Node exposes no ttyname(3), and fs.realpathSync('/dev/fd/0') returns /dev/fd/0 on macOS rather than the device. Devices are resolved by matching each tty descriptor's rdev against the /dev/ttys* entries. Every failure path logs — a silent one reproduces exactly the bug this fixes: no rule, no error, no raw mode.

Tests

test/sandbox/macos-pty-default.test.ts covers profile generation, the resolver, and end-to-end Seatbelt behaviour. Three Python helpers make the e2e cases real rather than approximate:

A hasDeps guard test prevents the Seatbelt-behaviour suite from silently degrading to string assertions if the helpers go missing.

Docs

README gains allowPty documentation (its absence is arguably the whole of #419) and a troubleshooting entry for the escape-sequence symptom. Also corrects the violation-monitoring predicate: sandbox-exec execs into the target, so violations are attributed to the child and a predicate on the sandbox-exec process name matches nothing.

Scope

macOS only. The Linux (seccomp/Landlock) and Windows paths are untouched — this is a Seatbelt profile bug and does not exist there.

@pcontrerasp pcontrerasp reopened this Aug 16, 2026
… raw mode

Seatbelt matches ioctl rules by device path. The generated profile allowed
file-ioctl on the literals /dev/tty, /dev/null, /dev/zero, /dev/random,
/dev/urandom and /dev/dtracehelper, but a terminal is a pty slave
(/dev/ttysNNN) that the /dev/tty alias does not cover. TIOCSETA/TIOCSETAW
therefore returned EPERM and no interactive program could enter raw mode.

Both filed symptoms follow from that single cause. With the terminal stuck in
canonical+ECHO mode it echoes whatever arrives on its input back to the
display, so the application's own protocol traffic becomes visible text:
capability replies such as XTVERSION and DA1 (anthropics#419), and the Kitty Keyboard
Protocol encodings and mouse reports that appear when a key is pressed (anthropics#391).
Input stays line-buffered in both.

allowPty already existed and fixed this, but it is documented nowhere, so anthropics#419
was filed eight months after it shipped concluding no workaround existed. It is
also wider than the common case needs: (allow pseudo-tty) plus read, write and
ioctl over every /dev/ttys, which reaches other terminals the same user owns.

Left unset, the profile now grants file-ioctl — and only file-ioctl, which is
all raw mode needs — on the terminals the child inherits, and does not emit
pseudo-tty, so allocating new ptys stays opt-in. Every distinct device across
fds 0, 1 and 2 gets a rule, because stdio can span two terminals and granting
only the first leaves the other returning EPERM. Paths that are not pty slaves
are filtered out rather than trusted, since the parameter is exported and the
value reaches a (literal ...) rule. allowPty: true keeps the existing broad
rules for tmux, script, expect and node-pty; allowPty: false behaves
identically to unset — inherited-terminal ioctl only, no wide grant — so an
explicit false cannot silently reproduce the raw-mode bug this fixes; only
allowPty: true is special. There is deliberately no "no pty rules" state: an
inherited terminal a process never touches is harmless to make ioctl-able.

The terminals are never detected while wrapping. Wrapping returns a command
string and the caller chooses stdio afterwards, so a terminal detected then is
a guess about a decision not yet made: wrap under pipes and launch into a fresh
pty and the rule is missing, wrap under terminal A and launch under B and the
rule names the wrong device. WrapWithSandboxOptions gains inheritsStdio, an
assertion by a caller that it spawns with this process's stdio inherited, and
only that resolves them. The CLI passes it; library consumers opt in.

Node exposes no ttyname(3) and fs.realpathSync('/dev/fd/0') returns
'/dev/fd/0' on macOS rather than the device, so devices are resolved by
matching the device number of each tty descriptor against the /dev/ttys*
entries. Every failure path logs, because a silent one reproduces exactly the
bug this fixes: no rule, no error, no raw mode.

Verified on a pty made a genuine controlling terminal (setsid + TIOCSCTTY):
raw mode succeeds with no allowPty key; the terminal reports -echo, which is
anthropics#391's mechanism; injecting the KKP Ctrl+C sequence is no longer echoed back
as literal text, where the pre-fix path returns it verbatim as ^[[99;5u; and
TIOCSTI keystroke injection stays denied in both modes. That denial is this
profile's doing and is pinned by a test rather than asserted in prose — macOS
itself permits TIOCSTI to an unprivileged process when the descriptor is its
own controlling terminal.

Also corrects the violation-monitoring predicate in the README: sandbox-exec
execs into the target, so violations are attributed to the child and a
predicate on the sandbox-exec process name matches nothing.

Reported as anthropics#419 and anthropics#391.
@pcontrerasp
pcontrerasp force-pushed the fix/macos-pty-default-inherited-ttys branch from dc0499d to a7a8377 Compare August 17, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant