{"repo":"NousResearch/hermes-agent","public_id":"20733","display_label":"Tirith fail-open flag ignored on ImportError","summary":"Multiple fixes and a bug report addressing that the tirith_fail_open configuration option was not being respected when a Tirith ImportError occurred, causing the approval guard to silently default to open behavior regardless of the setting.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53604},"resolution":{"state":"resolved","by":"pr-53604"},"freshness":{"state":"verified","last_verified_at":"2026-06-27T11:41:24+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":20733,"pr_number":53604,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":20733,"title":"[Bug]: approval.py silently fails open on Tirith ImportError, ignoring tirith_fail_open: false","url":null,"state":"closed"},{"kind":"pr","number":20766,"title":"fix(approval): honour tirith_fail_open=false on Tirith ImportError","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9c81c938d","number":0},{"kind":"pr","value":"#53604","number":53604}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":20803,"title":"fix(tools): honor tirith_fail_open config on ImportError in approval guard","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9c81c938d","number":0},{"kind":"pr","value":"#20766","number":20766},{"kind":"pr","value":"#20733","number":20733},{"kind":"pr","value":"#53604","number":53604}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":21005,"title":"fix(approval): honor tirith_fail_open when tirith module is unavailable","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"9c81c938d","number":0},{"kind":"pr","value":"#20766","number":20766},{"kind":"pr","value":"#20733","number":20733},{"kind":"pr","value":"#53604","number":53604}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":22498,"title":"fix(approval): respect tirith_fail_open=false on scanner ImportError","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#20766","number":20766},{"kind":"pr","value":"#21005","number":21005},{"kind":"pr","value":"#20803","number":20803},{"kind":"pr","value":"#20766","number":20766},{"kind":"pr","value":"#20733","number":20733}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":53604,"title":"fix(approval): honour tirith_fail_open=false on Tirith ImportError (#20733)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_approval.py","tools/approval.py"],"fix_hunks":[{"file":"tests/tools/test_approval.py","hunks":["@@ -1826,3 +1826,98 @@ def _capture(event_name, **kwargs):\n         assert last_post.get(\"choice\") == \"timeout\", (\n             f\"hook choice should be 'timeout' on no-response, got {last_post.get('choice')!r}\"\n         )\n+\n+\n+class TestTirithImportErrorFailOpenPolicy:\n+    \"\"\"Regression guard for #20733.\n+\n+    When ``tools.tirith_security`` cannot be imported, ``check_all_command_guards``\n+    must honour the ``security.tirith_fail_open`` config knob:\n+\n+    * ``tirith_fail_open: true``  (default) → allow, no approval prompt.\n+    * ``tirith_fail_open: false`` → surface a Tirith-style warning through\n+      the normal approval flow so the command is not silently permitted.\n+    \"\"\"\n+\n+    def _make_failing_import(self, real_import):\n+        \"\"\"Return a builtins.__import__ replacement that raises for tirith.\"\"\"\n+        def _fake(name, *args, **kwargs):\n+            if name == \"tools.tirith_security\":\n+                raise ImportError(\"simulated tirith import failure\")\n+            return real_import(name, *args, **kwargs)\n+        return _fake\n+\n+    def test_fail_open_true_allows_silently_on_import_error(self):\n+        \"\"\"Default fail-open: ImportError is silently swallowed, command allowed.\"\"\"\n+        import builtins\n+        from unittest.mock import patch as _patch\n+        from tools.approval import check_all_command_guards\n+\n+        cfg = {\n+            \"approvals\": {\"mode\": \"manual\"},\n+            \"security\": {\"tirith_enabled\": True, \"tirith_fail_open\": True},\n+        }\n+        real_import = builtins.__import__\n+        with _patch(\"builtins.__import__\", side_effect=self._make_failing_import(real_import)):\n+            with _patch(\"hermes_cli.config.load_config\", return_value=cfg):\n+                with _patch(\"tools.approval.detect_dangerous_command\", return_value=(False, None, None)):\n+                    with mock_patch.dict(\"os.environ\", {\"HERMES_INTERACTIVE\": \"1\"}, clear=False):\n+                        result = check_all_command_guards(\"echo hello\", \"local\")\n+\n+        assert result.get(\"approved\") is True\n+\n+    def test_fail_open_false_escalates_to_approval_on_import_error(self):\n+        \"\"\"Fail-closed: ImportError must NOT silently allow when tirith_fail_open=false.\"\"\"\n+        import builtins\n+        from unittest.mock import patch as _patch\n+        from tools.approval import check_all_command_guards\n+\n+        cfg = {\n+            \"approvals\": {\"mode\": \"manual\"},\n+            \"security\": {\"tirith_enabled\": True, \"tirith_fail_open\": False},\n+        }\n+        calls = []\n+\n+        def approval_callback(command, description, **kwargs):\n+            calls.append({\"command\": command, \"description\": description})\n+            return \"deny\"\n+\n+        real_import = builtins.__import__\n+        with _patch(\"builtins.__import__\", side_effect=self._make_failing_import(real_import)):\n+            with _patch(\"hermes_cli.config.load_config\", return_value=cfg):\n+                with _patch(\"tools.approval.detect_dangerous_command\", return_value=(False, None, None)):\n+                    with mock_patch.dict(\"os.environ\", {\"HERMES_INTERACTIVE\": \"1\"}, clear=False):\n+                        result = check_all_command_guards(\n+                            \"echo hello\",\n+                            \"local\",\n+                            approval_callback=approval_callback,\n+                        )\n+\n+        # The user must have been consulted — the command should NOT be silently allowed.\n+        assert result.get(\"approved\") is not True or calls, (\n+            \"Command was silently allowed despite tirith_fail_open=false and Tirith import failure. \"\n+            \"This is the bug described in issue #20733.\"\n+        )\n+        # Specifically: user denied via callback, so approved must be False.\n+        assert result.get(\"approved\") is False\n+        assert calls, \"Approval callback was never invoked — command slipped through silently\"\n+        assert \"tirith\" in calls[0][\"description\"].lower() or \"unavailable\" in calls[0][\"description\"].lower()\n+\n+    def test_tirith_disabled_skips_fail_open_check(self):\n+        \"\"\"When tirith_enabled=false, ImportError is irrelevant — allow without prompt.\"\"\"\n+        import builtins\n+        from unittest.mock import patch as _patch\n+        from tools.approval import check_all_command_guards\n+\n+        cfg = {\n+            \"approvals\": {\"mode\": \"manual\"},\n+            \"security\": {\"tirith_enabled\": False, \"tirith_fail_open\": False},\n+        }\n+        real_import = builtins.__import__\n+        with _patch(\"builtins.__import__\", side_effect=self._make_failing_import(real_import)):\n+            with _patch(\"hermes_cli.config.load_config\", return_value=cfg):\n+                with _patch(\"tools.approval.detect_dangerous_command\", return_value=(False, None, None)):\n+                    with mock_patch.dict(\"os.environ\", {\"HERMES_INTERACTIVE\": \"1\"}, clear=False):\n+                        result = check_all_command_guards(\"echo hello\", \"local\")\n+\n+        assert result.get(\"approved\") is True"]},{"file":"tools/approval.py","hunks":["@@ -1600,7 +1600,40 @@ def check_all_command_guards(command: str, env_type: str,\n         from tools.tirith_security import check_command_security\n         tirith_result = check_command_security(command)\n     except ImportError:\n-        pass  # tirith module not installed — allow\n+        # Tirith module not installed.  When tirith_fail_open is True (the\n+        # default) we silently allow, matching the pre-existing behaviour.\n+        # When tirith_fail_open is False the operator has explicitly opted into\n+        # fail-closed; an import failure must not silently grant access, so we\n+        # synthesize a warn result that will be surfaced to the user through the\n+        # normal approval flow.  Fixes #20733.\n+        _tirith_fail_open = True  # safe default if config is unreadable\n+        try:\n+            from hermes_cli.config import load_config as _load_cfg\n+            _sec = (_load_cfg() or {}).get(\"security\", {}) or {}\n+            _tirith_enabled = _sec.get(\"tirith_enabled\", True)\n+            if _tirith_enabled:\n+                _tirith_fail_open = _sec.get(\"tirith_fail_open\", True)\n+        except Exception:\n+            pass\n+        if not _tirith_fail_open:\n+            tirith_result = {\n+                \"action\": \"warn\",\n+                \"findings\": [\n+                    {\n+                        \"rule_id\": \"tirith-import-error\",\n+                        \"severity\": \"HIGH\",\n+                        \"title\": \"Tirith security module unavailable\",\n+                        \"description\": (\n+                            \"The Tirith security scanner could not be imported. \"\n+                            \"Because security.tirith_fail_open is false, this \"\n+                            \"command cannot be silently allowed. Approve only if \"\n+                            \"you have verified the command is safe.\"\n+                        ),\n+                    }\n+                ],\n+                \"summary\": \"Tirith unavailable (fail-closed)\",\n+            }\n+        # else: tirith_fail_open is True — allow as before (tirith_result stays \"allow\")\n \n     # Dangerous command check (detection only, no approval)\n     is_dangerous, pattern_key, description = detect_dangerous_command(command)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}