{"repo":"NousResearch/hermes-agent","public_id":"59067","display_label":"Route pre_tool_call approvals through human gate with rule keys","summary":"These items add support for passing rule keys from pre_tool_call approve directives through to the human approval gate, enabling escalation to request_tool_approval with identifying context. The work appears across multiple related attempts or layers of the same feature.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60504,"merged_at":"2026-07-07T22:14:30+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-07T22:14:30+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":59067,"pr_number":60504,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-07T22:14:30+00:00"}],"members":[{"kind":"issue","number":59067,"title":"Feature: Pass rule_key through from pre_tool_call approve directive to request_tool_approval","url":null,"state":"closed"},{"kind":"pr","number":59163,"title":"feat(plugins): pass approve rule keys to approval gate","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#60504","number":60504},{"kind":"pr","value":"#59413","number":59413},{"kind":"pr","value":"#58698","number":58698}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60504,"title":"feat(plugins): re-land pre_tool_call approve escalation with rule keys + working gateway notify","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_runtime_helpers.py","agent/tool_executor.py","hermes_cli/plugins.py","model_tools.py","scripts/release.py","tests/hermes_cli/test_plugins.py","tests/run_agent/test_run_agent.py","tests/run_agent/test_tool_call_guardrail_runtime.py","tests/tools/test_request_tool_approval.py","tools/approval.py"],"fix_hunks":[{"file":"agent/agent_runtime_helpers.py","hunks":["@@ -2115,12 +2115,12 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i\n     except Exception as _mw_err:\n         logger.debug(\"tool_request middleware error: %s\", _mw_err)\n \n-    # Check plugin hooks for a block directive before executing anything.\n+    # Check plugin hooks for a block or approval directive before executing.\n     block_message: Optional[str] = None\n     if not pre_tool_block_checked:\n         try:\n-            from hermes_cli.plugins import get_pre_tool_call_block_message\n-            block_message = get_pre_tool_call_block_message(\n+            from hermes_cli.plugins import resolve_pre_tool_block\n+            block_message = resolve_pre_tool_block(\n                 function_name,\n                 function_args,\n                 task_id=effective_task_id or \"\",","@@ -2131,7 +2131,7 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i\n                 middleware_trace=list(_tool_middleware_trace),\n             )\n         except Exception:\n-            pass\n+            block_message = None\n     if block_message is not None:\n         result = json.dumps({\"error\": block_message}, ensure_ascii=False)\n         try:"]},{"file":"agent/tool_executor.py","hunks":["@@ -415,8 +415,8 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe\n             )\n         else:\n             try:\n-                from hermes_cli.plugins import get_pre_tool_call_block_message\n-                block_message = get_pre_tool_call_block_message(\n+                from hermes_cli.plugins import resolve_pre_tool_block\n+                block_message = resolve_pre_tool_block(\n                     function_name,\n                     function_args,\n                     task_id=effective_task_id or \"\",","@@ -1034,8 +1034,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe\n             _block_error_type = \"tool_scope_block\"\n         else:\n             try:\n-                from hermes_cli.plugins import get_pre_tool_call_block_message\n-                _block_msg = get_pre_tool_call_block_message(\n+                from hermes_cli.plugins import resolve_pre_tool_block\n+                _block_msg = resolve_pre_tool_block(\n                     function_name,\n                     function_args,\n                     task_id=effective_task_id or \"\","]},{"file":"hermes_cli/plugins.py","hunks":["@@ -2077,6 +2077,13 @@ def has_hook(hook_name: str) -> bool:\n _thread_tool_whitelist = threading.local()\n \n \n+@dataclass(frozen=True)\n+class _PreToolCallDirective:\n+    action: Optional[str] = None\n+    message: Optional[str] = None\n+    rule_key: Optional[str] = None\n+\n+\n def set_thread_tool_whitelist(\n     allowed: Optional[Set[str]],\n     deny_msg_fmt: str = \"Tool '{tool_name}' denied: not in this thread's tool whitelist\",","@@ -2089,7 +2096,7 @@ def clear_thread_tool_whitelist() -> None:\n     _thread_tool_whitelist.allowed = None\n \n \n-def get_pre_tool_call_block_message(\n+def _get_pre_tool_call_directive_details(\n     tool_name: str,\n     args: Optional[Dict[str, Any]],\n     task_id: str = \"\",","@@ -2098,22 +2105,40 @@ def get_pre_tool_call_block_message(\n     turn_id: str = \"\",\n     api_request_id: str = \"\",\n     middleware_trace: Optional[List[Dict[str, Any]]] = None,\n-) -> Optional[str]:\n-    \"\"\"Check ``pre_tool_call`` hooks for a blocking directive.\n+) -> _PreToolCallDirective:\n+    \"\"\"Check ``pre_tool_call`` hooks for a blocking or approval directive.\n \n     Plugins that need to enforce policy (rate limiting, security\n-    restrictions, approval workflows) can return::\n-\n-        {\"action\": \"block\", \"message\": \"Reason the tool was blocked\"}\n-\n-    from their ``pre_tool_call`` callback.  The first valid block\n-    directive wins.  Invalid or irrelevant hook return values are\n-    silently ignored so existing observer-only hooks are unaffected.\n+    restrictions, approval workflows) can return one of::\n+\n+        {\"action\": \"block\",   \"message\": \"Reason the tool was blocked\"}\n+        {\"action\": \"approve\", \"message\": \"Why this needs human confirmation\"}\n+        {\"action\": \"approve\", \"message\": \"...\", \"rule_key\": \"write_file:ssh\"}\n+\n+    from their ``pre_tool_call`` callback.\n+\n+    - ``block`` vetoes the tool call outright (the message becomes the tool\n+      result the model sees).\n+    - ``approve`` ESCALATES to the existing human-approval gate\n+      (``prompt_dangerous_approval`` on CLI, the approval callback on the\n+      gateway) — the same mechanism Tier-2 dangerous shell patterns use.\n+      This lets a plugin require a human ``[o]nce/[s]ession/[a]lways/[d]eny``\n+      decision on ANY tool, not just terminal command strings. The caller is\n+      responsible for invoking the gate (see\n+      :func:`tools.approval.request_tool_approval`).\n+    - ``rule_key`` is optional and only honored for ``approve`` directives. It\n+      lets plugins choose the allowlist grain for `[a]lways` approvals.\n+\n+    The first valid directive wins. Invalid or irrelevant hook return values\n+    are silently ignored so existing observer-only hooks are unaffected.\n     \"\"\"\n     allowed = getattr(_thread_tool_whitelist, \"allowed\", None)\n     if allowed is not None and tool_name not in allowed:\n         fmt = getattr(_thread_tool_whitelist, \"fmt\", \"Tool '{tool_name}' denied\")\n-        return fmt.format(tool_name=tool_name)\n+        return _PreToolCallDirective(\n+            action=\"block\",\n+            message=fmt.format(tool_name=tool_name),\n+        )\n \n     hook_results = invoke_hook(\n         \"pre_tool_call\",","@@ -2130,12 +2155,122 @@ def get_pre_tool_call_block_message(\n     for result in hook_results:\n         if not isinstance(result, dict):\n             continue\n-        if result.get(\"action\") != \"block\":\n+        action = result.get(\"action\")\n+        if action not in (\"block\", \"approve\"):\n             continue\n         message = result.get(\"message\")\n-        if isinstance(message, str) and message:\n-            return message\n+        message = message if isinstance(message, str) and message else None\n+        # A block directive requires a message (it becomes the tool result);\n+        # an approve directive can carry an optional reason.\n+        if action == \"block\" and not message:\n+            continue\n+        rule_key = result.get(\"rule_key\") if action == \"approve\" else None\n+        rule_key = rule_key.strip() if isinstance(rule_key, str) else None\n+        if not rule_key:\n+            rule_key = None\n+        return _PreToolCallDirective(action=action, message=message, rule_key=rule_key)\n+\n+    return _PreToolCallDirective()\n \n+\n+def get_pre_tool_call_directive(\n+    tool_name: str,\n+    args: Optional[Dict[str, Any]],\n+    task_id: str = \"\",\n+    session_id: str = \"\",\n+    tool_call_id: str = \"\",\n+    turn_id: str = \"\",\n+    api_request_id: str = \"\",\n+    middleware_trace: Optional[List[Dict[str, Any]]] = None,\n+) -> tuple[Optional[str], Optional[str]]:\n+    \"\"\"Check ``pre_tool_call`` hooks for a blocking or approval directive.\n+\n+    Backward-compatible public helper: returns ``(directive, message)`` where\n+    ``directive`` is ``\"block\"``, ``\"approve\"``, or ``None``. Internal callers\n+    that need approve-specific metadata use\n+    :func:`_get_pre_tool_call_directive_details`.\n+    \"\"\"\n+    details = _get_pre_tool_call_directive_details(\n+        tool_name, args, task_id=task_id, session_id=session_id,\n+        tool_call_id=tool_call_id, turn_id=turn_id,\n+        api_request_id=api_request_id, middleware_trace=middleware_trace,\n+    )\n+    return (details.action, details.message)\n+\n+\n+def get_pre_tool_call_block_message(\n+    tool_name: str,\n+    args: Optional[Dict[str, Any]],\n+    task_id: str = \"\",\n+    session_id: str = \"\",\n+    tool_call_id: str = \"\",\n+    turn_id: str = \"\",\n+    api_request_id: str = \"\",\n+    middleware_trace: Optional[List[Dict[str, Any]]] = None,\n+) -> Optional[str]:\n+    \"\"\"Back-compat shim: return only a ``block`` message (or ``None``).\n+\n+    Deprecated in favor of :func:`get_pre_tool_call_directive`, which also\n+    surfaces the ``approve`` escalation directive. Kept so any external caller\n+    importing the old name keeps working; ``approve`` directives are invisible\n+    to this shim (it only reports blocks).\n+    \"\"\"\n+    directive, message = get_pre_tool_call_directive(\n+        tool_name, args, task_id=task_id, session_id=session_id,\n+        tool_call_id=tool_call_id, turn_id=turn_id,\n+        api_request_id=api_request_id, middleware_trace=middleware_trace,\n+    )\n+    return message if directive == \"block\" else None\n+\n+\n+def resolve_pre_tool_block(\n+    tool_name: str,\n+    args: Optional[Dict[str, Any]],\n+    task_id: str = \"\",\n+    session_id: str = \"\",\n+    tool_call_id: str = \"\",\n+    turn_id: str = \"\",\n+    api_request_id: str = \"\",\n+    middleware_trace: Optional[List[Dict[str, Any]]] = None,\n+) -> Optional[str]:\n+    \"\"\"Resolve the pre_tool_call directive to a final block message (or None).\n+\n+    Single entry point for every tool-dispatch site: fetches the plugin\n+    directive and, for an ``approve`` escalation, invokes the human-approval\n+    gate (:func:`tools.approval.request_tool_approval`). Returns the message\n+    the tool result should carry when the call is blocked, or ``None`` when\n+    the call may proceed.\n+\n+    Centralizing this keeps the security-critical fail-closed logic in ONE\n+    place instead of copy-pasted across the concurrent/sequential/helper\n+    dispatch paths: an ``approve`` directive whose gate errors, denies, or\n+    times out is fail-closed to a block; ``block`` blocks with its message;\n+    anything else proceeds.\n+    \"\"\"\n+    details = _get_pre_tool_call_directive_details(\n+        tool_name, args, task_id=task_id, session_id=session_id,\n+        tool_call_id=tool_call_id, turn_id=turn_id,\n+        api_request_id=api_request_id, middleware_trace=middleware_trace,\n+    )\n+    if details.action == \"block\":\n+        return details.message\n+    if details.action == \"approve\":\n+        try:\n+            from tools.approval import request_tool_approval\n+            result = request_tool_approval(\n+                tool_name,\n+                details.message or \"\",\n+                rule_key=details.rule_key or tool_name,\n+            )\n+        except Exception:\n+            # Fail-closed: if the gate itself errors, block rather than\n+            # silently execute an action a plugin flagged for approval.\n+            return f\"BLOCKED: plugin approval gate failed for {tool_name}\"\n+        if not result.get(\"approved\"):\n+            return str(\n+                result.get(\"message\")\n+                or f\"BLOCKED: plugin approval required for {tool_name}\"\n+            )\n     return None\n \n "]},{"file":"model_tools.py","hunks":["@@ -1161,21 +1161,22 @@ def handle_function_call(\n         if function_name in _AGENT_LOOP_TOOLS:\n             return json.dumps({\"error\": f\"{function_name} must be handled by the agent loop\"})\n \n-        # Check plugin hooks for a block directive (unless caller already\n-        # checked — e.g. run_agent._invoke_tool passes skip=True to\n+        # Check plugin hooks for a block/approve directive (unless caller\n+        # already checked — e.g. run_agent._invoke_tool passes skip=True to\n         # avoid double-firing the hook).\n         #\n         # Single-fire contract: pre_tool_call fires exactly once per tool\n-        # execution. get_pre_tool_call_block_message() internally calls\n-        # invoke_hook(\"pre_tool_call\", ...) and returns the first block\n-        # directive (if any), so observer plugins see the hook on that same\n-        # pass. When skip=True, the caller already fired it — do nothing\n-        # here.\n+        # execution. resolve_pre_tool_block() internally calls\n+        # invoke_hook(\"pre_tool_call\", ...) once and returns the block message\n+        # for a `block` directive OR for an `approve` directive whose human\n+        # gate denied/timed-out/errored (fail-closed). Observer plugins see\n+        # the hook on that same pass. When skip=True, the caller already\n+        # fired it — do nothing here.\n         if not skip_pre_tool_call_hook:\n             block_message: Optional[str] = None\n             try:\n-                from hermes_cli.plugins import get_pre_tool_call_block_message\n-                block_message = get_pre_tool_call_block_message(\n+                from hermes_cli.plugins import resolve_pre_tool_block\n+                block_message = resolve_pre_tool_block(\n                     function_name,\n                     function_args,\n                     task_id=task_id or \"\","]},{"file":"scripts/release.py","hunks":["@@ -291,6 +291,7 @@\n     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"[redacted:email]\": \"doncazper\",\n     \"30668368+alex107ivanov@users.noreply.github.com\": \"alex107ivanov\",\n     \"210088133+rungmc357@users.noreply.github.com\": \"rungmc357\",\n     \"[redacted:email]\": \"flo1t\","]},{"file":"tests/hermes_cli/test_plugins.py","hunks":["@@ -859,6 +859,171 @@ def test_first_valid_block_wins(self, monkeypatch):\n         assert get_pre_tool_call_block_message(\"terminal\", {}) == \"first blocker\"\n \n \n+class TestPreToolCallDirective:\n+    \"\"\"Tests for the extended (block | approve) directive helper.\"\"\"\n+\n+    def test_approve_directive_returned(self, monkeypatch):\n+        from hermes_cli.plugins import get_pre_tool_call_directive\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [\n+                {\"action\": \"approve\", \"message\": \"needs human ok\"}\n+            ],\n+        )\n+        assert get_pre_tool_call_directive(\"write_file\", {}) == (\n+            \"approve\", \"needs human ok\")\n+\n+    def test_approve_without_message_is_valid(self, monkeypatch):\n+        \"\"\"approve may omit a message (block may not).\"\"\"\n+        from hermes_cli.plugins import get_pre_tool_call_directive\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [{\"action\": \"approve\"}],\n+        )\n+        assert get_pre_tool_call_directive(\"write_file\", {}) == (\"approve\", None)\n+\n+    def test_block_still_requires_message(self, monkeypatch):\n+        from hermes_cli.plugins import get_pre_tool_call_directive\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [{\"action\": \"block\"}],\n+        )\n+        assert get_pre_tool_call_directive(\"terminal\", {}) == (None, None)\n+\n+    def test_first_directive_wins_across_actions(self, monkeypatch):\n+        from hermes_cli.plugins import get_pre_tool_call_directive\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [\n+                {\"action\": \"approve\", \"message\": \"gate first\"},\n+                {\"action\": \"block\", \"message\": \"block second\"},\n+            ],\n+        )\n+        assert get_pre_tool_call_directive(\"terminal\", {}) == (\n+            \"approve\", \"gate first\")\n+\n+    def test_shim_ignores_approve(self, monkeypatch):\n+        \"\"\"Back-compat shim only reports block, never approve.\"\"\"\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [\n+                {\"action\": \"approve\", \"message\": \"gate\"}\n+            ],\n+        )\n+        assert get_pre_tool_call_block_message(\"write_file\", {}) is None\n+\n+\n+class TestResolvePreToolBlock:\n+    \"\"\"Tests for the single dispatch-site chokepoint that resolves a\n+    directive (incl. the approve→gate escalation) to a block message.\"\"\"\n+\n+    def test_block_returns_message(self, monkeypatch):\n+        from hermes_cli.plugins import resolve_pre_tool_block\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [{\"action\": \"block\", \"message\": \"no\"}],\n+        )\n+        assert resolve_pre_tool_block(\"terminal\", {}) == \"no\"\n+\n+    def test_no_directive_returns_none(self, monkeypatch):\n+        from hermes_cli.plugins import resolve_pre_tool_block\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\", lambda hook_name, **kwargs: [])\n+        assert resolve_pre_tool_block(\"terminal\", {}) is None\n+\n+    def test_approve_denied_blocks(self, monkeypatch):\n+        from hermes_cli.plugins import resolve_pre_tool_block\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [{\"action\": \"approve\", \"message\": \"why\"}],\n+        )\n+        monkeypatch.setattr(\n+            \"tools.approval.request_tool_approval\",\n+            lambda *a, **k: {\"approved\": False, \"message\": \"user denied it\"},\n+        )\n+        assert resolve_pre_tool_block(\"write_file\", {}) == \"user denied it\"\n+\n+    def test_approve_granted_allows(self, monkeypatch):\n+        from hermes_cli.plugins import resolve_pre_tool_block\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [{\"action\": \"approve\", \"message\": \"why\"}],\n+        )\n+        monkeypatch.setattr(\n+            \"tools.approval.request_tool_approval\",\n+            lambda *a, **k: {\"approved\": True, \"message\": None},\n+        )\n+        assert resolve_pre_tool_block(\"write_file\", {}) is None\n+\n+    def test_approve_passes_plugin_rule_key_to_gate(self, monkeypatch):\n+        from hermes_cli.plugins import resolve_pre_tool_block\n+\n+        seen = {}\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [\n+                {\n+                    \"action\": \"approve\",\n+                    \"message\": \"why\",\n+                    \"rule_key\": \"write_file:ssh\",\n+                }\n+            ],\n+        )\n+\n+        def _approve(tool_name, reason, **kwargs):\n+            seen[\"tool_name\"] = tool_name\n+            seen[\"reason\"] = reason\n+            seen[\"rule_key\"] = kwargs.get(\"rule_key\")\n+            return {\"approved\": True, \"message\": None}\n+\n+        monkeypatch.setattr(\"tools.approval.request_tool_approval\", _approve)\n+\n+        assert resolve_pre_tool_block(\"write_file\", {}) is None\n+        assert seen == {\n+            \"tool_name\": \"write_file\",\n+            \"reason\": \"why\",\n+            \"rule_key\": \"write_file:ssh\",\n+        }\n+\n+    @pytest.mark.parametrize(\"rule_key\", [None, \"\", \"   \", 123, object()])\n+    def test_approve_falls_back_to_tool_name_without_valid_rule_key(\n+        self, monkeypatch, rule_key\n+    ):\n+        from hermes_cli.plugins import resolve_pre_tool_block\n+\n+        seen = {}\n+        directive = {\"action\": \"approve\", \"message\": \"why\"}\n+        if rule_key is not None:\n+            directive[\"rule_key\"] = rule_key\n+\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [directive],\n+        )\n+\n+        def _approve(tool_name, reason, **kwargs):\n+            seen[\"rule_key\"] = kwargs.get(\"rule_key\")\n+            return {\"approved\": True, \"message\": None}\n+\n+        monkeypatch.setattr(\"tools.approval.request_tool_approval\", _approve)\n+\n+        assert resolve_pre_tool_block(\"write_file\", {}) is None\n+        assert seen[\"rule_key\"] == \"write_file\"\n+\n+    def test_approve_gate_exception_fails_closed(self, monkeypatch):\n+        from hermes_cli.plugins import resolve_pre_tool_block\n+        monkeypatch.setattr(\n+            \"hermes_cli.plugins.invoke_hook\",\n+            lambda hook_name, **kwargs: [{\"action\": \"approve\", \"message\": \"why\"}],\n+        )\n+        def _boom(*a, **k):\n+            raise RuntimeError(\"gate crashed\")\n+        monkeypatch.setattr(\"tools.approval.request_tool_approval\", _boom)\n+        msg = resolve_pre_tool_block(\"terminal\", {})\n+        assert msg is not None and \"gate failed\" in msg  # fail-closed\n+\n+\n class TestGetPreVerifyContinueMessage:\n     \"\"\"`pre_verify` directive aggregation — mirrors the pre_tool_call block path.\"\"\"\n "]},{"file":"tests/run_agent/test_run_agent.py","hunks":["@@ -2966,7 +2966,7 @@ def test_invoke_tool_agent_level_tool_emits_terminal_post_tool_hook(self, agent,\n         \"\"\"Agent-owned tool paths should close observer tool spans.\"\"\"\n         hook_calls = []\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: None,\n         )\n         monkeypatch.setattr(","@@ -2990,7 +2990,7 @@ def test_invoke_tool_agent_level_tool_emits_terminal_post_tool_hook(self, agent,\n     def test_invoke_tool_blocked_returns_error_and_skips_execution(self, agent, monkeypatch):\n         \"\"\"_invoke_tool should return error JSON when a plugin blocks the tool.\"\"\"\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked by test policy\",\n         )\n         with patch(\"tools.todo_tool.todo_tool\", side_effect=AssertionError(\"should not run\")) as mock_todo:","@@ -3002,7 +3002,7 @@ def test_invoke_tool_blocked_returns_error_and_skips_execution(self, agent, monk\n     def test_invoke_tool_blocked_skips_handle_function_call(self, agent, monkeypatch):\n         \"\"\"Blocked registry tools should not reach handle_function_call.\"\"\"\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked\",\n         )\n         with patch(\"run_agent.handle_function_call\", side_effect=AssertionError(\"should not run\")):","@@ -3019,7 +3019,7 @@ def test_sequential_blocked_tool_skips_checkpoints_and_callbacks(self, agent, mo\n         messages = []\n \n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked by policy\",\n         )\n         agent._checkpoint_mgr.enabled = True","@@ -3049,7 +3049,7 @@ def test_sequential_blocked_tool_emits_terminal_post_tool_hook(self, agent, monk\n         hook_calls = []\n \n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked by policy\",\n         )\n         monkeypatch.setattr(","@@ -3076,7 +3076,7 @@ def test_sequential_agent_level_tool_emits_terminal_post_tool_hook(self, agent,\n         hook_calls = []\n \n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: None,\n         )\n         monkeypatch.setattr(","@@ -3123,7 +3123,7 @@ def execution_middleware(**kwargs):\n             lambda kind, **kwargs: [request_middleware(**kwargs)] if kind == \"tool_request\" else [],\n         )\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: None,\n         )\n         monkeypatch.setattr(","@@ -3161,7 +3161,7 @@ def request_middleware(**kwargs):\n             lambda kind, **kwargs: [request_middleware(**kwargs)] if kind == \"tool_request\" else [],\n         )\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: None,\n         )\n         monkeypatch.setattr(","@@ -3196,7 +3196,7 @@ def test_blocked_memory_tool_does_not_reset_counter(self, agent, monkeypatch):\n         \"\"\"Blocked memory tool should not reset the nudge counter.\"\"\"\n         agent._turns_since_memory = 5\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked\",\n         )\n         with patch(\"tools.memory_tool.memory_tool\", side_effect=AssertionError(\"should not run\")):","@@ -3209,7 +3209,7 @@ def test_blocked_memory_tool_does_not_reset_counter(self, agent, monkeypatch):\n \n     def test_invoke_tool_memory_remove_notifies_provider_with_old_text(self, agent, monkeypatch):\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: None,\n         )\n         calls = []","@@ -3241,7 +3241,7 @@ def on_memory_write(self, action, target, content, metadata=None):\n \n     def test_invoke_tool_memory_failed_remove_skips_provider_notification(self, agent, monkeypatch):\n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: None,\n         )\n         notify = MagicMock(side_effect=AssertionError(\"should not notify\"))","@@ -3281,7 +3281,7 @@ def test_concurrent_blocked_write_skips_checkpoint(self, agent, monkeypatch):\n         messages = []\n \n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked\" if args[0] == \"write_file\" else None,\n         )\n ","@@ -3308,7 +3308,7 @@ def test_concurrent_blocked_patch_skips_checkpoint(self, agent, monkeypatch):\n         messages = []\n \n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked\" if args[0] == \"patch\" else None,\n         )\n ","@@ -3335,7 +3335,7 @@ def test_concurrent_blocked_terminal_skips_checkpoint(self, agent, monkeypatch):\n         messages = []\n \n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             lambda *args, **kwargs: \"Blocked\" if args[0] == \"terminal\" else None,\n         )\n ","@@ -3369,7 +3369,7 @@ def block_first_only(*args, **kwargs):\n             return \"Blocked\" if call_count[\"n\"] == 1 else None\n \n         monkeypatch.setattr(\n-            \"hermes_cli.plugins.get_pre_tool_call_block_message\",\n+            \"hermes_cli.plugins.resolve_pre_tool_block\",\n             block_first_only,\n         )\n "]},{"file":"tests/run_agent/test_tool_call_guardrail_runtime.py","hunks":["@@ -228,7 +228,7 @@ def test_plugin_pre_tool_block_wins_without_counting_as_toolguard_block():\n     messages = []\n \n     with (\n-        patch(\"hermes_cli.plugins.get_pre_tool_call_block_message\", return_value=\"plugin policy\"),\n+        patch(\"hermes_cli.plugins.resolve_pre_tool_block\", return_value=\"plugin policy\"),\n         patch(\"run_agent.handle_function_call\", return_value=\"SHOULD_NOT_RUN\") as mock_hfc,\n     ):\n         agent._execute_tool_calls_sequential(msg, messages, \"task-1\")"]},{"file":"tests/tools/test_request_tool_approval.py","hunks":["@@ -0,0 +1,167 @@\n+\"\"\"Tests for tools.approval.request_tool_approval — the plugin pre_tool_call\n+``{\"action\": \"approve\"}`` escalation into the human-approval gate.\n+\n+These verify that a plugin-driven approval reuses the SAME machinery as a\n+Tier-2 dangerous-command match: session/permanent allowlist, the CLI prompt,\n+the gateway submit_pending path, cron_mode, and fail-closed timeouts.\n+\"\"\"\n+\n+import pytest\n+\n+import tools.approval as approval\n+from tools.approval import request_tool_approval\n+\n+\n[redacted:email](autouse=True)\n+def _isolate_approval_state(monkeypatch):\n+    \"\"\"Give each test a clean session key and empty allowlists.\"\"\"\n+    monkeypatch.setattr(\n+        approval, \"get_current_session_key\",\n+        lambda default=\"default\": \"test-session\",\n+    )\n+    # Empty session + permanent approval stores so nothing pre-approves.\n+    monkeypatch.setattr(approval, \"is_approved\", lambda sk, pk: False)\n+    # Not a yolo session (the shared gate checks this first).\n+    monkeypatch.setattr(approval, \"is_current_session_yolo_enabled\", lambda: False)\n+    monkeypatch.setattr(approval, \"_YOLO_MODE_FROZEN\", False, raising=False)\n+    # No thread-registered CLI callback by default.\n+    monkeypatch.setattr(\n+        \"tools.terminal_tool._get_approval_callback\", lambda: None, raising=False\n+    )\n+    yield\n+\n+\n+class TestRequestToolApproval:\n+    def test_session_cached_approval_short_circuits(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"is_approved\", lambda sk, pk: True)\n+        # Should NOT prompt at all.\n+        monkeypatch.setattr(\n+            approval, \"prompt_dangerous_approval\",\n+            lambda *a, **k: pytest.fail(\"should not prompt when already approved\"),\n+        )\n+        res = request_tool_approval(\"write_file\", \"sensitive path\", rule_key=\"ssh\")\n+        assert res == {\"approved\": True, \"message\": None}\n+\n+    def test_cli_approve_once(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: True)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"prompt_dangerous_approval\", lambda *a, **k: \"once\")\n+        res = request_tool_approval(\"write_file\", \"writing ~/.ssh/authorized_keys\")\n+        assert res[\"approved\"] is True\n+\n+    def test_cli_deny_blocks(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: True)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"prompt_dangerous_approval\", lambda *a, **k: \"deny\")\n+        res = request_tool_approval(\"terminal\", \"curl PUT to external API\")\n+        assert res[\"approved\"] is False\n+        assert \"denied\" in res[\"message\"].lower()\n+        assert res[\"pattern_key\"].startswith(\"plugin_rule:\")\n+\n+    def test_cli_session_persists_session_only(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: True)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"prompt_dangerous_approval\", lambda *a, **k: \"session\")\n+        calls = {\"session\": [], \"permanent\": []}\n+        monkeypatch.setattr(approval, \"approve_session\",\n+                            lambda sk, pk: calls[\"session\"].append(pk))\n+        monkeypatch.setattr(approval, \"approve_permanent\",\n+                            lambda pk: calls[\"permanent\"].append(pk))\n+        monkeypatch.setattr(approval, \"save_permanent_allowlist\", lambda x: None)\n+        res = request_tool_approval(\"write_file\", \"reason\", rule_key=\"ssh-writes\")\n+        assert res[\"approved\"] is True\n+        assert calls[\"session\"] == [\"plugin_rule:ssh-writes\"]\n+        assert calls[\"permanent\"] == []  # session != always\n+\n+    def test_cli_always_persists_permanent(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: True)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"prompt_dangerous_approval\", lambda *a, **k: \"always\")\n+        persisted = {}\n+        monkeypatch.setattr(approval, \"approve_session\", lambda sk, pk: None)\n+        monkeypatch.setattr(approval, \"approve_permanent\",\n+                            lambda pk: persisted.setdefault(\"key\", pk))\n+        monkeypatch.setattr(approval, \"save_permanent_allowlist\",\n+                            lambda x: persisted.setdefault(\"saved\", True))\n+        res = request_tool_approval(\"write_file\", \"reason\", rule_key=\"ssh-writes\")\n+        assert res[\"approved\"] is True\n+        assert persisted[\"key\"] == \"plugin_rule:ssh-writes\"\n+        assert persisted[\"saved\"] is True\n+\n+    def test_gateway_path_submits_pending_and_defers(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: False)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: True)\n+        submitted = {}\n+        monkeypatch.setattr(approval, \"submit_pending\",\n+                            lambda sk, data: submitted.update(data))\n+        res = request_tool_approval(\"browser_navigate\", \"external URL\",\n+                                    rule_key=\"ext-nav\")\n+        assert res[\"approved\"] is False\n+        assert res[\"status\"] == \"approval_required\"\n+        assert submitted[\"pattern_key\"] == \"plugin_rule:ext-nav\"\n+\n+    def test_cron_deny_mode_blocks(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: False)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"env_var_enabled\",\n+                            lambda v: v == \"HERMES_CRON_SESSION\")\n+        monkeypatch.setattr(approval, \"_get_cron_approval_mode\", lambda: \"deny\")\n+        res = request_tool_approval(\"terminal\", \"smtp send\")\n+        assert res[\"approved\"] is False\n+        assert \"cron\" in res[\"message\"].lower()\n+\n+    def test_cron_approve_mode_allows(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: False)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"env_var_enabled\",\n+                            lambda v: v == \"HERMES_CRON_SESSION\")\n+        monkeypatch.setattr(approval, \"_get_cron_approval_mode\", lambda: \"approve\")\n+        res = request_tool_approval(\"terminal\", \"smtp send\")\n+        assert res[\"approved\"] is True\n+\n+    def test_rule_key_derived_from_tool_and_reason(self, monkeypatch):\n+        \"\"\"With no explicit rule_key, the pattern key is derived from\n+        tool + a hash of the reason (so distinct reasons persist apart).\"\"\"\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: True)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"prompt_dangerous_approval\", lambda *a, **k: \"deny\")\n+        res = request_tool_approval(\"patch\", \"reason\")  # no rule_key\n+        assert res[\"pattern_key\"].startswith(\"plugin_rule:patch:\")\n+\n+    def test_distinct_reasons_get_distinct_keys(self, monkeypatch):\n+        \"\"\"Two different reasons on the SAME tool must not share an [a]lways\n+        allowlist entry (Finding 3: tool_name alone was too coarse).\"\"\"\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: True)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"prompt_dangerous_approval\", lambda *a, **k: \"deny\")\n+        k1 = request_tool_approval(\"write_file\", \"write to ~/.ssh\")[\"pattern_key\"]\n+        k2 = request_tool_approval(\"write_file\", \"send an email\")[\"pattern_key\"]\n+        assert k1 != k2\n+\n+    def test_explicit_rule_key_overrides_derivation(self, monkeypatch):\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: True)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"prompt_dangerous_approval\", lambda *a, **k: \"deny\")\n+        res = request_tool_approval(\"terminal\", \"any\", rule_key=\"my-rule\")\n+        assert res[\"pattern_key\"] == \"plugin_rule:my-rule\"\n+\n+    def test_no_human_non_cron_fails_closed(self, monkeypatch):\n+        \"\"\"Non-interactive, non-gateway, NON-cron context blocks (fail-closed)\n+        — a plugin-flagged action never runs ungated without a human.\"\"\"\n+        monkeypatch.setattr(approval, \"_is_interactive_cli\", lambda: False)\n+        monkeypatch.setattr(approval, \"_is_gateway_approval_context\", lambda: False)\n+        monkeypatch.setattr(approval, \"env_var_enabled\", lambda v: False)  # not cron\n+        res = request_tool_approval(\"terminal\", \"smtp send\")\n+        assert res[\"approved\"] is False\n+        assert \"no interactive user or gateway\" in res[\"message\"].lower()\n+\n+    def test_yolo_session_bypasses_gate(self, monkeypatch):\n+        \"\"\"A --yolo session skips the plugin approval gate (parity with the\n+        dangerous-command path, via the shared _run_approval_gate).\"\"\"\n+        monkeypatch.setattr(approval, \"is_current_session_yolo_enabled\", lambda: True)\n+        monkeypatch.setattr(\n+            approval, \"prompt_dangerous_approval\",\n+            lambda *a, **k: pytest.fail(\"yolo must not prompt\"),\n+        )\n+        res = request_tool_approval(\"terminal\", \"curl PUT\", rule_key=\"ext\")\n+        assert res == {\"approved\": True, \"message\": None}"]},{"file":"tools/approval.py","hunks":["@@ -11,6 +11,7 @@\n import contextvars\n import fnmatch\n import functools\n+import hashlib\n import logging\n import os\n import re","@@ -1995,6 +1996,224 @@ def _smart_approve(command: str, description: str) -> str:\n         return \"escalate\"\n \n \n+def _run_approval_gate(\n+    *,\n+    pattern_key: str,\n+    description: str,\n+    display_target: str,\n+    approval_callback=None,\n+    cron_deny_message: str,\n+    autoapprove_log_prefix: str,\n+    fail_closed_when_no_human: bool = False,\n+    no_human_block_message: str = \"\",\n+) -> dict:\n+    \"\"\"Shared human-approval gate for a flagged action (command or tool).\n+\n+    This is the single decision core reused by both\n+    :func:`check_dangerous_command` (dangerous shell patterns) and\n+    :func:`request_tool_approval` (plugin ``pre_tool_call`` ``approve``\n+    escalations). Extracting it keeps the fail-closed / cron / gateway /\n+    persist policy in ONE place so the two entry points can never drift.\n+\n+    Ordering mirrors the historical ``check_dangerous_command`` tail:\n+    yolo bypass → session-cache short-circuit → interactive/gateway/cron\n+    branch → prompt → ``deny/session/always`` persistence. The caller is\n+    responsible for the checks that are specific to its input shape\n+    (hardline detection, command-string permanent allowlist, dangerous-\n+    pattern detection) BEFORE calling this gate.\n+\n+    Args:\n+        pattern_key: Allowlist/session key this decision is stored under.\n+        description: Human-facing reason shown in the prompt.\n+        display_target: The command string or synthetic tool label shown\n+            to the user (redacted by ``prompt_dangerous_approval``).\n+        approval_callback: Optional CLI prompt callback. When ``None`` the\n+            per-thread callback registered via\n+            ``tools.terminal_tool.set_approval_callback`` is used.\n+        cron_deny_message: Message returned when a cron job hits this gate\n+            under ``cron_mode: deny``.\n+        autoapprove_log_prefix: Log line prefix for the non-interactive\n+            auto-approve warning (identifies command vs plugin origin).\n+        fail_closed_when_no_human: When True, a non-interactive non-gateway\n+            context that is NOT a cron session (e.g. a bare script with\n+            HERMES_INTERACTIVE unset) BLOCKS instead of auto-approving. The\n+            dangerous-command path keeps its historical fail-open default\n+            (False); the plugin-escalation path opts in to fail-closed so a\n+            plugin-flagged action never runs ungated without a human.\n+        no_human_block_message: Message returned when\n+            ``fail_closed_when_no_human`` blocks.\n+\n+    Returns:\n+        ``{\"approved\": bool, \"message\": str|None, ...}`` — shape shared with\n+        ``check_dangerous_command`` so all callers handle it uniformly.\n+    \"\"\"\n+    # --yolo bypasses all approval prompts (session- or process-scoped).\n+    # Hardline blocks are handled by the caller BEFORE this gate, so yolo\n+    # here only skips the recoverable approval layer.\n+    if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled():\n+        return {\"approved\": True, \"message\": None}\n+\n+    session_key = get_current_session_key()\n+    if is_approved(session_key, pattern_key):\n+        return {\"approved\": True, \"message\": None}\n+\n+    if approval_callback is None:\n+        try:\n+            from tools.terminal_tool import _get_approval_callback\n+            approval_callback = _get_approval_callback()\n+        except Exception:\n+            approval_callback = None\n+\n+    is_cli = _is_interactive_cli()\n+    is_gateway = _is_gateway_approval_context()\n+\n+    if not is_cli and not is_gateway:\n+        # Cron sessions: respect cron_mode config\n+        if env_var_enabled(\"HERMES_CRON_SESSION\"):\n+            if _get_cron_approval_mode() == \"deny\":\n+                return {\n+                    \"approved\": False,\n+                    \"message\": cron_deny_message,\n+                    \"pattern_key\": pattern_key,\n+                    \"description\": description,\n+                }\n+            # cron_mode: approve — fall through to auto-approve below.\n+        elif fail_closed_when_no_human:\n+            # Non-cron, non-interactive, no gateway: no human can answer.\n+            # The plugin-escalation path opts in to fail-closed here so a\n+            # plugin-flagged action never runs ungated. (The dangerous-\n+            # command path keeps the historical fail-open default.)\n+            logger.warning(\n+                \"%s (pattern: %s): %s — no interactive user/gateway present; \"\n+                \"BLOCKED (fail-closed). Set HERMES_INTERACTIVE or \"\n+                \"HERMES_GATEWAY_SESSION to answer the prompt.\",\n+                autoapprove_log_prefix, pattern_key, description,\n+            )\n+            return {\n+                \"approved\": False,\n+                \"message\": no_human_block_message or (\n+                    f\"BLOCKED: approval required ({description}) but no \"\n+                    \"interactive user or gateway is present to approve it.\"\n+                ),\n+                \"pattern_key\": pattern_key,\n+                \"description\": description,\n+            }\n+        logger.warning(\n+            \"%s (pattern: %s): %s — set HERMES_INTERACTIVE or \"\n+            \"HERMES_GATEWAY_SESSION to require approval.\",\n+            autoapprove_log_prefix, pattern_key, description,\n+        )\n+        return {\"approved\": True, \"message\": None}\n+\n+    if is_gateway or env_var_enabled(\"HERMES_EXEC_ASK\"):\n+        # Interactive gateway round-trip when a notify callback is\n+        # registered for this session (Discord/Telegram/Slack embed +\n+        # buttons, same mechanism as check_dangerous_command). Blocks the\n+        # agent thread until the user answers; the agent never sees\n+        # \"approval_required\" on this path — it gets a definitive\n+        # approved/BLOCKED outcome.\n+        notify_cb = None\n+        with _lock:\n+            notify_cb = _gateway_notify_cbs.get(session_key)\n+\n+        if notify_cb is not None:\n+            from agent.redact import redact_sensitive_text\n+            approval_data = {\n+                \"command\": redact_sensitive_text(display_target),\n+                \"pattern_key\": pattern_key,\n+                \"pattern_keys\": [pattern_key],\n+                \"description\": redact_sensitive_text(description),\n+                \"allow_permanent\": True,\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 approval request to user. Do NOT retry.\",\n+                    \"pattern_key\": pattern_key,\n+                    \"description\": description,\n+                }\n+            resolved = decision[\"resolved\"]\n+            choice = decision[\"choice\"]\n+            deny_reason = decision.get(\"reason\")\n+\n+            if not resolved or choice is None or choice == \"deny\":\n+                if not resolved:\n+                    reason = \"timed out without user response\"\n+                    timeout_addendum = \" Silence is not consent.\"\n+                else:\n+                    reason = \"denied by user\"\n+                    timeout_addendum = \"\"\n+                reason_addendum = \"\"\n+                if resolved and deny_reason:\n+                    reason_addendum = f' Reason given by the user: \"{deny_reason}\".'\n+                return {\n+                    \"approved\": False,\n+                    \"message\": (\n+                        f\"BLOCKED: Action {reason}.{reason_addendum} The user \"\n+                        f\"has NOT consented to this action. Do NOT retry it, \"\n+                        f\"do NOT rephrase it, and do NOT attempt the same \"\n+                        f\"outcome via a different path.{timeout_addendum}\"\n+                    ),\n+                    \"pattern_key\": pattern_key,\n+                    \"description\": description,\n+                    \"user_consent\": False,\n+                }\n+\n+            if choice == \"session\":\n+                approve_session(session_key, pattern_key)\n+            elif choice == \"always\":\n+                approve_session(session_key, pattern_key)\n+                approve_permanent(pattern_key)\n+                save_permanent_allowlist(_permanent_approved)\n+            return {\"approved\": True, \"message\": None}\n+\n+        # No notify callback (e.g. API server without an attached chat):\n+        # queue for /approve /deny review, agent sees approval_required.\n+        submit_pending(session_key, {\n+            \"command\": display_target,\n+            \"pattern_key\": pattern_key,\n+            \"description\": description,\n+        })\n+        return {\n+            \"approved\": False,\n+            \"pattern_key\": pattern_key,\n+            \"status\": \"approval_required\",\n+            \"command\": display_target,\n+            \"description\": description,\n+            \"message\": (\n+                f\"⚠️ This action is potentially dangerous ({description}). \"\n+                f\"Asking the user for approval.\\n\\n**Target:**\\n```\\n{display_target}\\n```\"\n+            ),\n+        }\n+\n+    choice = prompt_dangerous_approval(display_target, description,\n+                                       approval_callback=approval_callback)\n+\n+    if choice == \"deny\":\n+        return {\n+            \"approved\": False,\n+            \"message\": (\n+                f\"BLOCKED: User denied this potentially dangerous action \"\n+                f\"(matched '{description}'). Do NOT retry — the user has \"\n+                \"explicitly rejected it.\"\n+            ),\n+            \"pattern_key\": pattern_key,\n+            \"description\": description,\n+        }\n+\n+    if choice == \"session\":\n+        approve_session(session_key, pattern_key)\n+    elif choice == \"always\":\n+        approve_session(session_key, pattern_key)\n+        approve_permanent(pattern_key)\n+        save_permanent_allowlist(_permanent_approved)\n+\n+    return {\"approved\": True, \"message\": None}\n+\n+\n def _should_skip_container_guards(env_type: str, has_host_access: bool = False) -> bool:\n     \"\"\"Return True when the backend is isolated enough to skip dangerous-command prompts.\n ","@@ -2061,71 +2280,109 @@ def check_dangerous_command(command: str, env_type: str,\n     if not is_dangerous:\n         return {\"approved\": True, \"message\": None}\n \n-    session_key = get_current_session_key()\n-    if is_approved(session_key, pattern_key):\n-        return {\"approved\": True, \"message\": None}\n-\n-    is_cli = _is_interactive_cli()\n-    is_gateway = _is_gateway_approval_context()\n-\n-    if not is_cli and not is_gateway:\n-        # Cron sessions: respect cron_mode config\n-        if env_var_enabled(\"HERMES_CRON_SESSION\"):\n-            if _get_cron_approval_mode() == \"deny\":\n-                return {\n-                    \"approved\": False,\n-                    \"message\": (\n-                        f\"BLOCKED: Command flagged as dangerous ({description}) \"\n-                        \"but cron jobs run without a user present to approve it. \"\n-                        \"Find an alternative approach that avoids this command. \"\n-                        \"To allow dangerous commands in cron jobs, set \"\n-                        \"approvals.cron_mode: approve in config.yaml.\"\n-                    ),\n-                }\n-        logger.warning(\n-            \"AUTO-APPROVED dangerous command in non-interactive non-gateway context \"\n-            \"(pattern: %s): %s — set HERMES_INTERACTIVE or HERMES_GATEWAY_SESSION to require approval.\",\n-            description, command[:200],\n-        )\n-        return {\"approved\": True, \"message\": None}\n-\n-    if is_gateway or env_var_enabled(\"HERMES_EXEC_ASK\"):\n-        submit_pending(session_key, {\n-            \"command\": command,\n-            \"pattern_key\": pattern_key,\n-            \"description\": description,\n-        })\n-        return {\n-            \"approved\": False,\n-            \"pattern_key\": pattern_key,\n-            \"status\": \"approval_required\",\n-            \"command\": command,\n-            \"description\": description,\n-            \"message\": (\n-                f\"⚠️ This command is potentially dangerous ({description}). \"\n-                f\"Asking the user for approval.\\n\\n**Command:**\\n```\\n{command}\\n```\"\n-            ),\n-        }\n+    return _run_approval_gate(\n+        pattern_key=pattern_key,\n+        description=description,\n+        display_target=command,\n+        approval_callback=approval_callback,\n+        cron_deny_message=(\n+            f\"BLOCKED: Command flagged as dangerous ({description}) \"\n+            \"but cron jobs run without a user present to approve it. \"\n+            \"Find an alternative approach that avoids this command. \"\n+            \"To allow dangerous commands in cron jobs, set \"\n+            \"approvals.cron_mode: approve in config.yaml.\"\n+        ),\n+        autoapprove_log_prefix=(\n+            \"AUTO-APPROVED dangerous command in non-interactive non-gateway context\"\n+        ),\n+    )\n \n-    choice = prompt_dangerous_approval(command, description,\n-                                       approval_callback=approval_callback)\n \n-    if choice == \"deny\":\n-        return {\n-            \"approved\": False,\n-            \"message\": f\"BLOCKED: User denied this potentially dangerous command (matched '{description}' pattern). Do NOT retry this command - the user has explicitly rejected it.\",\n-            \"pattern_key\": pattern_key,\n-            \"description\": description,\n-        }\n+def request_tool_approval(\n+    tool_name: str,\n+    reason: str,\n+    *,\n+    rule_key: str = \"\",\n+    approval_callback=None,\n+) -> dict:\n+    \"\"\"Escalate an arbitrary tool call to the human-approval gate.\n+\n+    This is the entry point for a plugin ``pre_tool_call`` hook that returns\n+    ``{\"action\": \"approve\", \"message\": ...}``: instead of the plugin vetoing\n+    the call (``action: block``) or silently allowing it, it asks the SAME\n+    human gate that Tier-2 dangerous shell patterns use. The LLM cannot skip\n+    or bypass this — the tool call is intercepted before execution.\n+\n+    It reuses the existing approval primitives (session/permanent allowlist,\n+    ``prompt_dangerous_approval`` for CLI, ``submit_pending`` for the gateway\n+    callback, ``[o]nce/[s]ession/[a]lways/[d]eny``, timeout fail-closed) so\n+    behavior is identical to a dangerous-command match — only the trigger\n+    (a plugin rule on any tool) differs.\n \n-    if choice == \"session\":\n-        approve_session(session_key, pattern_key)\n-    elif choice == \"always\":\n-        approve_session(session_key, pattern_key)\n-        approve_permanent(pattern_key)\n-        save_permanent_allowlist(_permanent_approved)\n+    Args:\n+        tool_name: The tool being gated (e.g. ``\"write_file\"``, ``\"terminal\"``).\n+        reason: Human-facing message from the plugin explaining why approval\n+            is needed (rendered in the prompt).\n+        rule_key: Optional stable identifier the plugin can supply to control\n+            the ``[a]lways`` allowlist grain. When empty, the key is derived\n+            from ``tool_name`` + a hash of ``reason`` so that DISTINCT reasons\n+            on the same tool persist independently (answering ``[a]lways`` to\n+            \"write to ~/.ssh\" does NOT auto-approve a later \"send email\" rule\n+            on the same tool).\n+        approval_callback: Optional CLI callback for interactive prompts\n+            (same contract as ``check_dangerous_command``).\n \n-    return {\"approved\": True, \"message\": None}\n+    Returns:\n+        ``{\"approved\": True, \"message\": None}`` when allowed, or\n+        ``{\"approved\": False, \"message\": <reason>, ...}`` when denied /\n+        blocked. Shape matches ``check_dangerous_command`` so callers handle\n+        both paths identically.\n+\n+    Non-interactive contexts: cron jobs honor ``approvals.cron_mode`` (parity\n+    with dangerous commands); any OTHER non-interactive non-gateway context\n+    (a bare script with no ``HERMES_INTERACTIVE``) fails CLOSED — a plugin-\n+    flagged action never runs ungated without a human.\n+    \"\"\"\n+    description = reason or f\"Plugin requires approval for {tool_name}\"\n+    # Allowlist grain: an explicit plugin rule_key wins; otherwise derive from\n+    # tool + a short hash of the reason so distinct reasons on the same tool\n+    # get independent [a]lways entries (Finding: rule_key=tool_name alone was\n+    # too coarse — one \"always\" would blanket every rule on that tool).\n+    if rule_key:\n+        key_suffix = rule_key\n+    else:\n+        _reason_hash = hashlib.sha256(description.encode(\"utf-8\")).hexdigest()[:12]\n+        key_suffix = f\"{tool_name}:{_reason_hash}\"\n+    # Synthetic pattern key so plugin-rule approvals live in the same\n+    # session/permanent allowlist machinery as command patterns, namespaced\n+    # to avoid ever colliding with a real command pattern key.\n+    pattern_key = f\"plugin_rule:{key_suffix}\"\n+    # A synthetic \"command\" string for the display/allowlist layer. It never\n+    # executes; it only labels the gate. Namespaced identically.\n+    display_target = f\"<{tool_name}> (plugin approval rule)\"\n+\n+    return _run_approval_gate(\n+        pattern_key=pattern_key,\n+        description=description,\n+        display_target=display_target,\n+        approval_callback=approval_callback,\n+        cron_deny_message=(\n+            f\"BLOCKED: Tool '{tool_name}' requires approval ({description}) \"\n+            \"but cron jobs run without a user present to approve it. Find an \"\n+            \"alternative approach. To allow flagged actions in cron jobs, set \"\n+            \"approvals.cron_mode: approve in config.yaml.\"\n+        ),\n+        autoapprove_log_prefix=(\n+            f\"plugin-escalated tool call '{tool_name}' in \"\n+            \"non-interactive non-gateway context\"\n+        ),\n+        fail_closed_when_no_human=True,\n+        no_human_block_message=(\n+            f\"BLOCKED: Tool '{tool_name}' requires approval ({description}) \"\n+            \"but no interactive user or gateway is present to approve it. \"\n+            \"A plugin flagged this action for human confirmation.\"\n+        ),\n+    )\n \n \n # =========================================================================\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}