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 <humphreysun98@gmail.com>
Co-authored-by: yinkev <182213728+yinkev@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-18 00:47:27 -07:00
committed by Teknium
parent cc295509b4
commit fbbe916fa4
2 changed files with 55 additions and 0 deletions

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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.