diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index eb1c68ffd66b..066841d78552 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -544,6 +544,7 @@ def _collect(): message_type=MessageType.TEXT, source=_source, internal=True, + metadata={"unattended_session": True}, ) await adapter.handle_message(_synth_event) logger.info( diff --git a/gateway/run.py b/gateway/run.py index 2247b2a63ca8..42f3e06bd0b4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11843,7 +11843,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g context = build_session_context(source, self.config, session_entry) # Set session context variables for tools (task-local, concurrency-safe) - _session_env_tokens = self._set_session_env(context) + _session_env_tokens = self._set_session_env(context, event=event) # Read privacy.redact_pii from config (re-read per message) _redact_pii = False @@ -15994,7 +15994,7 @@ async def _send_home_channel_startup_notifications( return delivered - def _set_session_env(self, context: SessionContext) -> list: + def _set_session_env(self, context: SessionContext, event: Optional[MessageEvent] = None) -> list: """Set session context variables for the current async task. Uses ``contextvars`` instead of ``os.environ`` so that concurrent @@ -16014,6 +16014,11 @@ def _set_session_env(self, context: SessionContext) -> list: _adapters = getattr(self, "adapters", None) or {} _adapter = _adapters.get(context.source.platform) _async_delivery = getattr(_adapter, "supports_async_delivery", True) + _event_metadata = getattr(event, "metadata", None) if event is not None else None + _unattended = bool( + isinstance(_event_metadata, dict) + and _event_metadata.get("unattended_session") + ) return set_session_vars( platform=context.source.platform.value, chat_id=context.source.chat_id, @@ -16025,6 +16030,7 @@ def _set_session_env(self, context: SessionContext) -> list: message_id=str(context.source.message_id) if context.source.message_id else "", profile=getattr(context.source, "profile", "") or "", async_delivery=_async_delivery, + unattended=_unattended, ) def _clear_session_env(self, tokens: list) -> None: diff --git a/gateway/session_context.py b/gateway/session_context.py index a8e7c027fecb..ca98ba811a66 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -92,6 +92,7 @@ def session_context_engaged() -> bool: _SESSION_MESSAGE_ID: ContextVar = ContextVar("HERMES_SESSION_MESSAGE_ID", default=_UNSET) _SESSION_PROFILE: ContextVar = ContextVar("HERMES_SESSION_PROFILE", default=_UNSET) +_UNATTENDED_SESSION: ContextVar = ContextVar("HERMES_UNATTENDED_SESSION", default=_UNSET) # Whether the current session's delivery channel can route an ASYNC completion # back to the agent AFTER the current turn ends (i.e. wake a fresh turn). @@ -133,6 +134,7 @@ def session_context_engaged() -> bool: "HERMES_UI_SESSION_ID": _SESSION_UI_SESSION_ID, "HERMES_SESSION_MESSAGE_ID": _SESSION_MESSAGE_ID, "HERMES_SESSION_PROFILE": _SESSION_PROFILE, + "HERMES_UNATTENDED_SESSION": _UNATTENDED_SESSION, "HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM, "HERMES_CRON_AUTO_DELIVER_CHAT_ID": _CRON_AUTO_DELIVER_CHAT_ID, "HERMES_CRON_AUTO_DELIVER_THREAD_ID": _CRON_AUTO_DELIVER_THREAD_ID, @@ -169,6 +171,7 @@ def set_session_vars( cwd: str = "", async_delivery: bool = True, ui_session_id: str = "", + unattended: bool = False, ) -> list: """Set all session context variables and return reset tokens. @@ -184,6 +187,10 @@ def set_session_vars( background completion back to the agent after the turn ends (see ``_SESSION_ASYNC_DELIVERY`` / ``async_delivery_supported``). Stateless request/response adapters (the API server) pass ``False``. + + ``unattended`` marks synthetic no-human turns, such as kanban notification + wakeups, so approval guards fail closed instead of queueing prompts no one + can answer. """ # Mark the session-context machinery engaged for this process. The # subprocess-env bridge uses this to switch from "os.environ fallback" to @@ -203,6 +210,7 @@ def set_session_vars( _SESSION_UI_SESSION_ID.set(ui_session_id), _SESSION_MESSAGE_ID.set(message_id), _SESSION_PROFILE.set(profile), + _UNATTENDED_SESSION.set("1" if unattended else ""), _SESSION_ASYNC_DELIVERY.set(bool(async_delivery)), ] try: @@ -238,6 +246,7 @@ def clear_session_vars(tokens: list) -> None: _SESSION_UI_SESSION_ID, _SESSION_MESSAGE_ID, _SESSION_PROFILE, + _UNATTENDED_SESSION, ): var.set("") # Reset async-delivery capability to the "never set" sentinel rather than a diff --git a/skills/research/research-paper-writing/references/experiment-patterns.md b/skills/research/research-paper-writing/references/experiment-patterns.md index f9fb243fe506..1aa2174a68f2 100644 --- a/skills/research/research-paper-writing/references/experiment-patterns.md +++ b/skills/research/research-paper-writing/references/experiment-patterns.md @@ -481,7 +481,7 @@ Next: Run significance tests on these results. | **Process crash** | PID gone, log stops mid-problem | Re-run script (resumes from last checkpoint) | | **Wrong model ID** | Model not found errors | Fix ID (e.g., `claude-opus-4-6` not `claude-opus-4.6`) | | **Parallel slowdown** | Each experiment taking 2x longer | Reduce parallel experiments to 2-3 max | -| **Security scan blocks** | Commands blocked by security | Use `execute_code` instead of piped `terminal` commands | +| **Security scan blocks** | Commands blocked by security | Rewrite the command to avoid pipe-to-interpreter patterns, split fetch and parsing into separate approved steps, or ask for explicit approval | | **Delegation failures** | `delegate_task` returns errors | Fall back to doing work directly | | **Timeout on hard problems** | Process stuck, no log progress | Kill, skip problem, note in results | | **Dataset path mismatch** | File not found errors | Verify paths before launching | diff --git a/tests/gateway/test_42039_duplicate_user_message.py b/tests/gateway/test_42039_duplicate_user_message.py index 88f8b8961e97..8ec3894a24a0 100644 --- a/tests/gateway/test_42039_duplicate_user_message.py +++ b/tests/gateway/test_42039_duplicate_user_message.py @@ -41,7 +41,7 @@ def _bootstrap(monkeypatch, tmp_path): runner._pending_messages = {} runner._pending_approvals = {} runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._handle_active_session_busy_message = AsyncMock(return_value=False) runner._session_db = MagicMock() runner._recover_telegram_topic_thread_id = lambda _source: None diff --git a/tests/gateway/test_approve_deny_commands.py b/tests/gateway/test_approve_deny_commands.py index 6ac15ec8a0fd..1566e4430800 100644 --- a/tests/gateway/test_approve_deny_commands.py +++ b/tests/gateway/test_approve_deny_commands.py @@ -62,7 +62,7 @@ def _make_runner(): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None return runner diff --git a/tests/gateway/test_first_turn_session_meta_rebaseline.py b/tests/gateway/test_first_turn_session_meta_rebaseline.py index 1a5e5891b0b6..9f5c78280114 100644 --- a/tests/gateway/test_first_turn_session_meta_rebaseline.py +++ b/tests/gateway/test_first_turn_session_meta_rebaseline.py @@ -66,7 +66,7 @@ def _bootstrap(monkeypatch, tmp_path, db): runner._pending_messages = {} runner._pending_approvals = {} runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._handle_active_session_busy_message = AsyncMock(return_value=False) # REAL SessionDB behind the async facade the gateway holds — the # production re-baseline does ``await self._session_db.get_session(...)``, diff --git a/tests/gateway/test_footer_command_mid_run.py b/tests/gateway/test_footer_command_mid_run.py index 88bdb60dc43e..6594e8abd5c6 100644 --- a/tests/gateway/test_footer_command_mid_run.py +++ b/tests/gateway/test_footer_command_mid_run.py @@ -79,7 +79,7 @@ def _make_runner(session_entry: SessionEntry): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_gateway_command_dispatch_minimal.py b/tests/gateway/test_gateway_command_dispatch_minimal.py index e094f22caf93..7240c6974adb 100644 --- a/tests/gateway/test_gateway_command_dispatch_minimal.py +++ b/tests/gateway/test_gateway_command_dispatch_minimal.py @@ -74,7 +74,7 @@ def _make_runner(): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_gateway_silence_tokens.py b/tests/gateway/test_gateway_silence_tokens.py index df15f6a15b0d..25cc9611b94a 100644 --- a/tests/gateway/test_gateway_silence_tokens.py +++ b/tests/gateway/test_gateway_silence_tokens.py @@ -40,7 +40,7 @@ def _runner(monkeypatch, tmp_path): runner._pending_messages = {} runner._pending_approvals = {} runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._handle_active_session_busy_message = AsyncMock(return_value=False) runner._session_db = MagicMock() runner._recover_telegram_topic_thread_id = lambda _source: None diff --git a/tests/gateway/test_incomplete_gateway_turns.py b/tests/gateway/test_incomplete_gateway_turns.py index 1d777548faa6..ea23debd670c 100644 --- a/tests/gateway/test_incomplete_gateway_turns.py +++ b/tests/gateway/test_incomplete_gateway_turns.py @@ -102,7 +102,7 @@ def _make_runner(adapter: CaptureSlackAdapter) -> gateway_run.GatewayRunner: runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock(return_value=_make_incomplete_result()) return runner diff --git a/tests/gateway/test_priority_path_compression_demotion_56391.py b/tests/gateway/test_priority_path_compression_demotion_56391.py index 39253f7fe974..6d30b9973edb 100644 --- a/tests/gateway/test_priority_path_compression_demotion_56391.py +++ b/tests/gateway/test_priority_path_compression_demotion_56391.py @@ -101,7 +101,7 @@ def _make_runner(*, compression_in_flight: bool): runner._show_reasoning = False runner._service_tier = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_queue_command.py b/tests/gateway/test_queue_command.py index d9c66f5dbc10..7ae76c85d82e 100644 --- a/tests/gateway/test_queue_command.py +++ b/tests/gateway/test_queue_command.py @@ -59,7 +59,7 @@ def _make_runner(session_entry: SessionEntry): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_reload_skills_command.py b/tests/gateway/test_reload_skills_command.py index 5b9804bb1d04..4f7991f746f7 100644 --- a/tests/gateway/test_reload_skills_command.py +++ b/tests/gateway/test_reload_skills_command.py @@ -79,7 +79,7 @@ def _make_runner(): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False # Use the real _session_key_for_source binding so the key matches what # the agent-loop consumer will look up later. diff --git a/tests/gateway/test_running_agent_session_toggles.py b/tests/gateway/test_running_agent_session_toggles.py index 6bf8be99738e..204761f286ca 100644 --- a/tests/gateway/test_running_agent_session_toggles.py +++ b/tests/gateway/test_running_agent_session_toggles.py @@ -84,7 +84,7 @@ def _make_runner(): runner._show_reasoning = False runner._service_tier = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_session_context_inheritance.py b/tests/gateway/test_session_context_inheritance.py index 465458888cf5..b11ef4e11579 100644 --- a/tests/gateway/test_session_context_inheritance.py +++ b/tests/gateway/test_session_context_inheritance.py @@ -178,6 +178,18 @@ def test_reset_session_vars_restores_unset_not_empty(): assert var.get() is _UNSET, f"{name} is {var.get()!r}, expected _UNSET" +def test_unattended_session_var_is_context_local_and_bridgeable(): + set_session_vars(**MINE, unattended=True) + + assert sc.get_session_env("HERMES_UNATTENDED_SESSION") == "1" + env = _make_run_env({}) + assert env["HERMES_UNATTENDED_SESSION"] == "1" + + reset_session_vars() + assert sc.get_session_env("HERMES_UNATTENDED_SESSION") == "" + assert "HERMES_UNATTENDED_SESSION" not in _make_run_env({}) + + # --------------------------------------------------------------------------- # Async-delivery capability inheritance (the sibling var outside _VAR_MAP) # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index f5392ab2c220..f789693ed9fd 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -4,6 +4,7 @@ import pytest from gateway.config import Platform +from gateway.platforms.base import MessageEvent from gateway.run import GatewayRunner from gateway.session import SessionContext, SessionSource from gateway.session_context import ( @@ -169,6 +170,29 @@ def test_set_session_env_handles_missing_optional_fields(): runner._clear_session_env(tokens) +def test_set_session_env_marks_synthetic_event_as_unattended(): + runner = object.__new__(GatewayRunner) + source = SessionSource( + platform=Platform.DISCORD, + chat_id="kanban-notifications", + chat_type="channel", + ) + context = SessionContext(source=source, connected_platforms=[], home_channels={}) + event = MessageEvent( + text="Kanban task completed", + source=source, + metadata={"unattended_session": True}, + ) + + tokens = runner._set_session_env(context, event=event) + try: + assert get_session_env("HERMES_UNATTENDED_SESSION") == "1" + finally: + runner._clear_session_env(tokens) + + assert get_session_env("HERMES_UNATTENDED_SESSION") == "" + + # --------------------------------------------------------------------------- # SESSION_KEY contextvars tests # --------------------------------------------------------------------------- @@ -393,4 +417,3 @@ async def test_gateway_executor_refuses_resurrection_after_shutdown(): await runner._run_in_executor_with_context(lambda: "second") finally: runner._shutdown_executor() - diff --git a/tests/gateway/test_session_hygiene.py b/tests/gateway/test_session_hygiene.py index 7545665cfef8..bb9d50276d4c 100644 --- a/tests/gateway/test_session_hygiene.py +++ b/tests/gateway/test_session_hygiene.py @@ -353,7 +353,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", @@ -456,7 +456,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", @@ -554,7 +554,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", @@ -660,7 +660,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", @@ -780,7 +780,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", @@ -913,7 +913,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = SimpleNamespace(_db=fake_db) runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", @@ -1030,7 +1030,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", @@ -1133,7 +1133,7 @@ def _compress_context(self, messages, *_args, **_kwargs): runner._pending_approvals = {} runner._session_db = None runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._run_agent = AsyncMock( return_value={ "final_response": "ok", diff --git a/tests/gateway/test_slash_access_dispatch.py b/tests/gateway/test_slash_access_dispatch.py index 86f73abbf184..5a04dec986c0 100644 --- a/tests/gateway/test_slash_access_dispatch.py +++ b/tests/gateway/test_slash_access_dispatch.py @@ -102,7 +102,7 @@ def _make_runner(*, platform_extra: dict | None = None, runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None @@ -611,7 +611,7 @@ async def test_gating_isolated_per_platform(): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_stacked_skill_platform_disabled.py b/tests/gateway/test_stacked_skill_platform_disabled.py index 5cdb47bc4496..d50cc7d83ada 100644 --- a/tests/gateway/test_stacked_skill_platform_disabled.py +++ b/tests/gateway/test_stacked_skill_platform_disabled.py @@ -78,7 +78,7 @@ def _make_runner(): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False from gateway.run import GatewayRunner as _GR runner._session_key_for_source = _GR._session_key_for_source.__get__(runner, _GR) diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 2da89d16dc8f..9bd8bd985068 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -66,7 +66,7 @@ def _make_runner(session_entry: SessionEntry, *, platform: Platform = Platform.T runner._agent_cache_lock = MagicMock() runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_steer_command.py b/tests/gateway/test_steer_command.py index b756ff09622d..71f7f1e34c6e 100644 --- a/tests/gateway/test_steer_command.py +++ b/tests/gateway/test_steer_command.py @@ -69,7 +69,7 @@ def _make_runner(session_entry: SessionEntry): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index c309a328d518..e8b41f1fae03 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -140,7 +140,7 @@ def _switch_session(session_key, target_session_id): group_sessions_per_user=getattr(runner.config, "group_sessions_per_user", True), thread_sessions_per_user=getattr(runner.config, "thread_sessions_per_user", False), ) - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/gateway/test_unknown_command.py b/tests/gateway/test_unknown_command.py index 114134496383..8f90dfe042b4 100644 --- a/tests/gateway/test_unknown_command.py +++ b/tests/gateway/test_unknown_command.py @@ -71,7 +71,7 @@ def _make_runner(): runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True - runner._set_session_env = lambda _context: None + runner._set_session_env = lambda _context, **_kwargs: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False runner._send_voice_reply = AsyncMock() runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 1ebb3efe6d98..3402b7396d96 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -1722,12 +1722,64 @@ def test_safe_git_status_not_flagged(self): dangerous, _, _ = detect_dangerous_command(cmd) assert dangerous is False - def test_safe_git_push_not_flagged(self): - """Normal push without --force must not be flagged.""" + def test_git_push_detected(self): + """Remote ref updates require approval even without --force.""" cmd = "git push origin main" + dangerous, _, desc = detect_dangerous_command(cmd) + assert dangerous is True + assert "remote refs" in desc.lower() + + def test_git_push_delete_detected(self): + cmd = "git push origin --delete feature-branch" + dangerous, _, desc = detect_dangerous_command(cmd) + assert dangerous is True + assert "remote refs" in desc.lower() + + def test_git_push_dry_run_not_flagged(self): + cmd = "git push --dry-run origin main" + dangerous, _, _ = detect_dangerous_command(cmd) + assert dangerous is False + + def test_git_push_help_not_flagged(self): + cmd = "git push --help" + dangerous, _, _ = detect_dangerous_command(cmd) + assert dangerous is False + + def test_gh_pr_merge_detected(self): + cmd = "gh pr merge 60 --squash --delete-branch" + dangerous, _, desc = detect_dangerous_command(cmd) + assert dangerous is True + assert "merge" in desc.lower() + + def test_mutating_gh_api_short_method_detected(self): + cmd = "gh api -X POST repos/owner/repo/releases" + dangerous, _, desc = detect_dangerous_command(cmd) + assert dangerous is True + assert "mutating" in desc.lower() + + def test_mutating_gh_api_long_method_detected(self): + cmd = "gh api graphql --method DELETE -f id=THREAD" + dangerous, _, desc = detect_dangerous_command(cmd) + assert dangerous is True + assert "mutating" in desc.lower() + + def test_graphql_mutation_without_explicit_method_detected(self): + cmd = "gh api graphql -f query='mutation { resolveReviewThread(input: {}) { clientMutationId } }'" + dangerous, _, desc = detect_dangerous_command(cmd) + assert dangerous is True + assert "graphql mutation" in desc.lower() + + def test_readonly_gh_api_not_flagged(self): + cmd = "gh api graphql -f query='{viewer{login}}'" dangerous, _, _ = detect_dangerous_command(cmd) assert dangerous is False + def test_gh_release_create_detected(self): + cmd = "gh release create v1.0.0" + dangerous, _, desc = detect_dangerous_command(cmd) + assert dangerous is True + assert "release" in desc.lower() + def test_git_branch_lowercase_d_also_flagged(self): """git branch -d triggers approval too — IGNORECASE is global. @@ -1767,6 +1819,49 @@ def test_git_branch_long_delete_without_force_not_flagged(self): assert dangerous is False +class TestUnattendedApprovalContext: + def test_unattended_dangerous_command_blocks_without_gateway_prompt(self, monkeypatch): + monkeypatch.setenv("HERMES_UNATTENDED_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny") + + result = approval_module.check_all_command_guards( + "gh pr merge 60 --squash --delete-branch", + "local", + ) + + assert result["approved"] is False + assert "without a user present" in result["message"] + + def test_kanban_worker_dangerous_command_blocks(self, monkeypatch): + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_123") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "deny") + + result = approval_module.check_all_command_guards("git push origin main", "local") + + assert result["approved"] is False + assert "kanban worker" in result["message"].lower() + + def test_unattended_approve_mode_allows_existing_opt_in(self, monkeypatch): + monkeypatch.setenv("HERMES_UNATTENDED_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.setattr(approval_module, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(approval_module, "_get_cron_approval_mode", lambda: "approve") + + result = approval_module.check_all_command_guards("git push origin main", "local") + + assert result["approved"] is True + + class TestChmodExecuteCombo: """chmod +x && ./ is the two-step social engineering pattern where a script is first made executable then immediately run. The script diff --git a/tests/tools/test_execute_code_approval_cluster.py b/tests/tools/test_execute_code_approval_cluster.py index 7ea74a53b438..dbc66f05fc4d 100644 --- a/tests/tools/test_execute_code_approval_cluster.py +++ b/tests/tools/test_execute_code_approval_cluster.py @@ -188,6 +188,28 @@ def test_guard_cron_deny_blocks(monkeypatch): assert res["outcome"] == "blocked" +def test_guard_unattended_deny_blocks(monkeypatch): + monkeypatch.setenv("HERMES_UNATTENDED_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_get_cron_approval_mode", lambda: "deny") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is False + assert res["outcome"] == "blocked" + assert "without a user present" in res["message"] + + +def test_guard_unattended_approve_allows(monkeypatch): + monkeypatch.setenv("HERMES_UNATTENDED_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_get_cron_approval_mode", lambda: "approve") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is True + + def test_guard_gateway_user_approves_is_one_shot(gw_session): _register_resolver(gw_session, "once") res = A.check_execute_code_guard("import os; print(1)", "local") @@ -244,6 +266,67 @@ def test_guard_session_approval_short_circuits_prompt(gw_session): s.discard("execute_code") +@pytest.mark.parametrize( + "code", + [ + "import subprocess\nsubprocess.run(['git', 'push', 'origin', 'main'], check=True)", + ( + "import requests\n" + "requests.post('https://api.github.com/graphql', " + "json={'query': 'mutation { mergePullRequest(input: {}) { clientMutationId } }'})" + ), + ], + ids=["subprocess-git-push", "github-graphql-post"], +) +def test_generic_session_approval_does_not_cover_remote_mutations(gw_session, code): + A.approve_session(gw_session, "execute_code") + try: + shown = _register_capturing_resolver(gw_session, "deny") + result = A.check_execute_code_guard(code, "local") + + assert result["approved"] is False + assert result["outcome"] == "denied" + assert shown["approval_data"]["pattern_key"] == ( + "execute_code:remote_repository_mutation" + ) + assert A.is_approved(gw_session, "execute_code") is True + assert A.is_approved( + gw_session, "execute_code:remote_repository_mutation" + ) is False + finally: + with A._lock: + approvals = A._session_approved.get(gw_session, set()) + approvals.discard("execute_code") + approvals.discard("execute_code:remote_repository_mutation") + + +def test_remote_mutation_session_approval_uses_its_own_key(gw_session): + code = "import subprocess\nsubprocess.run(['git', 'push', 'origin', 'main'])" + try: + shown = _register_capturing_resolver(gw_session, "session") + result = A.check_execute_code_guard(code, "local") + + assert result["approved"] is True + assert shown["approval_data"]["pattern_key"] == ( + "execute_code:remote_repository_mutation" + ) + assert A.is_approved( + gw_session, "execute_code:remote_repository_mutation" + ) is True + assert A.is_approved(gw_session, "execute_code") is False + + no_prompt = A.check_execute_code_guard( + "import subprocess\nsubprocess.run(['git', 'push', 'origin', 'feature'])", + "local", + ) + assert no_prompt["approved"] is True + finally: + with A._lock: + A._session_approved.get(gw_session, set()).discard( + "execute_code:remote_repository_mutation" + ) + + def test_guard_gateway_user_denies_blocks(gw_session): _register_resolver(gw_session, "deny") res = A.check_execute_code_guard("import os", "local") diff --git a/tests/tools/test_gnu_long_option_abbreviation_bypass.py b/tests/tools/test_gnu_long_option_abbreviation_bypass.py index 5ad6c1fe215d..c0fde8c8a3c7 100644 --- a/tests/tools/test_gnu_long_option_abbreviation_bypass.py +++ b/tests/tools/test_gnu_long_option_abbreviation_bypass.py @@ -82,14 +82,22 @@ def test_git_push_short_f_still_detected(self): dangerous, _, _ = detect_dangerous_command("git push -f origin main") assert dangerous is True - def test_git_push_no_force_not_flagged(self): - dangerous, _, _ = detect_dangerous_command("git push origin main") - assert dangerous is False + def test_git_push_no_force_still_requires_approval(self): + dangerous, _, desc = detect_dangerous_command("git push origin main") + assert dangerous is True + assert "remote refs" in desc.lower() - def test_git_push_set_upstream_not_flagged(self): - dangerous, _, _ = detect_dangerous_command( + def test_git_push_set_upstream_still_requires_approval(self): + dangerous, _, desc = detect_dangerous_command( "git push --set-upstream origin feature" ) + assert dangerous is True + assert "remote refs" in desc.lower() + + def test_git_push_dry_run_not_flagged(self): + dangerous, _, _ = detect_dangerous_command( + "git push --dry-run origin feature" + ) assert dangerous is False diff --git a/tools/approval.py b/tools/approval.py index ea3bb8269069..285d63bf7da5 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -8,6 +8,7 @@ - Permanent allowlist persistence (config.yaml) """ +import ast import contextvars import fnmatch import functools @@ -224,6 +225,45 @@ def _get_session_platform() -> str: return os.getenv("HERMES_SESSION_PLATFORM", "") or "" +def _get_session_env_flag(name: str) -> bool: + """Read a truthy session/env flag, preferring gateway ContextVars.""" + try: + from gateway.session_context import get_session_env + + value = get_session_env(name, "") + except Exception: + value = os.getenv(name, "") + return is_truthy_value(value) + + +def _is_unattended_approval_context() -> bool: + """True when the current turn has no live approver attached.""" + return ( + env_var_enabled("HERMES_CRON_SESSION") + or _get_session_env_flag("HERMES_UNATTENDED_SESSION") + or bool(os.getenv("HERMES_KANBAN_TASK")) + ) + + +def _unattended_context_label() -> str: + if env_var_enabled("HERMES_CRON_SESSION"): + return "Cron jobs" + if bool(os.getenv("HERMES_KANBAN_TASK")): + return "Kanban worker sessions" + return "Unattended sessions" + + +def _unattended_dangerous_block_message(description: str) -> str: + label = _unattended_context_label() + return ( + f"BLOCKED: Command flagged as dangerous ({description}) " + f"but {label.lower()} run without a user present to approve it. " + "Find an alternative approach that avoids this command. " + "To allow dangerous commands in unattended jobs, set " + "approvals.cron_mode: approve in config.yaml." + ) + + def _is_gateway_approval_context() -> bool: """True when this call is inside a gateway/API session. @@ -238,7 +278,7 @@ def _is_gateway_approval_context() -> bool: fall through to the gateway branch would submit a pending approval with no listener and block the job indefinitely. """ - if env_var_enabled("HERMES_CRON_SESSION"): + if _is_unattended_approval_context(): return False if env_var_enabled("HERMES_GATEWAY_SESSION"): return True @@ -761,7 +801,8 @@ def _sudo_stdin_block_result(description: str) -> dict: # a full shell context. (r'\b(bash|sh|zsh|ksh)\s+<<', "shell execution via heredoc"), # Git destructive operations that can lose uncommitted work or rewrite - # shared history. Not captured by rm/chmod/etc patterns. + # shared history, plus remote VCS operations that mutate repository state + # outside the local checkout. Not captured by rm/chmod/etc patterns. # `git reset --hard` accepts any unambiguous long-flag prefix (--h, # --ha, --har, --hard) because git's own option parser resolves # abbreviated long flags -- `--hard` is the only `git reset` mode @@ -771,6 +812,8 @@ def _sudo_stdin_block_result(description: str) -> dict: (r'\bgit\s+reset\s+--h(?:a(?:r(?:d)?)?)?\b', "git reset --hard (destroys uncommitted changes)"), (r'\bgit\s+push\b.*--forc[a-z]*\b', "git force push (rewrites remote history)"), (r'\bgit\s+push\b.*-f\b', "git force push short flag (rewrites remote history)"), + (_CMDPOS + r'git\s+push\b(?![^;|&\n]*\s(?:--dry-run|-n)\b)(?![^;|&\n]*\s(?:--help|-h)\b)', + "git push (updates remote refs)"), (r'\bgit\s+clean\s+-[^\s]*f', "git clean with force (deletes untracked files)"), (r'\bgit\s+branch\s+-D\b', "git branch force delete"), # `-D` is shorthand for `-d --force`; the long-flag spellings @@ -783,6 +826,13 @@ def _sudo_stdin_block_result(description: str) -> dict: # later command in the same script. (r'\bgit\s+branch\b[^;|&\n]*?(?:-d\b|--delete\b)[^;|&\n]*?(?:-f\b|--force\b)', "git branch force delete (long flags)"), (r'\bgit\s+branch\b[^;|&\n]*?(?:-f\b|--force\b)[^;|&\n]*?(?:-d\b|--delete\b)', "git branch force delete (long flags, force-first)"), + (_CMDPOS + r'gh\s+pr\s+merge\b', "gh pr merge (merges remote pull request)"), + (_CMDPOS + r'gh\s+api\b[^;|&\n]*(?:-X\s*(?:post|put|patch|delete)\b|--method(?:=|\s+)(?:post|put|patch|delete)\b)', + "gh api mutating request"), + (_CMDPOS + r'gh\s+api\s+graphql\b[^;|&\n]*\bmutation\b', + "gh api GraphQL mutation"), + (_CMDPOS + r'gh\s+release\s+(?:create|delete|edit|upload)\b', + "gh release mutation"), # Script execution after chmod +x — catches the two-step pattern where # a script is first made executable then immediately run. The script # content may contain dangerous commands that individual patterns miss. @@ -2641,7 +2691,7 @@ def _run_approval_gate( description: str, display_target: str, approval_callback=None, - cron_deny_message: str, + unattended_deny_message: str, autoapprove_log_prefix: str, fail_closed_when_no_human: bool = False, no_human_block_message: str = "", @@ -2669,8 +2719,8 @@ def _run_approval_gate( approval_callback: Optional CLI prompt callback. When ``None`` the per-thread callback registered via ``tools.terminal_tool.set_approval_callback`` is used. - cron_deny_message: Message returned when a cron job hits this gate - under ``cron_mode: deny``. + unattended_deny_message: Message returned when an unattended turn hits + this gate under ``cron_mode: deny``. autoapprove_log_prefix: Log line prefix for the non-interactive auto-approve warning (identifies command vs plugin origin). fail_closed_when_no_human: When True, a non-interactive non-gateway @@ -2707,12 +2757,12 @@ def _run_approval_gate( is_gateway = _is_gateway_approval_context() if not is_cli and not is_gateway: - # Cron sessions: respect cron_mode config - if env_var_enabled("HERMES_CRON_SESSION"): + # No-human turns share the existing cron-mode trust switch. + if _is_unattended_approval_context(): if _get_cron_approval_mode() == "deny": return { "approved": False, - "message": cron_deny_message, + "message": unattended_deny_message, "pattern_key": pattern_key, "description": description, } @@ -2924,13 +2974,7 @@ def check_dangerous_command(command: str, env_type: str, description=description, display_target=command, approval_callback=approval_callback, - cron_deny_message=( - f"BLOCKED: Command flagged as dangerous ({description}) " - "but cron jobs run without a user present to approve it. " - "Find an alternative approach that avoids this command. " - "To allow dangerous commands in cron jobs, set " - "approvals.cron_mode: approve in config.yaml." - ), + unattended_deny_message=_unattended_dangerous_block_message(description), autoapprove_log_prefix=( "AUTO-APPROVED dangerous command in non-interactive non-gateway context" ), @@ -3005,10 +3049,10 @@ def request_tool_approval( description=description, display_target=display_target, approval_callback=approval_callback, - cron_deny_message=( + unattended_deny_message=( f"BLOCKED: Tool '{tool_name}' requires approval ({description}) " - "but cron jobs run without a user present to approve it. Find an " - "alternative approach. To allow flagged actions in cron jobs, set " + "but this unattended turn has no user present to approve it. Find " + "an alternative approach. To allow flagged unattended actions, set " "approvals.cron_mode: approve in config.yaml." ), autoapprove_log_prefix=( @@ -3231,24 +3275,19 @@ def check_all_command_guards(command: str, env_type: str, is_gateway = _is_gateway_approval_context() is_ask = env_var_enabled("HERMES_EXEC_ASK") - # Preserve the existing non-interactive behavior: outside CLI/gateway/ask - # flows, we do not block on approvals and we skip external guard work. + # Preserve the existing non-interactive behavior for truly local trusted + # flows, but fail closed for explicitly unattended jobs that have no + # approver attached. if not is_cli and not is_gateway and not is_ask: - # Cron sessions: respect cron_mode config - if env_var_enabled("HERMES_CRON_SESSION"): + # Unattended sessions: respect the existing cron-mode trust switch. + if _is_unattended_approval_context(): if _get_cron_approval_mode() == "deny": # Run detection to get a description for the block message is_dangerous, _pk, description = detect_dangerous_command(command) if is_dangerous: return { "approved": False, - "message": ( - f"BLOCKED: Command flagged as dangerous ({description}) " - "but cron jobs run without a user present to approve it. " - "Find an alternative approach that avoids this command. " - "To allow dangerous commands in cron jobs, set " - "approvals.cron_mode: approve in config.yaml." - ), + "message": _unattended_dangerous_block_message(description), } # Also run tirith check in cron-deny mode so content-level # threats (homograph URLs, pipe-to-interpreter, terminal @@ -3263,9 +3302,9 @@ def check_all_command_guards(command: str, env_type: str, "approved": False, "message": ( f"BLOCKED: {_cron_desc} " - "but cron jobs run without a user present to approve it. " + f"but {_unattended_context_label().lower()} run without a user present to approve it. " "Find an alternative approach that avoids this command. " - "To allow dangerous commands in cron jobs, set " + "To allow dangerous commands in unattended jobs, set " "approvals.cron_mode: approve in config.yaml." ), } @@ -3291,7 +3330,7 @@ def check_all_command_guards(command: str, env_type: str, "BLOCKED: the Tirith security scanner could not be " "imported and security.tirith_fail_open is false, " "so this command cannot be silently allowed — and " - "cron jobs run without a user present to approve it. " + f"{_unattended_context_label().lower()} run without a user present to approve it. " "Find an alternative approach, install tirith, or set " "approvals.cron_mode: approve in config.yaml." ), @@ -3612,6 +3651,130 @@ def check_all_command_guards(command: str, env_type: str, "user_approved": True, "description": combined_desc} +_EXECUTE_CODE_REMOTE_MUTATION_KEY = "execute_code:remote_repository_mutation" +_REMOTE_REPOSITORY_MUTATION_DESCRIPTIONS = frozenset({ + "git push (updates remote refs)", + "git force push (rewrites remote history)", + "git force push short flag (rewrites remote history)", + "gh pr merge (merges remote pull request)", + "gh api mutating request", + "gh api GraphQL mutation", + "gh release mutation", +}) +_RAW_PYTHON_COMMAND_CALLS = frozenset({ + "asyncio.create_subprocess_exec", + "asyncio.create_subprocess_shell", + "call", + "check_call", + "check_output", + "getoutput", + "getstatusoutput", + "os.popen", + "os.system", + "popen", + "run", + "subprocess.call", + "subprocess.check_call", + "subprocess.check_output", + "subprocess.getoutput", + "subprocess.getstatusoutput", + "subprocess.popen", + "subprocess.run", +}) + + +def _ast_qualified_name(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id.lower() + if isinstance(node, ast.Attribute): + parent = _ast_qualified_name(node.value) + return f"{parent}.{node.attr.lower()}" if parent else node.attr.lower() + return "" + + +def _ast_literal_text(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.JoinedStr): + return "".join( + value.value if isinstance(value, ast.Constant) and isinstance(value.value, str) + else "{value}" + for value in node.values + ) + if isinstance(node, (ast.List, ast.Tuple)): + parts = [] + for item in node.elts: + value = _ast_literal_text(item) + parts.append(value if value is not None else "{value}") + return " ".join(parts) + if isinstance(node, ast.Dict): + parts = [] + for key, value_node in zip(node.keys, node.values): + key_text = _ast_literal_text(key) if key is not None else None + value_text = _ast_literal_text(value_node) + if key_text is not None: + parts.append(key_text) + if value_text is not None: + parts.append(value_text) + return " ".join(parts) + return None + + +def _detect_execute_code_remote_mutation(code: str) -> str | None: + """Return a remote-repository mutation description for raw Python code.""" + try: + tree = ast.parse(code) + except SyntaxError: + return None + + literal_texts = [] + for node in ast.walk(tree): + literal = _ast_literal_text(node) + if literal: + literal_texts.append(literal) + + if not isinstance(node, ast.Call): + continue + call_name = _ast_qualified_name(node.func) + if call_name not in _RAW_PYTHON_COMMAND_CALLS: + continue + + command_parts = [] + args = node.args if call_name.endswith("create_subprocess_exec") else node.args[:1] + for arg in args: + text = _ast_literal_text(arg) + if text: + command_parts.append(text) + if not command_parts: + continue + + dangerous, _pattern_key, description = detect_dangerous_command( + " ".join(command_parts) + ) + if dangerous and description in _REMOTE_REPOSITORY_MUTATION_DESCRIPTIONS: + return description + + literals = "\n".join(literal_texts).lower() + if "mutation" in literals and "graphql" in literals and "github" in literals: + return "GitHub GraphQL mutation from raw Python" + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + method = _ast_qualified_name(node.func).rsplit(".", 1)[-1] + if method not in {"delete", "patch", "post", "put"}: + continue + call_literals = " ".join( + text + for arg in (*node.args, *(keyword.value for keyword in node.keywords)) + if (text := _ast_literal_text(arg)) + ).lower() + if "api.github.com" in call_literals or "github.com/graphql" in call_literals: + return "GitHub API mutation from raw Python" + + return None + + def check_execute_code_guard(code: str, env_type: str, has_host_access: bool = False) -> dict: """Approve an execute_code script before its child process is spawned. @@ -3623,20 +3786,25 @@ def check_execute_code_guard(code: str, env_type: str, the script as a whole before it runs (#30882). Returns the same dict contract as ``check_all_command_guards``. - Scope (documented limitation, #30882): in a purely local non-interactive - non-gateway session (no TTY, not gateway, not cron-deny) this returns - approved — matching the existing terminal auto-approve contract. The - hardline floor still blocks catastrophic ``terminal()`` commands the script - issues; running arbitrary code headlessly without any approval surface is - trusted-by-config (set a gateway/ask surface or ``approvals.cron_mode`` to - require approval). + Scope: in a purely local non-interactive non-gateway session that is not + marked unattended, this returns approved — matching the existing terminal + auto-approve contract. Explicit no-approver jobs (cron, kanban workers, + notification wakeups) fail closed by default via ``approvals.cron_mode``. """ - pattern_key = "execute_code" - description = ( - "execute_code script execution. The script can spawn subprocesses or " - "mutate files without passing through terminal command approval; " - "approval is one-shot for this run." - ) + remote_mutation = _detect_execute_code_remote_mutation(code) + if remote_mutation: + pattern_key = _EXECUTE_CODE_REMOTE_MUTATION_KEY + description = ( + "execute_code remote repository mutation. The script can mutate " + f"remote state without passing through terminal approval ({remote_mutation})." + ) + else: + pattern_key = "execute_code" + description = ( + "execute_code script execution. The script can spawn subprocesses or " + "mutate files without passing through terminal command approval; " + "approval is one-shot for this run." + ) # Isolated backends already sandbox the child — matches the container skip # in check_all_command_guards / check_dangerous_command. Docker stops @@ -3655,18 +3823,19 @@ def check_execute_code_guard(code: str, env_type: str, is_gateway = _is_gateway_approval_context() is_ask = env_var_enabled("HERMES_EXEC_ASK") - # Cron: no user is present to approve arbitrary code. - if env_var_enabled("HERMES_CRON_SESSION"): + # Unattended jobs have no user present to approve arbitrary code. + if _is_unattended_approval_context(): if _get_cron_approval_mode() == "deny": + label = _unattended_context_label() return { "approved": False, "message": ( "BLOCKED: execute_code runs arbitrary local Python " "(including subprocess calls that bypass shell-string " - "approval checks). Cron jobs run without a user present " + f"approval checks). {label} run without a user present " "to approve it. Use normal tools instead, or set " - "approvals.cron_mode: approve only if this cron profile " - "is intentionally trusted." + "approvals.cron_mode: approve only if this unattended " + "profile is intentionally trusted." ), "pattern_key": pattern_key, "description": description,