mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
482 lines
17 KiB
Python
482 lines
17 KiB
Python
"""``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 <subcommand>``."""
|
|
sub = getattr(args, "approvals_command", None)
|
|
if sub == "suggest":
|
|
return suggest_command(args)
|
|
print(
|
|
"usage: hermes approvals <subcommand>\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
|