mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
fix(windows): silence tirith-unavailable banner + skip install/spawn attempts on unsupported platforms (#26718)
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Docker Build and Publish / move-main (push) Blocked by required conditions
Docker Build and Publish / move-latest (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Tests / test (push) Waiting to run
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
Some checks are pending
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Docker Build and Publish / move-main (push) Blocked by required conditions
Docker Build and Publish / move-latest (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Waiting to run
Lint (ruff + ty) / ruff enforcement (blocking) (push) Waiting to run
Lint (ruff + ty) / Windows footguns (blocking) (push) Waiting to run
Nix / nix (macos-latest) (push) Waiting to run
Nix / nix (ubuntu-latest) (push) Waiting to run
OSV-Scanner / Scan lockfiles (push) Waiting to run
Tests / test (push) Waiting to run
Tests / e2e (push) Waiting to run
uv.lock check / uv lock --check (push) Waiting to run
Tirith ships no Windows binary, so on every Windows CLI startup users saw a scary 'tirith security scanner enabled but not available' banner they could not act on. The banner suggested degraded security; in reality pattern-matching guards still run and the message was pure noise. Fix: - New public is_platform_supported() helper in tools/tirith_security.py that returns False when _detect_target() doesn't resolve (Windows, any non-x86_64/aarch64 arch). - ensure_installed(), _resolve_tirith_path(), and check_command_security() short-circuit on unsupported platforms: cache _resolved_path = _INSTALL_FAILED with reason 'unsupported_platform', skip PATH probes, skip the background download thread, skip the disk failure marker, and return allow with an empty summary from check_command_security so the spawn loop never fires. - Explicit user-configured tirith_path is still honored everywhere (a user who built tirith themselves under WSL keeps that path). - CLI banner in cli.py gated on is_platform_supported() — fires only on platforms where tirith *should* work but isn't installed. - Docs note tirith's supported-platform list and point Windows users at WSL. Tests: tests/tools/test_tirith_security.py +8 tests covering Linux x86_64, Darwin arm64, Windows, and unknown-arch verdicts plus the silent ensure_installed / check_command_security / _resolve_tirith_path fast-paths and the explicit-path override. test_tirith_security.py 75 passed (8 new + 67 pre-existing) test_command_guards.py 19 passed
This commit is contained in:
parent
a31191c3f5
commit
c5dc9700eb
4 changed files with 143 additions and 4 deletions
8
cli.py
8
cli.py
|
|
@ -11736,11 +11736,13 @@ class HermesCLI:
|
||||||
|
|
||||||
# Ensure tirith security scanner is available (downloads if needed).
|
# Ensure tirith security scanner is available (downloads if needed).
|
||||||
# Warn the user if tirith is enabled in config but not available,
|
# Warn the user if tirith is enabled in config but not available,
|
||||||
# so they know command security scanning is degraded.
|
# so they know command security scanning is degraded. Suppressed
|
||||||
|
# on platforms where tirith ships no binary (Windows etc.) — the
|
||||||
|
# user can't act on it and pattern-matching guards still run.
|
||||||
try:
|
try:
|
||||||
from tools.tirith_security import ensure_installed
|
from tools.tirith_security import ensure_installed, is_platform_supported
|
||||||
tirith_path = ensure_installed(log_failures=False)
|
tirith_path = ensure_installed(log_failures=False)
|
||||||
if tirith_path is None:
|
if tirith_path is None and is_platform_supported():
|
||||||
security_cfg = self.config.get("security", {}) or {}
|
security_cfg = self.config.get("security", {}) or {}
|
||||||
tirith_enabled = security_cfg.get("tirith_enabled", True)
|
tirith_enabled = security_cfg.get("tirith_enabled", True)
|
||||||
if tirith_enabled:
|
if tirith_enabled:
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,103 @@ class TestEnsureInstalled:
|
||||||
_tirith_mod._resolved_path = None
|
_tirith_mod._resolved_path = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unsupported platform (Windows etc.) — silent fast-path everywhere
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestUnsupportedPlatform:
|
||||||
|
"""When _detect_target() returns None (no tirith binary for this OS+arch),
|
||||||
|
the entire subsystem must stay silent: no PATH probes, no download thread,
|
||||||
|
no disk failure marker, no spawn attempts, no CLI banner. Pattern-matching
|
||||||
|
guards still cover the gap; tirith content scanning is just absent."""
|
||||||
|
|
||||||
|
def test_is_platform_supported_true_on_linux_x86_64(self):
|
||||||
|
with patch("tools.tirith_security.platform.system", return_value="Linux"), \
|
||||||
|
patch("tools.tirith_security.platform.machine", return_value="x86_64"):
|
||||||
|
assert _tirith_mod.is_platform_supported() is True
|
||||||
|
|
||||||
|
def test_is_platform_supported_true_on_darwin_arm64(self):
|
||||||
|
with patch("tools.tirith_security.platform.system", return_value="Darwin"), \
|
||||||
|
patch("tools.tirith_security.platform.machine", return_value="arm64"):
|
||||||
|
assert _tirith_mod.is_platform_supported() is True
|
||||||
|
|
||||||
|
def test_is_platform_supported_false_on_windows(self):
|
||||||
|
with patch("tools.tirith_security.platform.system", return_value="Windows"), \
|
||||||
|
patch("tools.tirith_security.platform.machine", return_value="AMD64"):
|
||||||
|
assert _tirith_mod.is_platform_supported() is False
|
||||||
|
|
||||||
|
def test_is_platform_supported_false_on_unknown_arch(self):
|
||||||
|
with patch("tools.tirith_security.platform.system", return_value="Linux"), \
|
||||||
|
patch("tools.tirith_security.platform.machine", return_value="riscv64"):
|
||||||
|
assert _tirith_mod.is_platform_supported() is False
|
||||||
|
|
||||||
|
@patch("tools.tirith_security._load_security_config")
|
||||||
|
def test_ensure_installed_unsupported_returns_none_no_thread(self, mock_cfg):
|
||||||
|
"""Windows: don't start a background install thread, don't write a
|
||||||
|
failure marker — just cache the verdict and return None."""
|
||||||
|
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||||
|
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||||
|
_tirith_mod._resolved_path = None
|
||||||
|
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||||
|
patch("tools.tirith_security.threading.Thread") as MockThread, \
|
||||||
|
patch("tools.tirith_security._mark_install_failed") as mock_mark, \
|
||||||
|
patch("tools.tirith_security.shutil.which") as mock_which:
|
||||||
|
result = ensure_installed()
|
||||||
|
assert result is None
|
||||||
|
MockThread.assert_not_called()
|
||||||
|
mock_mark.assert_not_called()
|
||||||
|
mock_which.assert_not_called()
|
||||||
|
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
|
||||||
|
assert _tirith_mod._install_failure_reason == "unsupported_platform"
|
||||||
|
|
||||||
|
@patch("tools.tirith_security._load_security_config")
|
||||||
|
def test_check_command_security_unsupported_allows_silently(self, mock_cfg):
|
||||||
|
"""Windows: skip the resolver and spawn entirely — return allow with
|
||||||
|
an empty summary so callers can't accidentally surface 'tirith
|
||||||
|
unavailable' messaging to the user."""
|
||||||
|
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||||
|
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||||
|
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||||
|
patch("tools.tirith_security.subprocess.run") as mock_run, \
|
||||||
|
patch("tools.tirith_security._resolve_tirith_path") as mock_resolve:
|
||||||
|
result = check_command_security("rm -rf /")
|
||||||
|
assert result == {"action": "allow", "findings": [], "summary": ""}
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
mock_resolve.assert_not_called()
|
||||||
|
|
||||||
|
@patch("tools.tirith_security._load_security_config")
|
||||||
|
def test_resolve_path_unsupported_caches_failure_without_probing(self, mock_cfg):
|
||||||
|
"""The per-command resolver must also short-circuit on Windows so
|
||||||
|
long-running gateways don't churn through `shutil.which` and disk
|
||||||
|
I/O for every scanned command."""
|
||||||
|
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||||||
|
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||||
|
_tirith_mod._resolved_path = None
|
||||||
|
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||||
|
patch("tools.tirith_security.shutil.which") as mock_which:
|
||||||
|
result = _tirith_mod._resolve_tirith_path("tirith")
|
||||||
|
assert result == "tirith"
|
||||||
|
mock_which.assert_not_called()
|
||||||
|
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
|
||||||
|
assert _tirith_mod._install_failure_reason == "unsupported_platform"
|
||||||
|
|
||||||
|
@patch("tools.tirith_security._load_security_config")
|
||||||
|
def test_explicit_path_still_honored_on_unsupported_platform(self, mock_cfg):
|
||||||
|
"""If a user explicitly configured a tirith_path (e.g. they built it
|
||||||
|
themselves under WSL), the unsupported-platform short-circuit must
|
||||||
|
NOT override that — explicit config wins."""
|
||||||
|
mock_cfg.return_value = {"tirith_enabled": True,
|
||||||
|
"tirith_path": "/opt/custom/tirith",
|
||||||
|
"tirith_timeout": 5, "tirith_fail_open": True}
|
||||||
|
_tirith_mod._resolved_path = None
|
||||||
|
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||||||
|
patch("os.path.isfile", return_value=True), \
|
||||||
|
patch("os.access", return_value=True):
|
||||||
|
result = _tirith_mod._resolve_tirith_path("/opt/custom/tirith")
|
||||||
|
assert result == "/opt/custom/tirith"
|
||||||
|
assert _tirith_mod._resolved_path == "/opt/custom/tirith"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Failed download caches the miss (Finding #1)
|
# Failed download caches the miss (Finding #1)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,12 @@ def _hermes_bin_dir() -> str:
|
||||||
|
|
||||||
|
|
||||||
def _detect_target() -> str | None:
|
def _detect_target() -> str | None:
|
||||||
"""Return the Rust target triple for the current platform, or None."""
|
"""Return the Rust target triple for the current platform, or None.
|
||||||
|
|
||||||
|
Windows is intentionally unsupported — tirith does not ship a Windows
|
||||||
|
build. Callers should treat `None` as "this platform will never have
|
||||||
|
tirith" and silently fall back to pattern-matching guards.
|
||||||
|
"""
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
|
|
||||||
|
|
@ -236,6 +241,16 @@ def _detect_target() -> str | None:
|
||||||
return f"{arch}-{plat}"
|
return f"{arch}-{plat}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_platform_supported() -> bool:
|
||||||
|
"""True when tirith ships a prebuilt binary for this OS+arch.
|
||||||
|
|
||||||
|
Used by callers (CLI banner, etc.) to distinguish "tirith failed to
|
||||||
|
install" from "tirith was never going to install here" — the latter
|
||||||
|
is silent because there is nothing the user can do about it.
|
||||||
|
"""
|
||||||
|
return _detect_target() is not None
|
||||||
|
|
||||||
|
|
||||||
def _download_file(url: str, dest: str, timeout: int = 10):
|
def _download_file(url: str, dest: str, timeout: int = 10):
|
||||||
"""Download a URL to a local file."""
|
"""Download a URL to a local file."""
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
|
|
@ -448,6 +463,15 @@ def _resolve_tirith_path(configured_path: str) -> str:
|
||||||
explicit = _is_explicit_path(configured_path)
|
explicit = _is_explicit_path(configured_path)
|
||||||
install_failed = _resolved_path is _INSTALL_FAILED
|
install_failed = _resolved_path is _INSTALL_FAILED
|
||||||
|
|
||||||
|
# Platform has no tirith build (Windows etc.). Cache the verdict and
|
||||||
|
# return the unexpanded configured path — the spawn loop will fail-open
|
||||||
|
# via the dedupe'd OSError handler, but only after the first call; on
|
||||||
|
# subsequent calls the fast-path above short-circuits before spawning.
|
||||||
|
if not explicit and not is_platform_supported():
|
||||||
|
_resolved_path = _INSTALL_FAILED
|
||||||
|
_install_failure_reason = "unsupported_platform"
|
||||||
|
return expanded
|
||||||
|
|
||||||
# Explicit path: check it and stop. Never auto-download a replacement.
|
# Explicit path: check it and stop. Never auto-download a replacement.
|
||||||
if explicit:
|
if explicit:
|
||||||
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
||||||
|
|
@ -574,6 +598,14 @@ def ensure_installed(*, log_failures: bool = True):
|
||||||
return path
|
return path
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Platform has no tirith build (e.g. Windows) — don't probe PATH,
|
||||||
|
# don't start a download thread, don't write a disk failure marker.
|
||||||
|
# Pattern-matching guards still run; this path stays silent.
|
||||||
|
if not is_platform_supported():
|
||||||
|
_resolved_path = _INSTALL_FAILED
|
||||||
|
_install_failure_reason = "unsupported_platform"
|
||||||
|
return None
|
||||||
|
|
||||||
configured_path = cfg["tirith_path"]
|
configured_path = cfg["tirith_path"]
|
||||||
explicit = _is_explicit_path(configured_path)
|
explicit = _is_explicit_path(configured_path)
|
||||||
expanded = os.path.expanduser(configured_path)
|
expanded = os.path.expanduser(configured_path)
|
||||||
|
|
@ -659,6 +691,12 @@ def check_command_security(command: str) -> dict:
|
||||||
if not cfg["tirith_enabled"]:
|
if not cfg["tirith_enabled"]:
|
||||||
return {"action": "allow", "findings": [], "summary": ""}
|
return {"action": "allow", "findings": [], "summary": ""}
|
||||||
|
|
||||||
|
# Unsupported platform (Windows etc.) — tirith has no binary here and
|
||||||
|
# never will. Skip the resolver entirely so we don't even try to spawn.
|
||||||
|
# Pattern-matching guards still run via the rest of approval.py.
|
||||||
|
if not is_platform_supported():
|
||||||
|
return {"action": "allow", "findings": [], "summary": ""}
|
||||||
|
|
||||||
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
|
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
|
||||||
timeout = cfg["tirith_timeout"]
|
timeout = cfg["tirith_timeout"]
|
||||||
fail_open = cfg["tirith_fail_open"]
|
fail_open = cfg["tirith_fail_open"]
|
||||||
|
|
|
||||||
|
|
@ -537,6 +537,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.
|
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.
|
||||||
|
|
||||||
|
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.
|
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.
|
||||||
|
|
||||||
### Context File Injection Protection
|
### Context File Injection Protection
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue