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:
+18
-6
@@ -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)))
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user