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
This commit is contained in:
sheeki003
2026-03-11 14:20:32 +05:30
committed by teknium1
parent 2fe853bcc9
commit 375ce8a881
9 changed files with 2153 additions and 1902 deletions

View File

@@ -167,18 +167,24 @@ def save_permanent_allowlist(patterns: set):
def prompt_dangerous_approval(command: str, description: str,
timeout_seconds: int = 60,
allow_permanent: bool = True,
approval_callback=None) -> str:
"""Prompt the user to approve a dangerous command (CLI only).
Args:
allow_permanent: When False, hide the [a]lways option (used when
tirith warnings are present, since broad permanent allowlisting
is inappropriate for content-level security findings).
approval_callback: Optional callback registered by the CLI for
prompt_toolkit integration. Signature: (command, description) -> str.
prompt_toolkit integration. Signature:
(command, description, *, allow_permanent=True) -> str.
Returns: 'once', 'session', 'always', or 'deny'
"""
if approval_callback is not None:
try:
return approval_callback(command, description)
return approval_callback(command, description,
allow_permanent=allow_permanent)
except Exception:
return "deny"
@@ -191,7 +197,10 @@ def prompt_dangerous_approval(command: str, description: str,
print(f" {command[:80]}{'...' if is_truncated else ''}")
print()
view_hint = " | [v]iew full" if is_truncated else ""
print(f" [o]nce | [s]ession | [a]lways | [d]eny{view_hint}")
if allow_permanent:
print(f" [o]nce | [s]ession | [a]lways | [d]eny{view_hint}")
else:
print(f" [o]nce | [s]ession | [d]eny{view_hint}")
print()
sys.stdout.flush()
@@ -199,7 +208,8 @@ def prompt_dangerous_approval(command: str, description: str,
def get_input():
try:
result["choice"] = input(" Choice [o/s/a/D]: ").strip().lower()
prompt = " Choice [o/s/a/D]: " if allow_permanent else " Choice [o/s/D]: "
result["choice"] = input(prompt).strip().lower()
except (EOFError, OSError):
result["choice"] = ""
@@ -216,7 +226,7 @@ def prompt_dangerous_approval(command: str, description: str,
print()
print(" Full command:")
print(f" {command}")
is_truncated = False # show full on next loop iteration too
is_truncated = False
continue
if choice in ('o', 'once'):
print(" ✓ Allowed once")
@@ -225,6 +235,9 @@ def prompt_dangerous_approval(command: str, description: str,
print(" ✓ Allowed for this session")
return "session"
elif choice in ('a', 'always'):
if not allow_permanent:
print(" ✓ Allowed for this session")
return "session"
print(" ✓ Added to permanent allowlist")
return "always"
else:
@@ -311,3 +324,126 @@ def check_dangerous_command(command: str, env_type: str,
save_permanent_allowlist(_permanent_approved)
return {"approved": True, "message": None}
# =========================================================================
# Combined pre-exec guard (tirith + dangerous command detection)
# =========================================================================
def check_all_command_guards(command: str, env_type: str,
approval_callback=None) -> dict:
"""Run all pre-exec security checks and return a single approval decision.
Gathers findings from tirith and dangerous-command detection, then
presents them as a single combined approval request. This prevents
a gateway force=True replay from bypassing one check when only the
other was shown to the user.
"""
# Skip containers for both checks
if env_type in ("docker", "singularity", "modal", "daytona"):
return {"approved": True, "message": None}
# --- Phase 1: Gather findings from both checks ---
# Tirith check — wrapper guarantees no raise for expected failures.
# Only catch ImportError (module not installed).
tirith_result = {"action": "allow", "findings": [], "summary": ""}
try:
from tools.tirith_security import check_command_security
tirith_result = check_command_security(command)
except ImportError:
pass # tirith module not installed — allow
# Dangerous command check (detection only, no approval)
is_dangerous, pattern_key, description = detect_dangerous_command(command)
# --- Phase 2: Decide ---
# If tirith blocks, block immediately (no approval possible)
if tirith_result["action"] == "block":
summary = tirith_result.get("summary") or "security issue detected"
return {
"approved": False,
"message": f"BLOCKED: Command blocked by security scan ({summary}). Do NOT retry.",
}
# Collect warnings that need approval
warnings = [] # list of (pattern_key, description, is_tirith)
session_key = os.getenv("HERMES_SESSION_KEY", "default")
if tirith_result["action"] == "warn":
findings = tirith_result.get("findings") or []
rule_id = findings[0].get("rule_id", "unknown") if findings else "unknown"
tirith_key = f"tirith:{rule_id}"
tirith_desc = f"Security scan: {tirith_result.get('summary') or 'security warning detected'}"
if not is_approved(session_key, tirith_key):
warnings.append((tirith_key, tirith_desc, True))
if is_dangerous:
if not is_approved(session_key, pattern_key):
warnings.append((pattern_key, description, False))
# Nothing to warn about
if not warnings:
return {"approved": True, "message": None}
# --- Phase 3: Approval ---
is_cli = os.getenv("HERMES_INTERACTIVE")
is_gateway = os.getenv("HERMES_GATEWAY_SESSION")
# Non-interactive: auto-allow (matches existing behavior)
if not is_cli and not is_gateway:
return {"approved": True, "message": None}
# Combine descriptions for a single approval prompt
combined_desc = "; ".join(desc for _, desc, _ in warnings)
primary_key = warnings[0][0]
all_keys = [key for key, _, _ in warnings]
has_tirith = any(is_t for _, _, is_t in warnings)
# Gateway/async: single approval_required with combined description
# Store all pattern keys so gateway replay approves all of them
if is_gateway or os.getenv("HERMES_EXEC_ASK"):
submit_pending(session_key, {
"command": command,
"pattern_key": primary_key, # backward compat
"pattern_keys": all_keys, # all keys for replay
"description": combined_desc,
})
return {
"approved": False,
"pattern_key": primary_key,
"status": "approval_required",
"command": command,
"description": combined_desc,
"message": f"⚠️ {combined_desc}. Asking the user for approval...",
}
# CLI interactive: single combined prompt
# Hide [a]lways when any tirith warning is present
choice = prompt_dangerous_approval(command, combined_desc,
allow_permanent=not has_tirith,
approval_callback=approval_callback)
if choice == "deny":
return {
"approved": False,
"message": "BLOCKED: User denied. Do NOT retry.",
"pattern_key": primary_key,
"description": combined_desc,
}
# Persist approval for each warning individually
for key, _, is_tirith in warnings:
if choice == "session" or (choice == "always" and is_tirith):
# tirith: session only (no permanent broad allowlisting)
approve_session(session_key, key)
elif choice == "always":
# dangerous patterns: permanent allowed
approve_session(session_key, key)
approve_permanent(key)
save_permanent_allowlist(_permanent_approved)
return {"approved": True, "message": None}