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:
+3
-1
@@ -3031,8 +3031,10 @@ def init_agent(
|
||||
except Exception as _ce_err:
|
||||
_ra().logger.debug("Context engine on_session_start: %s", _ce_err)
|
||||
|
||||
from agent.runtime_cwd import scope_terminal_cwd as _scope_terminal_cwd
|
||||
|
||||
agent._subdirectory_hints = SubdirectoryHintTracker(
|
||||
working_dir=os.getenv("TERMINAL_CWD") or None,
|
||||
working_dir=_scope_terminal_cwd() or None,
|
||||
)
|
||||
agent._user_turn_count = 0
|
||||
# Copilot x-initiator flag: first API call of a user turn sends "user" (#3040).
|
||||
|
||||
+20
-2
@@ -1173,6 +1173,24 @@ _WINDOWS_BASH_SHELL_HINT = (
|
||||
)
|
||||
|
||||
|
||||
def _tenv_read(name: str, default: str = "") -> str:
|
||||
"""Scope-aware TERMINAL_* read (tools.terminal_scope.terminal_env).
|
||||
|
||||
The per-turn terminal scope installed by the multiplexing gateway carries
|
||||
the active profile's terminal settings; a raw os.getenv would read a value
|
||||
a previous profile's turn pinned into the process env.
|
||||
|
||||
Only an import failure falls back: an active refusal scope must raise —
|
||||
swapping it for the ambient process value would defeat the fail-closed
|
||||
boundary.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_scope import terminal_env
|
||||
except ImportError:
|
||||
return os.getenv(name, default)
|
||||
return terminal_env(name, default)
|
||||
|
||||
|
||||
def _probe_remote_backend(env_type: str) -> str | None:
|
||||
"""Run a tiny introspection command inside the active terminal backend.
|
||||
|
||||
@@ -1181,7 +1199,7 @@ def _probe_remote_backend(env_type: str) -> str | None:
|
||||
per process. Used only for non-local backends where the agent's tools
|
||||
operate on a different machine than the host Hermes runs on.
|
||||
"""
|
||||
cwd_hint = os.getenv("TERMINAL_CWD", "")
|
||||
cwd_hint = _tenv_read("TERMINAL_CWD", "")
|
||||
cache_key = (env_type, cwd_hint)
|
||||
cached = _BACKEND_PROBE_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
@@ -1330,7 +1348,7 @@ def build_environment_hints() -> str:
|
||||
|
||||
hints: list[str] = []
|
||||
|
||||
backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower()
|
||||
backend = (_tenv_read("TERMINAL_ENV") or "local").strip().lower()
|
||||
is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS or _plugin_backend_is_remote(backend)
|
||||
|
||||
if not is_remote_backend:
|
||||
|
||||
+27
-2
@@ -57,6 +57,31 @@ def _session_cwd_override() -> str:
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _terminal_cwd_env() -> str:
|
||||
"""Scope-aware TERMINAL_CWD read (tools.terminal_scope.terminal_env).
|
||||
|
||||
Under gateway multiplexing the per-turn terminal scope carries the active
|
||||
profile's cwd; the process-global env var may hold another profile's
|
||||
value. Only an import failure falls back: an active refusal scope must
|
||||
raise, not silently resolve the launch profile's cwd.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_scope import terminal_env
|
||||
except ImportError:
|
||||
return os.environ.get("TERMINAL_CWD", "")
|
||||
return terminal_env("TERMINAL_CWD", "")
|
||||
|
||||
|
||||
def scope_terminal_cwd() -> str:
|
||||
"""Public wrapper — the scope-aware TERMINAL_CWD value (may be empty).
|
||||
|
||||
Shared by agent_init / skill_utils / code_execution_tool so every cwd
|
||||
consumer reads through the per-turn terminal scope under gateway
|
||||
multiplexing instead of the process-global env var.
|
||||
"""
|
||||
return _terminal_cwd_env()
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
override = _session_cwd_override()
|
||||
if override:
|
||||
@@ -64,7 +89,7 @@ def resolve_agent_cwd() -> Path:
|
||||
if p.is_dir():
|
||||
return p
|
||||
logger.warning("configured working directory does not exist: %s", override)
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
raw = _terminal_cwd_env().strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
@@ -90,7 +115,7 @@ def resolve_context_cwd() -> Path | None:
|
||||
else:
|
||||
return p
|
||||
return None
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
raw = _terminal_cwd_env().strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if not p.is_dir():
|
||||
|
||||
@@ -755,7 +755,9 @@ def find_project_root(start: Optional[Path] = None) -> Optional[Path]:
|
||||
"""
|
||||
try:
|
||||
if start is None:
|
||||
env_cwd = os.environ.get("TERMINAL_CWD")
|
||||
from agent.runtime_cwd import scope_terminal_cwd
|
||||
|
||||
env_cwd = scope_terminal_cwd()
|
||||
start = Path(env_cwd) if env_cwd else Path.cwd()
|
||||
cur = Path(start).resolve()
|
||||
except OSError:
|
||||
|
||||
@@ -7394,6 +7394,22 @@ def _run_one_job_body(
|
||||
_scope_token = set_secret_scope(
|
||||
build_profile_secret_scope(_get_hermes_home())
|
||||
)
|
||||
# Same isolation for terminal settings (third profile seam; see
|
||||
# gateway/run.py _profile_runtime_scope): installs the firing
|
||||
# profile's COMPLETE terminal policy for this fire — run, delivery,
|
||||
# and bookkeeping — resetting in this function's finally alongside
|
||||
# the secret scope. Without it the ticker thread reads the
|
||||
# process-global TERMINAL_* env vars a concurrent profile's turn may
|
||||
# have pinned (#68559). Resolution failure installs a refusal scope:
|
||||
# terminal execution inside the fire raises instead of falling back
|
||||
# to the launch process's ambient policy.
|
||||
from tools.terminal_scope import (
|
||||
install_profile_terminal_scope,
|
||||
)
|
||||
|
||||
_terminal_scope_token = install_profile_terminal_scope(
|
||||
_get_hermes_home()
|
||||
)
|
||||
# Defer the cron agent's async-resource teardown until AFTER delivery.
|
||||
# run_job normally closes the agent (and reaps stale async clients) in
|
||||
# its finally block; doing that before _deliver_result runs means the
|
||||
@@ -7827,6 +7843,10 @@ def _run_one_job_body(
|
||||
# _deliver_result unscoped — do not move it back in a tidy-up.
|
||||
if _scope_token is not None:
|
||||
reset_secret_scope(_scope_token)
|
||||
if _terminal_scope_token is not None:
|
||||
from tools.terminal_scope import reset_terminal_scope
|
||||
|
||||
reset_terminal_scope(_terminal_scope_token)
|
||||
|
||||
|
||||
def _notify_provider_jobs_changed() -> None:
|
||||
|
||||
+29
-10
@@ -1549,6 +1549,25 @@ def _path_is_within(path: Path, root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _tenv(name: str, default: str = "") -> str:
|
||||
"""Scope-aware TERMINAL_* read (tools.terminal_scope.terminal_env).
|
||||
|
||||
Media-path translation runs in the gateway process concurrently for
|
||||
several profiles; the per-turn terminal scope carries the ACTIVE
|
||||
profile's terminal settings, while a raw os.getenv would read whatever
|
||||
profile's config a previous turn pinned into the process env.
|
||||
|
||||
Only an import failure falls back: an active refusal scope must raise —
|
||||
reconstructing mounts/backends from ambient env under refusal would
|
||||
rebuild another profile's terminal policy.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_scope import terminal_env
|
||||
except ImportError:
|
||||
return os.getenv(name, default)
|
||||
return terminal_env(name, default)
|
||||
|
||||
|
||||
def _parse_docker_volume_mounts() -> List[Tuple[Path, Path]]:
|
||||
"""Parse configured Docker volume mounts into ``(host_path, container_path)``.
|
||||
|
||||
@@ -1557,7 +1576,7 @@ def _parse_docker_volume_mounts() -> List[Tuple[Path, Path]]:
|
||||
Named volumes and non-absolute hosts are skipped because they cannot be
|
||||
resolved on the gateway host for media delivery.
|
||||
"""
|
||||
raw = os.getenv("TERMINAL_DOCKER_VOLUMES", "").strip()
|
||||
raw = _tenv("TERMINAL_DOCKER_VOLUMES", "").strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
@@ -1625,7 +1644,7 @@ def _docker_sandbox_dir_candidates(session_key: str = "") -> List[str]:
|
||||
except Exception:
|
||||
return ["default"]
|
||||
# Explicit trusted-profiles opt-in: one shared container identity.
|
||||
shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip()
|
||||
shared = _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip()
|
||||
if shared:
|
||||
candidates.append(sanitize_task_id_for_path(f"shared:{shared}"))
|
||||
try:
|
||||
@@ -1651,9 +1670,9 @@ def _default_docker_workspace_host_roots(session_key: str = "") -> List[Path]:
|
||||
actually resolves — the profile sandbox dir existing does not mean the
|
||||
file lives there when it was produced in a legacy per-session container.
|
||||
"""
|
||||
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
if _tenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
return []
|
||||
if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in {
|
||||
if _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
@@ -1661,13 +1680,13 @@ def _default_docker_workspace_host_roots(session_key: str = "") -> List[Path]:
|
||||
}:
|
||||
return []
|
||||
# Explicit cwd mount takes over /workspace when enabled.
|
||||
if os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() in {
|
||||
if _tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}:
|
||||
cwd = os.getenv("TERMINAL_CWD") or os.getcwd()
|
||||
cwd = _tenv("TERMINAL_CWD") or os.getcwd()
|
||||
try:
|
||||
host = Path(os.path.expanduser(cwd)).resolve(strict=False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
@@ -1695,9 +1714,9 @@ def _docker_persistent_home_host_roots(session_key: str = "") -> List[Path]:
|
||||
produced a real host file the gateway couldn't find. Ordered best-first:
|
||||
the profile-scoped layout, then the legacy bug-window per-session layout.
|
||||
"""
|
||||
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
if _tenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
return []
|
||||
if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in {
|
||||
if _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").strip().lower() not in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
@@ -1727,7 +1746,7 @@ def _cache_dir_container_mounts() -> List[Tuple[Path, Path]]:
|
||||
longer prefixes than the ``/root`` home mount, so longest-prefix matching
|
||||
picks the cache translation over the home translation for them.
|
||||
"""
|
||||
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
if _tenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
return []
|
||||
try:
|
||||
from tools.credential_files import get_cache_directory_mounts
|
||||
@@ -1748,7 +1767,7 @@ def _warn_unresolved_docker_media(candidate: Path, session_key: str, reason: str
|
||||
file seemingly vanished. Point at the sandbox/session mismatch instead.
|
||||
Gated to Docker mode so host-path rejections stay quiet.
|
||||
"""
|
||||
if os.getenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
if _tenv("TERMINAL_ENV", "").strip().lower() != "docker":
|
||||
return
|
||||
logger.warning(
|
||||
"Docker MEDIA path %s did not resolve to a host sandbox file (%s%s); "
|
||||
|
||||
+33
-7
@@ -2546,6 +2546,19 @@ async def _reclaim_stale(runner: object) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _terminal_scope_cwd(default: str = "") -> str:
|
||||
"""Scope-aware TERMINAL_CWD read for footer/context surfaces.
|
||||
|
||||
Only an import failure falls back: an active refusal scope must raise,
|
||||
not resolve the launch profile's cwd.
|
||||
"""
|
||||
try:
|
||||
from tools.terminal_scope import terminal_env as _ts_env
|
||||
except ImportError:
|
||||
return os.environ.get("TERMINAL_CWD", default)
|
||||
return _ts_env("TERMINAL_CWD", default)
|
||||
|
||||
|
||||
@_contextmanager
|
||||
def _profile_runtime_scope(profile_home: "Path"):
|
||||
"""Scope config/skills/memory AND credentials to a profile for one turn.
|
||||
@@ -2576,11 +2589,19 @@ def _profile_runtime_scope(profile_home: "Path"):
|
||||
home_token = set_hermes_home_override(str(profile_home))
|
||||
hydrate_profile_secret_sources(Path(profile_home))
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_secret_scope(secret_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
# Per-turn terminal scope (third seam of the profile boundary): installs
|
||||
# the routed profile's COMPLETE terminal policy — never ambient env — via
|
||||
# tools.terminal_scope. Without it terminal_tool reads the process-global
|
||||
# TERMINAL_* vars a previous profile's turn may have pinned
|
||||
# (first-writer-wins backend leak; #68559).
|
||||
from tools.terminal_scope import install_and_reset_profile_terminal_scope
|
||||
|
||||
with install_and_reset_profile_terminal_scope(Path(profile_home)):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_secret_scope(secret_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
|
||||
def load_gateway_config_for_runner() -> "GatewayConfig":
|
||||
@@ -20261,7 +20282,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
from agent.context_references import preprocess_context_references_async
|
||||
from agent.model_metadata import get_model_context_length_async
|
||||
|
||||
_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
|
||||
try:
|
||||
from tools.terminal_scope import terminal_env as _ts_env
|
||||
except ImportError:
|
||||
_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
|
||||
else:
|
||||
_msg_cwd = _ts_env("TERMINAL_CWD", os.path.expanduser("~"))
|
||||
_msg_config_ctx = None
|
||||
_msg_cfg = None
|
||||
_msg_model_cfg = {}
|
||||
@@ -22855,7 +22881,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
model=agent_result.get("model"),
|
||||
context_tokens=agent_result.get("last_prompt_tokens", 0) or 0,
|
||||
context_length=agent_result.get("context_length") or None,
|
||||
cwd=os.environ.get("TERMINAL_CWD", ""),
|
||||
cwd=_terminal_scope_cwd(""),
|
||||
turn_seconds=_turn_seconds,
|
||||
)
|
||||
except Exception as _footer_err:
|
||||
|
||||
@@ -138,7 +138,13 @@ def format_runtime_footer(
|
||||
if turn_seconds is not None and turn_seconds >= 0:
|
||||
parts.append(_format_latency(turn_seconds))
|
||||
elif field == "cwd":
|
||||
rel = _home_relative_cwd(cwd or os.environ.get("TERMINAL_CWD", ""))
|
||||
try:
|
||||
from tools.terminal_scope import terminal_env as _tenv
|
||||
except ImportError:
|
||||
env_cwd = os.environ.get("TERMINAL_CWD", "")
|
||||
else:
|
||||
env_cwd = _tenv("TERMINAL_CWD", "")
|
||||
rel = _home_relative_cwd(cwd or env_cwd)
|
||||
if rel:
|
||||
parts.append(rel)
|
||||
# Unknown field names are silently ignored.
|
||||
|
||||
@@ -3440,7 +3440,9 @@ class GatewaySlashCommandsMixin:
|
||||
max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"],
|
||||
)
|
||||
|
||||
cwd = os.getenv("TERMINAL_CWD", str(Path.home()))
|
||||
from tools.terminal_scope import terminal_env as _tenv
|
||||
|
||||
cwd = _tenv("TERMINAL_CWD", str(Path.home()))
|
||||
arg = event.get_command_args().strip()
|
||||
|
||||
# --all / --force: classic full restore, overwriting user edits too.
|
||||
@@ -3534,7 +3536,9 @@ class GatewaySlashCommandsMixin:
|
||||
elif low == "session":
|
||||
mode = "session"
|
||||
|
||||
cwd = os.getenv("TERMINAL_CWD", str(Path.home()))
|
||||
from tools.terminal_scope import terminal_env as _tenv
|
||||
|
||||
cwd = _tenv("TERMINAL_CWD", str(Path.home()))
|
||||
|
||||
if mode == "session":
|
||||
return await self._gateway_session_diff(cwd, stat_only)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Per-turn terminal scope isolation under profile multiplexing (#68559 class).
|
||||
|
||||
One multiplexed process serves several profiles, but terminal.* used to
|
||||
resolve through the process-global ``TERMINAL_*`` env vars bridged once at
|
||||
startup — so every routed profile inherited the launch profile's backend,
|
||||
cwd, docker mounts and shared-container key (#68559, #94200, #101132,
|
||||
#95470). ``tools.terminal_scope`` installs the routed profile's COMPLETE
|
||||
terminal policy as a ContextVar at each profile boundary; readers resolve
|
||||
ONLY from it (omitted key → defined default, never ``os.environ``) and an
|
||||
unresolvable policy fails closed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.terminal_scope import (
|
||||
TerminalPolicyRefusal,
|
||||
TerminalPolicyUnavailable,
|
||||
get_terminal_scope,
|
||||
install_profile_terminal_scope,
|
||||
reset_terminal_scope,
|
||||
set_terminal_scope,
|
||||
terminal_env,
|
||||
)
|
||||
|
||||
_LAUNCH_CWD = "/home/launch-user/private"
|
||||
_LAUNCH_VOLUMES = '["/host/secret:/data:rw"]'
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _polluted_launch_env(monkeypatch, tmp_path):
|
||||
"""Launch profile A bridged a docker backend with sensitive policy into
|
||||
the process env; every test proves a routed profile observes none of it."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
monkeypatch.setenv("TERMINAL_ENV", "docker")
|
||||
monkeypatch.setenv("TERMINAL_CWD", _LAUNCH_CWD)
|
||||
monkeypatch.setenv("TERMINAL_DOCKER_VOLUMES", _LAUNCH_VOLUMES)
|
||||
monkeypatch.setenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "alpha-shared")
|
||||
monkeypatch.setenv("TERMINAL_SSH_HOST", "10.10.0.103")
|
||||
monkeypatch.setattr("agent.secret_scope.build_profile_secret_scope", lambda _h: {})
|
||||
monkeypatch.setattr("hermes_cli.env_loader.hydrate_profile_secret_sources", lambda _h: None)
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
monkeypatch.setattr(tt, "_terminal_config_bridge_attempted", True)
|
||||
yield
|
||||
|
||||
|
||||
def _profile(tmp_path, name, config_yaml="", dotenv=""):
|
||||
home = tmp_path / "profiles" / name
|
||||
home.mkdir(parents=True)
|
||||
if config_yaml:
|
||||
(home / "config.yaml").write_text(config_yaml, encoding="utf-8")
|
||||
if dotenv:
|
||||
(home / ".env").write_text(dotenv, encoding="utf-8")
|
||||
return home
|
||||
|
||||
|
||||
def test_no_scope_keeps_process_env_behavior():
|
||||
"""Single-process CLI/TUI (no scope bound) is byte-identical to before."""
|
||||
assert terminal_env("TERMINAL_ENV") == "docker"
|
||||
assert terminal_env("TERMINAL_SSH_HOST") == "10.10.0.103"
|
||||
|
||||
|
||||
def test_scoped_read_never_falls_through_to_process_env():
|
||||
"""Omitted key under a scope → defined default, NOT the ambient value."""
|
||||
token = set_terminal_scope({"TERMINAL_ENV": "local"})
|
||||
try:
|
||||
assert terminal_env("TERMINAL_ENV") == "local"
|
||||
assert terminal_env("TERMINAL_SSH_HOST") == ""
|
||||
assert terminal_env("TERMINAL_DOCKER_VOLUMES", "[]") == "[]"
|
||||
assert os.environ["TERMINAL_ENV"] == "docker" # never mutated
|
||||
finally:
|
||||
reset_terminal_scope(token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_yaml,dotenv",
|
||||
[
|
||||
pytest.param("terminal:\n backend: local\n cwd: {cwd}\n", "", id="config-yaml"),
|
||||
pytest.param("", "TERMINAL_ENV=local\nTERMINAL_CWD={cwd}\n", id="dotenv-only"),
|
||||
],
|
||||
)
|
||||
def test_routed_turn_reads_every_terminal_consumer_from_profile(
|
||||
tmp_path, config_yaml, dotenv
|
||||
):
|
||||
"""Leak matrix through the REAL gateway boundary: a routed local profile
|
||||
with its own cwd must be seen as such by every terminal.* consumer —
|
||||
terminal_tool config, container key resolution, docker media translation,
|
||||
file_tools/runtime_cwd cwd anchors, and the browser/env_probe backend
|
||||
checks — with none of launch profile A's docker policy showing through."""
|
||||
import gateway.run as gw
|
||||
import tools.terminal_tool as tt
|
||||
from agent import runtime_cwd
|
||||
from gateway.platforms import base as gbase
|
||||
from tools import browser_tool, env_probe, file_tools
|
||||
|
||||
b_cwd = tmp_path / "b-work"
|
||||
b_cwd.mkdir()
|
||||
home = _profile(
|
||||
tmp_path, "bee",
|
||||
config_yaml.format(cwd=b_cwd), dotenv.format(cwd=b_cwd),
|
||||
)
|
||||
|
||||
with gw._profile_runtime_scope(home):
|
||||
cfg = tt._get_env_config()
|
||||
assert cfg["env_type"] == "local"
|
||||
assert cfg["cwd"] == str(b_cwd)
|
||||
assert cfg["docker_volumes"] == []
|
||||
assert cfg["docker_shared_container_key"] == ""
|
||||
assert tt._resolve_container_task_id(None) == "default"
|
||||
assert gbase._parse_docker_volume_mounts() == []
|
||||
assert not any(
|
||||
"alpha-shared" in c for c in gbase._docker_sandbox_dir_candidates("agent:bee:x")
|
||||
)
|
||||
assert file_tools._configured_terminal_cwd() == str(b_cwd)
|
||||
assert runtime_cwd.resolve_agent_cwd() == b_cwd
|
||||
assert browser_tool._is_local_backend() is True
|
||||
# env_probe bails out with "" for remote backends; a local profile
|
||||
# must not be treated as remote just because the launch env is docker.
|
||||
assert env_probe._resolve_terminal_backend() == "local"
|
||||
assert get_terminal_scope() is None
|
||||
# Process env untouched — the launch profile's own turns are unchanged.
|
||||
assert os.environ["TERMINAL_DOCKER_VOLUMES"] == _LAUNCH_VOLUMES
|
||||
|
||||
|
||||
def test_profile_omitting_keys_gets_defaults_not_launch_values(tmp_path):
|
||||
"""#101132/#95470: a docker profile that does NOT set docker_volumes or
|
||||
docker_shared_container_key must not inherit the launch profile's."""
|
||||
import gateway.run as gw
|
||||
import tools.terminal_tool as tt
|
||||
|
||||
home = _profile(tmp_path, "bee", "terminal:\n backend: docker\n")
|
||||
with gw._profile_runtime_scope(home):
|
||||
cfg = tt._get_env_config()
|
||||
assert cfg["env_type"] == "docker"
|
||||
assert cfg["docker_volumes"] == []
|
||||
assert cfg["docker_shared_container_key"] == ""
|
||||
assert cfg["ssh_host"] == ""
|
||||
assert cfg["cwd"] != _LAUNCH_CWD
|
||||
assert json.loads(os.environ["TERMINAL_DOCKER_VOLUMES"]) # A unchanged
|
||||
|
||||
|
||||
def test_malformed_profile_config_refuses_execution(tmp_path):
|
||||
"""Unresolvable policy → refusal scope; terminal_tool refuses instead of
|
||||
running under the launch process's ambient policy (fail closed)."""
|
||||
from tools.terminal_tool import terminal_tool
|
||||
|
||||
home = _profile(tmp_path, "broken", "terminal: [unclosed\n")
|
||||
token = install_profile_terminal_scope(home)
|
||||
try:
|
||||
assert isinstance(get_terminal_scope(), TerminalPolicyRefusal)
|
||||
with pytest.raises(TerminalPolicyUnavailable):
|
||||
terminal_env("TERMINAL_ENV")
|
||||
result = terminal_tool(command="whoami")
|
||||
assert "terminal policy unavailable" in result
|
||||
finally:
|
||||
reset_terminal_scope(token)
|
||||
|
||||
|
||||
def test_gateway_runtime_scope_resets_on_error(tmp_path):
|
||||
import gateway.run as gw
|
||||
|
||||
home = _profile(tmp_path, "qa", "terminal:\n backend: local\n")
|
||||
with pytest.raises(RuntimeError):
|
||||
with gw._profile_runtime_scope(home):
|
||||
assert terminal_env("TERMINAL_ENV") == "local"
|
||||
raise RuntimeError("turn blew up")
|
||||
assert get_terminal_scope() is None
|
||||
|
||||
|
||||
def test_tui_and_cron_boundaries_bind_and_reset(tmp_path):
|
||||
import tui_gateway.server as server
|
||||
from tools.terminal_scope import install_and_reset_profile_terminal_scope
|
||||
|
||||
home = _profile(tmp_path, "dash", "terminal:\n backend: local\n")
|
||||
with server._session_profile_runtime_scope({"profile_home": str(home)}):
|
||||
assert terminal_env("TERMINAL_ENV") == "local"
|
||||
assert terminal_env("TERMINAL_SSH_HOST") == ""
|
||||
assert get_terminal_scope() is None
|
||||
with install_and_reset_profile_terminal_scope(home): # cron fire helper
|
||||
assert terminal_env("TERMINAL_ENV") == "local"
|
||||
assert get_terminal_scope() is None
|
||||
@@ -1032,7 +1032,11 @@ def _is_local_backend() -> bool:
|
||||
return False
|
||||
# When terminal runs in a container, browser on host can access
|
||||
# internal networks the terminal can't → treat as non-local.
|
||||
terminal_backend = os.getenv("TERMINAL_ENV", "local").strip().lower()
|
||||
# Scope-aware: under gateway multiplexing the routed profile's backend
|
||||
# lives in the per-turn terminal scope, not the process env (#68559).
|
||||
from tools.terminal_scope import terminal_env
|
||||
|
||||
terminal_backend = terminal_env("TERMINAL_ENV", "local").strip().lower()
|
||||
return terminal_backend in ("local", "")
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
+20
-6
@@ -198,18 +198,23 @@ def _pip_python_version() -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_terminal_backend() -> str:
|
||||
"""Scope-aware terminal backend name (``local`` when unresolvable)."""
|
||||
try:
|
||||
from tools.terminal_scope import terminal_env
|
||||
|
||||
return (terminal_env("TERMINAL_ENV") or "local").strip().lower()
|
||||
except Exception: # never let policy resolution break prompt building
|
||||
logger.debug("terminal backend resolution failed", exc_info=True)
|
||||
return "local"
|
||||
|
||||
|
||||
def _build_probe_line() -> str:
|
||||
"""Build the one-liner. Returns "" when nothing notable is detected.
|
||||
|
||||
Emit only when SOMETHING is off — the goal is to save the model from
|
||||
hitting an avoidable wall, not to narrate a healthy environment.
|
||||
"""
|
||||
# Bail out if a remote terminal backend is configured; the host's
|
||||
# Python state isn't where the agent's tools run.
|
||||
backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower()
|
||||
if backend in _REMOTE_BACKENDS or _plugin_backend_is_remote(backend):
|
||||
return ""
|
||||
|
||||
py3_ver = _python_version_of("python3")
|
||||
py_ver = _python_version_of("python") # for systems with a `python` alias
|
||||
py3_has_pip = _has_pip_module("python3") if py3_ver else False
|
||||
@@ -305,6 +310,15 @@ def get_environment_probe_line(*, force_refresh: bool = False) -> str:
|
||||
_PROBE_GEN += 1
|
||||
_WAIT_ALREADY_TIMED_OUT = False
|
||||
|
||||
# Resolve the backend HERE, in the caller's context: under gateway
|
||||
# multiplexing the routed profile's backend lives in the per-turn terminal
|
||||
# scope, which the bare probe worker thread does not inherit (#68559). A
|
||||
# remote backend answers "" without consulting the cache — the cached line
|
||||
# describes the HOST toolchain, not where that profile's tools run.
|
||||
backend = _resolve_terminal_backend()
|
||||
if backend in _REMOTE_BACKENDS or _plugin_backend_is_remote(backend):
|
||||
return ""
|
||||
|
||||
if _PROBE_DONE.is_set():
|
||||
return _CACHED_LINE or ""
|
||||
|
||||
|
||||
+5
-1
@@ -256,7 +256,11 @@ def _configured_terminal_cwd() -> str | None:
|
||||
relative to, which is exactly the ambiguity that misroutes worktree edits.
|
||||
Only an absolute, sentinel-free value is honored.
|
||||
"""
|
||||
return _sentinel_free_abs_cwd(os.environ.get("TERMINAL_CWD"))
|
||||
# Scope-aware: under gateway multiplexing the routed profile's cwd lives in
|
||||
# the per-turn terminal scope, not the process env (#68559).
|
||||
from agent.runtime_cwd import scope_terminal_cwd
|
||||
|
||||
return _sentinel_free_abs_cwd(scope_terminal_cwd() or None)
|
||||
|
||||
|
||||
def _registered_task_cwd_override(task_id: str = "default") -> str | None:
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Per-turn terminal scope: profile-scoped TERMINAL_* policy.
|
||||
|
||||
The multiplexing gateway (and the unified dashboard/TUI, and cron) serve
|
||||
several Hermes profiles from one process. Terminal settings were historically
|
||||
mirrored into the process-global ``os.environ`` (first writer wins), so the
|
||||
first profile to touch the terminal after startup pinned its backend — and
|
||||
every other setting — onto all later turns: a ``local`` profile silently
|
||||
executing inside another profile's docker sandbox, or the reverse (a sandbox
|
||||
escape). Mirrors the isolation seam that ``agent/secret_scope.py`` provides
|
||||
for credentials: a ContextVar holds the active profile's COMPLETE effective
|
||||
``TERMINAL_*`` policy, installed at each in-process profile boundary.
|
||||
|
||||
Two contracts distinguish this from a plain override dict:
|
||||
|
||||
- **Authoritative projection.** While a scope is bound, ``terminal_env``
|
||||
resolves ONLY from that policy (built from defined defaults + the profile's
|
||||
``.env`` + its ``config.yaml`` explicit keys). Omitted keys resolve to the
|
||||
defined default — never to ambient ``os.environ`` — so a routed profile can
|
||||
neither inherit nor be escaped onto the launch process's mounts, SSH
|
||||
targets, or resource policy (#68559).
|
||||
- **Fail closed.** If the profile's policy cannot be resolved (unreadable or
|
||||
malformed ``.env``/``config.yaml``), the install raises
|
||||
:class:`TerminalPolicyUnavailable` and callers must install a *refusal*
|
||||
scope; terminal execution under a refusal scope is rejected outright
|
||||
rather than falling back to ambient authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar, Token
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ``None`` = no scope bound in this context; readers use the historical
|
||||
# process-env behavior (single-process CLI/TUI, unaffected surfaces).
|
||||
# A dict = the active profile's complete effective terminal policy.
|
||||
# A TerminalPolicyRefusal = resolution failed; terminal execution must refuse.
|
||||
_terminal_scope_var: ContextVar = ContextVar("hermes_terminal_scope", default=None)
|
||||
|
||||
|
||||
class TerminalPolicyUnavailable(Exception):
|
||||
"""The routed profile's terminal policy could not be resolved.
|
||||
|
||||
Raised when the profile's ``.env`` or ``config.yaml`` exists but cannot be
|
||||
read/parsed. Callers must install the returned refusal scope instead of
|
||||
continuing without a scope — executing under ambient process authority is
|
||||
exactly the leak this module exists to close.
|
||||
"""
|
||||
|
||||
|
||||
class TerminalPolicyRefusal(Dict[str, str]):
|
||||
"""Marker scope installed when policy resolution failed.
|
||||
|
||||
An (empty) dict subclass so existing dict-typed checks keep working, with
|
||||
a flag that makes ``terminal_env`` raise before any value is served.
|
||||
"""
|
||||
|
||||
refused = True
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
super().__init__()
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def set_terminal_scope(mapping: Optional[Dict[str, str]]) -> Token:
|
||||
"""Install *mapping* as the current context's terminal policy."""
|
||||
return _terminal_scope_var.set(mapping)
|
||||
|
||||
|
||||
def install_refusal_scope(reason: str) -> Token:
|
||||
"""Install a refusal scope after :class:`TerminalPolicyUnavailable`.
|
||||
|
||||
Terminal execution under this scope is rejected (fail closed) instead of
|
||||
running under the launch process's ambient policy.
|
||||
"""
|
||||
return _terminal_scope_var.set(TerminalPolicyRefusal(reason))
|
||||
|
||||
|
||||
def reset_terminal_scope(token: Token) -> None:
|
||||
_terminal_scope_var.reset(token)
|
||||
|
||||
|
||||
def get_terminal_scope() -> Optional[Dict[str, str]]:
|
||||
"""The active scope mapping/refusal, or ``None`` when no scope is bound."""
|
||||
return _terminal_scope_var.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def terminal_scope(mapping: Optional[Dict[str, str]]) -> Iterator[None]:
|
||||
"""Context manager form of set/reset_terminal_scope."""
|
||||
token = set_terminal_scope(mapping)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_terminal_scope(token)
|
||||
|
||||
|
||||
def terminal_env(name: str, default: str = "") -> str:
|
||||
"""Authoritative read of a ``TERMINAL_*`` variable.
|
||||
|
||||
- No scope bound: process env, then *default* (historical single-process
|
||||
behavior — CLI/TUI surfaces that never route profiles are unchanged).
|
||||
- Refusal scope bound: raise — policy is unavailable and execution must
|
||||
fail closed, not fall back to ambient authority.
|
||||
- Policy scope bound: resolve ONLY from the policy; a missing key yields
|
||||
the *default* (which callers derive from defined defaults), never
|
||||
``os.environ``.
|
||||
"""
|
||||
scope = _terminal_scope_var.get()
|
||||
if scope is None:
|
||||
import os
|
||||
|
||||
return os.environ.get(name, default)
|
||||
if isinstance(scope, TerminalPolicyRefusal):
|
||||
raise TerminalPolicyUnavailable(
|
||||
f"terminal policy unavailable for this profile: {scope.reason}"
|
||||
)
|
||||
value = scope.get(name)
|
||||
if value is not None:
|
||||
return str(value)
|
||||
return default
|
||||
|
||||
|
||||
def build_profile_terminal_scope(hermes_home: "Any") -> Dict[str, str]:
|
||||
"""Build the COMPLETE effective ``TERMINAL_*`` policy for a profile home.
|
||||
|
||||
Projection order: defined defaults (``DEFAULT_CONFIG['terminal']``) ← the
|
||||
profile's ``.env`` TERMINAL_* selections ← its ``config.yaml`` explicit
|
||||
``terminal:`` keys. The result is total: every key the terminal stack can
|
||||
ask for resolves from this mapping, so a bound scope never widens back to
|
||||
ambient process authority. Raises :class:`TerminalPolicyUnavailable` when
|
||||
either file exists but cannot be read/parsed (fail closed).
|
||||
"""
|
||||
home = Path(hermes_home)
|
||||
|
||||
from hermes_cli.config_defaults import DEFAULT_CONFIG
|
||||
|
||||
defaults = DEFAULT_CONFIG.get("terminal") if isinstance(
|
||||
DEFAULT_CONFIG, dict) else None
|
||||
defaults = dict(defaults) if isinstance(defaults, dict) else {}
|
||||
# Terminal keys whose env mirror exists but whose config default lives in
|
||||
# the consuming tool rather than DEFAULT_CONFIG. These are the documented
|
||||
# tool-level defaults (tools/terminal_tool.py); without them the
|
||||
# projection would not be total and reads could observe nothing (which is
|
||||
# correct) OR fall back ambiently (which is not).
|
||||
defaults.setdefault("cwd", ".") # per-surface placeholder
|
||||
defaults.setdefault("ssh_host", "") # remote backends: unset = none
|
||||
defaults.setdefault("ssh_user", "")
|
||||
defaults.setdefault("ssh_port", 22)
|
||||
defaults.setdefault("ssh_key", "")
|
||||
defaults.setdefault("docker_orphan_reaper", True)
|
||||
defaults.setdefault("docker_persist_across_processes", True)
|
||||
defaults.setdefault("sandbox_dir", "") # tool derives HERMES_HOME path
|
||||
defaults.setdefault("lifetime_seconds", 300)
|
||||
defaults.setdefault("docker_shared_container_key", "")
|
||||
defaults.setdefault("home_mode", "auto")
|
||||
|
||||
scope: Dict[str, str] = {}
|
||||
|
||||
def _apply(cfg_key: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
# cwd placeholders (".", "auto", "cwd") are resolved per-surface
|
||||
# later; they are not a policy value.
|
||||
if cfg_key == "cwd" and str(value).strip() in {".", "auto", "cwd"}:
|
||||
return
|
||||
from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP
|
||||
|
||||
env_var = TERMINAL_CONFIG_ENV_MAP.get(cfg_key)
|
||||
if env_var:
|
||||
scope[env_var] = str(value)
|
||||
|
||||
# 1) Defined defaults — the total baseline.
|
||||
for cfg_key, value in defaults.items():
|
||||
_apply(cfg_key, value)
|
||||
|
||||
# 2) The profile's .env TERMINAL_* selections. Fail closed on unreadable
|
||||
# files (missing file = no selections, fine).
|
||||
env_path = home / ".env"
|
||||
if env_path.exists():
|
||||
# Pre-flight readability: load_env_file swallows OSError/UnicodeError
|
||||
# by design (secret scope fails soft), but an unreadable profile .env
|
||||
# is a policy-resolution failure here and must fail closed.
|
||||
try:
|
||||
env_path.read_bytes()
|
||||
except Exception as exc:
|
||||
raise TerminalPolicyUnavailable(
|
||||
f"cannot read {env_path}: {exc}"
|
||||
) from exc
|
||||
from agent.secret_scope import load_env_file
|
||||
|
||||
selections = load_env_file(env_path)
|
||||
for key, value in selections.items():
|
||||
if key.startswith("TERMINAL_"):
|
||||
scope[key] = str(value)
|
||||
|
||||
# 3) The profile's config.yaml explicit terminal keys. Read through the
|
||||
# HERMES_HOME override so the profile's own file is consulted; a
|
||||
# present-but-unparseable file fails closed (matches the gateway's
|
||||
# _warn_config_parse_failure posture of refusing to guess policy).
|
||||
from hermes_constants import (
|
||||
get_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
|
||||
override_token = None
|
||||
if get_hermes_home_override() != str(home):
|
||||
override_token = set_hermes_home_override(home)
|
||||
try:
|
||||
config_path = home / "config.yaml"
|
||||
if config_path.exists():
|
||||
# Parse the profile's file directly rather than through
|
||||
# read_raw_config(): that helper collapses "missing" and
|
||||
# "unparseable" into the same {} result. Here the file's existence
|
||||
# is already established, so {} can only mean a parse failure —
|
||||
# which must fail closed rather than silently projecting defaults.
|
||||
from hermes_cli.config import fast_safe_load
|
||||
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw = fast_safe_load(f)
|
||||
except Exception as exc:
|
||||
raise TerminalPolicyUnavailable(
|
||||
f"cannot parse {config_path}: {exc}"
|
||||
) from exc
|
||||
raw_terminal = raw.get("terminal") if isinstance(raw, dict) else None
|
||||
if isinstance(raw_terminal, dict):
|
||||
for cfg_key, value in raw_terminal.items():
|
||||
_apply(cfg_key, value)
|
||||
except TerminalPolicyUnavailable:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise TerminalPolicyUnavailable(
|
||||
f"cannot resolve terminal config in {home}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if override_token is not None:
|
||||
reset_hermes_home_override(override_token)
|
||||
|
||||
return scope
|
||||
|
||||
|
||||
def install_profile_terminal_scope(hermes_home: "Any") -> Token:
|
||||
"""Build AND install a profile's policy in one call.
|
||||
|
||||
The single entry point for every profile boundary (gateway turn, TUI/
|
||||
dashboard turn, cron fire). On resolution failure this installs the
|
||||
refusal scope instead of raising — the turn continues only in the sense
|
||||
that terminal tools will refuse execution with the typed reason; it never
|
||||
falls back to ambient process policy.
|
||||
|
||||
Returns the token for ``reset_terminal_scope``.
|
||||
"""
|
||||
try:
|
||||
return set_terminal_scope(build_profile_terminal_scope(hermes_home))
|
||||
except TerminalPolicyUnavailable as exc:
|
||||
logger.warning("terminal policy unavailable: %s", exc)
|
||||
return install_refusal_scope(str(exc))
|
||||
|
||||
|
||||
def enforce_no_refusal() -> None:
|
||||
"""Raise when the active scope is a refusal scope (fail closed).
|
||||
|
||||
Execution paths (terminal tool, execute_code) call this before spawning
|
||||
anything: under a refusal scope the profile's terminal policy could not be
|
||||
resolved, and running with the launch process's ambient policy is exactly
|
||||
the authority leak this module closes (#68559 requires refusal, not
|
||||
fallback). Non-scoped and policy-scoped contexts pass silently.
|
||||
"""
|
||||
scope = _terminal_scope_var.get()
|
||||
if isinstance(scope, TerminalPolicyRefusal):
|
||||
raise TerminalPolicyUnavailable(
|
||||
f"terminal policy unavailable for this profile: {scope.reason}"
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def install_and_reset_profile_terminal_scope(
|
||||
hermes_home: "Any",
|
||||
) -> Iterator[None]:
|
||||
"""Install the profile's terminal policy for a bounded turn/fire.
|
||||
|
||||
Single call for every in-process profile boundary (gateway turn,
|
||||
dashboard/TUI turn, cron fire): builds the complete effective policy and
|
||||
resets it on exit. Resolution failure installs the refusal scope for the
|
||||
same duration — terminal execution inside the block raises (fail closed)
|
||||
instead of inheriting the launch process's ambient policy. Never raises.
|
||||
"""
|
||||
token = install_profile_terminal_scope(hermes_home)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_terminal_scope(token)
|
||||
+80
-43
@@ -839,7 +839,7 @@ def _sudo_nopasswd_works() -> bool:
|
||||
cache) so an expired sudo timestamp cannot make a later command silently
|
||||
block waiting for a password.
|
||||
"""
|
||||
terminal_env = os.getenv("TERMINAL_ENV", "local").strip().lower() or "local"
|
||||
terminal_env = _tenv("TERMINAL_ENV", "local").strip().lower() or "local"
|
||||
if terminal_env != "local":
|
||||
return False
|
||||
|
||||
@@ -1195,7 +1195,7 @@ def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None:
|
||||
# ``container_config`` only carries container_* keys, so read
|
||||
# lifetime_seconds from the env var the rest of the module uses.
|
||||
try:
|
||||
lifetime = int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300"))
|
||||
lifetime = int(_tenv("TERMINAL_LIFETIME_SECONDS", "300"))
|
||||
except (TypeError, ValueError):
|
||||
lifetime = 300
|
||||
lifetime = max(60, lifetime)
|
||||
@@ -1384,12 +1384,12 @@ def _session_isolation_enabled() -> bool:
|
||||
attach one live VM and delete it out from under each other).
|
||||
"""
|
||||
_ensure_terminal_env_bridged()
|
||||
env_type = os.getenv("TERMINAL_ENV", "local")
|
||||
env_type = _tenv("TERMINAL_ENV", "local")
|
||||
if env_type != "docker" and not _plugin_env_flag(
|
||||
env_type, "session_isolated_when_nonpersistent"
|
||||
):
|
||||
return False
|
||||
return os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() not in {"true", "1", "yes"}
|
||||
return _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() not in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def _docker_session_isolation_enabled() -> bool:
|
||||
@@ -1399,7 +1399,7 @@ def _docker_session_isolation_enabled() -> bool:
|
||||
selection, session-scoped container teardown) key off it; those must
|
||||
not fire for other backends.
|
||||
"""
|
||||
if os.getenv("TERMINAL_ENV", "local") != "docker":
|
||||
if _tenv("TERMINAL_ENV", "local") != "docker":
|
||||
return False
|
||||
return _session_isolation_enabled()
|
||||
|
||||
@@ -1419,9 +1419,9 @@ def _docker_persistent_profile_scoped() -> bool:
|
||||
keep the session-scoped cache key that fixed the original leak.
|
||||
"""
|
||||
_ensure_terminal_env_bridged()
|
||||
if os.getenv("TERMINAL_ENV", "local") != "docker":
|
||||
if _tenv("TERMINAL_ENV", "local") != "docker":
|
||||
return False
|
||||
return os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"}
|
||||
return _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"}
|
||||
|
||||
|
||||
def _current_session_profile() -> str:
|
||||
@@ -1515,7 +1515,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str:
|
||||
# Explicit opt-in: trusted profiles configuring the same
|
||||
# terminal.docker_shared_container_key share ONE container/cache
|
||||
# slot (and sandbox dir) regardless of profile name (#84671).
|
||||
shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip()
|
||||
shared = _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip()
|
||||
if shared:
|
||||
return f"shared:{shared}"
|
||||
profile = _current_session_profile() or "default"
|
||||
@@ -1528,7 +1528,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str:
|
||||
# sessions land in "shared:<key>" — splitting the very container the
|
||||
# setting exists to unify.
|
||||
if _docker_persistent_profile_scoped():
|
||||
shared = os.getenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip()
|
||||
shared = _tenv("TERMINAL_DOCKER_SHARED_CONTAINER_KEY", "").strip()
|
||||
if shared:
|
||||
return f"shared:{shared}"
|
||||
return "default"
|
||||
@@ -1607,6 +1607,10 @@ def _parse_env_var(name: str, default: str, converter: Any = int, type_label: st
|
||||
causes an unhandled ValueError that kills every terminal command.
|
||||
"""
|
||||
raw = os.getenv(name, default)
|
||||
if name.startswith("TERMINAL_"):
|
||||
# Scope-aware: under gateway multiplexing the active profile's
|
||||
# per-turn scope overrides the process env.
|
||||
raw = _tenv(name, default)
|
||||
try:
|
||||
return converter(raw)
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
@@ -1632,7 +1636,7 @@ def _safe_getcwd() -> str:
|
||||
try:
|
||||
return os.getcwd()
|
||||
except (FileNotFoundError, PermissionError):
|
||||
return os.getenv("TERMINAL_CWD") or os.path.expanduser("~")
|
||||
return _tenv("TERMINAL_CWD") or os.path.expanduser("~")
|
||||
|
||||
|
||||
# Path prefixes that identify a *host* working directory which cannot exist
|
||||
@@ -1703,6 +1707,20 @@ def _is_unusable_container_cwd(cwd: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _tenv(name: str, default: str = "") -> str:
|
||||
"""Scope-aware read of a ``TERMINAL_*`` variable.
|
||||
|
||||
Every terminal setting read in this module must go through this helper:
|
||||
under gateway multiplexing the active profile's terminal config arrives
|
||||
via a per-turn scope (``tools.terminal_scope``), and a raw ``os.getenv``
|
||||
would read whatever profile's config a previous turn pinned into the
|
||||
process env (the cross-profile backend leak fixed here).
|
||||
"""
|
||||
from tools.terminal_scope import terminal_env
|
||||
|
||||
return terminal_env(name, default)
|
||||
|
||||
|
||||
# One-shot guard for the config-fallback bridge below. Purely an
|
||||
# optimization: after the first attempt either TERMINAL_ENV is set (bridge
|
||||
# succeeded — merged config always carries terminal.backend) or the import
|
||||
@@ -1728,7 +1746,17 @@ def _ensure_terminal_env_bridged() -> None:
|
||||
be stale from ``hermes setup``). Environment values for omitted terminal
|
||||
keys are preserved. When no terminal section exists, exported/.env values
|
||||
keep working unchanged.
|
||||
|
||||
A per-turn terminal scope (multiplexed gateway / profile-scoped cron)
|
||||
suppresses this bridge entirely: the scope holds the active profile's
|
||||
authoritative values and reads fall through ``_tenv`` — writing them into
|
||||
the process-global ``os.environ`` would re-create the first-writer-wins
|
||||
cross-profile leak the scope exists to fix.
|
||||
"""
|
||||
from tools.terminal_scope import get_terminal_scope
|
||||
|
||||
if get_terminal_scope() is not None:
|
||||
return
|
||||
global _terminal_config_bridge_attempted
|
||||
if _terminal_config_bridge_attempted:
|
||||
return
|
||||
@@ -1762,9 +1790,9 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
# Default image with Python and Node.js for maximum compatibility
|
||||
default_image = "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
_ensure_terminal_env_bridged()
|
||||
env_type = os.getenv("TERMINAL_ENV", "local")
|
||||
env_type = _tenv("TERMINAL_ENV", "local")
|
||||
|
||||
mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"}
|
||||
mount_docker_cwd = _tenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"}
|
||||
container_backend = _is_container_backend(env_type)
|
||||
docker_backend = env_type == "docker"
|
||||
|
||||
@@ -1786,7 +1814,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
docker_volumes = _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON")
|
||||
docker_env = _parse_env_var("TERMINAL_DOCKER_ENV", "{}", json.loads, "valid JSON")
|
||||
docker_extra_args = _parse_env_var("TERMINAL_DOCKER_EXTRA_ARGS", "[]", json.loads, "valid JSON")
|
||||
docker_shm_size = os.getenv("TERMINAL_DOCKER_SHM_SIZE", "1g")
|
||||
docker_shm_size = _tenv("TERMINAL_DOCKER_SHM_SIZE", "1g")
|
||||
else:
|
||||
docker_forward_env = []
|
||||
docker_volumes = []
|
||||
@@ -1810,13 +1838,13 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
# If Docker cwd passthrough is explicitly enabled, remap the host path to
|
||||
# /workspace and track the original host path separately. Otherwise keep the
|
||||
# normal sandbox behavior and discard host paths.
|
||||
cwd = os.getenv("TERMINAL_CWD", default_cwd)
|
||||
cwd = _tenv("TERMINAL_CWD", default_cwd)
|
||||
from hermes_cli.config import _is_ssh_remote_tilde_cwd
|
||||
if cwd and not _is_ssh_remote_tilde_cwd(env_type, cwd):
|
||||
cwd = os.path.expanduser(cwd)
|
||||
host_cwd = None
|
||||
if env_type == "docker" and mount_docker_cwd:
|
||||
docker_cwd_source = os.getenv("TERMINAL_CWD") or _safe_getcwd()
|
||||
docker_cwd_source = _tenv("TERMINAL_CWD") or _safe_getcwd()
|
||||
candidate = os.path.abspath(os.path.expanduser(docker_cwd_source))
|
||||
if (
|
||||
any(candidate.startswith(p) for p in _HOST_CWD_PREFIXES)
|
||||
@@ -1834,41 +1862,41 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
|
||||
return {
|
||||
"env_type": env_type,
|
||||
"modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
|
||||
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
|
||||
"modal_mode": coerce_modal_mode(_tenv("TERMINAL_MODAL_MODE", "auto")),
|
||||
"docker_image": _tenv("TERMINAL_DOCKER_IMAGE", default_image),
|
||||
"docker_forward_env": docker_forward_env,
|
||||
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||
"modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image),
|
||||
"daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image),
|
||||
"vercel_runtime": os.getenv("TERMINAL_VERCEL_RUNTIME", "").strip(),
|
||||
"singularity_image": _tenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||
"modal_image": _tenv("TERMINAL_MODAL_IMAGE", default_image),
|
||||
"daytona_image": _tenv("TERMINAL_DAYTONA_IMAGE", default_image),
|
||||
"vercel_runtime": _tenv("TERMINAL_VERCEL_RUNTIME", "").strip(),
|
||||
"cwd": cwd,
|
||||
"host_cwd": host_cwd,
|
||||
"docker_mount_cwd_to_workspace": mount_docker_cwd,
|
||||
"timeout": _parse_env_var("TERMINAL_TIMEOUT", "180"),
|
||||
"lifetime_seconds": _parse_env_var("TERMINAL_LIFETIME_SECONDS", "300"),
|
||||
# SSH-specific config
|
||||
"ssh_host": os.getenv("TERMINAL_SSH_HOST", ""),
|
||||
"ssh_user": os.getenv("TERMINAL_SSH_USER", ""),
|
||||
"ssh_host": _tenv("TERMINAL_SSH_HOST", ""),
|
||||
"ssh_user": _tenv("TERMINAL_SSH_USER", ""),
|
||||
"ssh_port": _parse_env_var("TERMINAL_SSH_PORT", "22"),
|
||||
"ssh_key": os.getenv("TERMINAL_SSH_KEY", ""),
|
||||
"ssh_key": _tenv("TERMINAL_SSH_KEY", ""),
|
||||
# Persistent shell: SSH defaults to the config-level persistent_shell
|
||||
# setting (true by default for non-local backends); local is always opt-in.
|
||||
# Per-backend env vars override if explicitly set.
|
||||
"ssh_persistent": os.getenv(
|
||||
"ssh_persistent": _tenv(
|
||||
"TERMINAL_SSH_PERSISTENT",
|
||||
os.getenv("TERMINAL_PERSISTENT_SHELL", "true"),
|
||||
_tenv("TERMINAL_PERSISTENT_SHELL", "true"),
|
||||
).lower() in {"true", "1", "yes"},
|
||||
"local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"},
|
||||
"local_persistent": _tenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"},
|
||||
# Container resource config (applies to docker, singularity, modal,
|
||||
# daytona, and vercel_sandbox -- ignored for local/ssh)
|
||||
"container_cpu": container_cpu,
|
||||
"container_memory": container_memory, # MB (default 5GB)
|
||||
"container_disk": container_disk, # MB (default 50GB)
|
||||
"container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"},
|
||||
"container_persistent": _tenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"},
|
||||
"docker_volumes": docker_volumes,
|
||||
"docker_env": docker_env,
|
||||
"docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"},
|
||||
"docker_network": os.getenv("TERMINAL_DOCKER_NETWORK", "true").lower() in {"true", "1", "yes"},
|
||||
"docker_run_as_host_user": _tenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"},
|
||||
"docker_network": _tenv("TERMINAL_DOCKER_NETWORK", "true").lower() in {"true", "1", "yes"},
|
||||
"docker_extra_args": docker_extra_args,
|
||||
"docker_shm_size": docker_shm_size,
|
||||
# Cross-process container reuse (issue #20561). The docs claim
|
||||
@@ -1877,17 +1905,17 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
# attaching to it instead of always starting a fresh one. Set to
|
||||
# ``false`` for hard per-process isolation (no reuse, container is
|
||||
# removed on exit).
|
||||
"docker_persist_across_processes": os.getenv(
|
||||
"docker_persist_across_processes": _tenv(
|
||||
"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "true"
|
||||
).lower() in {"true", "1", "yes"},
|
||||
"docker_shared_container_key": os.getenv(
|
||||
"docker_shared_container_key": _tenv(
|
||||
"TERMINAL_DOCKER_SHARED_CONTAINER_KEY", ""
|
||||
).strip(),
|
||||
# Startup orphan reaper for hermes-tagged containers left behind by
|
||||
# crashed / SIGKILL'd previous processes that bypassed atexit.
|
||||
# Conservative: only sweeps Exited containers older than 2× the
|
||||
# idle-reap window AND scoped to the current profile. Issue #20561.
|
||||
"docker_orphan_reaper": os.getenv(
|
||||
"docker_orphan_reaper": _tenv(
|
||||
"TERMINAL_DOCKER_ORPHAN_REAPER", "true"
|
||||
).lower() in {"true", "1", "yes"},
|
||||
}
|
||||
@@ -2877,6 +2905,15 @@ def terminal_tool(
|
||||
config = _get_env_config()
|
||||
env_type = "local" if _host_local else config["env_type"]
|
||||
|
||||
# Fail closed under a refusal scope (#68559): the routed profile's
|
||||
# terminal policy could not be resolved, so executing with the launch
|
||||
# process's ambient policy is forbidden — refuse with a typed,
|
||||
# model-actionable error instead.
|
||||
if not _host_local:
|
||||
from tools.terminal_scope import enforce_no_refusal
|
||||
|
||||
enforce_no_refusal()
|
||||
|
||||
# Use task_id for environment isolation. By default all subagent
|
||||
# task_ids collapse back to "default" so the top-level agent and
|
||||
# every delegate_task child share one container; only task_ids with
|
||||
@@ -3868,7 +3905,7 @@ def terminal_tool(
|
||||
# warn (default) — return a structured degraded result the model
|
||||
# can act on (reason + retry hint, no traceback).
|
||||
# fail — preserve the historical error+traceback result.
|
||||
degraded_mode = os.getenv("TERMINAL_DEGRADED_MODE", "warn").strip().lower()
|
||||
degraded_mode = _tenv("TERMINAL_DEGRADED_MODE", "warn").strip().lower()
|
||||
if degraded_mode == "fail":
|
||||
import traceback
|
||||
tb_str = traceback.format_exc()
|
||||
@@ -4089,18 +4126,18 @@ if __name__ == "__main__":
|
||||
default_img = "nikolaik/python-nodejs:python3.11-nodejs20"
|
||||
print(
|
||||
" TERMINAL_ENV: "
|
||||
f"{os.getenv('TERMINAL_ENV', 'local')} "
|
||||
f"{_tenv('TERMINAL_ENV', 'local')} "
|
||||
"(local/docker/singularity/modal/daytona/vercel_sandbox/ssh)"
|
||||
)
|
||||
print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
|
||||
print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', _safe_getcwd())}")
|
||||
print(f" TERMINAL_DOCKER_IMAGE: {_tenv('TERMINAL_DOCKER_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_SINGULARITY_IMAGE: {_tenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}")
|
||||
print(f" TERMINAL_MODAL_IMAGE: {_tenv('TERMINAL_MODAL_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_DAYTONA_IMAGE: {_tenv('TERMINAL_DAYTONA_IMAGE', default_img)}")
|
||||
print(f" TERMINAL_CWD: {_tenv('TERMINAL_CWD', _safe_getcwd())}")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}")
|
||||
print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}")
|
||||
print(f" TERMINAL_LIFETIME_SECONDS: {os.getenv('TERMINAL_LIFETIME_SECONDS', '300')}")
|
||||
print(f" TERMINAL_SANDBOX_DIR: {_tenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}")
|
||||
print(f" TERMINAL_TIMEOUT: {_tenv('TERMINAL_TIMEOUT', '60')}")
|
||||
print(f" TERMINAL_LIFETIME_SECONDS: {_tenv('TERMINAL_LIFETIME_SECONDS', '300')}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3415,6 +3415,7 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
||||
notify_registered = False
|
||||
home_token = None
|
||||
secret_token = None
|
||||
build_terminal_token = None
|
||||
session_db = None
|
||||
owns_db = False
|
||||
profile_home = current.get("profile_home")
|
||||
@@ -3441,6 +3442,21 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
|
||||
except Exception:
|
||||
pass
|
||||
# Bind the profile's COMPLETE terminal policy for the agent
|
||||
# build (fail-closed: malformed policy → refusal scope) so
|
||||
# _make_agent's terminal probing / cwd hints resolve the
|
||||
# routed profile, never the launch process (#98581 class).
|
||||
try:
|
||||
from tools.terminal_scope import (
|
||||
install_profile_terminal_scope,
|
||||
reset_terminal_scope,
|
||||
)
|
||||
|
||||
build_terminal_token = install_profile_terminal_scope(
|
||||
Path(profile_home)
|
||||
)
|
||||
except Exception:
|
||||
build_terminal_token = None
|
||||
# DEDICATED handle — ours until _transfer_db_to_agent hands
|
||||
# it to the built agent in the finally below. Every path
|
||||
# that leaves this build without that transfer (the except
|
||||
@@ -3592,6 +3608,13 @@ def _start_agent_build(sid: str, session: dict) -> None:
|
||||
reset_secret_scope(secret_token)
|
||||
except Exception:
|
||||
pass
|
||||
if build_terminal_token is not None:
|
||||
try:
|
||||
from tools.terminal_scope import reset_terminal_scope
|
||||
|
||||
reset_terminal_scope(build_terminal_token)
|
||||
except Exception:
|
||||
pass
|
||||
# _attach_worker already closed the worker if this session was
|
||||
# reaped mid-build; only the late notify registration can still
|
||||
# leak (session.close unregistered before _build registered it).
|
||||
@@ -6457,9 +6480,20 @@ def _session_profile_runtime_scope(session: dict):
|
||||
return
|
||||
home_token = set_hermes_home_override(profile_home)
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(Path(profile_home)))
|
||||
# Same authoritative terminal policy the gateway binds per turn (#68559):
|
||||
# a docker-configured dashboard profile must never resolve the launch
|
||||
# process's pinned env. Failure → refusal scope (fail closed).
|
||||
from tools.terminal_scope import (
|
||||
install_profile_terminal_scope as _install_term_scope,
|
||||
)
|
||||
|
||||
terminal_token = _install_term_scope(Path(profile_home))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
from tools.terminal_scope import reset_terminal_scope
|
||||
|
||||
reset_terminal_scope(terminal_token)
|
||||
reset_secret_scope(secret_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
@@ -13226,6 +13260,20 @@ def _run_prompt_submit(
|
||||
if _profile_home_str:
|
||||
home_token = set_hermes_home_override(_profile_home_str)
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(Path(_profile_home_str)))
|
||||
# Fourth profile seam: bind the session profile's COMPLETE
|
||||
# terminal policy for this turn (dashboard/TUI analogue of the
|
||||
# gateway's per-turn scope). #98581's unified-desktop
|
||||
# reproduction ran a docker-configured profile on the host
|
||||
# because terminal_tool read the launch process's pinned env.
|
||||
# Failure installs a refusal scope → terminal tools raise
|
||||
# (fail closed) instead of inheriting ambient policy.
|
||||
from tools.terminal_scope import (
|
||||
install_profile_terminal_scope as _install_term_scope,
|
||||
)
|
||||
|
||||
_terminal_scope_token = _install_term_scope(Path(_profile_home_str))
|
||||
else:
|
||||
_terminal_scope_token = None
|
||||
# The sudo password callback is thread-local (tools.terminal_tool
|
||||
# _callback_tls), so wiring it on the build thread doesn't reach this
|
||||
# turn thread — terminal sudo prompts would fall through to /dev/tty
|
||||
@@ -14003,6 +14051,10 @@ def _run_prompt_submit(
|
||||
reset_hermes_home_override(home_token)
|
||||
if secret_token is not None:
|
||||
reset_secret_scope(secret_token)
|
||||
if _terminal_scope_token is not None:
|
||||
from tools.terminal_scope import reset_terminal_scope
|
||||
|
||||
reset_terminal_scope(_terminal_scope_token)
|
||||
_clear_session_context(session_tokens)
|
||||
_current_runtime_session_record.reset(runtime_session_token)
|
||||
reset_transport(transport_token)
|
||||
|
||||
Reference in New Issue
Block a user