fix(terminal): scope terminal config per turn under profile multiplexing

A multiplexed Hermes process (gateway.multiplex_profiles, unified
dashboard/TUI, or cron) serves several profiles at once, but terminal.*
resolved through process-global TERMINAL_* env vars bridged ONCE at
startup from the launch profile (gateway/run.py ~2700-2760) plus the
one-shot _ensure_terminal_env_bridged() guard. Every routed profile
therefore inherited the launch profile's backend, cwd, docker volumes,
SSH target and shared-container key: a local profile ran inside another
profile's docker sandbox (or a docker profile escaped to the host), and a
container labeled profile A carried profile B's RW bind mounts.

Fix: an authoritative per-profile terminal policy seam, mirroring
agent/secret_scope.py:

- tools/terminal_scope.py: ContextVar holding the routed profile's
  COMPLETE effective TERMINAL_* policy (defined defaults <- profile .env
  TERMINAL_* <- config.yaml terminal:). While bound, terminal_env()
  resolves ONLY from it - an omitted key yields the defined default,
  never os.environ. Unreadable/malformed policy installs a refusal
  scope; terminal_tool / execute_code refuse instead of running under
  ambient launch-process policy (fail closed).
- Installed at every in-process profile boundary: gateway
  _profile_runtime_scope, tui_gateway session/build/turn scopes, cron
  per-job fire. The unscoped single-process path is byte-identical.
- Every terminal.* consumer reads through the scope: terminal_tool
  (_get_env_config, _resolve_container_task_id shared key, orphan
  reaper lifetime, degraded mode), gateway/platforms/base.py docker
  media translation (volumes, shared key, persistence), runtime_cwd /
  agent_init / skill_utils / code_execution_tool / file_tools cwd
  anchors, prompt_builder / browser_tool / env_probe backend checks,
  gateway footer, @-refs and slash-command cwd. env_probe resolves the
  backend in the caller's context, since the probe worker thread does
  not inherit the ContextVar.

Salvage of #99225 onto current main: adds the three ambient reads the PR
missed (tools/file_tools.py TERMINAL_CWD, tools/browser_tool.py and
tools/env_probe.py TERMINAL_ENV; shape from #79117) and trims the test
module to the leak matrix driven through the real gateway boundary,
omitted-key defaults, refusal, and boundary reset.

Fixes #68559
Fixes #94200
Fixes #101132
Fixes #95470

Co-authored-by: x7peeps <9640837+x7peeps@users.noreply.github.com>
Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: ExitMaster <292490062+ExitMaster@users.noreply.github.com>
This commit is contained in:
muhifni
2026-08-31 10:53:22 +07:00
committed by Teknium
parent d2085389d6
commit 1cd736ff63
17 changed files with 810 additions and 78 deletions
+18 -1
View File
@@ -1553,6 +1553,21 @@ def execute_code(
"Use normal tool calls (terminal, read_file, write_file, ...) instead."
)
# Fail closed under a terminal-policy refusal scope (#68559): the routed
# profile's terminal policy could not be resolved and execute_code runs on
# the configured terminal backend — refuse rather than inheriting the
# launch process's ambient policy.
try:
from tools.terminal_scope import enforce_no_refusal
enforce_no_refusal()
except Exception as refusal:
return tool_error(
f"execute_code refused: {refusal} "
"(profile terminal policy unresolved; fix the profile's "
"config.yaml / .env and retry)"
)
if not code or not code.strip():
return tool_error(
"No code provided. execute_code requires a non-empty 'code' "
@@ -2282,7 +2297,9 @@ def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str:
session_cwd = None
if session_cwd and os.path.isdir(session_cwd):
return session_cwd
raw = os.environ.get("TERMINAL_CWD", "").strip()
from agent.runtime_cwd import scope_terminal_cwd
raw = scope_terminal_cwd().strip()
if raw:
expanded = os.path.expanduser(raw)
if os.path.isdir(expanded):