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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user