Skip to content

feat: optional bubblewrap sandbox for terminal sessions (TTYD_SANDBOX) - #58

Merged
axisrow merged 2 commits into
mainfrom
feat/terminal-sandbox-bwrap
Jun 15, 2026
Merged

feat: optional bubblewrap sandbox for terminal sessions (TTYD_SANDBOX)#58
axisrow merged 2 commits into
mainfrom
feat/terminal-sandbox-bwrap

Conversation

@axisrow

@axisrow axisrow commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Что и зачем

Опциональный джейл 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 нужен интернет.
  • tmux-сокет перенесён под $HOME/.cache/tmux/clihost.sock, чтобы new-session -A переподключался между запусками ttyd (per-jail tmpfs /tmp иначе ломает reattach).
  • Fail-closed: если bwrap не стартует (например, нет seccomp=unconfined) — терминал не открывается, без отката к неизолированному шеллу.
  • entrypoint.sh дефолтит TTYD_SANDBOX и пробрасывает его в env-блок прокси (закрытый allow-list), чтобы флаг дошёл до ttyd → tmux-wrapper (Popen наследует env).

⚠️ Требование к запуску

Джейл требует запуска контейнера с --security-opt seccomp=unconfined (дефолтный seccomp Docker блокирует clone(CLONE_NEW*), нужный bwrap). На Dokku:

dokku docker-options:add <app> deploy,run "--security-opt seccomp=unconfined"

Проверка

  • Unit: новый класс TestTmuxWrapperSandboxRegressions (флаги, guarded binds, отсутствие сетевого unshare, сокет под $HOME, flag-off byte-for-byte), entrypoint pass-through тест, tmux-wrapper.sh добавлен в bash -n. Весь сьют: 216 passed.
  • Runtime (Docker, seccomp=unconfined): реальный tmux-wrapper.sh в режиме джейла подтвердил — чужая /home/secretuserNo such file or directory; ls /home → только свой; запись в /etc → read-only; своя home пишется; tmux вызван с правильным сокетом.

Известные ограничения (задокументированы в CLAUDE.md)

  • Изоляция процессов неполная: /proc наследуется (свежий не монтируется на Docker Desktop), хостовые PID видны через /proc/{pid}. Файловая изоляция — полная. Скрытие процессов — отдельная задача (hidepid).
  • Вложенный bwrap (будущая песочница codex внутри нашего джейла) ломается под внешним --unshare-user. Сейчас не критично — codex bwrap не вызывает.
  • manager.py kill-session при джейле — no-op (другой сокет); жизненный цикл держит --die-with-parent, осиротевший tmux-сервер безвреден и переиспользуется при reattach.

Closes #56.

🤖 Generated with Claude Code

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>
@axisrow

axisrow commented Jun 15, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1)

Reviewed in local mode (in-process Claude subagent reading gh pr diff + current file contents; no bot ping). This is review-only — no auto-merge.

Verdict: no FIX findings. Safe to merge from a correctness/security standpoint for this repo's default (single-user, off-by-default) behavior.

Verified to hold in real code:

  • Env passthrough (default path) worksentrypoint.sh adds TTYD_SANDBOX to the proxy's runuser env block; proxy runs as TTYD_USER (euid≠0), so manager.py:_as_ttyd_user returns the command unwrapped and Popen is called with no env=, so ttyd → tmux-wrapper inherits the flag.
  • /home/* isolation holds — only --bind "$HOME" is writable; /home parent is never bound, sibling homes invisible.
  • Fail-closed holds — flag-on branch ends in exec bwrap … -- tmux …; bwrap failure exits the shell, never falls through to unsandboxed exec tmux. Pinned by test_default_path_is_byte_for_byte_original.
  • Quoting is sound"${binds[@]}", $HOME, $SESSION_NAME, $TMUX_SOCK all double-quoted; no word-splitting.

Minor findings — triaged SKIP (deferred, not merge-blocking)

# 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.

@axisrow

axisrow commented Jun 15, 2026

Copy link
Copy Markdown
Owner Author

🔍 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 FIX, 2 SKIP. No merge-blocking issues beyond the limitations the PR already documents.

Verdict Reviewer Finding Location
SKIP codex Parent procfs (--ro-bind /proc) lets a same-UID shell read host PIDs / follow /proc/<pid>/root bin/tmux-wrapper.sh:48
SKIP codex kill-session targets the default tmux socket, not the jail socket under $HOME app/ttydproxy/manager.py:79-89
claude No critical findings (env-passthrough, fail-closed, /home isolation, quoting all verified)

Triage reasoning (Codex claims re-verified against the code — not taken at face value)

#1 procfs — SKIP (documented limitation, not a hidden gap).
Confirmed: tmux-wrapper.sh:48 binds the parent /proc (--ro-bind /proc /proc) instead of mounting a private --proc /proc. With --unshare-pid this leaves host PIDs visible. But this is explicitly documented as an accepted limitation in the PR body, CLAUDE.md, and the inline comment at tmux-wrapper.sh:44-45 ("файловая изоляция полная, скрытие чужих процессов — нет"). Codex's recommendation (--proc /proc after --unshare-pid, fail closed if unsupported) is a reasonable hardening for the multi-tenant fork, but it's a known-and-stated trade-off here, not a defect that blocks this PR. Severity overstated as critical.

#2 kill-session socket — SKIP (defense-in-depth gap, not a live leak).
Confirmed: manager.py:79-89 runs tmux kill-session on the default socket, while the jailed tmux lives on $HOME/.cache/tmux/clihost.sock (tmux-wrapper.sh:31,53), so it's a no-op under the jail. However, the session does not survive deletion: the process chain is ttyd → exec bwrap (--die-with-parent) → tmux. On DELETE /terminals/<id>, _stop_process (manager.py:144) SIGTERMs ttyd → bwrap's --die-with-parent tears down the jail → the jailed tmux (PID 1 of the unshared PID ns) dies with it. So the MAX_TERMINALS-bypass / orphaned-workload scenario does not materialize while ttyd is killed. The kill-session call is redundant under the jail (worth fixing to use tmux -S "$HOME/.cache/tmux/clihost.sock" kill-session for belt-and-suspenders + an explicit regression test), but it is not an active resource leak — matches the PR's own "kill-session становится no-op; жизненный цикл держит --die-with-parent" note.

Both findings are real code observations but were triaged down from Codex's critical after verifying the actual process/lifecycle semantics. Local mode does not auto-merge — trigger merge manually when ready. The two hardening suggestions (--proc /proc; jail-aware kill-session + regression test) are reasonable follow-ups for the multi-tenant fork.

@axisrow

axisrow commented Jun 15, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 1)

Reviewed locally (Claude subagent + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
FIX claude Misleading test name: test_bubblewrap_is_installed_for_codex_sandboxing — bubblewrap используется для TTYD_SANDBOX, не для codex tests/unit/test_shell_scripts.py:174
FIX claude Избыточный /usr/local в bind-loop — уже покрыт биндом /usr bin/tmux-wrapper.sh:37
FIX claude Отсутствует set -euo pipefail — при падении mkdir -p скрипт продолжит exec bwrap с невалидным сокетом bin/tmux-wrapper.sh:1
SKIP codex /proc bind undermines tenant sandbox — задокументировано в CLAUDE.md как known limitation (hidepid — отдельная задача); файловая изоляция полная, process hiding — out of scope PR bin/tmux-wrapper.sh:48
SKIP codex Sandboxed terminal kill-session no-op — задокументировано в CLAUDE.md; lifecycle держит --die-with-parent; MAX_TERMINALS считает ttyd-процессы, не tmux-сессии bin/tmux-wrapper.sh:31-53

…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>
@axisrow

axisrow commented Jun 15, 2026

Copy link
Copy Markdown
Owner Author

🔍 Local review (cycle 2)

Reviewed locally (Claude subagent + Codex companion), no bots pinged.

Verdict Reviewer Finding Location
IRRELEVANT codex Sandbox does not enforce terminal ownership at HTTP boundary — в текущей архитектуре TTYD_USER единственный пользователь всех терминалов; multi-tenancy это будущий форк clihost_cloud, PR явно описывает себя как фундамент app/ttydproxy/app.py:426
SKIP codex Deleting a sandboxed terminal leaves tmux session behind — задокументировано как known design в tmux-wrapper.sh строки 14-16 и CLAUDE.md; lifecycle держит --die-with-parent, manager.py корректно убивает ttyd-процесс bin/tmux-wrapper.sh:32

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.

Добавить bubblewrap

1 participant