mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user