feat: optional bubblewrap sandbox for terminal sessions (TTYD_SANDBOX) - #58
Conversation
Wrap each tmux session in a bubblewrap (bwrap) jail when TTYD_SANDBOX=true, so multi-tenant deployments (each terminal as a different Linux user) get per-user /home isolation and read-only system dirs. Off by default — single-user clihost behavior is byte-for-byte unchanged. Design (verified by a Docker proof-of-concept): - Inserted in bin/tmux-wrapper.sh, gated on TTYD_SANDBOX; the flag-off path is the original `exec tmux new-session` line untouched. - Jail flags: --ro-bind system dirs (guarded so a missing source can't abort bwrap), --bind $HOME rw, /proc inherited read-only (a fresh --proc can't be mounted in some envs), --unshare-user/-pid/-ipc, --die-with-parent. Network is NOT unshared (AI CLIs need it). - tmux socket pinned under $HOME/.cache/tmux so `new-session -A` reattach survives across launches despite the per-jail tmpfs /tmp. - Fail-closed: if bwrap can't start (e.g. missing seccomp=unconfined) the terminal does not open; no fallback to an unsandboxed shell. - entrypoint.sh defaults TTYD_SANDBOX and passes it into the proxy's runuser env block so it reaches the ttyd -> tmux-wrapper child (Popen inherits env). Requires the container to run with --security-opt seccomp=unconfined (Docker's default seccomp blocks the clone(CLONE_NEW*) bwrap needs). Documented in CLAUDE.md, README.md, .env.example, incl. the Dokku docker-options command and the known nested-bwrap (codex) limitation under --unshare-user. Tests: TestTmuxWrapperSandboxRegressions + entrypoint pass-through test + tmux-wrapper.sh added to the bash -n syntax check. Full suite: 216 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🔍 Local review (cycle 1)Reviewed in local mode (in-process Claude subagent reading Verdict: no Verified to hold in real code:
Minor findings — triaged
|
| # | Finding | Why SKIP |
|---|---|---|
| 1 | runuser (util-linux, PAM) may strip TTYD_SANDBOX in the multi-tenant root path the PR targets (clihost_cloud fork), as it resets the target user's env without --preserve-environment/-w → effective fail-open in that fork. |
Does not affect this repo: proxy runs as TTYD_USER (euid≠0), the runuser wrap is skipped, env inherited cleanly. Fork-only, runtime-unverifiable here. Worth a --setenv TTYD_SANDBOX true on the bwrap side (or -w on runuser) when the fork lands. |
| 2 | "Orphaned tmux server is harmlessly reattached on reconnect" (CLAUDE.md) slightly overstates persistence: across a ttyd restart, --die-with-parent + --unshare-pid kills the PID-ns init → the daemonized tmux server dies, socket survives but new-session -A starts fresh. Reattach works within one ttyd lifetime, not across restarts. |
Doc nuance, not a code bug. Runtime-dependent (no bwrap on macOS). |
| 3 | --ro-bind /proc + --unshare-pid leaks the host process table (/proc/{pid} of other tenants visible) → incomplete process isolation. |
Explicitly documented as an accepted limitation in the PR body, CLAUDE.md, and an inline comment. Not a defect. Future --proc /proc + hidepid would close it. |
Local mode does not auto-merge — merge is triggered manually when you're ready.
🔍 Local review (cycle 2)Reviewed locally (Claude subagent + Codex companion), no bots pinged. Both reviewers ran in parallel; findings merged and triaged. This is review-only — no auto-merge. Verdict: 0
Triage reasoning (Codex claims re-verified against the code — not taken at face value)#1 procfs — #2 kill-session socket — Both findings are real code observations but were triaged down from Codex's |
🔍 Local review (cycle 1)Reviewed locally (Claude subagent + Codex companion), no bots pinged.
|
…t name - bin/tmux-wrapper.sh: add `set -euo pipefail` so mkdir failure on TMUX_SOCK dir is not silently swallowed before bwrap exec - bin/tmux-wrapper.sh: remove /usr/local from ro-bind loop; /usr is already bound and /usr/local is a subdirectory, making the entry redundant - tests/unit/test_shell_scripts.py: rename test_bubblewrap_is_installed_for_codex_sandboxing → test_bubblewrap_is_installed_for_ttyd_sandbox (bubblewrap serves TTYD_SANDBOX, not codex; codex does not invoke bwrap today per CLAUDE.md) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
🔍 Local review (cycle 2)Reviewed locally (Claude subagent + Codex companion), no bots pinged.
|
Что и зачем
Опциональный джейл bubblewrap (
bwrap) вокруг каждой tmux-сессии. ПриTTYD_SANDBOX=trueмультитенантные деплои (где каждый терминал запускается под своим Linux-юзером) получают изоляцию: пользователь не видит чужие/home/*, системные каталоги — только на чтение.По умолчанию OFF — single-user clihost не меняется (flag-off путь байт-в-байт оригинальный
exec tmux new-session).Мотивация: используется как фундамент для мультиюзер-форка
clihost_cloud, но полезно любому, кто хочет изолировать терминал.bubblewrapуже был в Dockerfile («for codex sandboxing») — теперь он реально задействован.Дизайн (подтверждён Docker proof-of-concept)
bin/tmux-wrapper.sh, под флагомTTYD_SANDBOX. Flag-off ветка не тронута.--ro-bindсистемных каталогов (с guard'ом «если путь существует» — иначе bwrap падает на отсутствующем источнике),--bind $HOME(RW),/procнаследуется read-only (свежий--procне монтируется в части окружений),--unshare-user/-pid/-ipc,--die-with-parent. Сеть НЕ изолируется — AI CLI нужен интернет.$HOME/.cache/tmux/clihost.sock, чтобыnew-session -Aпереподключался между запусками ttyd (per-jail tmpfs/tmpиначе ломает reattach).seccomp=unconfined) — терминал не открывается, без отката к неизолированному шеллу.TTYD_SANDBOXи пробрасывает его в env-блок прокси (закрытый allow-list), чтобы флаг дошёл доttyd → tmux-wrapper(Popen наследует env).Джейл требует запуска контейнера с
--security-opt seccomp=unconfined(дефолтный seccomp Docker блокируетclone(CLONE_NEW*), нужный bwrap). На Dokku:Проверка
TestTmuxWrapperSandboxRegressions(флаги, guarded binds, отсутствие сетевого unshare, сокет под$HOME, flag-off byte-for-byte), entrypoint pass-through тест,tmux-wrapper.shдобавлен вbash -n. Весь сьют: 216 passed.tmux-wrapper.shв режиме джейла подтвердил — чужая/home/secretuser→No such file or directory;ls /home→ только свой; запись в/etc→ read-only; своя home пишется; tmux вызван с правильным сокетом.Известные ограничения (задокументированы в CLAUDE.md)
/procнаследуется (свежий не монтируется на Docker Desktop), хостовые PID видны через/proc/{pid}. Файловая изоляция — полная. Скрытие процессов — отдельная задача (hidepid).--unshare-user. Сейчас не критично — codex bwrap не вызывает.manager.pykill-session при джейле — no-op (другой сокет); жизненный цикл держит--die-with-parent, осиротевший tmux-сервер безвреден и переиспользуется при reattach.Closes #56.
🤖 Generated with Claude Code