From 06bfcae47fa4552074a053d2fc2da1ff56591a44 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:47:54 -0700 Subject: [PATCH] fix(tui-gateway): launch-profile turns keep their env-only terminal policy once multiplexing is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 606903badc49 made launch-profile turns bind a file-backed terminal scope as soon as any secondary home is served, so a poisoned ambient bridge can never be the launch turn's authority. That scope is rebuilt from defaults + /.env + config.yaml only, which drops the launch process's legitimate env-only policy: TERMINAL_ENV=ssh TERMINAL_SSH_HOST=example.test with a `{}` config.yaml became backend=local, ssh_host='' the moment a second profile was served. tui_gateway/launch_terminal_policy.py freezes the process TERMINAL_* once, in _profile_home right before the first secondary home is registered as served — the last moment ambient env is provably the launch profile's own. build_profile_terminal_scope takes that snapshot as a trusted env_overlay sitting where the process env sits in the standalone bridge (explicit YAML keys still win). Launch turns overlay the snapshot; ambient os.environ is never re-read after activation, so a later secondary write is still rejected, and the scope is reset after the turn as before. Refs #108440 review (andrexibiza), #107442 (ehz0ah). --- tools/terminal_scope.py | 24 +++++++++++---- tui_gateway/launch_terminal_policy.py | 42 +++++++++++++++++++++++++++ tui_gateway/prompt_turn.py | 7 ++++- tui_gateway/server.py | 5 ++++ 4 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 tui_gateway/launch_terminal_policy.py diff --git a/tools/terminal_scope.py b/tools/terminal_scope.py index 5c90b4b6f3..56988ad2a6 100644 --- a/tools/terminal_scope.py +++ b/tools/terminal_scope.py @@ -86,12 +86,21 @@ def terminal_env(name: str, default: str = "") -> str: return default if value is None else str(value) -def build_profile_terminal_scope(hermes_home: "Any") -> Dict[str, str]: +def build_profile_terminal_scope( + hermes_home: "Any", *, env_overlay: Optional[Dict[str, str]] = None) -> Dict[str, str]: """Build the COMPLETE effective ``TERMINAL_*`` policy for a profile home. - Projection: ``DEFAULT_CONFIG['terminal']`` <- profile ``.env`` TERMINAL_* <- profile - ``config.yaml`` ``terminal:``. Total by construction, so a bound scope never widens back to - ambient authority. Raises :class:`TerminalPolicyUnavailable` if a present file is unreadable. + Projection: ``DEFAULT_CONFIG['terminal']`` <- profile ``.env`` TERMINAL_* <- *env_overlay* + <- profile ``config.yaml`` ``terminal:``. Total by construction, so a bound scope never + widens back to ambient authority. Raises :class:`TerminalPolicyUnavailable` if a present + file is unreadable. + + *env_overlay* is a TRUSTED ``TERMINAL_*`` mapping captured from the launch process before + multiplexing began (``tui_gateway/launch_terminal_policy.py``): the launch profile's + env-only policy (``TERMINAL_ENV=ssh`` from systemd, ``op run``, a launcher bridge) has no + file to rebuild it from, and reading live ``os.environ`` here is the leak this module + closes. It sits where the process env sits in the standalone bridge — explicit YAML keys + still win (``apply_terminal_config_to_env``). """ from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP, _terminal_env_value from hermes_cli.config_defaults import DEFAULT_CONFIG @@ -124,6 +133,8 @@ def build_profile_terminal_scope(hermes_home: "Any") -> Dict[str, str]: scope.update((k, str(v)) for k, v in load_env_file(env_path).items() if k.startswith("TERMINAL_")) + if env_overlay: + scope.update((k, str(v)) for k, v in env_overlay.items() if k.startswith("TERMINAL_")) # Read config.yaml directly, not via read_raw_config() (which collapses "missing" and # "unparseable" into {}): present-but-unparseable must fail closed. config_path = home / "config.yaml" @@ -168,10 +179,11 @@ def _resolve_scope_cwd_placeholder(scope: Dict[str, str]) -> None: scope["TERMINAL_CWD"] = resolved -def install_profile_terminal_scope(hermes_home: "Any") -> Token: +def install_profile_terminal_scope( + hermes_home: "Any", *, env_overlay: Optional[Dict[str, str]] = None) -> Token: """Build AND install a profile's policy; on failure install the refusal scope. Never raises.""" try: - return set_terminal_scope(build_profile_terminal_scope(hermes_home)) + return set_terminal_scope(build_profile_terminal_scope(hermes_home, env_overlay=env_overlay)) except TerminalPolicyUnavailable as exc: logger.warning("terminal policy unavailable: %s", exc) return _terminal_scope_var.set(TerminalPolicyRefusal(str(exc))) diff --git a/tui_gateway/launch_terminal_policy.py b/tui_gateway/launch_terminal_policy.py new file mode 100644 index 0000000000..8025388e57 --- /dev/null +++ b/tui_gateway/launch_terminal_policy.py @@ -0,0 +1,42 @@ +"""Launch-profile ``TERMINAL_*`` snapshot for multiplexed TUI-gateway turns. + +Once this backend serves a secondary profile, launch-profile turns bind a terminal scope instead +of reading ambient ``os.environ`` (a secondary context must never become the launch turn's +authority; #107422). A scope rebuilt from ``/.env`` + ``config.yaml`` alone drops +the launch process's legitimate env-only policy — ``TERMINAL_ENV=ssh TERMINAL_SSH_HOST=...`` +injected by systemd / ``op run`` / a launcher bridge has no file to rebuild it from and silently +became ``backend=local``. The env is trusted exactly once: frozen at multiplex activation, before +any secondary code has run in this process, and never re-read from ambient state afterwards. +""" + +from __future__ import annotations + +import os +import threading +from typing import Dict, Optional + +_lock = threading.Lock() +_snapshot: Optional[Dict[str, str]] = None + + +def capture_launch_terminal_env() -> Dict[str, str]: + """Freeze the process's ``TERMINAL_*`` env; the first capture wins, later calls are no-ops. + + Called by ``server._profile_home`` immediately before the first secondary home is registered + as served — the last moment ambient env is provably the launch profile's own. + """ + global _snapshot + with _lock: + if _snapshot is None: + _snapshot = {k: v for k, v in os.environ.items() if k.startswith("TERMINAL_")} + return dict(_snapshot) + + +def launch_terminal_env() -> Dict[str, str]: + """The frozen launch ``TERMINAL_*`` overlay for a launch-profile turn's terminal scope. + + Production always captured at activation (``_profile_home`` is the only writer of + ``_served_profile_homes``); a first capture here only happens when a harness populated the + served set directly. + """ + return capture_launch_terminal_env() diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 0121c947a9..5270c4df83 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -455,8 +455,13 @@ def _prepare_turn_input(sid: str, session: dict, st: _TurnRun, text: Any, images # unscoped and fall back to ambient os.environ. Once any secondary home # has been served, bind the launch home's own terminal policy so a # poisoned ambient bridge can never become the launch turn's authority. + # The launch process's env-only policy (TERMINAL_ENV=ssh from systemd / + # a launcher) has no file to rebuild it from: overlay the TERMINAL_* + # snapshot frozen at multiplex activation, never live os.environ. from tools.terminal_scope import install_profile_terminal_scope - scopes.terminal = install_profile_terminal_scope(Path(_hermes_home)) + from tui_gateway.launch_terminal_policy import launch_terminal_env + scopes.terminal = install_profile_terminal_scope( + Path(_hermes_home), env_overlay=launch_terminal_env()) # The sudo password callback is thread-local: without re-wiring here, sudo prompts # fall through to /dev/tty and hang the headless gateway (re-run is a no-op). _wire_callbacks(sid) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 089c88a29f..ec7a7d13e1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -492,6 +492,11 @@ def _profile_home(profile: str | None) -> Path | None: raise FileNotFoundError(f"Profile '{name}' does not exist.") if home.resolve() == Path(_hermes_home).resolve(): return None # already the launch profile (no override needed) + if home not in _served_profile_homes: + # Last moment ambient TERMINAL_* is provably the launch profile's own: freeze it for + # launch-profile turns before any secondary code runs (tui_gateway/launch_terminal_policy.py). + from tui_gateway.launch_terminal_policy import capture_launch_terminal_env + capture_launch_terminal_env() _served_profile_homes.add(home) # the change watcher must stat every served sibling store too return home