fix(macos): grant the inherited terminals so sandboxed TUIs can enter raw mode - #480
Open
pcontrerasp wants to merge 1 commit into
Open
fix(macos): grant the inherited terminals so sandboxed TUIs can enter raw mode#480pcontrerasp wants to merge 1 commit into
pcontrerasp wants to merge 1 commit into
Conversation
… 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
force-pushed
the
fix/macos-pty-default-inherited-ttys
branch
from
August 17, 2026 10:35
dc0499d to
a7a8377
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-ioctlrules by device path. The generated profile allowsfile-ioctlon the literals/dev/tty,/dev/null,/dev/zero,/dev/random,/dev/urandomand/dev/dtracehelper— but a terminal is a pty slave (/dev/ttysNNN), which the/dev/ttyalias does not cover.TIOCSETA/TIOCSETAWtherefore returnEPERM, and nothing undersrtcan leave canonical mode.Both filed symptoms follow from that. A terminal stuck in canonical +
ECHOechoes 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: truealready fixed this, but it is documented nowhere.What changed
The default now grants
file-ioctland onlyfile-ioctlon the terminals the child inherits on stdin/stdout/stderr, which is all raw mode needs.allowPtyioctlon the inherited terminals only (default)truefalsebehaves 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
wrapWithSandboxreturns 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
WrapWithSandboxOptionsgainsinheritsStdio, 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, sinceinheritedTtysis an exported parameter whose value reaches a(literal ...)rule.Resolving the devices
Node exposes no
ttyname(3), andfs.realpathSync('/dev/fd/0')returns/dev/fd/0on macOS rather than the device. Devices are resolved by matching each tty descriptor'srdevagainst 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.tscovers profile generation, the resolver, and end-to-end Seatbelt behaviour. Three Python helpers make the e2e cases real rather than approximate:pty-ctty.py— gives the child a pty that is genuinely its controlling terminalpty-split.py— stdin on one pty, stdout/stderr on another; the only way to exercise the multi-device pathpty-kkp.py— the KKP escape sequences leak as literal text through sandbox-exec PTY layer on macOS #391 repro, gated on a readiness marker so a child that dies at startup fails loudly instead of passing vacuouslyA
hasDepsguard test prevents the Seatbelt-behaviour suite from silently degrading to string assertions if the helpers go missing.Docs
README gains
allowPtydocumentation (its absence is arguably the whole of #419) and a troubleshooting entry for the escape-sequence symptom. Also corrects the violation-monitoring predicate:sandbox-execexecs into the target, so violations are attributed to the child and a predicate on thesandbox-execprocess 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.