Files
hermes-agent/tools/code_execution_env.py
T
unsupportedpastels 979c08e35d fix(tools): run a CLI/TUI session's tools with its client's toolchain environment
On main the terminal tool, its background processes and execute_code ran
with the launching shell's environment: an activated venv, nvm/pyenv
shims, PATH. Under the one gateway they ran with the daemon's
environment, so `python`, `node` and friends resolved to whatever the
gateway was started with.

The allowlisted toolchain part of a session's client_env (PATH,
VIRTUAL_ENV, CONDA_*/NODE_*/NVM_*/PYENV_*, GOPATH, GOROOT, CARGO_HOME,
RUSTUP_HOME, JAVA_HOME, plus names a profile lists in the new
terminal.client_env_passthrough) now replaces the daemon's values for that
session's local terminal commands, background processes and execute_code
children only, including execute_code's project-mode interpreter choice.
PATH stays the gateway's when the client sent none. The backend's own
terminal.env still wins. No full shell snapshot is taken. Messaging, cron
and Desktop sessions keep the gateway's environment.

Credential-looking names are never sent, stored or bound: one predicate,
is_secret_env_name, now shared with execute_code's secret policy, drops
them on the client, before the policy is stored and again before binding
(secrets keep using terminal.env_passthrough). execute_code applies its
whole scrub, including the delegated-child restrictions, after merging the
client's toolchain, and its list now also strips *COOKIE* names.

Refs #134889 (T3)
Refs #139523
2026-10-16 17:49:54 -04:00

316 lines
16 KiB
Python

"""Child-process environment for execute_code: env scrubbing, interpreter and cwd resolution.
Both the per-call remote path and the local session kernel build their child
env through ``_build_child_env`` so the security rules (secret scrubbing,
PYTHONPATH hygiene, UTF-8 forcing, TZ) cannot drift between them.
"""
import logging
import os
import platform
import subprocess
import sys
from typing import Dict
# Logger name kept as the origin module's so existing log expectations hold.
logger = logging.getLogger("tools.code_execution_tool")
_IS_WINDOWS = platform.system() == "Windows"
# Scrub order: secret-substring block first; whatever is left must match a safe
# prefix, the exact-name HERMES_ allowlist, or (Windows) an OS-essential name.
# The broad "HERMES_" prefix is deliberately NOT safe — it leaked config vars
# without a secret substring (HERMES_BASE_URL, HERMES_KANBAN_DB, *_WEBHOOK).
# HERMES_RPC_SOCKET / HERMES_RPC_DIR / TZ / HOME are injected after scrubbing.
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM", "TMPDIR", "TMP", "TEMP", "SHELL",
"LOGNAME", "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
# "PASS" is intentionally absent: it false-positives on BYPASS_CACHE /
# COMPASS_DIR / PASSENGER_HOST while PASSWORD/PASSWD already cover credentials.
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "PASSWD", "AUTH", "DSN",
"WEBHOOK", "CREDS", "BEARER", "APIKEY", "COOKIE")
def is_secret_env_name(name: str) -> bool:
"""The credential-name policy, shared with a local session's client environment (``agent.session_client_env``)."""
return any(s in name.upper() for s in _SECRET_SUBSTRINGS)
# Non-secret runtime-location flags that repo-root modules a sandbox script
# imports may read at import time. HERMES_DELEGATED_CHILD_CONTEXT must ride
# along or a child that imports Hermes code loses the Kanban mutation guard
# while still inheriting HERMES_HOME.
_HERMES_CHILD_ALLOWED = frozenset({
"HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV", "HERMES_DELEGATED_CHILD_CONTEXT",
})
# Windows-only: without these the CRT itself fails — socket.socket() raises
# WinError 10106 (Winsock can't find mswsock.dll) and subprocess can't resolve
# cmd.exe. Well-known OS paths, not secrets; the substring block still runs.
_WINDOWS_ESSENTIAL_ENV_VARS = frozenset({
"SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT", "OS",
"PROCESSOR_ARCHITECTURE", "NUMBER_OF_PROCESSORS", "PUBLIC", "ALLUSERSPROFILE",
"PROGRAMDATA", "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432",
"APPDATA", "LOCALAPPDATA", "USERPROFILE", "USERDOMAIN", "USERNAME",
"HOMEDRIVE", "HOMEPATH", "COMPUTERNAME",
})
def _scrub_child_env(source_env, is_passthrough=None, is_windows=None, toolchain=frozenset()):
"""Produce the scrubbed child-process env for execute_code.
Rules, in order: (1) passthrough vars (skill/config-declared) resolve
through the active profile secret scope — an absent scoped value is
omitted; (2) secret-substring names are blocked; (3) safe prefixes pass,
as do a local session's client ``toolchain`` names (NVM_DIR, GOPATH...);
(4) operational HERMES_* pass by exact name; (5) on Windows the
OS-essential allowlist passes by exact name.
"""
try:
from tools.env_passthrough import is_env_passthrough, resolve_passthrough_value, scoped_passthrough_additions
except Exception:
is_env_passthrough = lambda _: False
resolve_passthrough_value = lambda _name, _fallback: None
scoped_passthrough_additions = lambda _present: {}
if is_passthrough is None:
is_passthrough = is_env_passthrough
if is_windows is None:
is_windows = _IS_WINDOWS
scrubbed = {}
# Non-secret HERMES_* vars no allowlist admits are dropped on purpose; a script importing a
# repo module that reads one would see it silently unset — log the drop, point at the opt-in.
_dropped_hermes = []
for k, v in source_env.items():
if is_passthrough(k):
resolved = resolve_passthrough_value(k, v)
if resolved is not None:
scrubbed[k] = resolved
continue
if is_secret_env_name(k):
continue
if (any(k.startswith(p) for p in _SAFE_ENV_PREFIXES) or k in toolchain
or k in _HERMES_CHILD_ALLOWED
or (is_windows and k.upper() in _WINDOWS_ESSENTIAL_ENV_VARS)):
scrubbed[k] = v
elif k.startswith("HERMES_"):
_dropped_hermes.append(k)
# Declared names only the bound profile scope holds (a routed profile's own .env / sources
# never enter the process env) — the loop above sees only names ``source_env`` carries.
scrubbed.update((k, v) for k, v in scoped_passthrough_additions(scrubbed).items() if is_passthrough(k))
if _dropped_hermes:
logger.debug(
"execute_code: dropped %d non-allowlisted HERMES_* var(s) from the "
"sandbox child env (%s). This is intentional hardening (#27303); if "
"a sandbox script legitimately needs one, declare it via "
"env_passthrough in the skill/config so it passes by explicit opt-in.",
len(_dropped_hermes), ", ".join(sorted(_dropped_hermes)),
)
# delegate_task children are marked by a ContextVar, not os.environ, and the sandbox crosses
# a process boundary: strip dispatcher-owned Kanban vars AFTER the scrub so an explicit
# passthrough cannot re-grant a delegated child the parent's board mutation capability.
from agent.delegation_context import (
DELEGATED_CHILD_ENV_MARKER, delegated_child_subprocess_env,
)
scoped = delegated_child_subprocess_env(source_env)
# Preserve location only when carrying the descendant fence, not for arbitrary
# non-allowlisted HERMES_* values in otherwise ordinary execution environments.
if scoped.get(DELEGATED_CHILD_ENV_MARKER):
for key in (DELEGATED_CHILD_ENV_MARKER, "HERMES_KANBAN_DB", "HERMES_KANBAN_BOARD"):
if key in scoped:
scrubbed[key] = scoped[key]
return delegated_child_subprocess_env(scrubbed)
def _build_child_env(*, rpc_endpoint: str, rpc_token: str, tmpdir: str,
child_python: str) -> dict[str, str]:
"""Build the scrubbed child environment both execution paths share."""
from hermes_constants import apply_scratch_tmp_env, apply_subprocess_home_env, get_hermes_home_override
from agent.session_client_env import tool_env, toolchain_environ
# A local session's client toolchain replaces the daemon's PATH / venv, then the whole scrub (secret
# names, delegated-child restrictions) runs over the merged environment.
child_env = _scrub_child_env(toolchain_environ(), toolchain=frozenset(tool_env()))
child_env["HERMES_RPC_SOCKET"] = rpc_endpoint
child_env["HERMES_RPC_TOKEN"] = rpc_token
child_env["PYTHONDONTWRITEBYTECODE"] = "1"
# Force UTF-8 stdio and default file encoding: on Windows sys.stdout is bound to the console
# code page (cp1252) and print("→") raises; harmless under a C/POSIX locale (containers).
child_env["PYTHONIOENCODING"] = "utf-8"
child_env["PYTHONUTF8"] = "1"
# Only TZ reaches the child; HERMES_TIMEZONE is an internal setting (and under the multiplexed
# gateway holds only the default profile's value — hermes_time resolves the routed profile's).
from hermes_time import get_timezone_name
_tz_name = get_timezone_name()
# Windows CPython does not support IANA names in TZ. Leaving TZ unset
# preserves the OS-configured local timezone for the child process.
if _tz_name and not _IS_WINDOWS:
child_env["TZ"] = _tz_name
child_env.pop("HERMES_TIMEZONE", None)
apply_subprocess_home_env(child_env)
# Multiplexed gateway/Desktop (#110303): the server process env carries the machine-default
# HERMES_HOME, but this turn runs under a per-profile override (ContextVar bound per turn).
# The scrub above passed the stale default through; rewrite it so skill scripts see the
# active profile's home — the same per-turn rewrite apply_subprocess_home_env does for HOME.
# No override (dedicated per-profile process) → leave the inherited value untouched.
_home_override = get_hermes_home_override()
if _home_override:
child_env["HERMES_HOME"] = _home_override
apply_scratch_tmp_env(child_env) # TMPDIR follows the routed home, like HOME does
# PYTHONPATH: the staging dir (hermes_tools.py) must always be importable even when project
# mode changes CWD. Hermes's root is added ONLY when the child runs in Hermes's Python env —
# exposing Hermes's site-packages to an external interpreter can mix incompatible compiled
# extensions (3.12 NumPy under a 3.9 venv). Inherited Hermes-owned entries are stripped first.
# Before re-injecting PYTHONPATH, strip Hermes-owned entries that leaked through _scrub_child_env
# (PYTHONPATH is in _SAFE_ENV_PREFIXES so it passes the scrub). External project interpreters
# must not inherit Hermes dependencies (#74817). PM's own interpreter, however, can be a
# bare bundled Python whose dependencies live in the selected generation, not sys.prefix.
from tools.environments.local_pythonpath import (
_strip_hermes_owned_pythonpath, _validated_runtime_venv, _same_path,
)
_runtime_path = None
if child_python == sys.executable:
runtime_venv = _validated_runtime_venv(child_env)
if runtime_venv is not None:
from pathlib import Path
from pm.environments import site_packages
candidate = site_packages(runtime_venv)
# Restore only a dependency path the launcher actually supplied, not a newly
# selected generation that this still-running interpreter has never loaded.
if any(_same_path(Path(entry), candidate)
for entry in child_env.get("PYTHONPATH", "").split(os.pathsep) if entry):
_runtime_path = str(candidate)
_strip_hermes_owned_pythonpath(child_env)
_existing_pp = child_env.get("PYTHONPATH", "")
_pp_parts = [tmpdir]
if _runtime_path is not None:
_pp_parts.append(_runtime_path)
if _uses_hermes_python_environment(child_python):
_pp_parts.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
elif child_python not in _external_env_logged:
# Surface once per interpreter so "import hermes_constants fails" is diagnosable.
_external_env_logged.add(child_python)
logger.info("execute_code: child interpreter %s is outside the Hermes "
"environment; hermes root omitted from PYTHONPATH", child_python)
if _existing_pp:
_pp_parts.append(_existing_pp)
child_env["PYTHONPATH"] = os.pathsep.join(_pp_parts)
return child_env
# Interpreter-probe caches: success-only dicts (FIFO-evicted at the cap) rather than lru_cache —
# a transient probe failure (fork pressure, 5s timeout) must not stick for the process lifetime.
_PROBE_CACHE_MAX = 32
_usable_python_cache: dict = {}
_python_prefix_cache: dict = {}
# Interpreter paths already reported as outside the Hermes environment.
_external_env_logged: set = set()
def _cache_probe_result(cache: dict, key: str, value):
"""Insert into a bounded probe cache, FIFO-evicting at the cap."""
if len(cache) >= _PROBE_CACHE_MAX:
cache.pop(next(iter(cache)))
cache[key] = value
def _probe_python(python_path: str, code: str, *, text: bool = False):
"""Run ``python_path -c code``; None if missing, unspawnable, or past the 5s timeout."""
try:
from agent.delegation_context import delegated_child_subprocess_env
return subprocess.run(
[python_path, "-c", code], timeout=5, capture_output=True, text=text,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
stdin=subprocess.DEVNULL, env=delegated_child_subprocess_env(),
check=False,
)
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
return None
def _is_usable_python(python_path: str) -> bool:
"""Whether the interpreter is Python 3.8+ (what the RPC stubs need); success cached, failure retried."""
cached = _usable_python_cache.get(python_path)
if cached is not None:
return cached
result = _probe_python(python_path, "import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)")
if result is None:
return False
usable = result.returncode == 0
_cache_probe_result(_usable_python_cache, python_path, usable)
return usable
def _python_environment_prefix(python_path: str) -> str:
"""Resolved ``sys.prefix`` reported by *python_path* ("" on failure; failures are not cached)."""
cached = _python_prefix_cache.get(python_path)
if cached is not None:
return cached
result = _probe_python(python_path, "import sys; print(sys.prefix)", text=True)
if result is not None and result.returncode == 0 and result.stdout.strip():
prefix = os.path.realpath(result.stdout.strip())
_cache_probe_result(_python_prefix_cache, python_path, prefix)
return prefix
return ""
def _uses_hermes_python_environment(python_path: str) -> bool:
"""Whether *python_path* belongs to Hermes's active Python environment. Short-circuits when
it IS the running interpreter (by path or realpath — covers ``uv run`` venvs) so no probe
runs on the default strict path and a flaky probe can never drop the hermes root."""
if python_path == sys.executable or os.path.realpath(python_path) == os.path.realpath(sys.executable):
return True
return _python_environment_prefix(python_path) == os.path.realpath(sys.prefix)
def _resolve_child_python(mode: str) -> str:
"""Child interpreter: ``sys.executable`` in strict mode; in project mode the active
VIRTUAL_ENV/CONDA_PREFIX python if it exists and passes the 3.8+ probe, else ``sys.executable``."""
if mode != "project":
return sys.executable
from agent.session_client_env import toolchain_environ
environ = toolchain_environ() # a local session's: its client's venv/conda, not the daemon's
subdir, exe_names = ("Scripts", ("python.exe", "python3.exe")) if _IS_WINDOWS else ("bin", ("python", "python3"))
for var in ("VIRTUAL_ENV", "CONDA_PREFIX"):
root = environ.get(var, "").strip()
for exe in exe_names if root else ():
candidate = os.path.join(root, subdir, exe)
if not (os.path.isfile(candidate) and os.access(candidate, os.X_OK)):
continue
if _is_usable_python(candidate):
return candidate
logger.info("execute_code: skipping %s=%s (Python version < 3.8 or broken). "
"Using sys.executable instead.", var, candidate)
return sys.executable
return sys.executable
def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str:
"""Child cwd. Strict: the staging dir. Project mirrors the terminal/file-tool ladder so every
file-writing path agrees: session cwd record (`cd` state) → registered ``session.cwd.set``
override → TERMINAL_CWD → os.getcwd() → staging dir (never Popen on a missing cwd).
(#56047)
"""
if mode != "project":
return staging_dir
if task_id:
try:
from tools.terminal_tool import get_session_cwd
recorded = get_session_cwd(task_id)
except Exception:
recorded = None
if recorded and os.path.isdir(recorded):
return recorded
try:
from tools.file_tools_paths import _registered_task_cwd_override
session_cwd = _registered_task_cwd_override(task_id)
except Exception:
session_cwd = None
if session_cwd and os.path.isdir(session_cwd):
return session_cwd
from agent.runtime_cwd import scope_terminal_cwd
raw = scope_terminal_cwd().strip()
for candidate in (os.path.expanduser(raw) if raw else "", os.getcwd()):
if candidate and os.path.isdir(candidate):
return candidate
return staging_dir