From fbbe916fa485d88447616bc0c8fe659c053fa17d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:47:27 -0700 Subject: [PATCH] test(tirith): pin half-open recovery and single-flight probing; docs Two invariants in the mirroring test file, adapted from the contributor's test commit: (1) after the retry window one real scan runs and any verdict closes the breaker so the next command is scanned again; (2) inside the window nothing spawns, exactly one probe runs per window, and a failed probe re-arms it. The block/warn reset covered by the fix also supersedes the counter-only fixes in #76578 and #71495. Document the five-minute suspend-and-reprobe behaviour in the security guide next to tirith_fail_open. Co-authored-by: Ryan Gu <133598845+ryangu00@users.noreply.github.com> Co-authored-by: HumphreySun98 Co-authored-by: yinkev <182213728+yinkev@users.noreply.github.com> --- tests/tools/test_tirith_security.py | 53 +++++++++++++++++++++++++++++ website/docs/user-guide/security.md | 2 ++ 2 files changed, 55 insertions(+) diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index 12178783ef..261b34b9ed 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -25,12 +25,14 @@ def _reset_resolved_path(): _tirith_mod._install_failure_reason = "" _tirith_mod._crash_count = 0 _tirith_mod._circuit_open = False + _tirith_mod._circuit_open_at = 0.0 yield _tirith_mod._resolved_path = None _tirith_mod._install_thread = None _tirith_mod._install_failure_reason = "" _tirith_mod._crash_count = 0 _tirith_mod._circuit_open = False + _tirith_mod._circuit_open_at = 0.0 # --------------------------------------------------------------------------- @@ -165,6 +167,57 @@ class TestUnknownExitCode: assert "exit code 99" in result["summary"] +# --------------------------------------------------------------------------- +# Circuit breaker: half-open recovery +# --------------------------------------------------------------------------- + +def _open_breaker(age_s): + """Put the breaker in the open state as if it tripped ``age_s`` seconds ago.""" + _tirith_mod._crash_count = _tirith_mod._CRASH_LIMIT + _tirith_mod._circuit_open = True + _tirith_mod._circuit_open_at = time.monotonic() - age_s + + +class TestCircuitBreakerHalfOpen: + @pytest.mark.parametrize("returncode, action", [(0, "allow"), (1, "block"), (2, "warn")]) + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_completed_probe_after_retry_window_closes_breaker(self, mock_cfg, mock_run, returncode, action): + """Once the retry window has elapsed, one real scan runs; any verdict (allow/block/warn) + proves the binary healthy and closes the breaker, so the next command is scanned again.""" + mock_cfg.return_value = _CFG + _open_breaker(age_s=_tirith_mod._CIRCUIT_RETRY_S + 1) + mock_run.return_value = _mock_run(returncode, _json_stdout()) + + result = check_command_security("echo hi") + + assert result["action"] == action + assert mock_run.call_count == 1 + assert (_tirith_mod._circuit_open, _tirith_mod._crash_count) == (False, 0) + # Breaker closed: the following command is scanned rather than short-circuited. + check_command_security("echo again") + assert mock_run.call_count == 2 + + @patch("tools.tirith_security.subprocess.run") + @patch("tools.tirith_security._load_security_config") + def test_open_breaker_probes_once_per_window_and_failed_probe_rearms(self, mock_cfg, mock_run): + """Inside the window nothing spawns; after it exactly one probe runs, and a probe that + fails re-arms the window so the next caller is fail-open without spawning again.""" + mock_cfg.return_value = _CFG + _open_breaker(age_s=1) + mock_run.side_effect = OSError("binary gone") + + assert check_command_security("echo hi")["summary"] == "tirith disabled (circuit breaker)" + assert mock_run.call_count == 0 + + _open_breaker(age_s=_tirith_mod._CIRCUIT_RETRY_S + 1) + assert check_command_security("echo hi")["action"] == "allow" # probe spawned and failed + assert mock_run.call_count == 1 + assert _tirith_mod._circuit_open is True + assert check_command_security("echo hi")["summary"] == "tirith disabled (circuit breaker)" + assert mock_run.call_count == 1 # re-armed: no second probe inside the fresh window + + # --------------------------------------------------------------------------- # Disabled # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index c87333c14c..a4ecad4e78 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -793,6 +793,8 @@ security: When `tirith_fail_open` is `true` (default), commands proceed if tirith is not installed or times out. Set to `false` in high-security environments to block commands when tirith is unavailable. +Three consecutive operational failures (spawn error, timeout, crash) suspend scanning for five minutes so a broken binary cannot stall every command; after that window one command re-probes tirith, and any completed scan (allow, warn or block) resumes normal scanning. A probe that fails again re-arms the five-minute window. + Tirith ships prebuilt binaries for Linux (x86_64 / aarch64) and macOS (x86_64 / arm64). On platforms with no prebuilt binary (Windows, etc.), tirith is silently skipped — pattern-matching guards still run, and the CLI does not surface an "unavailable" banner. To use tirith on Windows, run Hermes under WSL. Tirith's verdict integrates with the approval flow: safe commands pass through, while both suspicious and blocked commands trigger user approval with the full tirith findings (severity, title, description, safer alternatives). Users can approve or deny — the default choice is deny to keep unattended scenarios secure.