Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user