Files
hermes-agent/hermes_cli/subcommands/approvals.py
Teknium 563f0a6fde feat(cli): add hermes approvals test — dry-run approval verdict CLI
Answers "what would the approval system do with this command?" without
executing it, prompting anyone, or persisting anything. Composes the
REAL runtime evaluators from tools/approval.py in the same order as
check_all_command_guards: container-skip gate, hardline blocklist,
sudo-stdin guard, user approvals.deny rules, yolo/mode-off bypass,
permanent command_allowlist, dangerous-pattern detection. Because the
same functions run — including _command_detection_variants's
normalization/de-obfuscation path — an obfuscated command gets exactly
the verdict its plain form would get at runtime, and the output shows
the normalized-variant trace the detectors actually evaluated.

- hermes_cli/approvals_test.py: evaluate_command() + text/JSON output.
  Script-friendly exit codes: 0 allow, 1 usage, 2 ask-approval, 3 deny
  (hardline / sudo-stdin / user deny rule).
- hermes_cli/subcommands/approvals.py: `test` subparser with --env-type
  (default local), --json, and a REMAINDER command (dest command_words —
  NOT "command", which main.py's startup path reads as the top-level
  subcommand name).
- hermes_cli/approvals_suggest.py: dispatch `test` and mention it in the
  bare-`hermes approvals` usage text.
- tests/hermes_cli/test_approvals_test.py: verdict matrix (benign /
  hardline / dangerous / user-deny from config / container skip /
  mode=off vs hardline), obfuscated==plain verdict parity with
  normalized trace, spy proof that the real runtime detectors are the
  ones invoked, read-only invariants (nothing executed; prompt and
  persistence paths rigged to explode), JSON shape, dispatcher and
  parser wiring.

Read-only by construction: only detection/matching functions are
called; the approval gate, prompts, gateway notify, and allowlist
writers are never reached.

Inspired by: Amp `permissions test` (idea-level, proprietary — zero code)
2026-08-07 08:57:39 -07:00

116 lines
4.3 KiB
Python

"""``hermes approvals`` subcommand parser.
Follows the cron/security pattern: parser construction lives here, the
handler is injected by ``main.py`` so this module never imports ``main``
(cycle avoidance).
"""
from __future__ import annotations
import argparse
from typing import Callable
def build_approvals_parser(subparsers, *, cmd_approvals: Callable) -> None:
"""Attach the ``approvals`` subcommand to ``subparsers``."""
approvals_parser = subparsers.add_parser(
"approvals",
help="Approval-prompt tools (mine history into allowlist proposals)",
description=(
"Tools for the dangerous-command approval system. "
"`hermes approvals suggest` mines past approval decisions from "
"the session database and proposes command_allowlist entries so "
"repeatedly-approved commands stop prompting."
),
)
approvals_subparsers = approvals_parser.add_subparsers(
dest="approvals_command",
metavar="<subcommand>",
)
suggest_parser = approvals_subparsers.add_parser(
"suggest",
help="Propose command_allowlist entries from past approvals",
description=(
"Scan the session database for dangerous-classified commands "
"that ran with user approval, rank the recurring patterns, and "
"print a numbered allowlist proposal. Nothing is written unless "
"--apply is given. Destructive classes (recursive delete, sudo, "
"disk writes, credential edits, ...) are never proposed."
),
)
suggest_parser.add_argument(
"--apply",
dest="apply_indices",
metavar="N[,M...]",
help="Merge the numbered proposals (from a prior run) into "
"command_allowlist in config.yaml",
)
suggest_parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of human-readable text",
)
suggest_parser.add_argument(
"--days",
type=int,
default=90,
help="How far back to scan session history (default: 90; 0 = all)",
)
suggest_parser.add_argument(
"--min-count",
dest="min_count",
type=int,
default=2,
help="Minimum approval count for a pattern to be proposed (default: 2)",
)
suggest_parser.add_argument(
"--limit",
type=int,
default=20,
help="Maximum number of proposals to show (default: 20)",
)
suggest_parser.add_argument(
"--db",
help="Path to an alternate session database (default: ~/.hermes/state.db)",
)
suggest_parser.set_defaults(func=cmd_approvals)
test_parser = approvals_subparsers.add_parser(
"test",
help="Dry-run the approval verdict for a command (never executes it)",
description=(
"Evaluate a command against the REAL runtime approval guards — "
"hardline blocklist, user approvals.deny rules, dangerous-pattern "
"detection, allowlist, yolo/off bypass — and print the verdict, "
"the matching rule, and the normalized-command trace, without "
"executing the command, prompting anyone, or persisting anything. "
"Exit codes: 0 allow, 2 ask-approval, 3 deny (hardline or user "
"deny rule). Tip: use `--` before the command so its own flags "
"aren't parsed: hermes approvals test -- rm -rf /tmp/x"
),
)
test_parser.add_argument(
"--env-type",
dest="env_type",
default="local",
help="Terminal backend type to evaluate against (default: local; "
"isolated container backends like docker skip the guards)",
)
test_parser.add_argument(
"--json",
action="store_true",
help="Emit machine-readable JSON instead of human-readable text",
)
test_parser.add_argument(
"command_words",
nargs=argparse.REMAINDER,
metavar="command",
# NOTE: dest must NOT be "command" — main.py's startup path reads
# args.command as the top-level subcommand name ("approvals").
help="The command to evaluate (prefix with -- to protect its flags)",
)
test_parser.set_defaults(func=cmd_approvals)
approvals_parser.set_defaults(func=cmd_approvals)