"""``hermes approvals suggest`` — mine approval history into allowlist proposals. Hermes has no dedicated approval-decision ledger: ``always`` answers land in ``command_allowlist`` (config.yaml) via :func:`tools.approval.save_permanent_allowlist`, while ``once``/``session`` approvals are in-memory only. What *does* persist is the session DB (``~/.hermes/state.db``): every assistant ``terminal`` tool call is stored with its arguments, and the paired ``role='tool'`` result records whether the command was blocked/denied ("BLOCKED: User denied …", "Asking the user for approval") or actually executed. So this module mines *implied approvals*: a command that matches a dangerous-command class (the same :func:`tools.approval.detect_dangerous_command` classifier that triggers the prompt) AND whose tool result is not a block/denial marker must have been approved by the user (once, session, always, smart-approve, or yolo) before it ran. Frequently re-approved patterns are exactly the prompts worth turning into one-time allowlist policy — the port of Claude Code's ``/fewer-permission-prompts``. Safety posture: * **Never auto-applies.** The default run is a dry proposal; only an explicit ``--apply N[,M...]`` merges the chosen patterns into ``command_allowlist`` via the existing :func:`tools.approval.save_permanent_allowlist` path. * **Hardline commands are never proposed** — anything matched by :func:`tools.approval.detect_hardline_command` is dropped outright. * **Destructive / privilege / credential / obfuscation classes are never proposed**, no matter how often they were approved. ``rm -rf build/`` approved 100 times still never yields an ``rm`` allowlist entry. Only benign, recoverable classes (container lifecycle, git force push, service restarts, hermes self-management, …) are eligible. * **Dangerous root binaries never become globs** (``rm *``, ``sudo *`` …). """ from __future__ import annotations import json import re import sqlite3 import time from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Iterator, Optional # --------------------------------------------------------------------------- # Safety exclusions # --------------------------------------------------------------------------- # Dangerous-class descriptions matching ANY of these are never proposed, # regardless of approval frequency. Matched case-insensitively against the # pattern-key/description strings produced by tools.approval's # DANGEROUS_PATTERNS / execution-flag findings. Deliberately conservative: # a benign class accidentally excluded costs the user one manual config edit; # a destructive class accidentally proposed costs them data. _UNSAFE_CLASS_PATTERNS = [ r"delete", # recursive delete, find -delete, branch force delete, ... r"\brm\b", # xargs with rm, find -exec rm r"destro", # git reset --hard (destroys ...), destructive r"wipe", r"format", # format filesystem r"\bdisk\b", r"block device", r"fork bomb", r"kill (?:all )?process", # force/regex/all process kills r"kill all", r"self-termination", r"\bsudo\b", r"privilege", r"credential", r"\bssh\b", r"shell.rc", r"system config", r"system file", r"\bsql\b", # SQL DROP / TRUNCATE / DELETE without WHERE r"\bchown\b", r"\bchmod\b", r"writable", # world/other-writable permissions r"overwrite", r"in-place edit", r"pipe", # pipe remote/decoded content to shell r"obfuscation", r"remote content", r"remote script", r"heredoc", r"encoded", # PowerShell encoded command execution r"command substitution", r"process substitution", r"\bdd\b", r"shutdown", r"reboot", r"hardline", ] _UNSAFE_CLASS_RE = re.compile("|".join(_UNSAFE_CLASS_PATTERNS), re.IGNORECASE) # Root binaries that must never anchor a proposed command glob, even if the # class survived the description filter. Prefix match for the mkfs family. _UNSAFE_ROOT_BINARIES = { "rm", "rmdir", "unlink", "shred", "dd", "fdisk", "parted", "wipefs", "sudo", "doas", "su", "chmod", "chown", "chgrp", "kill", "killall", "pkill", "halt", "shutdown", "reboot", "poweroff", "init", "del", "format", "truncate", "mkswap", } _UNSAFE_ROOT_PREFIXES = ("mkfs",) # Substrings in a role='tool' result that mean the command did NOT execute # with user consent (blocked, denied, timed out, or still pending). Kept in # sync with the message templates in tools/approval.py. _BLOCK_MARKERS = ( "BLOCKED (hardline)", "BLOCKED: User denied", "BLOCKED: Action ", "BLOCKED: Command flagged as dangerous", "BLOCKED: approval required", "BLOCKED: Failed to send approval request", "The user has NOT consented", "Asking the user for approval", "approval_required", "BLOCKED by user deny rule", ) @dataclass class Proposal: """One ranked allowlist proposal.""" pattern: str # command glob ("git push *") or class key kind: str # "glob" | "class" count: int = 0 classes: set = field(default_factory=set) examples: list = field(default_factory=list) def add_example(self, command: str) -> None: short = command.strip() if len(short) > 100: short = short[:97] + "..." if short not in self.examples and len(self.examples) < 3: self.examples.append(short) # --------------------------------------------------------------------------- # Scan: session DB -> (command, class description) records # --------------------------------------------------------------------------- def default_db_path() -> Path: from hermes_constants import get_hermes_home return get_hermes_home() / "state.db" def _connect_readonly(db_path: Path) -> sqlite3.Connection: uri = f"file:{db_path}?mode=ro" return sqlite3.connect(uri, uri=True) def _iter_terminal_calls( con: sqlite3.Connection, since_ts: float ) -> Iterator[tuple[str, str]]: """Yield ``(tool_call_id, command)`` for every terminal tool call.""" cur = con.execute( "SELECT tool_calls FROM messages " "WHERE role='assistant' AND tool_calls IS NOT NULL " "AND tool_calls LIKE '%terminal%' AND timestamp >= ?", (since_ts,), ) while True: rows = cur.fetchmany(2000) if not rows: break for (raw,) in rows: try: calls = json.loads(raw) except (TypeError, ValueError): continue if not isinstance(calls, list): continue for call in calls: if not isinstance(call, dict): continue fn = call.get("function") or {} if fn.get("name") != "terminal": continue try: args = json.loads(fn.get("arguments") or "{}") except (TypeError, ValueError): continue command = args.get("command") if isinstance(command, str) and command.strip(): yield (call.get("id") or "", command) def _blocked_tool_call_ids(con: sqlite3.Connection, since_ts: float) -> set: """Collect tool_call_ids whose result shows the command never ran freely.""" blocked: set = set() cur = con.execute( "SELECT tool_call_id, content FROM messages " "WHERE role='tool' AND tool_call_id IS NOT NULL AND timestamp >= ? " "AND (content LIKE '%BLOCKED%' OR content LIKE '%approval%')", (since_ts,), ) while True: rows = cur.fetchmany(2000) if not rows: break for tool_call_id, content in rows: if not content: continue if any(marker in content for marker in _BLOCK_MARKERS): blocked.add(tool_call_id) return blocked def scan_approval_history( db_path: Optional[Path] = None, days: int = 90 ) -> list[tuple[str, str]]: """Return ``(command, dangerous_class_description)`` records mined from the session DB — dangerous-classified terminal commands that actually executed (i.e. carried an implied user approval). """ from tools.approval import detect_dangerous_command, detect_hardline_command path = Path(db_path) if db_path else default_db_path() if not path.exists(): return [] since_ts = 0.0 if days <= 0 else time.time() - days * 86400 records: list[tuple[str, str]] = [] con = _connect_readonly(path) try: blocked = _blocked_tool_call_ids(con, since_ts) for tool_call_id, command in _iter_terminal_calls(con, since_ts): if tool_call_id in blocked: continue is_hardline, _desc = detect_hardline_command(command) if is_hardline: # Hardline commands are unconditionally blocked at runtime; # never mine them (defense in depth against stale DB rows). continue is_dangerous, _key, description = detect_dangerous_command(command) if not is_dangerous: continue records.append((command, description)) finally: con.close() return records # --------------------------------------------------------------------------- # Normalize -> aggregate -> rank -> exclude # --------------------------------------------------------------------------- def normalize_command(command: str) -> str: """Fold user/hermes home prefixes and collapse whitespace. Reuses tools.approval's home-folding machinery so proposals are portable across machines/users (``/home/alice/x`` -> ``~/x``). """ from tools.approval import ( _rewrite_resolved_hermes_home, _rewrite_resolved_user_home, ) folded = _rewrite_resolved_user_home(_rewrite_resolved_hermes_home(command)) return " ".join(folded.split()) def is_unsafe_class(description: str) -> bool: """True when a dangerous-class description must never be proposed.""" return bool(_UNSAFE_CLASS_RE.search(description or "")) def _unsafe_root_binary(token: str) -> bool: tok = token.lower().rsplit("/", 1)[-1] if tok in _UNSAFE_ROOT_BINARIES: return True return any(tok.startswith(p) for p in _UNSAFE_ROOT_PREFIXES) def derive_glob(normalized: str) -> Optional[str]: """Derive a narrow command glob (``git push *``) from a simple command. Returns None for compound commands (shell operators — the runtime allowlist matcher refuses those anyway) and for commands anchored on an unsafe root binary. """ from tools.approval import _has_allowlist_shell_operator if _has_allowlist_shell_operator(normalized): return None tokens = normalized.split() if not tokens: return None if _unsafe_root_binary(tokens[0]): return None if len(tokens) == 1: return tokens[0] second = tokens[1] if second.startswith("-") or any(ch in second for ch in "*?[$"): return f"{tokens[0]} *" return f"{tokens[0]} {second} *" def build_proposals( records: Iterable[tuple[str, str]], existing: Optional[set] = None, min_count: int = 2, limit: int = 20, ) -> list[Proposal]: """Aggregate scan records into a ranked, safety-filtered proposal list. Grain: a command glob (``git push *``) for simple commands; the dangerous-class description itself (the same key an interactive ``[a]lways`` answer persists) for compound commands where no safe glob can be derived. """ existing = existing or set() by_pattern: dict[tuple[str, str], Proposal] = {} for command, description in records: if is_unsafe_class(description): continue normalized = normalize_command(command) glob = derive_glob(normalized) if glob is not None: key = (glob, "glob") else: key = (description, "class") pattern, kind = key if pattern in existing: continue proposal = by_pattern.get(key) if proposal is None: proposal = by_pattern[key] = Proposal(pattern=pattern, kind=kind) proposal.count += 1 proposal.classes.add(description) proposal.add_example(normalized) ranked = [p for p in by_pattern.values() if p.count >= max(min_count, 1)] ranked.sort(key=lambda p: (-p.count, p.pattern)) return ranked[: max(limit, 1)] # --------------------------------------------------------------------------- # Apply / render # --------------------------------------------------------------------------- def parse_apply_indices(spec: str, total: int) -> list[int]: """Parse ``"1,3"`` into validated zero-based indices.""" indices: list[int] = [] for part in (spec or "").split(","): part = part.strip() if not part: continue try: n = int(part) except ValueError: raise ValueError(f"invalid selection {part!r} — expected numbers like 1,3") if n < 1 or n > total: raise ValueError(f"selection {n} out of range (1..{total})") if (n - 1) not in indices: indices.append(n - 1) if not indices: raise ValueError("no valid selections in --apply") return indices def apply_proposals(proposals: list[Proposal], indices: list[int]) -> set: """Merge chosen proposal patterns into command_allowlist and persist.""" import tools.approval as approval_module merged = set(approval_module.load_permanent_allowlist()) for idx in indices: merged.add(proposals[idx].pattern) approval_module.save_permanent_allowlist(merged) # Keep the in-process allowlist consistent so a long-lived process sees # the new entries immediately (mirrors the interactive 'always' path). approval_module.load_permanent(merged) return merged def _render_text(proposals: list[Proposal], days: int) -> None: window = "all history" if days <= 0 else f"last {days} days" if not proposals: print( f"No allowlist candidates found in approval history ({window}).\n" "Either nothing dangerous was approved often enough " "(see --min-count/--days), or the approved classes are " "excluded for safety." ) return print(f"Proposed command_allowlist additions (from approval history, {window}):\n") for i, p in enumerate(proposals, 1): kind = " (class key)" if p.kind == "class" else "" print(f" {i}. {p.pattern} — approved {p.count}x{kind}") for cls in sorted(p.classes): print(f" class: {cls}") for ex in p.examples: print(f" e.g. {ex}") print( "\nNothing has been changed. Apply selected entries with:\n" " hermes approvals suggest --apply 1,3\n" "Entries are merged into command_allowlist in ~/.hermes/config.yaml." ) def suggest_command(args) -> int: """Entry point for ``hermes approvals suggest``.""" db_path = Path(args.db) if getattr(args, "db", None) else default_db_path() days = getattr(args, "days", 90) if not db_path.exists(): print(f"Session database not found: {db_path}") return 1 import tools.approval as approval_module existing = set(approval_module.load_permanent_allowlist()) records = scan_approval_history(db_path, days=days) proposals = build_proposals( records, existing=existing, min_count=getattr(args, "min_count", 2), limit=getattr(args, "limit", 20), ) apply_spec = getattr(args, "apply_indices", None) if apply_spec: try: indices = parse_apply_indices(apply_spec, len(proposals)) except ValueError as exc: print(f"--apply error: {exc}") return 1 merged = apply_proposals(proposals, indices) applied = [proposals[i].pattern for i in indices] if getattr(args, "json", False): print(json.dumps({"applied": applied, "allowlist_size": len(merged)})) else: print("Added to command_allowlist:") for pattern in applied: print(f" + {pattern}") print(f"\ncommand_allowlist now has {len(merged)} entries " "(~/.hermes/config.yaml).") return 0 if getattr(args, "json", False): payload = { "db": str(db_path), "days": days, "proposals": [ { "n": i, "pattern": p.pattern, "kind": p.kind, "count": p.count, "classes": sorted(p.classes), "examples": p.examples, } for i, p in enumerate(proposals, 1) ], } print(json.dumps(payload, indent=2)) return 0 _render_text(proposals, days) return 0 def approvals_command(args) -> int: """Dispatch ``hermes approvals ``.""" sub = getattr(args, "approvals_command", None) if sub == "suggest": return suggest_command(args) print( "usage: hermes approvals \n" "\n" "subcommands:\n" " suggest Mine past approval decisions into a proposed\n" " command_allowlist (dry by default; --apply N,M to merge)\n" "\n" "Run `hermes approvals suggest -h` for details." ) return 1