feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
"""Tirith pre-exec security scanning wrapper.
|
|
|
|
|
|
|
|
|
|
Runs the tirith binary as a subprocess to scan commands for content-level
|
|
|
|
|
threats (homograph URLs, pipe-to-interpreter, terminal injection, etc.).
|
|
|
|
|
|
|
|
|
|
Exit code is the verdict source of truth:
|
|
|
|
|
0 = allow, 1 = block, 2 = warn
|
|
|
|
|
|
|
|
|
|
JSON stdout enriches findings/summary but never overrides the verdict.
|
|
|
|
|
Operational failures (spawn error, timeout, unknown exit code) respect
|
|
|
|
|
the fail_open config setting. Programming errors propagate.
|
|
|
|
|
|
|
|
|
|
Auto-install: if tirith is not found on PATH or at the configured path,
|
|
|
|
|
it is automatically downloaded from GitHub releases to $HERMES_HOME/bin/tirith.
|
fix: send_animation metadata, MarkdownV2 inline code splitting, tirith cosign-free install (#1626)
* fix: Anthropic OAuth compatibility — Claude Code identity fingerprinting
Anthropic routes OAuth/subscription requests based on Claude Code's
identity markers. Without them, requests get intermittent 500 errors
(~25% failure rate observed). This matches what pi-ai (clawdbot) and
OpenCode both implement for OAuth compatibility.
Changes (OAuth tokens only — API key users unaffected):
1. Headers: user-agent 'claude-cli/2.1.2 (external, cli)' + x-app 'cli'
2. System prompt: prepend 'You are Claude Code, Anthropic's official CLI'
3. System prompt sanitization: replace Hermes/Nous references
4. Tool names: prefix with 'mcp_' (Claude Code convention for non-native tools)
5. Tool name stripping: remove 'mcp_' prefix from response tool calls
Before: 9/12 OK, 1 hard fail, 4 needed retries (~25% error rate)
After: 16/16 OK, 0 failures, 0 retries (0% error rate)
* fix: three gateway issues from user error logs
1. send_animation missing metadata kwarg (base.py)
- Base class send_animation lacked the metadata parameter that the
call site in base.py line 917 passes. Telegram's override accepted
it, but any platform without an override (Discord, Slack, etc.)
hit TypeError. Added metadata to base class signature.
2. MarkdownV2 split-inside-inline-code (base.py truncate_message)
- truncate_message could split at a space inside an inline code span
(e.g. `function(arg1, arg2)`), leaving an unpaired backtick and
unescaped parentheses in the chunk. Telegram rejects with
'character ( is reserved'. Added inline code awareness to the
split-point finder — detects odd backtick counts and moves the
split before the code span.
3. tirith auto-install without cosign (tirith_security.py)
- Previously required cosign on PATH for auto-install, blocking
install entirely with a warning if missing. Now proceeds with
SHA-256 checksum verification only when cosign is unavailable.
Cosign is still used for full supply chain verification when
present. If cosign IS present but verification explicitly fails,
install is still aborted (tampered release).
2026-03-16 23:39:41 -07:00
|
|
|
The download always verifies SHA-256 checksums. When cosign is available on
|
|
|
|
|
PATH, provenance verification (GitHub Actions workflow signature) is also
|
|
|
|
|
performed. If cosign is not installed, the download proceeds with SHA-256
|
|
|
|
|
verification only — still secure via HTTPS + checksum, just without supply
|
|
|
|
|
chain provenance proof. Installation runs in a background thread so startup
|
|
|
|
|
never blocks.
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import platform
|
|
|
|
|
import shutil
|
|
|
|
|
import stat
|
|
|
|
|
import subprocess
|
|
|
|
|
import tarfile
|
|
|
|
|
import tempfile
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
_REPO = "sheeki03/tirith"
|
|
|
|
|
|
|
|
|
|
# Cosign provenance verification — pinned to the specific release workflow
|
|
|
|
|
_COSIGN_IDENTITY_REGEXP = f"^https://github.com/{_REPO}/\\.github/workflows/release\\.yml@refs/tags/v"
|
|
|
|
|
_COSIGN_ISSUER = "https://token.actions.githubusercontent.com"
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Config helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _env_bool(key: str, default: bool) -> bool:
|
|
|
|
|
val = os.getenv(key)
|
|
|
|
|
if val is None:
|
|
|
|
|
return default
|
2026-05-11 11:13:25 -07:00
|
|
|
return val.lower() in {"1", "true", "yes"}
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def _env_int(key: str, default: int) -> int:
|
|
|
|
|
val = os.getenv(key)
|
|
|
|
|
if val is None:
|
|
|
|
|
return default
|
|
|
|
|
try:
|
|
|
|
|
return int(val)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_security_config() -> dict:
|
|
|
|
|
"""Load security settings from config.yaml, with env var overrides."""
|
|
|
|
|
defaults = {
|
|
|
|
|
"tirith_enabled": True,
|
|
|
|
|
"tirith_path": "tirith",
|
|
|
|
|
"tirith_timeout": 5,
|
|
|
|
|
"tirith_fail_open": True,
|
|
|
|
|
}
|
|
|
|
|
try:
|
|
|
|
|
from hermes_cli.config import load_config
|
|
|
|
|
cfg = load_config().get("security", {}) or {}
|
|
|
|
|
except Exception:
|
|
|
|
|
cfg = {}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"tirith_enabled": _env_bool("TIRITH_ENABLED", cfg.get("tirith_enabled", defaults["tirith_enabled"])),
|
|
|
|
|
"tirith_path": os.getenv("TIRITH_BIN", cfg.get("tirith_path", defaults["tirith_path"])),
|
|
|
|
|
"tirith_timeout": _env_int("TIRITH_TIMEOUT", cfg.get("tirith_timeout", defaults["tirith_timeout"])),
|
|
|
|
|
"tirith_fail_open": _env_bool("TIRITH_FAIL_OPEN", cfg.get("tirith_fail_open", defaults["tirith_fail_open"])),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Auto-install
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
# Cached path after first resolution (avoids repeated shutil.which per command).
|
|
|
|
|
# _INSTALL_FAILED means "we tried and failed" — prevents retry on every command.
|
|
|
|
|
_resolved_path: str | None | bool = None
|
|
|
|
|
_INSTALL_FAILED = False # sentinel: distinct from "not yet tried"
|
|
|
|
|
_install_failure_reason: str = "" # reason tag when _resolved_path is _INSTALL_FAILED
|
|
|
|
|
|
2026-06-07 20:02:57 +00:00
|
|
|
# Circuit breaker: after _CRASH_LIMIT consecutive spawn/execution failures,
|
|
|
|
|
# disable tirith for the rest of the process to prevent agent hangs (#41400).
|
|
|
|
|
# Reset on successful execution (see _record_tirith_crash / check_command_security).
|
|
|
|
|
#
|
|
|
|
|
# Thread safety: _crash_count and _circuit_open are module-level globals
|
|
|
|
|
# mutated without a lock. check_command_security can be called from
|
|
|
|
|
# concurrent agent threads (gateway multi-session). The race is benign —
|
|
|
|
|
# at worst two threads both increment past _CRASH_LIMIT and both set
|
|
|
|
|
# _circuit_open = True, opening the breaker one call early. No data
|
|
|
|
|
# corruption or security bypass is possible. This intentionally matches
|
|
|
|
|
# the lock-free style of error counters in mcp_tool.py rather than the
|
|
|
|
|
# locked _warn_once pattern, because the worst case is harmless.
|
|
|
|
|
_CRASH_LIMIT = 3
|
|
|
|
|
_crash_count: int = 0
|
|
|
|
|
_circuit_open: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _record_tirith_crash() -> None:
|
|
|
|
|
"""Increment the crash counter and open the circuit breaker if needed."""
|
|
|
|
|
global _crash_count, _circuit_open
|
|
|
|
|
_crash_count += 1
|
|
|
|
|
if _crash_count >= _CRASH_LIMIT:
|
|
|
|
|
_circuit_open = True
|
|
|
|
|
logger.warning(
|
|
|
|
|
"tirith circuit breaker opened after %d consecutive failures; "
|
|
|
|
|
"disabling for the rest of the process",
|
|
|
|
|
_crash_count,
|
|
|
|
|
)
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
# Background install thread coordination
|
|
|
|
|
_install_lock = threading.Lock()
|
|
|
|
|
_install_thread: threading.Thread | None = None
|
|
|
|
|
|
fix(windows): stop spamming cwd-missing + tirith-spawn warnings on every terminal call
Two log-spam fixes surfaced by a Windows user (Git Bash + Python 3.11.9):
1. LocalEnvironment cwd warn spam
============================
Git Bash's `pwd -P` emits paths like `/c/Users/x`. The base-class
`_extract_cwd_from_output` was assigning this verbatim to `self.cwd`
without validation, then `_resolve_safe_cwd`'s `os.path.isdir(/c/...)`
returned False on Windows, triggering:
LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk;
falling back to '/' so terminal commands keep working.
...on every terminal call. The pre-existing Windows-path translation
inside `_run_bash` ran AFTER the safe-cwd check, so it could never
prevent the warning.
Fix:
- New `_msys_to_windows_path` helper (idempotent, no-op off Windows).
- `_resolve_safe_cwd` normalizes before `isdir`, so a valid MSYS path
is recognized as the real directory it points at.
- `LocalEnvironment._update_cwd` and a new override of
`_extract_cwd_from_output` translate + validate before mutating
`self.cwd`. Stale / non-existent marker paths roll back to the
previous cwd instead of clobbering it.
- The fallback warning still fires when the directory really is gone
(deletion-recovery scenario from #17558 still covered).
2. tirith spawn-failed warn spam
=============================
When tirith isn't installed (background install in flight, or marked
failed for the day) and the configured path stays as the bare string
`tirith`, every `subprocess.run([tirith_path, ...])` raises OSError
and logged:
tirith spawn failed: [WinError 2] The system cannot find the file specified
...on every command. fail_open=True means behaviour is correct, but
the log noise is severe.
Fix:
- `_warn_once(key, ...)` thread-safe dedupe helper.
- Three hot-path warnings (`tirith path resolved to None`,
`tirith spawn failed: ...`, `tirith timed out after Ns`) now log
once per (exception class, errno) / timeout-value / path-none key.
- Dedupe set is cleared on `_clear_install_failed` so a successful
install lets a subsequent failure surface again.
Tests
=====
- `tests/tools/test_local_env_windows_msys.py`: 12 tests covering the
MSYS→Windows translator, the resolve fast-path, update_cwd validation,
and extract_cwd_from_output rollback.
- `tests/tools/test_tirith_security.py`: 4 new dedupe tests (15 spawn
failures → 1 log line; distinct exc types → 2 lines; timeout dedupe;
path-None dedupe).
Targeted runs:
test_local_env_windows_msys.py 12 passed
test_local_env_cwd_recovery.py 7 passed (pre-existing, no regressions)
test_tirith_security.py 67 passed (63 pre-existing + 4 new)
test_base_environment + local_* 37 passed (no regressions)
test_local_env_blocklist + neighbours 114 passed
Reported via Hermes log capture: 19× cwd warnings + 15× tirith warnings
in a single short session.
2026-05-15 14:58:28 -07:00
|
|
|
# Warning de-duplication. The spawn/path warnings live in the hot path —
|
|
|
|
|
# without this dedupe set, a Windows install where ``tirith`` isn't on PATH
|
|
|
|
|
# (e.g. background install thread still running, or install marked failed)
|
|
|
|
|
# spams ``tirith spawn failed: [WinError 2]...`` once per terminal command,
|
|
|
|
|
# easily filling errors.log with hundreds of identical lines.
|
|
|
|
|
_warned_messages: set[str] = set()
|
|
|
|
|
_warned_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _warn_once(key: str, message: str, *args) -> None:
|
|
|
|
|
"""``logger.warning`` but at-most-once per ``key`` for the process
|
|
|
|
|
lifetime. Used to avoid drowning the log when a fail-open tirith
|
|
|
|
|
misconfiguration fires on every command."""
|
|
|
|
|
with _warned_lock:
|
|
|
|
|
if key in _warned_messages:
|
|
|
|
|
return
|
|
|
|
|
_warned_messages.add(key)
|
|
|
|
|
logger.warning(message, *args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _reset_spawn_warning_state() -> None:
|
|
|
|
|
"""Clear the warn-once dedupe set. Called when tirith is freshly
|
|
|
|
|
(re)installed so a subsequent failure surfaces again — e.g. user
|
|
|
|
|
deletes the binary mid-session.
|
|
|
|
|
"""
|
|
|
|
|
with _warned_lock:
|
|
|
|
|
_warned_messages.clear()
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
# Disk-persistent failure marker — avoids retry across process restarts
|
|
|
|
|
_MARKER_TTL = 86400 # 24 hours
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_hermes_home() -> str:
|
refactor: consolidate get_hermes_home() and parse_reasoning_effort() (#3062)
Centralizes two widely-duplicated patterns into hermes_constants.py:
1. get_hermes_home() — Path resolution for ~/.hermes (HERMES_HOME env var)
- Was copy-pasted inline across 30+ files as:
Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
- Now defined once in hermes_constants.py (zero-dependency module)
- hermes_cli/config.py re-exports it for backward compatibility
- Removed local wrapper functions in honcho_integration/client.py,
tools/website_policy.py, tools/tirith_security.py, hermes_cli/uninstall.py
2. parse_reasoning_effort() — Reasoning effort string validation
- Was copy-pasted in cli.py, gateway/run.py, cron/scheduler.py
- Same validation logic: check against (xhigh, high, medium, low, minimal, none)
- Now defined once in hermes_constants.py, called from all 3 locations
- Warning log for unknown values kept at call sites (context-specific)
31 files changed, net +31 lines (125 insertions, 94 deletions)
Full test suite: 6179 passed, 0 failed
2026-03-25 15:54:28 -07:00
|
|
|
"""Return the Hermes home directory, respecting HERMES_HOME env var."""
|
|
|
|
|
return str(get_hermes_home())
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def _failure_marker_path() -> str:
|
|
|
|
|
"""Return the path to the install-failure marker file."""
|
|
|
|
|
return os.path.join(_get_hermes_home(), ".tirith-install-failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_failure_reason() -> str | None:
|
|
|
|
|
"""Read the failure reason from the disk marker.
|
|
|
|
|
|
|
|
|
|
Returns the reason string, or None if the marker doesn't exist or is
|
|
|
|
|
older than _MARKER_TTL.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
p = _failure_marker_path()
|
|
|
|
|
mtime = os.path.getmtime(p)
|
|
|
|
|
if (time.time() - mtime) >= _MARKER_TTL:
|
|
|
|
|
return None
|
codebase: add encoding='utf-8' to all bare open() calls (PLW1514)
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
2026-05-07 19:24:45 -07:00
|
|
|
with open(p, "r", encoding="utf-8") as f:
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
return f.read().strip()
|
|
|
|
|
except OSError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_install_failed_on_disk() -> bool:
|
|
|
|
|
"""Check if a recent install failure was persisted to disk.
|
|
|
|
|
|
|
|
|
|
Returns False (allowing retry) when:
|
|
|
|
|
- No marker exists
|
|
|
|
|
- Marker is older than _MARKER_TTL (24h)
|
|
|
|
|
- Marker reason is 'cosign_missing' and cosign is now on PATH
|
|
|
|
|
"""
|
|
|
|
|
reason = _read_failure_reason()
|
|
|
|
|
if reason is None:
|
|
|
|
|
return False
|
|
|
|
|
if reason == "cosign_missing" and shutil.which("cosign"):
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mark_install_failed(reason: str = ""):
|
|
|
|
|
"""Persist install failure to disk to avoid retry on next process.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
reason: Short tag identifying the failure cause. Use "cosign_missing"
|
|
|
|
|
when cosign is not on PATH so the marker can be auto-cleared
|
|
|
|
|
once cosign becomes available.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
p = _failure_marker_path()
|
|
|
|
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
codebase: add encoding='utf-8' to all bare open() calls (PLW1514)
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
2026-05-07 19:24:45 -07:00
|
|
|
with open(p, "w", encoding="utf-8") as f:
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
f.write(reason)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_install_failed():
|
|
|
|
|
"""Remove the failure marker after successful install."""
|
fix(windows): stop spamming cwd-missing + tirith-spawn warnings on every terminal call
Two log-spam fixes surfaced by a Windows user (Git Bash + Python 3.11.9):
1. LocalEnvironment cwd warn spam
============================
Git Bash's `pwd -P` emits paths like `/c/Users/x`. The base-class
`_extract_cwd_from_output` was assigning this verbatim to `self.cwd`
without validation, then `_resolve_safe_cwd`'s `os.path.isdir(/c/...)`
returned False on Windows, triggering:
LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk;
falling back to '/' so terminal commands keep working.
...on every terminal call. The pre-existing Windows-path translation
inside `_run_bash` ran AFTER the safe-cwd check, so it could never
prevent the warning.
Fix:
- New `_msys_to_windows_path` helper (idempotent, no-op off Windows).
- `_resolve_safe_cwd` normalizes before `isdir`, so a valid MSYS path
is recognized as the real directory it points at.
- `LocalEnvironment._update_cwd` and a new override of
`_extract_cwd_from_output` translate + validate before mutating
`self.cwd`. Stale / non-existent marker paths roll back to the
previous cwd instead of clobbering it.
- The fallback warning still fires when the directory really is gone
(deletion-recovery scenario from #17558 still covered).
2. tirith spawn-failed warn spam
=============================
When tirith isn't installed (background install in flight, or marked
failed for the day) and the configured path stays as the bare string
`tirith`, every `subprocess.run([tirith_path, ...])` raises OSError
and logged:
tirith spawn failed: [WinError 2] The system cannot find the file specified
...on every command. fail_open=True means behaviour is correct, but
the log noise is severe.
Fix:
- `_warn_once(key, ...)` thread-safe dedupe helper.
- Three hot-path warnings (`tirith path resolved to None`,
`tirith spawn failed: ...`, `tirith timed out after Ns`) now log
once per (exception class, errno) / timeout-value / path-none key.
- Dedupe set is cleared on `_clear_install_failed` so a successful
install lets a subsequent failure surface again.
Tests
=====
- `tests/tools/test_local_env_windows_msys.py`: 12 tests covering the
MSYS→Windows translator, the resolve fast-path, update_cwd validation,
and extract_cwd_from_output rollback.
- `tests/tools/test_tirith_security.py`: 4 new dedupe tests (15 spawn
failures → 1 log line; distinct exc types → 2 lines; timeout dedupe;
path-None dedupe).
Targeted runs:
test_local_env_windows_msys.py 12 passed
test_local_env_cwd_recovery.py 7 passed (pre-existing, no regressions)
test_tirith_security.py 67 passed (63 pre-existing + 4 new)
test_base_environment + local_* 37 passed (no regressions)
test_local_env_blocklist + neighbours 114 passed
Reported via Hermes log capture: 19× cwd warnings + 15× tirith warnings
in a single short session.
2026-05-15 14:58:28 -07:00
|
|
|
# Reset the warn-once dedupe set so a subsequent failure (e.g. user
|
|
|
|
|
# deletes the binary) surfaces in the log again instead of being
|
|
|
|
|
# silently suppressed by a stale dedupe key from before the fix.
|
|
|
|
|
_reset_spawn_warning_state()
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
try:
|
|
|
|
|
os.unlink(_failure_marker_path())
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hermes_bin_dir() -> str:
|
|
|
|
|
"""Return $HERMES_HOME/bin, creating it if needed."""
|
|
|
|
|
d = os.path.join(_get_hermes_home(), "bin")
|
|
|
|
|
os.makedirs(d, exist_ok=True)
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detect_target() -> str | None:
|
fix(windows): silence tirith-unavailable banner + skip install/spawn attempts on unsupported platforms (#26718)
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
2026-05-15 20:29:28 -07:00
|
|
|
"""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.
|
|
|
|
|
"""
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
system = platform.system()
|
|
|
|
|
machine = platform.machine().lower()
|
|
|
|
|
|
2026-04-16 13:55:56 +08:00
|
|
|
# Android (Termux) is ABI-compatible with Linux — reuse Linux binaries.
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
if system == "Darwin":
|
|
|
|
|
plat = "apple-darwin"
|
2026-05-11 11:13:25 -07:00
|
|
|
elif system in {"Linux", "Android"}:
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
plat = "unknown-linux-gnu"
|
|
|
|
|
else:
|
|
|
|
|
return None
|
|
|
|
|
|
2026-05-11 11:13:25 -07:00
|
|
|
if machine in {"x86_64", "amd64"}:
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
arch = "x86_64"
|
2026-05-11 11:13:25 -07:00
|
|
|
elif machine in {"aarch64", "arm64"}:
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
arch = "aarch64"
|
|
|
|
|
else:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return f"{arch}-{plat}"
|
|
|
|
|
|
|
|
|
|
|
fix(windows): silence tirith-unavailable banner + skip install/spawn attempts on unsupported platforms (#26718)
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
2026-05-15 20:29:28 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
def _download_file(url: str, dest: str, timeout: int = 10):
|
|
|
|
|
"""Download a URL to a local file."""
|
|
|
|
|
req = urllib.request.Request(url)
|
|
|
|
|
token = os.getenv("GITHUB_TOKEN")
|
|
|
|
|
if token:
|
|
|
|
|
req.add_header("Authorization", f"token {token}")
|
|
|
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp, open(dest, "wb") as f:
|
|
|
|
|
shutil.copyfileobj(resp, f)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | None:
|
|
|
|
|
"""Verify cosign provenance signature on checksums.txt.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
True — cosign verified successfully
|
|
|
|
|
False — cosign found but verification failed
|
|
|
|
|
None — cosign not available (not on PATH, or execution failed)
|
|
|
|
|
|
|
|
|
|
The caller treats both False and None as "abort auto-install" — only
|
|
|
|
|
True allows the install to proceed.
|
|
|
|
|
"""
|
|
|
|
|
cosign = shutil.which("cosign")
|
|
|
|
|
if not cosign:
|
|
|
|
|
logger.info("cosign not found on PATH")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[cosign, "verify-blob",
|
|
|
|
|
"--certificate", cert_path,
|
|
|
|
|
"--signature", sig_path,
|
|
|
|
|
"--certificate-identity-regexp", _COSIGN_IDENTITY_REGEXP,
|
|
|
|
|
"--certificate-oidc-issuer", _COSIGN_ISSUER,
|
|
|
|
|
checksums_path],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=15,
|
fix: prevent TUI gateway stdin EOF crash across all TUI-context subprocess calls
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this stdin fd can trigger a race condition where the child's stdin
read returns EOF, causing the gateway to exit cleanly (exit code 0) mid-tool-
execution.
This is the same root cause as issue #14036 (byterover plugin) and PR #39257
(SSH environment backend). This commit applies the fix — stdin=subprocess.DEVNULL
— to all 85 subprocess.run() and subprocess.Popen() calls that execute inside
the TUI gateway child process.
Scope: TUI-context code only (agent/, tools/, plugins/, tui_gateway/server.py).
CLI code (cli.py, hermes_cli/), tests, scripts, and gateway process management
are excluded — they don't run inside the TUI child and inherit the terminal's
stdin, not the JSON-RPC pipe.
85 call sites across 28 files. All files pass syntax check.
2026-06-08 10:06:45 -07:00
|
|
|
stdin=subprocess.DEVNULL,
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
)
|
|
|
|
|
if result.returncode == 0:
|
|
|
|
|
logger.info("cosign provenance verification passed")
|
|
|
|
|
return True
|
|
|
|
|
else:
|
|
|
|
|
logger.warning("cosign verification failed (exit %d): %s",
|
|
|
|
|
result.returncode, result.stderr.strip())
|
|
|
|
|
return False
|
|
|
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
|
|
|
logger.warning("cosign execution failed: %s", exc)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str) -> bool:
|
|
|
|
|
"""Verify SHA-256 of the archive against checksums.txt."""
|
|
|
|
|
expected = None
|
codebase: add encoding='utf-8' to all bare open() calls (PLW1514)
Closes the last Python-on-Windows UTF-8 exposure by making every
text-mode open() call explicit about its encoding.
Before: on Windows, bare open(path, 'r') defaults to the system
locale encoding (cp1252 on US-locale installs). That means reading
any config/yaml/markdown/json file with non-ASCII content either
crashes with UnicodeDecodeError or silently mis-decodes bytes.
After: all 89 affected call sites in production code now pass
encoding='utf-8' explicitly. Works identically on every platform
and every locale, no surprise behavior.
Mechanical sweep via:
ruff check --preview --extend-select PLW1514 --unsafe-fixes --fix --exclude 'tests,venv,.venv,node_modules,website,optional-skills, skills,tinker-atropos,plugins' .
All 89 fixes have the same shape: open(x) or open(x, mode) became
open(x, encoding='utf-8') or open(x, mode, encoding='utf-8'). Nothing
else changed. Every modified file still parses and the Windows/sandbox
test suite is still green (85 passed, 14 skipped, 0 failed across
tests/tools/test_code_execution_windows_env.py +
tests/tools/test_code_execution_modes.py + tests/tools/test_env_passthrough.py +
tests/test_hermes_bootstrap.py).
Scope notes:
- tests/ excluded: test fixtures can use locale encoding intentionally
(exercising edge cases). If we want to tighten tests later that's
a separate PR.
- plugins/ excluded: plugin-specific conventions may differ; plugin
authors own their code.
- optional-skills/ and skills/ excluded: skill scripts are user-authored
and we don't want to mass-edit them.
- website/ and tinker-atropos/ excluded: vendored / generated content.
46 files touched, 89 +/- lines (symmetric replacement). No behavior
change on POSIX or on Windows when the file is ASCII; bug fix on
Windows when the file contains non-ASCII.
2026-05-07 19:24:45 -07:00
|
|
|
with open(checksums_path, encoding="utf-8") as f:
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
for line in f:
|
|
|
|
|
# Format: "<hash> <filename>"
|
|
|
|
|
parts = line.strip().split(" ", 1)
|
|
|
|
|
if len(parts) == 2 and parts[1] == archive_name:
|
|
|
|
|
expected = parts[0]
|
|
|
|
|
break
|
|
|
|
|
if not expected:
|
|
|
|
|
logger.warning("No checksum entry for %s", archive_name)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
sha = hashlib.sha256()
|
|
|
|
|
with open(archive_path, "rb") as f:
|
|
|
|
|
for chunk in iter(lambda: f.read(8192), b""):
|
|
|
|
|
sha.update(chunk)
|
|
|
|
|
actual = sha.hexdigest()
|
|
|
|
|
if actual != expected:
|
|
|
|
|
logger.warning("Checksum mismatch: expected %s, got %s", expected, actual)
|
|
|
|
|
return False
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 17:42:53 +03:00
|
|
|
def _extract_tirith_binary(tar: tarfile.TarFile, dest_dir: str, log) -> tuple[str | None, str]:
|
|
|
|
|
"""Extract the tirith binary from a release archive into dest_dir."""
|
|
|
|
|
for member in tar.getmembers():
|
|
|
|
|
if member.name == "tirith" or member.name.endswith("/tirith"):
|
|
|
|
|
if ".." in member.name:
|
|
|
|
|
continue
|
|
|
|
|
if not member.isfile():
|
|
|
|
|
log("tirith archive member is not a regular file: %s", member.name)
|
|
|
|
|
return None, "binary_not_regular_file"
|
|
|
|
|
src_file = tar.extractfile(member)
|
|
|
|
|
if src_file is None:
|
|
|
|
|
log("tirith binary could not be read from archive")
|
|
|
|
|
return None, "binary_extract_failed"
|
|
|
|
|
|
|
|
|
|
dest_path = os.path.join(dest_dir, "tirith")
|
|
|
|
|
try:
|
|
|
|
|
with open(dest_path, "wb") as out:
|
|
|
|
|
shutil.copyfileobj(src_file, out)
|
|
|
|
|
finally:
|
|
|
|
|
src_file.close()
|
|
|
|
|
return dest_path, ""
|
|
|
|
|
|
|
|
|
|
log("tirith binary not found in archive")
|
|
|
|
|
return None, "binary_not_in_archive"
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 18:07:03 -07:00
|
|
|
def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
"""Download and install tirith to $HERMES_HOME/bin/tirith.
|
|
|
|
|
|
|
|
|
|
Verifies provenance via cosign and SHA-256 checksum.
|
|
|
|
|
Returns (installed_path, failure_reason). On success failure_reason is "".
|
|
|
|
|
failure_reason is a short tag used by the disk marker to decide if the
|
|
|
|
|
failure is retryable (e.g. "cosign_missing" clears when cosign appears).
|
|
|
|
|
"""
|
2026-03-15 18:07:03 -07:00
|
|
|
log = logger.warning if log_failures else logger.debug
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
target = _detect_target()
|
|
|
|
|
if not target:
|
|
|
|
|
logger.info("tirith auto-install: unsupported platform %s/%s",
|
|
|
|
|
platform.system(), platform.machine())
|
|
|
|
|
return None, "unsupported_platform"
|
|
|
|
|
|
|
|
|
|
archive_name = f"tirith-{target}.tar.gz"
|
|
|
|
|
base_url = f"https://github.com/{_REPO}/releases/latest/download"
|
|
|
|
|
|
2026-06-25 02:03:57 +05:30
|
|
|
try:
|
|
|
|
|
tmpdir = tempfile.mkdtemp(prefix="tirith-install-")
|
|
|
|
|
except OSError as exc:
|
|
|
|
|
log("tirith install failed: cannot create temp dir: %s", exc)
|
|
|
|
|
return None, "no_space"
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
try:
|
|
|
|
|
archive_path = os.path.join(tmpdir, archive_name)
|
|
|
|
|
checksums_path = os.path.join(tmpdir, "checksums.txt")
|
|
|
|
|
sig_path = os.path.join(tmpdir, "checksums.txt.sig")
|
|
|
|
|
cert_path = os.path.join(tmpdir, "checksums.txt.pem")
|
|
|
|
|
|
|
|
|
|
logger.info("tirith not found — downloading latest release for %s...", target)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
_download_file(f"{base_url}/{archive_name}", archive_path)
|
|
|
|
|
_download_file(f"{base_url}/checksums.txt", checksums_path)
|
|
|
|
|
except Exception as exc:
|
2026-03-15 18:07:03 -07:00
|
|
|
log("tirith download failed: %s", exc)
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
return None, "download_failed"
|
|
|
|
|
|
fix: send_animation metadata, MarkdownV2 inline code splitting, tirith cosign-free install (#1626)
* fix: Anthropic OAuth compatibility — Claude Code identity fingerprinting
Anthropic routes OAuth/subscription requests based on Claude Code's
identity markers. Without them, requests get intermittent 500 errors
(~25% failure rate observed). This matches what pi-ai (clawdbot) and
OpenCode both implement for OAuth compatibility.
Changes (OAuth tokens only — API key users unaffected):
1. Headers: user-agent 'claude-cli/2.1.2 (external, cli)' + x-app 'cli'
2. System prompt: prepend 'You are Claude Code, Anthropic's official CLI'
3. System prompt sanitization: replace Hermes/Nous references
4. Tool names: prefix with 'mcp_' (Claude Code convention for non-native tools)
5. Tool name stripping: remove 'mcp_' prefix from response tool calls
Before: 9/12 OK, 1 hard fail, 4 needed retries (~25% error rate)
After: 16/16 OK, 0 failures, 0 retries (0% error rate)
* fix: three gateway issues from user error logs
1. send_animation missing metadata kwarg (base.py)
- Base class send_animation lacked the metadata parameter that the
call site in base.py line 917 passes. Telegram's override accepted
it, but any platform without an override (Discord, Slack, etc.)
hit TypeError. Added metadata to base class signature.
2. MarkdownV2 split-inside-inline-code (base.py truncate_message)
- truncate_message could split at a space inside an inline code span
(e.g. `function(arg1, arg2)`), leaving an unpaired backtick and
unescaped parentheses in the chunk. Telegram rejects with
'character ( is reserved'. Added inline code awareness to the
split-point finder — detects odd backtick counts and moves the
split before the code span.
3. tirith auto-install without cosign (tirith_security.py)
- Previously required cosign on PATH for auto-install, blocking
install entirely with a warning if missing. Now proceeds with
SHA-256 checksum verification only when cosign is unavailable.
Cosign is still used for full supply chain verification when
present. If cosign IS present but verification explicitly fails,
install is still aborted (tampered release).
2026-03-16 23:39:41 -07:00
|
|
|
# Cosign provenance verification — preferred but not mandatory.
|
|
|
|
|
# When cosign is available, we verify that the release was produced
|
|
|
|
|
# by the expected GitHub Actions workflow (full supply chain proof).
|
|
|
|
|
# Without cosign, SHA-256 checksum + HTTPS still provides integrity
|
|
|
|
|
# and transport-level authenticity.
|
|
|
|
|
cosign_verified = False
|
|
|
|
|
if shutil.which("cosign"):
|
|
|
|
|
try:
|
|
|
|
|
_download_file(f"{base_url}/checksums.txt.sig", sig_path)
|
|
|
|
|
_download_file(f"{base_url}/checksums.txt.pem", cert_path)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.info("cosign artifacts unavailable (%s), proceeding with SHA-256 only", exc)
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
else:
|
fix: send_animation metadata, MarkdownV2 inline code splitting, tirith cosign-free install (#1626)
* fix: Anthropic OAuth compatibility — Claude Code identity fingerprinting
Anthropic routes OAuth/subscription requests based on Claude Code's
identity markers. Without them, requests get intermittent 500 errors
(~25% failure rate observed). This matches what pi-ai (clawdbot) and
OpenCode both implement for OAuth compatibility.
Changes (OAuth tokens only — API key users unaffected):
1. Headers: user-agent 'claude-cli/2.1.2 (external, cli)' + x-app 'cli'
2. System prompt: prepend 'You are Claude Code, Anthropic's official CLI'
3. System prompt sanitization: replace Hermes/Nous references
4. Tool names: prefix with 'mcp_' (Claude Code convention for non-native tools)
5. Tool name stripping: remove 'mcp_' prefix from response tool calls
Before: 9/12 OK, 1 hard fail, 4 needed retries (~25% error rate)
After: 16/16 OK, 0 failures, 0 retries (0% error rate)
* fix: three gateway issues from user error logs
1. send_animation missing metadata kwarg (base.py)
- Base class send_animation lacked the metadata parameter that the
call site in base.py line 917 passes. Telegram's override accepted
it, but any platform without an override (Discord, Slack, etc.)
hit TypeError. Added metadata to base class signature.
2. MarkdownV2 split-inside-inline-code (base.py truncate_message)
- truncate_message could split at a space inside an inline code span
(e.g. `function(arg1, arg2)`), leaving an unpaired backtick and
unescaped parentheses in the chunk. Telegram rejects with
'character ( is reserved'. Added inline code awareness to the
split-point finder — detects odd backtick counts and moves the
split before the code span.
3. tirith auto-install without cosign (tirith_security.py)
- Previously required cosign on PATH for auto-install, blocking
install entirely with a warning if missing. Now proceeds with
SHA-256 checksum verification only when cosign is unavailable.
Cosign is still used for full supply chain verification when
present. If cosign IS present but verification explicitly fails,
install is still aborted (tampered release).
2026-03-16 23:39:41 -07:00
|
|
|
cosign_result = _verify_cosign(checksums_path, sig_path, cert_path)
|
|
|
|
|
if cosign_result is True:
|
|
|
|
|
cosign_verified = True
|
|
|
|
|
elif cosign_result is False:
|
|
|
|
|
# Verification explicitly rejected — abort, the release
|
|
|
|
|
# may have been tampered with.
|
|
|
|
|
log("tirith install aborted: cosign provenance verification failed")
|
|
|
|
|
return None, "cosign_verification_failed"
|
|
|
|
|
else:
|
|
|
|
|
# None = execution failure (timeout/OSError) — proceed
|
|
|
|
|
# with SHA-256 only since cosign itself is broken.
|
|
|
|
|
logger.info("cosign execution failed, proceeding with SHA-256 only")
|
|
|
|
|
else:
|
|
|
|
|
logger.info("cosign not on PATH — installing tirith with SHA-256 verification only "
|
|
|
|
|
"(install cosign for full supply chain verification)")
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
|
|
|
|
|
if not _verify_checksum(archive_path, checksums_path, archive_name):
|
|
|
|
|
return None, "checksum_failed"
|
|
|
|
|
|
|
|
|
|
with tarfile.open(archive_path, "r:gz") as tar:
|
2026-05-25 17:42:53 +03:00
|
|
|
src, reason = _extract_tirith_binary(tar, tmpdir, log)
|
|
|
|
|
if src is None:
|
|
|
|
|
return None, reason
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
|
|
|
|
|
dest = os.path.join(_hermes_bin_dir(), "tirith")
|
2026-04-15 14:50:07 -07:00
|
|
|
try:
|
|
|
|
|
shutil.move(src, dest)
|
|
|
|
|
except OSError:
|
|
|
|
|
# Cross-device move (common in Docker, NFS): shutil.move() falls
|
|
|
|
|
# back to copy2 + unlink, but copy2's metadata step can raise
|
|
|
|
|
# PermissionError. Use plain copy + manual chmod instead.
|
|
|
|
|
try:
|
|
|
|
|
shutil.copy(src, dest)
|
|
|
|
|
except OSError:
|
|
|
|
|
# Clean up partial dest to prevent a non-executable retry loop
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(dest)
|
|
|
|
|
except OSError:
|
|
|
|
|
pass
|
|
|
|
|
return None, "cross_device_copy_failed"
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
|
|
|
|
fix: send_animation metadata, MarkdownV2 inline code splitting, tirith cosign-free install (#1626)
* fix: Anthropic OAuth compatibility — Claude Code identity fingerprinting
Anthropic routes OAuth/subscription requests based on Claude Code's
identity markers. Without them, requests get intermittent 500 errors
(~25% failure rate observed). This matches what pi-ai (clawdbot) and
OpenCode both implement for OAuth compatibility.
Changes (OAuth tokens only — API key users unaffected):
1. Headers: user-agent 'claude-cli/2.1.2 (external, cli)' + x-app 'cli'
2. System prompt: prepend 'You are Claude Code, Anthropic's official CLI'
3. System prompt sanitization: replace Hermes/Nous references
4. Tool names: prefix with 'mcp_' (Claude Code convention for non-native tools)
5. Tool name stripping: remove 'mcp_' prefix from response tool calls
Before: 9/12 OK, 1 hard fail, 4 needed retries (~25% error rate)
After: 16/16 OK, 0 failures, 0 retries (0% error rate)
* fix: three gateway issues from user error logs
1. send_animation missing metadata kwarg (base.py)
- Base class send_animation lacked the metadata parameter that the
call site in base.py line 917 passes. Telegram's override accepted
it, but any platform without an override (Discord, Slack, etc.)
hit TypeError. Added metadata to base class signature.
2. MarkdownV2 split-inside-inline-code (base.py truncate_message)
- truncate_message could split at a space inside an inline code span
(e.g. `function(arg1, arg2)`), leaving an unpaired backtick and
unescaped parentheses in the chunk. Telegram rejects with
'character ( is reserved'. Added inline code awareness to the
split-point finder — detects odd backtick counts and moves the
split before the code span.
3. tirith auto-install without cosign (tirith_security.py)
- Previously required cosign on PATH for auto-install, blocking
install entirely with a warning if missing. Now proceeds with
SHA-256 checksum verification only when cosign is unavailable.
Cosign is still used for full supply chain verification when
present. If cosign IS present but verification explicitly fails,
install is still aborted (tampered release).
2026-03-16 23:39:41 -07:00
|
|
|
verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only"
|
|
|
|
|
logger.info("tirith installed to %s (%s)", dest, verification)
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
return dest, ""
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_explicit_path(configured_path: str) -> bool:
|
|
|
|
|
"""Return True if the user explicitly configured a non-default tirith path."""
|
|
|
|
|
return configured_path != "tirith"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_tirith_path(configured_path: str) -> str:
|
|
|
|
|
"""Resolve the tirith binary path, auto-installing if necessary.
|
|
|
|
|
|
|
|
|
|
If the user explicitly set a path (anything other than the bare "tirith"
|
|
|
|
|
default), that path is authoritative — we never fall through to
|
|
|
|
|
auto-download a different binary.
|
|
|
|
|
|
|
|
|
|
For the default "tirith":
|
|
|
|
|
1. PATH lookup via shutil.which
|
|
|
|
|
2. $HERMES_HOME/bin/tirith (previously auto-installed)
|
|
|
|
|
3. Auto-install from GitHub releases → $HERMES_HOME/bin/tirith
|
|
|
|
|
|
|
|
|
|
Failed installs are cached for the process lifetime (and persisted to
|
|
|
|
|
disk for 24h) to avoid repeated network attempts.
|
|
|
|
|
"""
|
|
|
|
|
global _resolved_path, _install_failure_reason
|
|
|
|
|
|
|
|
|
|
# Fast path: successfully resolved on a previous call.
|
|
|
|
|
if _resolved_path is not None and _resolved_path is not _INSTALL_FAILED:
|
|
|
|
|
return _resolved_path
|
|
|
|
|
|
|
|
|
|
expanded = os.path.expanduser(configured_path)
|
|
|
|
|
explicit = _is_explicit_path(configured_path)
|
|
|
|
|
install_failed = _resolved_path is _INSTALL_FAILED
|
|
|
|
|
|
fix(windows): silence tirith-unavailable banner + skip install/spawn attempts on unsupported platforms (#26718)
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
2026-05-15 20:29:28 -07:00
|
|
|
# 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
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
# Explicit path: check it and stop. Never auto-download a replacement.
|
|
|
|
|
if explicit:
|
|
|
|
|
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
|
|
|
|
_resolved_path = expanded
|
|
|
|
|
return expanded
|
|
|
|
|
# Also try shutil.which in case it's a bare name on PATH
|
|
|
|
|
found = shutil.which(expanded)
|
|
|
|
|
if found:
|
|
|
|
|
_resolved_path = found
|
|
|
|
|
return found
|
|
|
|
|
logger.warning("Configured tirith path %r not found; scanning disabled", configured_path)
|
|
|
|
|
_resolved_path = _INSTALL_FAILED
|
|
|
|
|
_install_failure_reason = "explicit_path_missing"
|
|
|
|
|
return expanded
|
|
|
|
|
|
|
|
|
|
# Default "tirith" — always re-run cheap local checks so a manual
|
|
|
|
|
# install is picked up even after a previous network failure (P2 fix:
|
|
|
|
|
# long-lived gateway/CLI recovers without restart).
|
|
|
|
|
found = shutil.which("tirith")
|
|
|
|
|
if found:
|
|
|
|
|
_resolved_path = found
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
|
|
|
|
|
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
|
|
|
|
|
_resolved_path = hermes_bin
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
return hermes_bin
|
|
|
|
|
|
|
|
|
|
# Local checks failed. If a previous install attempt already failed,
|
|
|
|
|
# skip the network retry — UNLESS the failure was "cosign_missing" and
|
|
|
|
|
# cosign is now available (retryable cause resolved in-process).
|
|
|
|
|
if install_failed:
|
|
|
|
|
if _install_failure_reason == "cosign_missing" and shutil.which("cosign"):
|
|
|
|
|
# Retryable cause resolved — clear sentinel and fall through to retry
|
|
|
|
|
_resolved_path = None
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
install_failed = False
|
|
|
|
|
else:
|
|
|
|
|
return expanded
|
|
|
|
|
|
|
|
|
|
# If a background install thread is running, don't start a parallel one —
|
|
|
|
|
# return the configured path; the OSError handler in check_command_security
|
|
|
|
|
# will apply fail_open until the thread finishes.
|
|
|
|
|
if _install_thread is not None and _install_thread.is_alive():
|
|
|
|
|
return expanded
|
|
|
|
|
|
|
|
|
|
# Check disk failure marker before attempting network download.
|
|
|
|
|
# Preserve the marker's real reason so in-memory retry logic can
|
|
|
|
|
# detect retryable causes (e.g. cosign_missing) without restart.
|
|
|
|
|
disk_reason = _read_failure_reason()
|
|
|
|
|
if disk_reason is not None and _is_install_failed_on_disk():
|
|
|
|
|
_resolved_path = _INSTALL_FAILED
|
|
|
|
|
_install_failure_reason = disk_reason
|
|
|
|
|
return expanded
|
|
|
|
|
|
|
|
|
|
installed, reason = _install_tirith()
|
|
|
|
|
if installed:
|
|
|
|
|
_resolved_path = installed
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
return installed
|
|
|
|
|
|
|
|
|
|
# Install failed — cache the miss and persist reason to disk
|
|
|
|
|
_resolved_path = _INSTALL_FAILED
|
|
|
|
|
_install_failure_reason = reason
|
|
|
|
|
_mark_install_failed(reason)
|
|
|
|
|
return expanded
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 18:07:03 -07:00
|
|
|
def _background_install(*, log_failures: bool = True):
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
"""Background thread target: download and install tirith."""
|
|
|
|
|
global _resolved_path, _install_failure_reason
|
|
|
|
|
with _install_lock:
|
|
|
|
|
# Double-check after acquiring lock (another thread may have resolved)
|
|
|
|
|
if _resolved_path is not None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Re-check local paths (may have been installed by another process)
|
|
|
|
|
found = shutil.which("tirith")
|
|
|
|
|
if found:
|
|
|
|
|
_resolved_path = found
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
|
|
|
|
|
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
|
|
|
|
|
_resolved_path = hermes_bin
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
return
|
|
|
|
|
|
2026-03-15 18:07:03 -07:00
|
|
|
installed, reason = _install_tirith(log_failures=log_failures)
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
if installed:
|
|
|
|
|
_resolved_path = installed
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
else:
|
|
|
|
|
_resolved_path = _INSTALL_FAILED
|
|
|
|
|
_install_failure_reason = reason
|
|
|
|
|
_mark_install_failed(reason)
|
|
|
|
|
|
|
|
|
|
|
2026-03-15 18:07:03 -07:00
|
|
|
def ensure_installed(*, log_failures: bool = True):
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
"""Ensure tirith is available, downloading in background if needed.
|
|
|
|
|
|
|
|
|
|
Quick PATH/local checks are synchronous; network download runs in a
|
|
|
|
|
daemon thread so startup never blocks. Safe to call multiple times.
|
|
|
|
|
Returns the resolved path immediately if available, or None.
|
|
|
|
|
"""
|
|
|
|
|
global _resolved_path, _install_thread, _install_failure_reason
|
|
|
|
|
|
|
|
|
|
cfg = _load_security_config()
|
|
|
|
|
if not cfg["tirith_enabled"]:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Already resolved from a previous call
|
|
|
|
|
if _resolved_path is not None and _resolved_path is not _INSTALL_FAILED:
|
|
|
|
|
path = _resolved_path
|
|
|
|
|
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
|
|
|
return path
|
|
|
|
|
return None
|
|
|
|
|
|
fix(windows): silence tirith-unavailable banner + skip install/spawn attempts on unsupported platforms (#26718)
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
2026-05-15 20:29:28 -07:00
|
|
|
# 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
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
configured_path = cfg["tirith_path"]
|
|
|
|
|
explicit = _is_explicit_path(configured_path)
|
|
|
|
|
expanded = os.path.expanduser(configured_path)
|
|
|
|
|
|
|
|
|
|
# Explicit path: synchronous check only, no download
|
|
|
|
|
if explicit:
|
|
|
|
|
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
|
|
|
|
|
_resolved_path = expanded
|
|
|
|
|
return expanded
|
|
|
|
|
found = shutil.which(expanded)
|
|
|
|
|
if found:
|
|
|
|
|
_resolved_path = found
|
|
|
|
|
return found
|
|
|
|
|
_resolved_path = _INSTALL_FAILED
|
|
|
|
|
_install_failure_reason = "explicit_path_missing"
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Default "tirith" — quick local checks first (no network)
|
|
|
|
|
found = shutil.which("tirith")
|
|
|
|
|
if found:
|
|
|
|
|
_resolved_path = found
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
|
|
|
|
|
if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
|
|
|
|
|
_resolved_path = hermes_bin
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
return hermes_bin
|
|
|
|
|
|
|
|
|
|
# If previously failed in-memory, check if the cause is now resolved
|
|
|
|
|
if _resolved_path is _INSTALL_FAILED:
|
|
|
|
|
if _install_failure_reason == "cosign_missing" and shutil.which("cosign"):
|
|
|
|
|
_resolved_path = None
|
|
|
|
|
_install_failure_reason = ""
|
|
|
|
|
_clear_install_failed()
|
|
|
|
|
else:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Check disk failure marker (skip network attempt for 24h, unless
|
|
|
|
|
# the cosign_missing reason was resolved — handled by _is_install_failed_on_disk).
|
|
|
|
|
# Preserve the marker's real reason for in-memory retry logic.
|
|
|
|
|
disk_reason = _read_failure_reason()
|
|
|
|
|
if disk_reason is not None and _is_install_failed_on_disk():
|
|
|
|
|
_resolved_path = _INSTALL_FAILED
|
|
|
|
|
_install_failure_reason = disk_reason
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Need to download — launch background thread so startup doesn't block
|
|
|
|
|
if _install_thread is None or not _install_thread.is_alive():
|
|
|
|
|
_install_thread = threading.Thread(
|
2026-03-15 18:07:03 -07:00
|
|
|
target=_background_install,
|
|
|
|
|
kwargs={"log_failures": log_failures},
|
|
|
|
|
daemon=True,
|
|
|
|
|
)
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
_install_thread.start()
|
|
|
|
|
|
|
|
|
|
return None # Not available yet; commands will fail-open until ready
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Main API
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
_MAX_FINDINGS = 50
|
|
|
|
|
_MAX_SUMMARY_LEN = 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_command_security(command: str) -> dict:
|
|
|
|
|
"""Run tirith security scan on a command.
|
|
|
|
|
|
|
|
|
|
Exit code determines action (0=allow, 1=block, 2=warn). JSON enriches
|
|
|
|
|
findings/summary. Spawn failures and timeouts respect fail_open config.
|
|
|
|
|
Programming errors propagate.
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
{"action": "allow"|"warn"|"block", "findings": [...], "summary": str}
|
|
|
|
|
"""
|
2026-06-07 20:02:57 +00:00
|
|
|
global _crash_count, _circuit_open
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
cfg = _load_security_config()
|
|
|
|
|
|
|
|
|
|
if not cfg["tirith_enabled"]:
|
|
|
|
|
return {"action": "allow", "findings": [], "summary": ""}
|
|
|
|
|
|
2026-06-07 20:02:57 +00:00
|
|
|
# Circuit breaker: if tirith has crashed _CRASH_LIMIT times in a row,
|
|
|
|
|
# stop trying for the rest of the process. Without this, a corrupted
|
|
|
|
|
# or missing binary causes every tool call to hit the same spawn failure
|
|
|
|
|
# → fail-open → agent retry loop, hanging the user for 20+ minutes
|
|
|
|
|
# (issue #41400).
|
|
|
|
|
if _circuit_open:
|
|
|
|
|
return {"action": "allow", "findings": [], "summary": "tirith disabled (circuit breaker)"}
|
|
|
|
|
|
fix(windows): silence tirith-unavailable banner + skip install/spawn attempts on unsupported platforms (#26718)
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
2026-05-15 20:29:28 -07:00
|
|
|
# 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": ""}
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
|
|
|
|
|
timeout = cfg["tirith_timeout"]
|
|
|
|
|
fail_open = cfg["tirith_fail_open"]
|
|
|
|
|
|
2026-04-07 17:07:37 +10:00
|
|
|
if tirith_path is None:
|
fix(windows): stop spamming cwd-missing + tirith-spawn warnings on every terminal call
Two log-spam fixes surfaced by a Windows user (Git Bash + Python 3.11.9):
1. LocalEnvironment cwd warn spam
============================
Git Bash's `pwd -P` emits paths like `/c/Users/x`. The base-class
`_extract_cwd_from_output` was assigning this verbatim to `self.cwd`
without validation, then `_resolve_safe_cwd`'s `os.path.isdir(/c/...)`
returned False on Windows, triggering:
LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk;
falling back to '/' so terminal commands keep working.
...on every terminal call. The pre-existing Windows-path translation
inside `_run_bash` ran AFTER the safe-cwd check, so it could never
prevent the warning.
Fix:
- New `_msys_to_windows_path` helper (idempotent, no-op off Windows).
- `_resolve_safe_cwd` normalizes before `isdir`, so a valid MSYS path
is recognized as the real directory it points at.
- `LocalEnvironment._update_cwd` and a new override of
`_extract_cwd_from_output` translate + validate before mutating
`self.cwd`. Stale / non-existent marker paths roll back to the
previous cwd instead of clobbering it.
- The fallback warning still fires when the directory really is gone
(deletion-recovery scenario from #17558 still covered).
2. tirith spawn-failed warn spam
=============================
When tirith isn't installed (background install in flight, or marked
failed for the day) and the configured path stays as the bare string
`tirith`, every `subprocess.run([tirith_path, ...])` raises OSError
and logged:
tirith spawn failed: [WinError 2] The system cannot find the file specified
...on every command. fail_open=True means behaviour is correct, but
the log noise is severe.
Fix:
- `_warn_once(key, ...)` thread-safe dedupe helper.
- Three hot-path warnings (`tirith path resolved to None`,
`tirith spawn failed: ...`, `tirith timed out after Ns`) now log
once per (exception class, errno) / timeout-value / path-none key.
- Dedupe set is cleared on `_clear_install_failed` so a successful
install lets a subsequent failure surface again.
Tests
=====
- `tests/tools/test_local_env_windows_msys.py`: 12 tests covering the
MSYS→Windows translator, the resolve fast-path, update_cwd validation,
and extract_cwd_from_output rollback.
- `tests/tools/test_tirith_security.py`: 4 new dedupe tests (15 spawn
failures → 1 log line; distinct exc types → 2 lines; timeout dedupe;
path-None dedupe).
Targeted runs:
test_local_env_windows_msys.py 12 passed
test_local_env_cwd_recovery.py 7 passed (pre-existing, no regressions)
test_tirith_security.py 67 passed (63 pre-existing + 4 new)
test_base_environment + local_* 37 passed (no regressions)
test_local_env_blocklist + neighbours 114 passed
Reported via Hermes log capture: 19× cwd warnings + 15× tirith warnings
in a single short session.
2026-05-15 14:58:28 -07:00
|
|
|
_warn_once(
|
|
|
|
|
"tirith_path_none",
|
|
|
|
|
"tirith path resolved to None; scanning disabled",
|
|
|
|
|
)
|
2026-04-07 17:07:37 +10:00
|
|
|
if fail_open:
|
|
|
|
|
return {"action": "allow", "findings": [], "summary": "tirith path unavailable"}
|
|
|
|
|
return {"action": "block", "findings": [], "summary": "tirith path unavailable (fail-closed)"}
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[tirith_path, "check", "--json", "--non-interactive",
|
|
|
|
|
"--shell", "posix", "--", command],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=timeout,
|
fix: prevent TUI gateway stdin EOF crash across all TUI-context subprocess calls
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this stdin fd can trigger a race condition where the child's stdin
read returns EOF, causing the gateway to exit cleanly (exit code 0) mid-tool-
execution.
This is the same root cause as issue #14036 (byterover plugin) and PR #39257
(SSH environment backend). This commit applies the fix — stdin=subprocess.DEVNULL
— to all 85 subprocess.run() and subprocess.Popen() calls that execute inside
the TUI gateway child process.
Scope: TUI-context code only (agent/, tools/, plugins/, tui_gateway/server.py).
CLI code (cli.py, hermes_cli/), tests, scripts, and gateway process management
are excluded — they don't run inside the TUI child and inherit the terminal's
stdin, not the JSON-RPC pipe.
85 call sites across 28 files. All files pass syntax check.
2026-06-08 10:06:45 -07:00
|
|
|
stdin=subprocess.DEVNULL,
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
)
|
|
|
|
|
except OSError as exc:
|
fix(windows): stop spamming cwd-missing + tirith-spawn warnings on every terminal call
Two log-spam fixes surfaced by a Windows user (Git Bash + Python 3.11.9):
1. LocalEnvironment cwd warn spam
============================
Git Bash's `pwd -P` emits paths like `/c/Users/x`. The base-class
`_extract_cwd_from_output` was assigning this verbatim to `self.cwd`
without validation, then `_resolve_safe_cwd`'s `os.path.isdir(/c/...)`
returned False on Windows, triggering:
LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk;
falling back to '/' so terminal commands keep working.
...on every terminal call. The pre-existing Windows-path translation
inside `_run_bash` ran AFTER the safe-cwd check, so it could never
prevent the warning.
Fix:
- New `_msys_to_windows_path` helper (idempotent, no-op off Windows).
- `_resolve_safe_cwd` normalizes before `isdir`, so a valid MSYS path
is recognized as the real directory it points at.
- `LocalEnvironment._update_cwd` and a new override of
`_extract_cwd_from_output` translate + validate before mutating
`self.cwd`. Stale / non-existent marker paths roll back to the
previous cwd instead of clobbering it.
- The fallback warning still fires when the directory really is gone
(deletion-recovery scenario from #17558 still covered).
2. tirith spawn-failed warn spam
=============================
When tirith isn't installed (background install in flight, or marked
failed for the day) and the configured path stays as the bare string
`tirith`, every `subprocess.run([tirith_path, ...])` raises OSError
and logged:
tirith spawn failed: [WinError 2] The system cannot find the file specified
...on every command. fail_open=True means behaviour is correct, but
the log noise is severe.
Fix:
- `_warn_once(key, ...)` thread-safe dedupe helper.
- Three hot-path warnings (`tirith path resolved to None`,
`tirith spawn failed: ...`, `tirith timed out after Ns`) now log
once per (exception class, errno) / timeout-value / path-none key.
- Dedupe set is cleared on `_clear_install_failed` so a successful
install lets a subsequent failure surface again.
Tests
=====
- `tests/tools/test_local_env_windows_msys.py`: 12 tests covering the
MSYS→Windows translator, the resolve fast-path, update_cwd validation,
and extract_cwd_from_output rollback.
- `tests/tools/test_tirith_security.py`: 4 new dedupe tests (15 spawn
failures → 1 log line; distinct exc types → 2 lines; timeout dedupe;
path-None dedupe).
Targeted runs:
test_local_env_windows_msys.py 12 passed
test_local_env_cwd_recovery.py 7 passed (pre-existing, no regressions)
test_tirith_security.py 67 passed (63 pre-existing + 4 new)
test_base_environment + local_* 37 passed (no regressions)
test_local_env_blocklist + neighbours 114 passed
Reported via Hermes log capture: 19× cwd warnings + 15× tirith warnings
in a single short session.
2026-05-15 14:58:28 -07:00
|
|
|
# Covers FileNotFoundError, PermissionError, exec format error.
|
|
|
|
|
# Dedupe by ``(errno, exc class)`` so a transient failure mode
|
|
|
|
|
# surfaces once but doesn't drown the log on every command —
|
|
|
|
|
# commonly seen on Windows when the configured path "tirith"
|
|
|
|
|
# isn't on PATH yet (background install still running, or
|
|
|
|
|
# install marked failed for the day).
|
|
|
|
|
spawn_key = f"tirith_spawn_failed:{type(exc).__name__}:{getattr(exc, 'errno', '')}"
|
|
|
|
|
_warn_once(spawn_key, "tirith spawn failed: %s", exc)
|
2026-06-07 20:02:57 +00:00
|
|
|
_record_tirith_crash()
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
if fail_open:
|
|
|
|
|
return {"action": "allow", "findings": [], "summary": f"tirith unavailable: {exc}"}
|
|
|
|
|
return {"action": "block", "findings": [], "summary": f"tirith spawn failed (fail-closed): {exc}"}
|
|
|
|
|
except subprocess.TimeoutExpired:
|
fix(windows): stop spamming cwd-missing + tirith-spawn warnings on every terminal call
Two log-spam fixes surfaced by a Windows user (Git Bash + Python 3.11.9):
1. LocalEnvironment cwd warn spam
============================
Git Bash's `pwd -P` emits paths like `/c/Users/x`. The base-class
`_extract_cwd_from_output` was assigning this verbatim to `self.cwd`
without validation, then `_resolve_safe_cwd`'s `os.path.isdir(/c/...)`
returned False on Windows, triggering:
LocalEnvironment cwd '/c/Users/NVIDIA' is missing on disk;
falling back to '/' so terminal commands keep working.
...on every terminal call. The pre-existing Windows-path translation
inside `_run_bash` ran AFTER the safe-cwd check, so it could never
prevent the warning.
Fix:
- New `_msys_to_windows_path` helper (idempotent, no-op off Windows).
- `_resolve_safe_cwd` normalizes before `isdir`, so a valid MSYS path
is recognized as the real directory it points at.
- `LocalEnvironment._update_cwd` and a new override of
`_extract_cwd_from_output` translate + validate before mutating
`self.cwd`. Stale / non-existent marker paths roll back to the
previous cwd instead of clobbering it.
- The fallback warning still fires when the directory really is gone
(deletion-recovery scenario from #17558 still covered).
2. tirith spawn-failed warn spam
=============================
When tirith isn't installed (background install in flight, or marked
failed for the day) and the configured path stays as the bare string
`tirith`, every `subprocess.run([tirith_path, ...])` raises OSError
and logged:
tirith spawn failed: [WinError 2] The system cannot find the file specified
...on every command. fail_open=True means behaviour is correct, but
the log noise is severe.
Fix:
- `_warn_once(key, ...)` thread-safe dedupe helper.
- Three hot-path warnings (`tirith path resolved to None`,
`tirith spawn failed: ...`, `tirith timed out after Ns`) now log
once per (exception class, errno) / timeout-value / path-none key.
- Dedupe set is cleared on `_clear_install_failed` so a successful
install lets a subsequent failure surface again.
Tests
=====
- `tests/tools/test_local_env_windows_msys.py`: 12 tests covering the
MSYS→Windows translator, the resolve fast-path, update_cwd validation,
and extract_cwd_from_output rollback.
- `tests/tools/test_tirith_security.py`: 4 new dedupe tests (15 spawn
failures → 1 log line; distinct exc types → 2 lines; timeout dedupe;
path-None dedupe).
Targeted runs:
test_local_env_windows_msys.py 12 passed
test_local_env_cwd_recovery.py 7 passed (pre-existing, no regressions)
test_tirith_security.py 67 passed (63 pre-existing + 4 new)
test_base_environment + local_* 37 passed (no regressions)
test_local_env_blocklist + neighbours 114 passed
Reported via Hermes log capture: 19× cwd warnings + 15× tirith warnings
in a single short session.
2026-05-15 14:58:28 -07:00
|
|
|
_warn_once(
|
|
|
|
|
f"tirith_timeout:{timeout}",
|
|
|
|
|
"tirith timed out after %ds",
|
|
|
|
|
timeout,
|
|
|
|
|
)
|
2026-06-07 20:02:57 +00:00
|
|
|
_record_tirith_crash()
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
if fail_open:
|
|
|
|
|
return {"action": "allow", "findings": [], "summary": f"tirith timed out ({timeout}s)"}
|
chore: fix 154 f-strings, simplify getattr/URL patterns, remove dead code (#3119)
Three categories of cleanup, all zero-behavioral-change:
1. F-strings without placeholders (154 fixes across 29 files)
- Converted f'...' to '...' where no {expression} was present
- Heaviest files: run_agent.py (24), cli.py (20), honcho_integration/cli.py (34)
2. Simplify defensive patterns in run_agent.py
- Added explicit self._is_anthropic_oauth = False in __init__ (before
the api_mode branch that conditionally sets it)
- Replaced 7x getattr(self, '_is_anthropic_oauth', False) with direct
self._is_anthropic_oauth (attribute always initialized now)
- Added _is_openrouter_url() and _is_anthropic_url() helper methods
- Replaced 3 inline 'openrouter' in self._base_url_lower checks
3. Remove dead code in small files
- hermes_cli/claw.py: removed unused 'total' computation
- tools/fuzzy_match.py: removed unused strip_indent() function and
pattern_stripped variable
Full test suite: 6184 passed, 0 failures
E2E PTY: banner clean, tool calls work, zero garbled ANSI
2026-03-25 19:47:58 -07:00
|
|
|
return {"action": "block", "findings": [], "summary": "tirith timed out (fail-closed)"}
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
|
|
|
|
|
# Map exit code to action
|
|
|
|
|
exit_code = result.returncode
|
|
|
|
|
if exit_code == 0:
|
|
|
|
|
action = "allow"
|
2026-06-07 20:02:57 +00:00
|
|
|
# Successful execution — reset circuit breaker
|
|
|
|
|
_crash_count = 0
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
elif exit_code == 1:
|
|
|
|
|
action = "block"
|
|
|
|
|
elif exit_code == 2:
|
|
|
|
|
action = "warn"
|
|
|
|
|
else:
|
2026-06-07 20:02:57 +00:00
|
|
|
# Unknown exit code (includes signal-killed processes like -11/SIGSEGV)
|
|
|
|
|
# — respect fail_open
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
logger.warning("tirith returned unexpected exit code %d", exit_code)
|
2026-06-07 20:02:57 +00:00
|
|
|
_record_tirith_crash()
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
if fail_open:
|
|
|
|
|
return {"action": "allow", "findings": [], "summary": f"tirith exit code {exit_code} (fail-open)"}
|
|
|
|
|
return {"action": "block", "findings": [], "summary": f"tirith exit code {exit_code} (fail-closed)"}
|
|
|
|
|
|
|
|
|
|
# Parse JSON for enrichment (never overrides the exit code verdict)
|
|
|
|
|
findings = []
|
|
|
|
|
summary = ""
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(result.stdout) if result.stdout.strip() else {}
|
|
|
|
|
raw_findings = data.get("findings", [])
|
|
|
|
|
findings = raw_findings[:_MAX_FINDINGS]
|
|
|
|
|
summary = (data.get("summary", "") or "")[:_MAX_SUMMARY_LEN]
|
|
|
|
|
except (json.JSONDecodeError, AttributeError):
|
|
|
|
|
# JSON parse failure degrades findings/summary, not the verdict
|
|
|
|
|
logger.debug("tirith JSON parse failed, using exit code only")
|
|
|
|
|
if action == "block":
|
|
|
|
|
summary = "security issue detected (details unavailable)"
|
|
|
|
|
elif action == "warn":
|
|
|
|
|
summary = "security warning detected (details unavailable)"
|
|
|
|
|
|
2026-05-12 18:53:44 -03:00
|
|
|
# Suppress warn verdicts that consist solely of a lookalike_tld finding for
|
|
|
|
|
# the .app TLD. .app is a legitimate gTLD used by many production services
|
|
|
|
|
# and the "can be confused with file extensions" heuristic generates false
|
|
|
|
|
# positives for normal API calls. Any other finding (including other
|
|
|
|
|
# lookalike_tld entries for non-.app TLDs) preserves the warn action.
|
|
|
|
|
if action == "warn" and findings:
|
|
|
|
|
non_suppressible = [f for f in findings if not _is_app_tld_finding(f)]
|
|
|
|
|
if not non_suppressible:
|
|
|
|
|
action = "allow"
|
|
|
|
|
findings = []
|
|
|
|
|
summary = ""
|
|
|
|
|
|
feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.
Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.
New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
mandatory cosign provenance verification, non-blocking background
download, disk-persistent failure markers with retryable-cause
tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
mapping, fail_open, cosign verification, background install,
HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
combined guard orchestration
Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-11 14:20:32 +05:30
|
|
|
return {"action": action, "findings": findings, "summary": summary}
|
2026-05-12 18:53:44 -03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _is_app_tld_finding(finding: dict) -> bool:
|
|
|
|
|
"""Return True if this finding is a lookalike_tld warning for the .app TLD only.
|
|
|
|
|
|
|
|
|
|
Checks the rule_id and inspects common value/detail field names that
|
|
|
|
|
Tirith may use to carry the TLD string.
|
|
|
|
|
"""
|
|
|
|
|
if not isinstance(finding, dict):
|
|
|
|
|
return False
|
|
|
|
|
if finding.get("rule_id") != "lookalike_tld":
|
|
|
|
|
return False
|
|
|
|
|
for field in ("value", "tld", "detail", "description", "message"):
|
|
|
|
|
val = finding.get(field)
|
|
|
|
|
if val is not None and ".app" in str(val).lower():
|
|
|
|
|
return True
|
|
|
|
|
return False
|