Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 10 additions & 35 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from __future__ import annotations

import concurrent.futures
import contextvars
import json
import logging
import os
Expand All @@ -38,12 +37,9 @@
make_tool_result_message,
)
from tools.terminal_tool import (
_get_approval_callback,
_get_sudo_password_callback,
set_approval_callback as _set_approval_callback,
set_sudo_password_callback as _set_sudo_password_callback,
get_active_env,
)
from tools.thread_context import propagate_context_to_thread
from tools.tool_result_storage import (
maybe_persist_tool_result,
enforce_turn_budget,
Expand Down Expand Up @@ -186,14 +182,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
agent._current_tool = tool_names_str
agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}")

# Capture CLI callbacks from the agent thread so worker threads can
# register them locally. Without this, _get_approval_callback() in
# terminal_tool returns None in ThreadPoolExecutor workers, causing
# the dangerous-command prompt to fall back to input() — which
# deadlocks against prompt_toolkit's raw terminal mode (#13617).
_parent_approval_cb = _get_approval_callback()
_parent_sudo_cb = _get_sudo_password_callback()

def _run_tool(index, tool_call, function_name, function_args):
"""Worker function executed in a thread."""
# Register this worker tid so the agent can fan out an interrupt
Expand All @@ -220,18 +208,9 @@ def _run_tool(index, tool_call, function_name, function_args):
set_activity_callback(agent._touch_activity)
except Exception:
pass
# Propagate approval/sudo callbacks to this worker thread.
# Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr).
if _parent_approval_cb is not None:
try:
_set_approval_callback(_parent_approval_cb)
except Exception:
pass
if _parent_sudo_cb is not None:
try:
_set_sudo_password_callback(_parent_sudo_cb)
except Exception:
pass
# Approval/sudo callbacks (thread-local) and the agent turn's
# ContextVars are propagated by propagate_context_to_thread() at the
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
start = time.time()
try:
result = agent._invoke_tool(
Expand Down Expand Up @@ -261,13 +240,6 @@ def _run_tool(index, tool_call, function_name, function_args):
_ra()._set_interrupt(False, _worker_tid)
except Exception:
pass
# Clear thread-local callbacks so a recycled worker thread
# doesn't hold stale references to a disposed CLI instance.
try:
_set_approval_callback(None)
_set_sudo_password_callback(None)
except Exception:
pass

# Start spinner for CLI mode (skip when TUI handles tool progress)
spinner = None
Expand All @@ -287,9 +259,12 @@ def _run_tool(index, tool_call, function_name, function_args):
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for i, tc, name, args in runnable_calls:
# Propagate ContextVars (e.g. _approval_session_key); mirrors asyncio.to_thread.
ctx = contextvars.copy_context()
f = executor.submit(ctx.run, _run_tool, i, tc, name, args)
# Propagate the agent turn's ContextVars (e.g.
# _approval_session_key) AND thread-local approval/sudo
# callbacks into the worker thread; clears callbacks on exit.
f = executor.submit(
propagate_context_to_thread(_run_tool), i, tc, name, args
)
futures.append(f)

# Wait for all to complete with periodic heartbeats so the
Expand Down
29 changes: 28 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1807,7 +1807,34 @@ def __init__(self, config: Optional[GatewayConfig] = None):
ensure_installed(log_failures=False)
except Exception:
pass # Non-fatal — fail-open at scan time if unavailable


# Startup heads-up (#30882): a gateway in manual approval mode with no
# automated risk assessor (tirith disabled AND no auxiliary.approval
# model) can only gate dangerous commands / execute_code scripts via
# live in-chat approval. With approval routing fixed, those actions now
# fail closed (block) rather than silently auto-running — surface that
# so operators knowingly enable tirith or configure auxiliary.approval
# for unattended gateways.
try:
from hermes_cli.config import load_config as _load_full_config
_appr_cfg = _load_full_config()
_appr_mode = str(
cfg_get(_appr_cfg, "approvals", "mode", default="manual") or "manual"
).strip().lower()
_tirith_on = bool(cfg_get(_appr_cfg, "security", "tirith_enabled", default=True))
_aux_approval = cfg_get(_appr_cfg, "auxiliary", "approval", default=None)
if _appr_mode == "manual" and not _tirith_on and not _aux_approval:
logger.warning(
"Gateway approvals.mode=manual with no automated risk "
"assessor (security.tirith_enabled is false and "
"auxiliary.approval is unset): dangerous commands and "
"execute_code scripts will BLOCK until a human approves "
"them in chat. Enable security.tirith_enabled or configure "
"auxiliary.approval for unattended operation."
)
except Exception:
logger.debug("approvals.mode startup check skipped", exc_info=True)

# Initialize session database for session_search tool support
self._session_db = None
try:
Expand Down
13 changes: 13 additions & 0 deletions tests/run_agent/test_tool_executor_contextvar_propagation.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,19 @@ def test_run_agent_concurrent_executor_wraps_submit_with_copy_context():
and call.args[1].id == "_run_tool"
):
tool_submits.append(("fixed", call))
# Fixed (shared helper): executor.submit(
# propagate_context_to_thread(_run_tool), ...) — the helper in
# tools/thread_context.py does copy_context().run(...) internally and
# additionally propagates the thread-local approval/sudo callbacks.
elif (
isinstance(first, ast.Call)
and isinstance(first.func, ast.Name)
and first.func.id == "propagate_context_to_thread"
and first.args
and isinstance(first.args[0], ast.Name)
and first.args[0].id == "_run_tool"
):
tool_submits.append(("fixed", call))

assert tool_submits, (
"Could not locate `executor.submit(... _run_tool ...)` in "
Expand Down
38 changes: 26 additions & 12 deletions tests/tools/test_code_execution_windows_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,20 +253,24 @@ def test_child_can_create_socket_with_scrubbed_env(self):
# ---------------------------------------------------------------------------

def _legacy_posix_scrubber(source_env, is_passthrough):
"""Verbatim copy of the pre-Windows-fix inline scrubbing logic.

This is the oracle used by TestPosixEquivalence to prove the refactor
did not change POSIX behavior. DO NOT edit this to "match" a future
production change — if _scrub_child_env's POSIX behavior legitimately
needs to evolve, delete this function and adjust the equivalence test
on purpose, so the churn is visible in review.
"""Independent oracle for TestPosixEquivalence — a from-scratch reimpl of
_scrub_child_env's POSIX behavior, used to prove the production helper does
what we think it does.

Deliberately updated for #27303 (the broad ``HERMES_`` prefix was dropped
in favor of an explicit operational allowlist, and DSN/WEBHOOK were added
to the secret substrings). The original docstring said: if POSIX behavior
legitimately needs to evolve, adjust this oracle on purpose so the churn is
visible in review — that is what this change is.
"""
_SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM",
"TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME",
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA",
"HERMES_")
"XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA")
_SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL",
"PASSWD", "AUTH")
"PASSWD", "AUTH", "DSN", "WEBHOOK")
_HERMES_CHILD_ALLOWED = frozenset({
"HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV",
})
out = {}
for k, v in source_env.items():
if is_passthrough(k):
Expand All @@ -276,6 +280,9 @@ def _legacy_posix_scrubber(source_env, is_passthrough):
continue
if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):
out[k] = v
continue
if k in _HERMES_CHILD_ALLOWED:
out[k] = v
return out


Expand Down Expand Up @@ -308,13 +315,20 @@ class TestPosixEquivalence:
"PYTHONPATH": "/opt/lib",
"VIRTUAL_ENV": "/home/alice/.venv",
"CONDA_PREFIX": "/opt/conda",
"HERMES_HOME": "/home/alice/.hermes",
"HERMES_INTERACTIVE": "1",
# HERMES_* handling (#27303): only the operational allowlist passes;
# every other HERMES_* is dropped (the broad prefix was removed).
"HERMES_HOME": "/home/alice/.hermes", # allowlisted → kept
"HERMES_PROFILE": "default", # allowlisted → kept
"HERMES_INTERACTIVE": "1", # not allowlisted → dropped
"HERMES_BASE_URL": "https://api.internal", # not allowlisted → dropped
"HERMES_KANBAN_DB": "postgres://u:p@h/db", # not allowlisted → dropped
# Secret-substring blocks
"OPENAI_API_KEY": "sk-xxx",
"GITHUB_TOKEN": "ghp_xxx",
"AWS_SECRET_ACCESS_KEY": "yyy",
"MY_PASSWORD": "hunter2",
"SENTRY_DSN": "https://abc@sentry.io/1", # DSN substring → blocked
"SLACK_WEBHOOK": "https://hooks.slack/x", # WEBHOOK substring → blocked
# Uncategorized — must be dropped
"RANDOM_UNKNOWN": "drop-me",
"DISPLAY": ":0",
Expand Down
Loading
Loading