fix(tui-gateway): launch-profile turns keep their env-only terminal policy once multiplexing is active

606903badc 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 +
<home>/.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).
This commit is contained in:
teknium1
2026-09-13 12:47:54 -07:00
committed by Teknium
parent 484b8e96e8
commit 06bfcae47f
4 changed files with 71 additions and 7 deletions
+18 -6
View File
@@ -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)))
+42
View File
@@ -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 ``<launch home>/.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()
+6 -1
View File
@@ -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)
+5
View File
@@ -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