ttmux runs one user's terminal session on that user's own machine. It
protects the control socket, the session state, and the child processes from
other users on the same machine. The uid that starts the server is the trust
boundary. ttmux is not a privilege boundary between users, and it is not a
sandbox. Any process that runs as you can connect to the socket and get a
shell, because ttmux send-keys types into a pane. Treat a ttmux session as
equal to a login shell for your account.
Only the newest release gets fixes. ttmux is beta software before 1.0, so there is no backport branch.
| Version | Supported |
|---|---|
| 0.5.x | Yes |
| 0.4.x and older | No |
Open a private security advisory at https://github.com/statico/ttmux/security/advisories/new. The repository has no security email address. Do not open a public issue for a vulnerability.
- The socket directory is
$XDG_RUNTIME_DIR,$TMPDIR, or/tmp, plusttmux-<uid>. Seesocket_dirinsrc/proto.rs. - ttmux creates the directory with mode
0700and sets the mode to0700again on every start. - ttmux reads the directory with
symlink_metadataand refuses to continue when the owner uid is not yours. A wrong owner on a shared/tmpis a hijack, so tightening the mode is not enough. - The socket file gets mode
0600right afterbind. Seespawninsrc/server.rs. - A session name that is empty, or that holds
/,.., or a NUL byte, is rejected insocket_path. - The socket name carries the protocol version. A new binary starts its own server, so an upgrade never hands an old client a new wire format.
is_liveconnects to test a socket.cleanup_staledeletes the sockets of servers that are gone, andspawnremoves a stale file before it binds.- The daemon does
fork,setsid,forkagain. It has no controlling terminal and it cannot get one. stdin and stdout go to/dev/null. stderr goes to a log file that ttmux opens with mode0600.
- Frames are a 4-byte little-endian length, then JSON. See
src/proto.rs. MAX_FRAMEis 96 MiB, room for a large inline image.read_msgchecks the length prefix and returns an error before it allocates the body.- A short read at a frame boundary is a clean EOF. A partial frame is an
UnexpectedEoferror, so "peer detached" and "peer died" stay apart. - Both sides check the protocol number. The server answers a mismatched
HellowithErrorandBye, then closes. The client refuses any message that arrives beforeWelcome. - A client gets 5 seconds to send
Hello. A connection that says nothing loses its thread. - Each client has a 256-frame queue and its own writer thread. The app thread never blocks on a socket. A client that stops reading fills its queue and is dropped.
- Writes time out after 5 seconds during the handshake and after 2 seconds once the client is attached.
- A scripted command waits at most 5 seconds for the app thread. The client
side waits 10 seconds for the reply and 5 seconds for
kill-server. - The server parses a scripted command before it reaches the app thread. A parse error goes back to the script and never touches the session.
- A pane runs the shell from
general.shell, else$SHELL, else/bin/sh. SeePane::spawninsrc/pty.rs. portable_ptycallssetsidbeforeexec, so each pane child is its own process group and session leader.Pane::killsendsSIGHUPto the process group, kills the child, sendsSIGKILLto the group, and then runspkill -9 -s <sid>to catch a grandchild in a job-control group of its own. It then callswaitto reap.Drop for Panecallskill, so every exit path cleans up. Areapedflag stops a second signal reaching a recycled pid.pumpfeeds the emulator at most 4 MiB per pass, so a noisy pane cannot starve the UI. A pane holds at most 32 pending images.- A program can set a pane title with an escape sequence. ttmux strips every control character from that title before it stores it.
- A pane gets
TERM,COLORTERM,TTMUX(the socket path),TTMUX_PANE, andTTMUX_SESSIONon top of your environment.
- The config file is
$TTMUX_CONFIG, else$XDG_CONFIG_HOME/ttmux/ttmux.toml, else$HOME/.config/ttmux/ttmux.toml. Seeconfig_pathinsrc/config.rs. - A missing file gives the defaults. A parse error is reported and the defaults load, so a bad edit never locks you out of the session.
- ttmux reads the file at start and again only when you ask: the
reload-configkey (prefix r), a save from the settings screen, orttmux set-option. It does not watch the file, so a program that rewrites it cannot get a widget command run behind your back. tmux also waits forsource-file. - ttmux writes the file with mode
0600. Seewrite_privateinsrc/config.rs. - A custom widget is a shell command. ttmux runs it with
sh -c, with stdin on/dev/nulland stderr dropped. Seesrc/widget.rs. - Each widget runs on its own thread. An
AtomicBoolswap stops a second copy of a widget that still runs. - Widget output goes through the tmux markup parser and into the cell grid. ratatui drops graphemes that hold control characters, so a widget cannot write escape sequences to your terminal.
- A custom widget name never shadows a built-in widget. See
src/status.rs.
src/graphics.rscaptures only three framings: kittyESC _ G … ESC \, iTerm2ESC ] 1337 ; …, and sixelESC P … q … ESC \.- Only sequences that draw are replayed. See
is_safeinsrc/graphics.rs. A kitty image must carry its data inline (t=d):t=f,t=t, andt=swould make your terminal read a file or shared memory, and are dropped. An iTerm2 sequence must beFile=withinline=1: downloads, user variables, focus stealing, and the rest of OSC 1337 are dropped. - Past that check ttmux does not interpret the payload. It replays the exact bytes at the pane cursor.
- One captured sequence is capped at 4 MiB, and a chunked kitty image at 64 MiB once joined. Past a cap ttmux drops the image, swallows the rest to its terminator, and carries on.
passthrough-images = falsein[general]turns replay off.- OSC 52 copies reach your clipboard, and OSC 9, 99 and 777 notifications
reach your terminal.
clipboard = falseandnotifications = falsein[general]turn each off. A pane that asks to paste gets its own last copy back, never your real clipboard. - With all three off, no pane bytes reach your terminal outside the cell grid.
ttmux aims to be at least as strict as a default tmux.
| tmux default | ttmux default | |
|---|---|---|
| Socket | 0700 directory, owner only |
the same |
| A pane scripting its session | allowed (send-keys, run-shell) |
allowed (send-keys, split-window <cmd>) |
| Config that runs commands | run-shell, #(), and plugins, read at start or on source-file |
widgets and shell, read at start or on reload |
| Image passthrough | off (allow-passthrough) |
on, drawing sequences only |
| OSC 52 from a program | ignored (set-clipboard external) |
copied, clipboard = false to ignore |
| Notifications | not forwarded | forwarded, notifications = false to drop |
The two defaults that differ are there because neovim over ssh, image viewers, and coding agents need them, and each has a switch.
- The direct dependency list in
Cargo.tomlis small:anyhow,crossterm,ratatui,portable-pty,vt100,serde,serde_json,toml,unicode-width, andlibc.vt100is a fork kept invendor/vt100, with its changes listed invendor/vt100/TTMUX.md. Cargo.lockis in the repository, so a source build is reproducible at the same lock file.- CI runs
cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test --all-targets, and a release build. It runs on macOS arm64, macOS x86_64, and Linux x86_64. Releases also build Linux aarch64. - The release workflow builds four targets and writes a
.sha256file for each archive withshasum -a 256. - The
publishjob takescontents: writeand nothing more. The Homebrew tap job uses a separate token, because the defaultGITHUB_TOKENcannot push to another repository.
ttmux does not defend against these.
- Another process running as you. The socket permissions are the only
control. There is no token and no per-client authentication. Any process
with your uid connects and runs
send-keys, which is arbitrary code in your shell. Root gets the same access. This includes a program in a pane, so a sandboxed coding agent in one pane can type into an unsandboxed shell in another. Keep the socket directory (ttmux-<uid>under$XDG_RUNTIME_DIR,$TMPDIR, or/tmp) out of an agent sandbox. $TTMUX_SOCKET. The override wins outright. It skips the session-name check and the0700directory check. Point it only at a path you own.- The config file. ttmux treats it as trusted input and runs its widget
commands and
shellwithout asking. A program that can write the file, such as a coding agent allowed to edit files, gets those commands run at the next start or reload.ttmux set-optionwrites the same keys over the socket. Keep~/.config/ttmuxout of what an agent may write, as you would~/.zshrc. - The pane environment. A pane child inherits your full environment. ttmux
adds
TERM,COLORTERM,TTMUX,TTMUX_PANE, andTTMUX_SESSION. It removes nothing, so secrets in your environment reach every pane. - Image decoders in your terminal. A replayed image is untrusted data
that your terminal decodes. Set
passthrough-images = falsewhen you run untrusted output. - Clipboard writes. With
clipboardon, any program in a pane can replace your clipboard, for you to paste somewhere later. - Terminal escape sequences inside a pane. vt100 renders pane output, and ttmux does not audit that emulator for parser bugs.
- A hung widget. After 30 seconds ttmux frees the widget slot but leaves the process running. It never kills the command.
- Memory-unsafe code.
srcholds about 20unsafeblocks forlibccalls:fork,setsid,killpg,dup2, and the signal handler. There is no#![forbid(unsafe_code)]. - Supply chain scanning. CI runs no
cargo auditand nocargo deny. There is no Dependabot config. GitHub Actions are pinned to a major tag, not to a commit SHA. - Release provenance. Release binaries are not signed and carry no build
attestation. The
.sha256files sit next to the archives in the same release, so they detect a corrupt download and not a compromised release. - Beta status. ttmux is before 1.0. The wire format and the config format change between releases.