{"repo":"NousResearch/hermes-agent","public_id":"21558","display_label":"Approval system bugs and fixes across multiple surfaces","summary":"These issues and PRs concern a cluster of related problems with tool-call approval flows failing, being silently bypassed, or not rendering correctly across gateway, desktop, MCP, kanban workers, and execute_code contexts. Both bug reports and attempted fixes are represented, spanning UI, IPC, security enforcement, and cross-process callback wiring.","subsystem":"agent","counts":{"members":23,"open_issues":6,"open_issues_distinct":6,"open_prs":5,"closed_issues":6,"merged_prs":1,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34497},"resolution":{"state":"partially_resolved","by":"pr-34497"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T10:44:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.06450592885375493},"fix_edges":[{"issue_number":4146,"pr_number":34497,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":27303,"pr_number":34497,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":30882,"pr_number":34497,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":33057,"pr_number":34497,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":21558,"title":"MCP approvals subsystem (permissions_list_open / permissions_respond) lacks gateway IPC and is non-functional","url":null,"state":"closed"},{"kind":"issue","number":21563,"title":"MCP approval tools are no-ops: bridge subprocess has no IPC channel to gateway approval state","url":null,"state":"open"},{"kind":"issue","number":22001,"title":"MCP `permissions_list_open` and `permissions_respond` are non-functional — `_pending_approvals` is never populated","url":null,"state":"open"},{"kind":"issue","number":25808,"title":"Feature: Remote human-input bridge for in-flight CLI/TUI runs via Gateway platforms","url":null,"state":"open"},{"kind":"issue","number":30882,"title":"[Bug]: [Security] approvals.mode=manual silently bypassed in gateway mode — dangerous commands execute without approval, including via execute_code sandbox evasion","url":null,"state":"closed"},{"kind":"issue","number":46526,"title":"[Bug]: In Hermes Desktop, approval-gated tool calls time out without a visible approval prompt","url":null,"state":"closed"},{"kind":"issue","number":59201,"title":"[Bug]: Approval plugin doesn't show approve controls on desktop and gateway (works on CLI)","url":null,"state":"closed"},{"kind":"issue","number":59260,"title":"[Bug]: Plugin tool approval prompts fail silently over gateway bridge with 300s timeout","url":null,"state":"open"},{"kind":"issue","number":59413,"title":"Bug: request_tool_approval doesn't trigger gateway approval UI (Discord/Telegram/Slack)","url":null,"state":"closed"},{"kind":"issue","number":60260,"title":"[Bug]: Desktop approval bar UI not rendered (val.request event arrives but DOM stays hidden)","url":null,"state":"closed"},{"kind":"issue","number":63183,"title":"[Security] Kanban workers inherit gateway/cron approval context but not the approval callback","url":null,"state":"open"},{"kind":"issue","number":65291,"title":"Bug: Sub-agent approval callback (_subagent_auto_deny) silently bypassed in gateway mode — misleading safety comment","url":null,"state":"open"},{"kind":"pr","number":22300,"title":"fix(mcp): drop broken approval tools from MCP surface","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":30893,"title":"[codex] Guard execute_code behind gateway approvals","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#34497","number":34497}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":34131,"title":"fix(security): restore approval/sudo context in execute_code RPC threads + guard entry points","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"21aeefe5f","number":0},{"kind":"pr","value":"#4146","number":4146},{"kind":"pr","value":"#27303","number":27303},{"kind":"pr","value":"#30882","number":30882},{"kind":"pr","value":"#33057","number":33057},{"kind":"pr","value":"#4146","number":4146},{"kind":"pr","value":"#27303","number":27303},{"kind":"pr","value":"#30882","number":30882},{"kind":"pr","value":"#33057","number":33057},{"kind":"pr","value":"#34594","number":34594}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34497,"title":"fix(security): restore approval/sudo context in execute_code + guard entry points (salvage #34131)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/tool_executor.py","gateway/run.py","tests/run_agent/test_tool_executor_contextvar_propagation.py","tests/tools/test_code_execution_windows_env.py","tests/tools/test_execute_code_approval_cluster.py","tools/approval.py","tools/code_execution_tool.py","tools/thread_context.py"],"fix_hunks":[{"file":"agent/tool_executor.py","hunks":["@@ -13,7 +13,6 @@\n from __future__ import annotations\n \n import concurrent.futures\n-import contextvars\n import json\n import logging\n import os","@@ -38,12 +37,9 @@\n     make_tool_result_message,\n )\n from tools.terminal_tool import (\n-    _get_approval_callback,\n-    _get_sudo_password_callback,\n-    set_approval_callback as _set_approval_callback,\n-    set_sudo_password_callback as _set_sudo_password_callback,\n     get_active_env,\n )\n+from tools.thread_context import propagate_context_to_thread\n from tools.tool_result_storage import (\n     maybe_persist_tool_result,\n     enforce_turn_budget,","@@ -186,14 +182,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe\n     agent._current_tool = tool_names_str\n     agent._touch_activity(f\"executing {num_tools} tools concurrently: {tool_names_str}\")\n \n-    # Capture CLI callbacks from the agent thread so worker threads can\n-    # register them locally.  Without this, _get_approval_callback() in\n-    # terminal_tool returns None in ThreadPoolExecutor workers, causing\n-    # the dangerous-command prompt to fall back to input() — which\n-    # deadlocks against prompt_toolkit's raw terminal mode (#13617).\n-    _parent_approval_cb = _get_approval_callback()\n-    _parent_sudo_cb = _get_sudo_password_callback()\n-\n     def _run_tool(index, tool_call, function_name, function_args):\n         \"\"\"Worker function executed in a thread.\"\"\"\n         # Register this worker tid so the agent can fan out an interrupt","@@ -220,18 +208,9 @@ def _run_tool(index, tool_call, function_name, function_args):\n             set_activity_callback(agent._touch_activity)\n         except Exception:\n             pass\n-        # Propagate approval/sudo callbacks to this worker thread.\n-        # Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr).\n-        if _parent_approval_cb is not None:\n-            try:\n-                _set_approval_callback(_parent_approval_cb)\n-            except Exception:\n-                pass\n-        if _parent_sudo_cb is not None:\n-            try:\n-                _set_sudo_password_callback(_parent_sudo_cb)\n-            except Exception:\n-                pass\n+        # Approval/sudo callbacks (thread-local) and the agent turn's\n+        # ContextVars are propagated by propagate_context_to_thread() at the\n+        # submit site below (GHSA-qg5c-hvr5-hjgr, #13617).\n         start = time.time()\n         try:\n             result = agent._invoke_tool(","@@ -261,13 +240,6 @@ def _run_tool(index, tool_call, function_name, function_args):\n             _ra()._set_interrupt(False, _worker_tid)\n         except Exception:\n             pass\n-        # Clear thread-local callbacks so a recycled worker thread\n-        # doesn't hold stale references to a disposed CLI instance.\n-        try:\n-            _set_approval_callback(None)\n-            _set_sudo_password_callback(None)\n-        except Exception:\n-            pass\n \n     # Start spinner for CLI mode (skip when TUI handles tool progress)\n     spinner = None","@@ -287,9 +259,12 @@ def _run_tool(index, tool_call, function_name, function_args):\n             max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)\n             with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:\n                 for i, tc, name, args in runnable_calls:\n-                    # Propagate ContextVars (e.g. _approval_session_key); mirrors asyncio.to_thread.\n-                    ctx = contextvars.copy_context()\n-                    f = executor.submit(ctx.run, _run_tool, i, tc, name, args)\n+                    # Propagate the agent turn's ContextVars (e.g.\n+                    # _approval_session_key) AND thread-local approval/sudo\n+                    # callbacks into the worker thread; clears callbacks on exit.\n+                    f = executor.submit(\n+                        propagate_context_to_thread(_run_tool), i, tc, name, args\n+                    )\n                     futures.append(f)\n \n                 # Wait for all to complete with periodic heartbeats so the"]},{"file":"gateway/run.py","hunks":["@@ -1807,7 +1807,34 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n             ensure_installed(log_failures=False)\n         except Exception:\n             pass  # Non-fatal — fail-open at scan time if unavailable\n-        \n+\n+        # Startup heads-up (#30882): a gateway in manual approval mode with no\n+        # automated risk assessor (tirith disabled AND no auxiliary.approval\n+        # model) can only gate dangerous commands / execute_code scripts via\n+        # live in-chat approval. With approval routing fixed, those actions now\n+        # fail closed (block) rather than silently auto-running — surface that\n+        # so operators knowingly enable tirith or configure auxiliary.approval\n+        # for unattended gateways.\n+        try:\n+            from hermes_cli.config import load_config as _load_full_config\n+            _appr_cfg = _load_full_config()\n+            _appr_mode = str(\n+                cfg_get(_appr_cfg, \"approvals\", \"mode\", default=\"manual\") or \"manual\"\n+            ).strip().lower()\n+            _tirith_on = bool(cfg_get(_appr_cfg, \"security\", \"tirith_enabled\", default=True))\n+            _aux_approval = cfg_get(_appr_cfg, \"auxiliary\", \"approval\", default=None)\n+            if _appr_mode == \"manual\" and not _tirith_on and not _aux_approval:\n+                logger.warning(\n+                    \"Gateway approvals.mode=manual with no automated risk \"\n+                    \"assessor (security.tirith_enabled is false and \"\n+                    \"auxiliary.approval is unset): dangerous commands and \"\n+                    \"execute_code scripts will BLOCK until a human approves \"\n+                    \"them in chat. Enable security.tirith_enabled or configure \"\n+                    \"auxiliary.approval for unattended operation.\"\n+                )\n+        except Exception:\n+            logger.debug(\"approvals.mode startup check skipped\", exc_info=True)\n+\n         # Initialize session database for session_search tool support\n         self._session_db = None\n         try:"]},{"file":"tests/run_agent/test_tool_executor_contextvar_propagation.py","hunks":["@@ -197,6 +197,19 @@ def test_run_agent_concurrent_executor_wraps_submit_with_copy_context():\n             and call.args[1].id == \"_run_tool\"\n         ):\n             tool_submits.append((\"fixed\", call))\n+        # Fixed (shared helper): executor.submit(\n+        #     propagate_context_to_thread(_run_tool), ...) — the helper in\n+        # tools/thread_context.py does copy_context().run(...) internally and\n+        # additionally propagates the thread-local approval/sudo callbacks.\n+        elif (\n+            isinstance(first, ast.Call)\n+            and isinstance(first.func, ast.Name)\n+            and first.func.id == \"propagate_context_to_thread\"\n+            and first.args\n+            and isinstance(first.args[0], ast.Name)\n+            and first.args[0].id == \"_run_tool\"\n+        ):\n+            tool_submits.append((\"fixed\", call))\n \n     assert tool_submits, (\n         \"Could not locate `executor.submit(... _run_tool ...)` in \""]},{"file":"tests/tools/test_code_execution_windows_env.py","hunks":["@@ -253,20 +253,24 @@ def test_child_can_create_socket_with_scrubbed_env(self):\n # ---------------------------------------------------------------------------\n \n def _legacy_posix_scrubber(source_env, is_passthrough):\n-    \"\"\"Verbatim copy of the pre-Windows-fix inline scrubbing logic.\n-\n-    This is the oracle used by TestPosixEquivalence to prove the refactor\n-    did not change POSIX behavior.  DO NOT edit this to \"match\" a future\n-    production change — if _scrub_child_env's POSIX behavior legitimately\n-    needs to evolve, delete this function and adjust the equivalence test\n-    on purpose, so the churn is visible in review.\n+    \"\"\"Independent oracle for TestPosixEquivalence — a from-scratch reimpl of\n+    _scrub_child_env's POSIX behavior, used to prove the production helper does\n+    what we think it does.\n+\n+    Deliberately updated for #27303 (the broad ``HERMES_`` prefix was dropped\n+    in favor of an explicit operational allowlist, and DSN/WEBHOOK were added\n+    to the secret substrings).  The original docstring said: if POSIX behavior\n+    legitimately needs to evolve, adjust this oracle on purpose so the churn is\n+    visible in review — that is what this change is.\n     \"\"\"\n     _SAFE_ENV_PREFIXES = (\"PATH\", \"HOME\", \"USER\", \"LANG\", \"LC_\", \"TERM\",\n                           \"TMPDIR\", \"TMP\", \"TEMP\", \"SHELL\", \"LOGNAME\",\n-                          \"XDG_\", \"PYTHONPATH\", \"VIRTUAL_ENV\", \"CONDA\",\n-                          \"HERMES_\")\n+                          \"XDG_\", \"PYTHONPATH\", \"VIRTUAL_ENV\", \"CONDA\")\n     _SECRET_SUBSTRINGS = (\"KEY\", \"TOKEN\", \"SECRET\", \"PASSWORD\", \"CREDENTIAL\",\n-                          \"PASSWD\", \"AUTH\")\n+                          \"PASSWD\", \"AUTH\", \"DSN\", \"WEBHOOK\")\n+    _HERMES_CHILD_ALLOWED = frozenset({\n+        \"HERMES_HOME\", \"HERMES_PROFILE\", \"HERMES_CONFIG\", \"HERMES_ENV\",\n+    })\n     out = {}\n     for k, v in source_env.items():\n         if is_passthrough(k):","@@ -276,6 +280,9 @@ def _legacy_posix_scrubber(source_env, is_passthrough):\n             continue\n         if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):\n             out[k] = v\n+            continue\n+        if k in _HERMES_CHILD_ALLOWED:\n+            out[k] = v\n     return out\n \n ","@@ -308,13 +315,20 @@ class TestPosixEquivalence:\n         \"PYTHONPATH\": \"/opt/lib\",\n         \"VIRTUAL_ENV\": \"/home/alice/.venv\",\n         \"CONDA_PREFIX\": \"/opt/conda\",\n-        \"HERMES_HOME\": \"/home/alice/.hermes\",\n-        \"HERMES_INTERACTIVE\": \"1\",\n+        # HERMES_* handling (#27303): only the operational allowlist passes;\n+        # every other HERMES_* is dropped (the broad prefix was removed).\n+        \"HERMES_HOME\": \"/home/alice/.hermes\",        # allowlisted → kept\n+        \"HERMES_PROFILE\": \"default\",                 # allowlisted → kept\n+        \"HERMES_INTERACTIVE\": \"1\",                   # not allowlisted → dropped\n+        \"HERMES_BASE_URL\": \"https://api.internal\",   # not allowlisted → dropped\n+        \"HERMES_KANBAN_DB\": \"postgres://[redacted-credentials]@h/db\",   # not allowlisted → dropped\n         # Secret-substring blocks\n         \"OPENAI_API_KEY\": \"sk-xxx\",\n         \"GITHUB_TOKEN\": \"ghp_xxx\",\n         \"AWS_SECRET_ACCESS_KEY\": \"yyy\",\n         \"MY_PASSWORD\": \"hunter2\",\n+        \"SENTRY_DSN\": \"https://[redacted:email]/1\",     # DSN substring → blocked\n+        \"SLACK_WEBHOOK\": \"https://hooks.slack/x\",    # WEBHOOK substring → blocked\n         # Uncategorized — must be dropped\n         \"RANDOM_UNKNOWN\": \"drop-me\",\n         \"DISPLAY\": \":0\","]},{"file":"tests/tools/test_execute_code_approval_cluster.py","hunks":["@@ -0,0 +1,349 @@\n+\"\"\"Regression tests for the execute_code approval-bypass cluster.\n+\n+Covers the canonical fix for issues #4146, #27303, #30882, #33057:\n+\n+  1. tools.thread_context.propagate_context_to_thread — propagates the agent\n+     turn's ContextVars AND thread-local approval/sudo callbacks into worker\n+     threads, and clears the callbacks on teardown.\n+  2. Both execute_code RPC threads are wrapped with that helper (source guard).\n+  3. tools.approval.check_execute_code_guard — the entry-point guard decision\n+     matrix (isolated backends, yolo/off, cron-deny, headless-local,\n+     gateway approve/deny/timeout/missing-notify, smart mode).\n+  4. tools.code_execution_tool._scrub_child_env — broad HERMES_ prefix dropped,\n+     operational allowlist kept, DSN/WEBHOOK blocked, passthrough precedence.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import concurrent.futures\n+import contextvars\n+import threading\n+\n+import pytest\n+\n+from tools import approval as A\n+from tools.thread_context import propagate_context_to_thread\n+\n+\n+# ---------------------------------------------------------------------------\n+# 1. Context + callback propagation helper\n+# ---------------------------------------------------------------------------\n+\n+def test_helper_propagates_contextvar_and_approval_callback():\n+    from tools import terminal_tool as TT\n+\n+    probe: contextvars.ContextVar[str] = contextvars.ContextVar(\n+        \"cluster_probe\", default=\"unset\"\n+    )\n+    probe.set(\"parent-value\")\n+    sentinel = object()\n+    TT.set_approval_callback(sentinel)\n+    try:\n+        seen: dict = {}\n+\n+        def worker():\n+            seen[\"probe\"] = probe.get()\n+            seen[\"cb\"] = TT._get_approval_callback()\n+\n+        t = threading.Thread(target=propagate_context_to_thread(worker))\n+        t.start()\n+        t.join(timeout=5)\n+\n+        assert seen[\"probe\"] == \"parent-value\"  # ContextVar propagated\n+        assert seen[\"cb\"] is sentinel            # thread-local callback propagated\n+    finally:\n+        TT.set_approval_callback(None)\n+\n+\n+def test_helper_clears_callbacks_on_teardown():\n+    \"\"\"A recycled worker thread must not retain the propagated callback after\n+    the wrapped target finishes (mirrors the GHSA-qg5c-hvr5-hjgr teardown).\"\"\"\n+    from tools import terminal_tool as TT\n+\n+    sentinel = object()\n+    TT.set_approval_callback(sentinel)\n+    try:\n+        seen: dict = {}\n+\n+        def first():\n+            seen[\"during\"] = TT._get_approval_callback()\n+\n+        def second():  # NOT wrapped — runs on the same recycled worker thread\n+            seen[\"after\"] = TT._get_approval_callback()\n+\n+        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:\n+            ex.submit(propagate_context_to_thread(first)).result(timeout=5)\n+            ex.submit(second).result(timeout=5)\n+\n+        assert seen[\"during\"] is sentinel  # installed for the wrapped target\n+        assert seen[\"after\"] is None       # cleared on teardown\n+    finally:\n+        TT.set_approval_callback(None)\n+\n+\n+def test_both_rpc_threads_use_propagation_helper():\n+    \"\"\"Source guard: both execute_code RPC threads must wrap their target with\n+    propagate_context_to_thread, or the gateway approval bypass (#33057)\n+    silently returns.\"\"\"\n+    import inspect\n+    import tools.code_execution_tool as cet\n+\n+    src = inspect.getsource(cet)\n+    assert \"propagate_context_to_thread(_rpc_server_loop)\" in src, (\n+        \"local UDS RPC server thread is not wrapped with \"\n+        \"propagate_context_to_thread — gateway approval routing will be lost.\"\n+    )\n+    assert \"propagate_context_to_thread(_rpc_poll_loop)\" in src, (\n+        \"remote file-RPC poll thread is not wrapped with \"\n+        \"propagate_context_to_thread — gateway approval routing will be lost.\"\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# 3. check_execute_code_guard decision matrix\n+# ---------------------------------------------------------------------------\n+\n[redacted:email]\n+def gw_session(monkeypatch):\n+    \"\"\"A clean gateway session: HERMES_GATEWAY_SESSION set, a bound session\n+    key, and isolated gateway queues/callbacks. Yields the session_key.\"\"\"\n+    monkeypatch.setenv(\"HERMES_GATEWAY_SESSION\", \"1\")\n+    monkeypatch.delenv(\"HERMES_INTERACTIVE\", raising=False)\n+    monkeypatch.delenv(\"HERMES_CRON_SESSION\", raising=False)\n+    monkeypatch.delenv(\"HERMES_EXEC_ASK\", raising=False)\n+    # Force manual mode regardless of host config.\n+    monkeypatch.setattr(A, \"_get_approval_mode\", lambda: \"manual\")\n+\n+    session_key = \"cluster-test-session\"\n+    token = [redacted:secret-assignment]\n+    with A._lock:\n+        A._gateway_queues.pop(session_key, None)\n+        A._gateway_notify_cbs.pop(session_key, None)\n+    try:\n+        yield session_key\n+    finally:\n+        A.reset_current_session_key(token)\n+        with A._lock:\n+            A._gateway_queues.pop(session_key, None)\n+            A._gateway_notify_cbs.pop(session_key, None)\n+\n+\n+def _register_resolver(session_key: str, result):\n+    \"\"\"Register a gateway notify callback that immediately resolves the most\n+    recent queued approval entry with *result* (simulating a user response).\"\"\"\n+    def cb(_approval_data):\n+        with A._lock:\n+            entries = A._gateway_queues.get(session_key, [])\n+            if entries:\n+                entry = entries[-1]\n+                entry.result = result\n+                entry.event.set()\n+    with A._lock:\n+        A._gateway_notify_cbs[session_key] = cb\n+\n+\n+def test_guard_isolated_backend_approved():\n+    # Container backends already sandbox the child — no-op approve.\n+    assert A.check_execute_code_guard(\"import os\", \"docker\")[\"approved\"] is True\n+\n+\n+def test_guard_headless_local_approved(monkeypatch):\n+    # Documented #30882 limitation: no approval surface → preserve auto-run.\n+    monkeypatch.delenv(\"HERMES_GATEWAY_SESSION\", raising=False)\n+    monkeypatch.delenv(\"HERMES_INTERACTIVE\", raising=False)\n+    monkeypatch.delenv(\"HERMES_CRON_SESSION\", raising=False)\n+    monkeypatch.delenv(\"HERMES_EXEC_ASK\", raising=False)\n+    monkeypatch.setattr(A, \"_get_approval_mode\", lambda: \"manual\")\n+    assert A.check_execute_code_guard(\"import os\", \"local\")[\"approved\"] is True\n+\n+\n+def test_guard_cron_deny_blocks(monkeypatch):\n+    monkeypatch.setenv(\"HERMES_CRON_SESSION\", \"1\")\n+    monkeypatch.delenv(\"HERMES_GATEWAY_SESSION\", raising=False)\n+    monkeypatch.setattr(A, \"_get_approval_mode\", lambda: \"manual\")\n+    monkeypatch.setattr(A, \"_get_cron_approval_mode\", lambda: \"deny\")\n+    res = A.check_execute_code_guard(\"import os\", \"local\")\n+    assert res[\"approved\"] is False\n+    assert res[\"outcome\"] == \"blocked\"\n+\n+\n+def test_guard_gateway_user_approves_is_one_shot(gw_session):\n+    _register_resolver(gw_session, \"once\")\n+    res = A.check_execute_code_guard(\"import os; print(1)\", \"local\")\n+    assert res[\"approved\"] is True\n+    assert res.get(\"user_approved\") is True\n+    # One-shot: approval must NOT persist to future scripts.\n+    assert A.is_approved(gw_session, \"execute_code\") is False\n+\n+\n+def test_guard_gateway_user_denies_blocks(gw_session):\n+    _register_resolver(gw_session, \"deny\")\n+    res = A.check_execute_code_guard(\"import os\", \"local\")\n+    assert res[\"approved\"] is False\n+    assert res[\"outcome\"] == \"denied\"\n+    assert res[\"user_consent\"] is False\n+\n+\n+def test_guard_gateway_timeout_blocks(gw_session, monkeypatch):\n+    # Register a callback that never resolves; force an immediate timeout.\n+    with A._lock:\n+        A._gateway_notify_cbs[gw_session] = lambda _d: None\n+    monkeypatch.setattr(A, \"_get_approval_config\", lambda: {\"gateway_timeout\": 0})\n+    res = A.check_execute_code_guard(\"import os\", \"local\")\n+    assert res[\"approved\"] is False\n+    assert res[\"outcome\"] == \"timeout\"\n+\n+\n+def test_guard_gateway_missing_notify_is_pending(gw_session):\n+    # No notify callback registered → backward-compat pending approval.\n+    res = A.check_execute_code_guard(\"import os\", \"local\")\n+    assert res[\"approved\"] is False\n+    assert res[\"status\"] == \"pending_approval\"\n+\n+\n+def test_guard_smart_mode(gw_session, monkeypatch):\n+    monkeypatch.setattr(A, \"_get_approval_mode\", lambda: \"smart\")\n+\n+    monkeypatch.setattr(A, \"_smart_approve\", lambda c, d: \"approve\")\n+    res = A.check_execute_code_guard(\"import os\", \"local\")\n+    assert res[\"approved\"] is True and res.get(\"smart_approved\") is True\n+\n+    monkeypatch.setattr(A, \"_smart_approve\", lambda c, d: \"deny\")\n+    res = A.check_execute_code_guard(\"import os\", \"local\")\n+    assert res[\"approved\"] is False and res.get(\"smart_denied\") is True\n+\n+    # escalate → falls through to manual gateway approval\n+    monkeypatch.setattr(A, \"_smart_approve\", lambda c, d: \"escalate\")\n+    _register_resolver(gw_session, \"once\")\n+    res = A.check_execute_code_guard(\"import os\", \"local\")\n+    assert res[\"approved\"] is True\n+\n+\n+def test_guard_session_yolo_bypasses(gw_session):\n+    A.enable_session_yolo(gw_session)\n+    try:\n+        # Even with a denier registered, yolo short-circuits before the prompt.\n+        _register_resolver(gw_session, \"deny\")\n+        assert A.check_execute_code_guard(\"import os\", \"local\")[\"approved\"] is True\n+    finally:\n+        A.disable_session_yolo(gw_session)\n+\n+\n+# ---------------------------------------------------------------------------\n+# 4. Env scrubbing (#27303)\n+# ---------------------------------------------------------------------------\n+\n+def test_env_scrub_hermes_allowlist_and_secret_blocks():\n+    from tools.code_execution_tool import _scrub_child_env\n+\n+    env = {\n+        # operational allowlist → kept\n+        \"HERMES_HOME\": \"/h\", \"HERMES_PROFILE\": \"p\",\n+        \"HERMES_CONFIG\": \"/c.yaml\", \"HERMES_ENV\": \"/e\",\n+        # other HERMES_* → dropped (broad prefix removed)\n+        \"HERMES_BASE_URL\": \"https://x\", \"HERMES_INTERACTIVE\": \"1\",\n+        \"HERMES_KANBAN_DB\": \"postgres://[redacted-credentials]@h/db\",\n+        # secret substrings (incl. new DSN/WEBHOOK) → dropped\n+        \"SENTRY_DSN\": \"https://[redacted:email]/1\", \"SLACK_WEBHOOK\": \"https://h/x\",\n+        \"OPENAI_API_KEY\": \"sk\", \"GITHUB_TOKEN\": \"ghp\",\n+        # safe prefix → kept; uncategorized → dropped\n+        \"PATH\": \"/usr/bin\", \"RANDOM_X\": \"y\",\n+    }\n+    out = _scrub_child_env(env, is_passthrough=lambda _: False, is_windows=False)\n+\n+    for kept in (\"HERMES_HOME\", \"HERMES_PROFILE\", \"HERMES_CONFIG\", \"HERMES_ENV\", \"PATH\"):\n+        assert kept in out, f\"{kept} should be kept\"\n+    for dropped in (\n+        \"HERMES_BASE_URL\", \"HERMES_INTERACTIVE\", \"HERMES_KANBAN_DB\",\n+        \"SENTRY_DSN\", \"SLACK_WEBHOOK\", \"OPENAI_API_KEY\", \"GITHUB_TOKEN\",\n+        \"RANDOM_X\",\n+    ):\n+        assert dropped not in out, f\"{dropped} should be dropped\"\n+\n+\n+def test_env_scrub_passthrough_overrides_secret_block():\n+    \"\"\"A skill/config-declared passthrough var is an explicit user opt-in and\n+    passes even if it matches a secret substring (precedence is intentional).\"\"\"\n+    from tools.code_execution_tool import _scrub_child_env\n+\n+    env = {\"MY_SERVICE_DSN\": \"value\"}\n+    out = _scrub_child_env(env, is_passthrough=lambda k: k == \"MY_SERVICE_DSN\",\n+                           is_windows=False)\n+    assert out.get(\"MY_SERVICE_DSN\") == \"value\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# 5. File-tool sensitive-path refusal (security B1)\n+# ---------------------------------------------------------------------------\n+\n+def test_execute_code_entry_blocks_before_spawn_when_guard_denies(monkeypatch, tmp_path):\n+    \"\"\"Behavioral wiring test: execute_code() consults the entry guard and, on\n+    denial, returns the block message WITHOUT spawning the child — proven by a\n+    marker file the script would create that never appears.\"\"\"\n+    import json\n+\n+    import tools.code_execution_tool as cet\n+    from tools import terminal_tool as TT\n+\n+    marker = tmp_path / \"child-ran.marker\"\n+    monkeypatch.setenv(\"HERMES_CRON_SESSION\", \"1\")\n+    monkeypatch.delenv(\"HERMES_GATEWAY_SESSION\", raising=False)\n+    monkeypatch.delenv(\"HERMES_INTERACTIVE\", raising=False)\n+    monkeypatch.setattr(A, \"_get_approval_mode\", lambda: \"manual\")\n+    monkeypatch.setattr(A, \"_get_cron_approval_mode\", lambda: \"deny\")\n+    monkeypatch.setattr(TT, \"_get_env_config\", lambda: {\"env_type\": \"local\"})\n+\n+    result = json.loads(\n+        cet.execute_code(f\"open({str(marker)!r}, 'w').close()\", task_id=\"cluster-t\")\n+    )\n+    assert result[\"status\"] == \"error\"\n+    assert \"BLOCKED\" in result[\"error\"]\n+    assert not marker.exists()  # guard denied before the child was spawned\n+\n+\n+# ---------------------------------------------------------------------------\n+# 6. Env-scrub diagnosability mitigation (#27303 follow-up)\n+# ---------------------------------------------------------------------------\n+\n+def test_env_scrub_logs_dropped_hermes_vars(caplog):\n+    \"\"\"Dropping a non-allowlisted, non-secret HERMES_* var must be diagnosable:\n+    the scrub emits a one-shot debug log naming the dropped vars and pointing at\n+    the env_passthrough opt-in, so the silent behavior change (#27303) doesn't\n+    leave users guessing why a sandbox script sees an unset HERMES_* var.\"\"\"\n+    import logging\n+\n+    from tools.code_execution_tool import _scrub_child_env\n+\n+    env = {\n+        \"HERMES_HOME\": \"/h\",          # allowlisted → kept, not logged\n+        \"HERMES_BASE_URL\": \"https://x\",   # dropped → logged\n+        \"HERMES_KANBAN_DB\": \"postgres://[redacted-credentials]@h/db\",  # dropped → logged\n+        \"HERMES_API_KEY\": \"sk\",       # secret → dropped silently (not logged)\n+        \"PATH\": \"/usr/bin\",           # safe prefix → kept\n+    }\n+    with caplog.at_level(logging.DEBUG, logger=\"tools.code_execution_tool\"):\n+        out = _scrub_child_env(env, is_passthrough=lambda _: False, is_windows=False)\n+\n+    assert \"HERMES_HOME\" in out and \"PATH\" in out\n+    assert \"HERMES_BASE_URL\" not in out and \"HERMES_KANBAN_DB\" not in out\n+\n+    msgs = \"\\n\".join(r.getMessage() for r in caplog.records)\n+    assert \"HERMES_BASE_URL\" in msgs and \"HERMES_KANBAN_DB\" in msgs\n+    assert \"env_passthrough\" in msgs\n+    # Secret vars are dropped but must NOT be named in the diagnostic log.\n+    assert \"HERMES_API_KEY\" not in msgs\n+\n+\n+def test_env_scrub_no_log_when_nothing_dropped(caplog):\n+    \"\"\"No diagnostic noise when there are no dropped HERMES_* vars.\"\"\"\n+    import logging\n+\n+    from tools.code_execution_tool import _scrub_child_env\n+\n+    with caplog.at_level(logging.DEBUG, logger=\"tools.code_execution_tool\"):\n+        _scrub_child_env(\n+            {\"HERMES_HOME\": \"/h\", \"PATH\": \"/usr/bin\"},\n+            is_passthrough=lambda _: False,\n+            is_windows=False,\n+        )\n+    assert \"dropped\" not in \"\\n\".join(r.getMessage() for r in caplog.records)"]},{"file":"tools/approval.py","hunks":["@@ -1057,6 +1057,107 @@ def _format_tirith_description(tirith_result: dict) -> str:\n     return \"Security scan — \" + \"; \".join(parts)\n \n \n+def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict,\n+                            *, surface: str = \"gateway\") -> dict:\n+    \"\"\"Enqueue *approval_data*, notify the user, and block the calling agent\n+    thread until the request is resolved or the gateway approval timeout\n+    elapses — firing pre/post approval hooks and cleaning up the queue entry.\n+\n+    Shared by the terminal command guard (``check_all_command_guards``) and\n+    the execute_code guard (``check_execute_code_guard``) so the fiddly\n+    heartbeat-polling wait loop lives in one place.\n+\n+    Returns ``{\"resolved\": bool, \"choice\": str|None}`` on completion, or\n+    ``{\"resolved\": False, \"choice\": None, \"notify_failed\": True}`` if the\n+    notify callback raised.  Persistence of an approved choice and building\n+    the final tool-facing result dict remain the caller's responsibility.\n+    \"\"\"\n+    command = approval_data.get(\"command\", \"\")\n+    description = approval_data.get(\"description\", \"\")\n+    primary_key = approval_data.get(\"pattern_key\", \"\")\n+    all_keys = approval_data.get(\"pattern_keys\", [primary_key])\n+\n+    entry = _ApprovalEntry(approval_data)\n+    with _lock:\n+        _gateway_queues.setdefault(session_key, []).append(entry)\n+\n+    def _drop_entry() -> None:\n+        with _lock:\n+            queue = _gateway_queues.get(session_key, [])\n+            if entry in queue:\n+                queue.remove(entry)\n+            if not queue:\n+                _gateway_queues.pop(session_key, None)\n+\n+    # Notify plugins that an approval is being requested. Fires before the\n+    # gateway notify callback so observers get the event in real time.\n+    _fire_approval_hook(\n+        \"pre_approval_request\",\n+        command=command,\n+        description=description,\n+        pattern_key=primary_key,\n+        pattern_keys=list(all_keys),\n+        session_key=session_key,\n+        surface=surface,\n+    )\n+\n+    # Notify the user (bridges sync agent thread → async gateway)\n+    try:\n+        notify_cb(approval_data)\n+    except Exception as exc:\n+        logger.warning(\"Gateway approval notify failed: %s\", exc)\n+        _drop_entry()\n+        return {\"resolved\": False, \"choice\": None, \"notify_failed\": True}\n+\n+    # Block until the user responds or timeout (default 5 min). Poll in short\n+    # slices so we can fire activity heartbeats every ~10s to the agent's\n+    # inactivity tracker — otherwise the gateway watchdog kills the agent\n+    # while the user is still responding. Mirrors _wait_for_process() cadence.\n+    timeout = _get_approval_config().get(\"gateway_timeout\", 300)\n+    try:\n+        timeout = int(timeout)\n+    except (ValueError, TypeError):\n+        timeout = 300\n+\n+    try:\n+        from tools.environments.base import touch_activity_if_due\n+    except Exception:  # pragma: no cover\n+        touch_activity_if_due = None\n+\n+    _now = time.monotonic()\n+    _deadline = _now + max(timeout, 0)\n+    _activity_state = {\"last_touch\": _now, \"start\": _now}\n+    resolved = False\n+    while True:\n+        _remaining = _deadline - time.monotonic()\n+        if _remaining <= 0:\n+            break\n+        if entry.event.wait(timeout=min(1.0, _remaining)):\n+            resolved = True\n+            break\n+        if touch_activity_if_due is not None:\n+            touch_activity_if_due(_activity_state, \"waiting for user approval\")\n+\n+    _drop_entry()\n+\n+    choice = entry.result\n+    # Normalize outcome for the post hook. Unresolved (timeout) and None both\n+    # mean the user never responded; report that explicitly so plugins can\n+    # distinguish timeout from explicit deny.\n+    _outcome = \"timeout\" if not resolved else (choice if choice else \"timeout\")\n+    _fire_approval_hook(\n+        \"post_approval_response\",\n+        command=command,\n+        description=description,\n+        pattern_key=primary_key,\n+        pattern_keys=list(all_keys),\n+        session_key=session_key,\n+        surface=surface,\n+        choice=_outcome,\n+    )\n+    return {\"resolved\": resolved, \"choice\": choice}\n+\n+\n def check_all_command_guards(command: str, env_type: str,\n                              approval_callback=None) -> dict:\n     \"\"\"Run all pre-exec security checks and return a single approval decision.","@@ -1207,113 +1308,27 @@ def check_all_command_guards(command: str, env_type: str,\n \n         if notify_cb is not None:\n             # --- Blocking gateway approval (queue-based) ---\n-            # Each call gets its own _ApprovalEntry so parallel subagents\n-            # and execute_code threads can block concurrently.\n+            # Block the agent thread until the user responds; the notify +\n+            # heartbeat wait loop is shared with check_execute_code_guard via\n+            # _await_gateway_decision().\n             approval_data = {\n                 \"command\": command,\n                 \"pattern_key\": primary_key,\n                 \"pattern_keys\": all_keys,\n                 \"description\": combined_desc,\n             }\n-            entry = _ApprovalEntry(approval_data)\n-            with _lock:\n-                _gateway_queues.setdefault(session_key, []).append(entry)\n-\n-            # Notify plugins that an approval is being requested. Fires before\n-            # the gateway notify callback so observers (e.g. macOS notifier\n-            # plugins, audit logs, Slack alerts) get the event in real time.\n-            _fire_approval_hook(\n-                \"pre_approval_request\",\n-                command=command,\n-                description=combined_desc,\n-                pattern_key=primary_key,\n-                pattern_keys=list(all_keys),\n-                session_key=session_key,\n-                surface=\"gateway\",\n+            decision = _await_gateway_decision(\n+                session_key, notify_cb, approval_data, surface=\"gateway\"\n             )\n-\n-            # Notify the user (bridges sync agent thread → async gateway)\n-            try:\n-                notify_cb(approval_data)\n-            except Exception as exc:\n-                logger.warning(\"Gateway approval notify failed: %s\", exc)\n-                with _lock:\n-                    queue = _gateway_queues.get(session_key, [])\n-                    if entry in queue:\n-                        queue.remove(entry)\n-                    if not queue:\n-                        _gateway_queues.pop(session_key, None)\n+            if decision.get(\"notify_failed\"):\n                 return {\n                     \"approved\": False,\n                     \"message\": \"BLOCKED: Failed to send approval request to user. Do NOT retry.\",\n                     \"pattern_key\": primary_key,\n                     \"description\": combined_desc,\n                 }\n-\n-            # Block until the user responds or timeout (default 5 min).\n-            # Poll in short slices so we can fire activity heartbeats every\n-            # ~10s to the agent's inactivity tracker.  Without this, the\n-            # blocking event.wait() never touches activity, and the\n-            # gateway's inactivity watchdog (agent.gateway_timeout, default\n-            # 1800s) kills the agent while the user is still responding to\n-            # the approval prompt.  Mirrors the _wait_for_process() cadence\n-            # in tools/environments/base.py.\n-            timeout = _get_approval_config().get(\"gateway_timeout\", 300)\n-            try:\n-                timeout = int(timeout)\n-            except (ValueError, TypeError):\n-                timeout = 300\n-\n-            try:\n-                from tools.environments.base import touch_activity_if_due\n-            except Exception:  # pragma: no cover\n-                touch_activity_if_due = None\n-\n-            _now = time.monotonic()\n-            _deadline = _now + max(timeout, 0)\n-            _activity_state = {\"last_touch\": _now, \"start\": _now}\n-            resolved = False\n-            while True:\n-                _remaining = _deadline - time.monotonic()\n-                if _remaining <= 0:\n-                    break\n-                # 1s poll slice — the event is set immediately when the\n-                # user responds, so slice length only controls heartbeat\n-                # cadence, not user-visible responsiveness.\n-                if entry.event.wait(timeout=min(1.0, _remaining)):\n-                    resolved = True\n-                    break\n-                if touch_activity_if_due is not None:\n-                    touch_activity_if_due(\n-                        _activity_state, \"waiting for user approval\"\n-                    )\n-\n-            # Clean up this entry from the queue\n-            with _lock:\n-                queue = _gateway_queues.get(session_key, [])\n-                if entry in queue:\n-                    queue.remove(entry)\n-                if not queue:\n-                    _gateway_queues.pop(session_key, None)\n-\n-            choice = entry.result\n-            # Normalize outcome for the post hook. Unresolved (timeout) and\n-            # None both mean the user never responded; report that explicitly\n-            # so plugins can distinguish timeout from explicit deny.\n-            _outcome = (\n-                \"timeout\" if not resolved\n-                else (choice if choice else \"timeout\")\n-            )\n-            _fire_approval_hook(\n-                \"post_approval_response\",\n-                command=command,\n-                description=combined_desc,\n-                pattern_key=primary_key,\n-                pattern_keys=list(all_keys),\n-                session_key=session_key,\n-                surface=\"gateway\",\n-                choice=_outcome,\n-            )\n+            resolved = decision[\"resolved\"]\n+            choice = decision[\"choice\"]\n \n             if not resolved or choice is None or choice == \"deny\":\n                 # Consent contract: silence is NOT consent, and an explicit","@@ -1437,5 +1452,173 @@ def check_all_command_guards(command: str, env_type: str,\n             \"user_approved\": True, \"description\": combined_desc}\n \n \n+def check_execute_code_guard(code: str, env_type: str) -> dict:\n+    \"\"\"Approve an execute_code script before its child process is spawned.\n+\n+    execute_code runs arbitrary local Python — the script can call\n+    ``subprocess``, ``os.system``, ``ctypes``, or other process/file APIs\n+    directly, none of which pass through ``terminal()`` /\n+    ``DANGEROUS_PATTERNS``. In gateway/ask contexts we fail closed by approving\n+    the script as a whole before it runs (#30882). Returns the same dict\n+    contract as ``check_all_command_guards``.\n+\n+    Scope (documented limitation, #30882): in a purely local non-interactive\n+    non-gateway session (no TTY, not gateway, not cron-deny) this returns\n+    approved — matching the existing terminal auto-approve contract. The\n+    hardline floor still blocks catastrophic ``terminal()`` commands the script\n+    issues; running arbitrary code headlessly without any approval surface is\n+    trusted-by-config (set a gateway/ask surface or ``approvals.cron_mode`` to\n+    require approval).\n+    \"\"\"\n+    pattern_key = \"execute_code\"\n+    description = (\n+        \"execute_code script execution. The script can spawn subprocesses or \"\n+        \"mutate files without passing through terminal command approval; \"\n+        \"approval is one-shot for this run.\"\n+    )\n+\n+    # Isolated backends already sandbox the child — matches the container skip\n+    # in check_all_command_guards / check_dangerous_command.\n+    if env_type in {\"docker\", \"singularity\", \"modal\", \"daytona\", \"vercel_sandbox\"}:\n+        return {\"approved\": True, \"message\": None}\n+\n+    # --yolo or approvals.mode=off: bypass (session- or process-scoped).\n+    approval_mode = _get_approval_mode()\n+    if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == \"off\":\n+        return {\"approved\": True, \"message\": None}\n+\n+    is_gateway = _is_gateway_approval_context()\n+    is_ask = env_var_enabled(\"HERMES_EXEC_ASK\")\n+\n+    # Cron: no user is present to approve arbitrary code.\n+    if env_var_enabled(\"HERMES_CRON_SESSION\"):\n+        if _get_cron_approval_mode() == \"deny\":\n+            return {\n+                \"approved\": False,\n+                \"message\": (\n+                    \"BLOCKED: execute_code runs arbitrary local Python \"\n+                    \"(including subprocess calls that bypass shell-string \"\n+                    \"approval checks). Cron jobs run without a user present \"\n+                    \"to approve it. Use normal tools instead, or set \"\n+                    \"approvals.cron_mode: approve only if this cron profile \"\n+                    \"is intentionally trusted.\"\n+                ),\n+                \"pattern_key\": pattern_key,\n+                \"description\": description,\n+                \"outcome\": \"blocked\",\n+                \"user_consent\": False,\n+            }\n+        return {\"approved\": True, \"message\": None}\n+\n+    # Only gateway/ask contexts get the one-shot whole-script approval.\n+    #   * CLI interactive: the script's terminal() calls are guarded per-call\n+    #     (context now propagates into the RPC thread, #33057); a whole-script\n+    #     prompt would fire on every execute_code call.\n+    #   * Local non-interactive non-gateway: documented limitation above.\n+    if not is_gateway and not is_ask:\n+        return {\"approved\": True, \"message\": None}\n+\n+    session_key = get_current_session_key()\n+    # Built only now (past the early-return gates) so the common non-approval\n+    # paths don't pay to copy a potentially-large script into this string.\n+    command = f\"execute_code <<'PY'\\n{code}\\nPY\"\n+\n+    # Smart mode: ask the aux LLM about the whole script. An APPROVE here only\n+    # suppresses the redundant whole-script prompt; the per-call terminal()\n+    # guards (restored by context propagation) still run independently.\n+    if approval_mode == \"smart\":\n+        verdict = _smart_approve(command, description)\n+        if verdict == \"approve\":\n+            logger.debug(\"Smart approval: auto-approved execute_code for session %s\",\n+                         session_key)\n+            return {\"approved\": True, \"message\": None,\n+                    \"smart_approved\": True, \"description\": description}\n+        if verdict == \"deny\":\n+            return {\n+                \"approved\": False,\n+                \"message\": (\"BLOCKED by smart approval: execute_code script \"\n+                            \"execution was assessed as genuinely dangerous. \"\n+                            \"Do NOT retry.\"),\n+                \"smart_denied\": True,\n+                \"pattern_key\": pattern_key,\n+                \"description\": description,\n+                \"outcome\": \"denied\",\n+                \"user_consent\": False,\n+            }\n+        # verdict == \"escalate\" → fall through to manual approval\n+\n+    notify_cb = None\n+    with _lock:\n+        notify_cb = _gateway_notify_cbs.get(session_key)\n+\n+    if notify_cb is None:\n+        # No gateway callback registered (e.g. ask-mode without a notifier):\n+        # surface a pending approval for backward compatibility.\n+        submit_pending(session_key, {\n+            \"command\": command,\n+            \"pattern_key\": pattern_key,\n+            \"pattern_keys\": [pattern_key],\n+            \"description\": description,\n+        })\n+        return {\n+            \"approved\": False,\n+            \"pattern_key\": pattern_key,\n+            \"status\": \"pending_approval\",\n+            \"approval_pending\": True,\n+            \"command\": command,\n+            \"description\": description,\n+            \"message\": (\n+                f\"⚠️ {description}. Asking the user for approval.\\n\\n\"\n+                f\"**Code:**\\n```python\\n{code}\\n```\"\n+            ),\n+        }\n+\n+    approval_data = {\n+        \"command\": command,\n+        \"pattern_key\": pattern_key,\n+        \"pattern_keys\": [pattern_key],\n+        \"description\": description,\n+    }\n+    decision = _await_gateway_decision(\n+        session_key, notify_cb, approval_data, surface=\"gateway\"\n+    )\n+    if decision.get(\"notify_failed\"):\n+        return {\n+            \"approved\": False,\n+            \"message\": (\"BLOCKED: Failed to send execute_code approval request \"\n+                        \"to user. Do NOT retry.\"),\n+            \"pattern_key\": pattern_key,\n+            \"description\": description,\n+            \"outcome\": \"notify_failed\",\n+            \"user_consent\": False,\n+        }\n+\n+    resolved = decision[\"resolved\"]\n+    choice = decision[\"choice\"]\n+\n+    if not resolved or choice is None or choice == \"deny\":\n+        reason = \"timed out without user response\" if not resolved else \"denied by user\"\n+        addendum = \" Silence is not consent.\" if not resolved else \"\"\n+        return {\n+            \"approved\": False,\n+            \"message\": (\n+                f\"BLOCKED: execute_code script {reason}. The user has NOT \"\n+                f\"consented to running this code. Do NOT retry, do NOT rephrase \"\n+                f\"the script, and do NOT attempt the same outcome via a \"\n+                f\"different tool.{addendum}\"\n+            ),\n+            \"pattern_key\": pattern_key,\n+            \"description\": description,\n+            \"outcome\": \"timeout\" if not resolved else \"denied\",\n+            \"user_consent\": False,\n+        }\n+\n+    # Approved — one-shot only. Deliberately NO approve_session/approve_permanent:\n+    # each execute_code script is distinct arbitrary code, so approval never\n+    # persists to future scripts.\n+    return {\"approved\": True, \"message\": None,\n+            \"user_approved\": True, \"description\": description}\n+\n+\n # Load permanent allowlist from config on module import\n load_permanent_allowlist()"]},{"file":"tools/code_execution_tool.py","hunks":["@@ -46,6 +46,8 @@\n _IS_WINDOWS = platform.system() == \"Windows\"\n from typing import Any, Dict, List, Optional\n \n+from tools.thread_context import propagate_context_to_thread\n+\n # Availability gate.  On Windows we fall back to loopback TCP for the\n # sandbox RPC transport (AF_UNIX is unreliable on Windows Python) — see\n # ``_use_tcp_rpc`` in ``_execute_local`` below.  That makes execute_code","@@ -74,13 +76,30 @@\n \n # Environment variable scrubbing rules (shared between the local + remote\n # backends).  Secret-substring block is applied first; anything left must\n-# match either a safe prefix or, on Windows, an OS-essential name.\n+# match a safe prefix, the operational HERMES_ allowlist, or (on Windows) an\n+# OS-essential name.\n+#\n+# NB: the broad \"HERMES_\" prefix was deliberately removed (#27303) — it leaked\n+# HERMES_*-named config that lacks a secret substring (e.g. HERMES_BASE_URL,\n+# HERMES_KANBAN_DB, HERMES_*_WEBHOOK).  The child only needs the few\n+# location/profile vars in _HERMES_CHILD_ALLOWED below; HERMES_RPC_SOCKET /\n+# HERMES_RPC_DIR / TZ / HOME are injected explicitly after scrubbing.\n _SAFE_ENV_PREFIXES = (\"PATH\", \"HOME\", \"USER\", \"LANG\", \"LC_\", \"TERM\",\n                       \"TMPDIR\", \"TMP\", \"TEMP\", \"SHELL\", \"LOGNAME\",\n-                      \"XDG_\", \"PYTHONPATH\", \"VIRTUAL_ENV\", \"CONDA\",\n-                      \"HERMES_\")\n+                      \"XDG_\", \"PYTHONPATH\", \"VIRTUAL_ENV\", \"CONDA\")\n _SECRET_SUBSTRINGS = (\"KEY\", \"TOKEN\", \"SECRET\", \"PASSWORD\", \"CREDENTIAL\",\n-                      \"PASSWD\", \"AUTH\")\n+                      \"PASSWD\", \"AUTH\", \"DSN\", \"WEBHOOK\")\n+\n+# Operational HERMES_* vars the child legitimately needs by exact name — these\n+# are non-secret runtime-location flags (the same set hermes_cli treats as the\n+# runtime location) that repo-root modules a sandbox script imports may read at\n+# import time.  None match _SECRET_SUBSTRINGS.\n+_HERMES_CHILD_ALLOWED = frozenset({\n+    \"HERMES_HOME\",\n+    \"HERMES_PROFILE\",\n+    \"HERMES_CONFIG\",\n+    \"HERMES_ENV\",\n+})\n \n # Windows-only: a handful of variables are required by the OS/CRT itself.\n # Without them, even stdlib calls like ``socket.socket()`` fail with","@@ -119,9 +138,10 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):\n \n     Rules (order matters):\n       1. Passthrough vars (skill- or config-declared) always pass.\n-      2. Secret-substring names (KEY/TOKEN/etc.) are blocked.\n+      2. Secret-substring names (KEY/TOKEN/DSN/WEBHOOK/etc.) are blocked.\n       3. Names matching a safe prefix pass.\n-      4. On Windows, a small OS-essential allowlist passes by exact name\n+      4. Operational HERMES_* vars (_HERMES_CHILD_ALLOWED) pass by exact name.\n+      5. On Windows, a small OS-essential allowlist passes by exact name\n          — without these the child can't even create a socket or spawn a\n          subprocess.\n ","@@ -138,6 +158,14 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):\n         is_windows = _IS_WINDOWS\n \n     scrubbed = {}\n+    # Non-secret HERMES_* vars dropped by the tightened allowlist (#27303). The\n+    # broad \"HERMES_\" prefix used to pass these through; now only the\n+    # operational set does. The drop is intentional (those vars can carry\n+    # config like HERMES_KANBAN_DB / HERMES_BASE_URL), but a sandbox script\n+    # that imports a repo module reading one at import time would otherwise see\n+    # it silently unset. Surface the drop once so the behavior change is\n+    # diagnosable and points at the env_passthrough opt-in escape hatch.\n+    _dropped_hermes = []\n     for k, v in source_env.items():\n         if is_passthrough(k):\n             scrubbed[k] = v","@@ -147,8 +175,25 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None):\n         if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES):\n             scrubbed[k] = v\n             continue\n+        if k in _HERMES_CHILD_ALLOWED:\n+            scrubbed[k] = v\n+            continue\n         if is_windows and k.upper() in _WINDOWS_ESSENTIAL_ENV_VARS:\n             scrubbed[k] = v\n+            continue\n+        if k.startswith(\"HERMES_\"):\n+            # Non-secret (secrets were already dropped above) and not in any\n+            # allowlist — a deliberately-dropped HERMES_* var.\n+            _dropped_hermes.append(k)\n+    if _dropped_hermes:\n+        logger.debug(\n+            \"execute_code: dropped %d non-allowlisted HERMES_* var(s) from the \"\n+            \"sandbox child env (%s). This is intentional hardening (#27303); if \"\n+            \"a sandbox script legitimately needs one, declare it via \"\n+            \"env_passthrough in the skill/config so it passes by explicit opt-in.\",\n+            len(_dropped_hermes),\n+            \", \".join(sorted(_dropped_hermes)),\n+        )\n     return scrubbed\n \n ","@@ -887,9 +932,11 @@ def _execute_remote(\n         _ship_file_to_remote(env, f\"{sandbox_dir}/hermes_tools.py\", tools_src)\n         _ship_file_to_remote(env, f\"{sandbox_dir}/script.py\", code)\n \n-        # Start RPC polling thread\n+        # Wrapped so the thread inherits the turn's approval context + callbacks\n+        # (see tools.thread_context) — else sandbox RPC tool calls lose approval\n+        # routing (#33057).\n         rpc_thread = threading.Thread(\n-            target=_rpc_poll_loop,\n+            target=propagate_context_to_thread(_rpc_poll_loop),\n             args=(\n                 env, f\"{sandbox_dir}/rpc\", effective_task_id,\n                 tool_call_log, tool_call_counter, max_tool_calls,","@@ -1049,6 +1096,21 @@ def execute_code(\n     # Dispatch: remote backends use file-based RPC, local uses UDS\n     from tools.terminal_tool import _get_env_config\n     env_type = _get_env_config()[\"env_type\"]\n+\n+    # execute_code runs arbitrary Python (subprocess/os.system/...) that never\n+    # passes through terminal()/DANGEROUS_PATTERNS, so guard the whole script\n+    # here before either dispatch path spawns it. Runs synchronously in the\n+    # caller (tool-executor) thread, which holds the session context (#30882).\n+    from tools.approval import check_execute_code_guard\n+    _guard = check_execute_code_guard(code, env_type)\n+    if not _guard.get(\"approved\", False):\n+        return json.dumps({\n+            \"status\": \"error\",\n+            \"error\": _guard.get(\"message\") or \"execute_code blocked by approval guard.\",\n+            \"tool_calls_made\": 0,\n+            \"duration_seconds\": 0,\n+        }, ensure_ascii=False)\n+\n     if env_type != \"local\":\n         return _execute_remote(code, task_id, enabled_tools)\n ","@@ -1135,8 +1197,11 @@ def execute_code(\n             os.chmod(sock_path, 0o600)\n         server_sock.listen(1)\n \n+        # Wrapped so the thread inherits the turn's approval context + callbacks\n+        # (see tools.thread_context) — else gateway sandbox tool calls silently\n+        # auto-approve dangerous commands (#33057, #30882).\n         rpc_thread = threading.Thread(\n-            target=_rpc_server_loop,\n+            target=propagate_context_to_thread(_rpc_server_loop),\n             args=(\n                 server_sock, task_id, tool_call_log,\n                 tool_call_counter, max_tool_calls, sandbox_tools,"]},{"file":"tools/thread_context.py","hunks":["@@ -0,0 +1,120 @@\n+#!/usr/bin/env python3\n+\"\"\"Propagate agent-turn context into worker threads that dispatch Hermes tools.\n+\n+A bare ``threading.Thread`` / ``ThreadPoolExecutor`` worker starts with an\n+empty ``contextvars.Context`` and no thread-local approval/sudo callbacks.\n+Tool dispatch inside such a thread therefore silently loses:\n+\n+  * the approval *session/platform* ContextVars (``tools.approval`` /\n+    ``gateway.session_context``) — so gateway sessions fall into\n+    ``check_dangerous_command``'s non-interactive auto-approve branch and\n+    dangerous commands run without prompting (#33057, #30882);\n+  * the thread-local CLI approval/sudo callbacks (``tools.terminal_tool``) —\n+    so ``prompt_dangerous_approval`` cannot reach the user\n+    (GHSA-qg5c-hvr5-hjgr, #15216).\n+\n+This helper factors out that capture/install/clear lifecycle so the several\n+places that fan tool dispatch onto worker threads (``agent.tool_executor`` and\n+the ``execute_code`` RPC threads) share one audited implementation instead of\n+divergent copies.\n+\n+Usage — call :func:`propagate_context_to_thread` **on the parent thread**\n+(it snapshots the parent's ContextVars and callbacks at call time) and use the\n+returned callable as the worker's target::\n+\n+    t = threading.Thread(target=propagate_context_to_thread(loop_fn), args=(...))\n+    # or\n+    executor.submit(propagate_context_to_thread(worker_fn), *args)\n+\n+Approval/sudo callbacks are installed for the worker's lifetime and **always\n+cleared on exit**, so a recycled thread never holds a stale reference to a\n+disposed CLI instance.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import contextvars\n+import logging\n+from typing import Callable\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+def _callback_api():\n+    \"\"\"Resolve the terminal_tool callback getters/setters.\n+\n+    Imported lazily: ``tools.terminal_tool`` imports ``tools.approval`` at\n+    module load, so a top-level import here would risk an import cycle for\n+    callers that live in ``tools.approval``.\n+    \"\"\"\n+    from tools.terminal_tool import (\n+        _get_approval_callback,\n+        _get_sudo_password_callback,\n+        set_approval_callback,\n+        set_sudo_password_callback,\n+    )\n+    return (\n+        _get_approval_callback,\n+        _get_sudo_password_callback,\n+        set_approval_callback,\n+        set_sudo_password_callback,\n+    )\n+\n+\n+def propagate_context_to_thread(target: Callable) -> Callable:\n+    \"\"\"Wrap *target* for execution on a worker thread with the *current*\n+    thread's ContextVars and approval/sudo callbacks propagated.\n+\n+    Call this on the parent thread; pass the returned callable as the\n+    thread/executor target.  The returned callable forwards its positional\n+    and keyword arguments to *target* and returns its result.\n+\n+    Fail-closed: if callback installation raises, the callbacks are left\n+    unset (``None``).  That is the safe outcome — ``prompt_dangerous_approval``\n+    denies dangerous commands when no callback is registered in an interactive\n+    context, and the gateway approval queue blocks when its notify callback is\n+    absent.\n+    \"\"\"\n+    ctx = contextvars.copy_context()\n+    parent_approval_cb = parent_sudo_cb = None\n+    setters = None\n+    try:\n+        get_approval, get_sudo, set_approval, set_sudo = _callback_api()\n+        parent_approval_cb = get_approval()\n+        parent_sudo_cb = get_sudo()\n+        setters = (set_approval, set_sudo)\n+    except Exception:\n+        logger.debug(\"Could not capture parent approval/sudo callbacks\", exc_info=True)\n+\n+    def _runner(*args, **kwargs):\n+        def _inner():\n+            if setters is not None:\n+                set_approval, set_sudo = setters\n+                try:\n+                    if parent_approval_cb is not None:\n+                        set_approval(parent_approval_cb)\n+                    if parent_sudo_cb is not None:\n+                        set_sudo(parent_sudo_cb)\n+                except Exception:\n+                    logger.debug(\n+                        \"Failed to install propagated approval/sudo callbacks; \"\n+                        \"dangerous-command approval will fail closed\",\n+                        exc_info=True,\n+                    )\n+            try:\n+                return target(*args, **kwargs)\n+            finally:\n+                if setters is not None:\n+                    set_approval, set_sudo = setters\n+                    try:\n+                        set_approval(None)\n+                        set_sudo(None)\n+                    except Exception:\n+                        logger.debug(\n+                            \"Failed to clear propagated approval/sudo callbacks\",\n+                            exc_info=True,\n+                        )\n+\n+        return ctx.run(_inner)\n+\n+    return _runner\n"]}]},{"kind":"pr","number":56971,"title":"fix(mcp): wire bridge approval tools to the gateway via a cross-process handshake","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"fe3cb2355","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":60544,"title":"fix(plugins): re-enable pre_tool_call approve escalation with correct gateway notify path (#59201)","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":63189,"title":"fix(kanban): drop approval context env vars from spawned workers","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#63183","number":63183},{"kind":"pr","value":"#55945","number":55945},{"kind":"pr","value":"#55946","number":55946},{"kind":"pr","value":"#37968","number":37968},{"kind":"pr","value":"#56771","number":56771},{"kind":"pr","value":"#58662","number":58662},{"kind":"pr","value":"#59179","number":59179},{"kind":"pr","value":"#21563","number":21563},{"kind":"pr","value":"#63183","number":63183}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":63342,"title":"fix(mcp): unblock bridge approval responses","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"ee5e40c638ec","number":0},{"kind":"pr","value":"#59260","number":59260},{"kind":"pr","value":"#63342","number":63342},{"kind":"pr","value":"#63342","number":63342},{"kind":"pr","value":"#63342","number":63342},{"kind":"pr","value":"#63342","number":63342},{"kind":"pr","value":"#56971","number":56971},{"kind":"pr","value":"#63342","number":63342},{"kind":"pr","value":"#56971","number":56971},{"kind":"pr","value":"#63342","number":63342},{"kind":"pr","value":"#56971","number":56971},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#59260","number":59260},{"kind":"pr","value":"#63342","number":63342}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":63421,"title":"fix(kanban): broker detached worker approvals across processes","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":65325,"title":"fix(delegation): honor worker approval callbacks in gateways","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#65415","number":65415}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":83771,"title":"fix(mcp): drop broken approval tools from MCP surface","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22300","number":22300},{"kind":"pr","value":"#56971","number":56971},{"kind":"pr","value":"#63342","number":63342}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}