hermes-agent/tests/hermes_cli/test_argparse_flag_propagation.py
Peter Fontana 3988c3c245 feat: shell hooks — wire shell scripts as Hermes hook callbacks
Users can declare shell scripts in config.yaml under a hooks: block that
fire on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
subagent_stop, etc). Scripts receive JSON on stdin, can return JSON on
stdout to block tool calls or inject context pre-LLM.

Key design:
- Registers closures on existing PluginManager._hooks dict — zero changes
  to invoke_hook() call sites
- subprocess.run(shell=False) via shlex.split — no shell injection
- First-use consent per (event, command) pair, persisted to allowlist JSON
- Bypass via --accept-hooks, HERMES_ACCEPT_HOOKS=1, or hooks_auto_accept
- hermes hooks list/test/revoke/doctor CLI subcommands
- Adds subagent_stop hook event fired after delegate_task children exit
- Claude Code compatible response shapes accepted

Cherry-picked from PR #13143 by @pefontana.
2026-04-20 20:53:51 -07:00

132 lines
5.3 KiB
Python

"""Tests for parent→subparser flag propagation.
When flags like --yolo, -w, -s exist on both the parent parser and the 'chat'
subparser, placing the flag BEFORE the subcommand (e.g. 'hermes --yolo chat')
must not silently drop the flag value.
Regression test for: argparse subparser default=False overwriting parent's
parsed True when the same argument is defined on both parsers.
Fix: chat subparser uses default=argparse.SUPPRESS for all duplicated flags,
so the subparser only sets the attribute when the user explicitly provides it.
"""
import argparse
import os
import sys
from unittest.mock import patch
import pytest
def _build_parser():
"""Build the hermes argument parser from the real code.
We import the real main() and extract the parser it builds.
Since main() is a large function that does much more than parse args,
we replicate just the parser structure here to avoid side effects.
"""
parser = argparse.ArgumentParser(prog="hermes")
parser.add_argument("--resume", "-r", metavar="SESSION", default=None)
parser.add_argument(
"--continue", "-c", dest="continue_last", nargs="?",
const=True, default=None, metavar="SESSION_NAME",
)
parser.add_argument("--worktree", "-w", action="store_true", default=False)
parser.add_argument("--skills", "-s", action="append", default=None)
parser.add_argument("--yolo", action="store_true", default=False)
parser.add_argument("--pass-session-id", action="store_true", default=False)
subparsers = parser.add_subparsers(dest="command")
chat = subparsers.add_parser("chat")
# These MUST use argparse.SUPPRESS to avoid overwriting parent values
chat.add_argument("--yolo", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--worktree", "-w", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--skills", "-s", action="append",
default=argparse.SUPPRESS)
chat.add_argument("--pass-session-id", action="store_true",
default=argparse.SUPPRESS)
chat.add_argument("--resume", "-r", metavar="SESSION_ID",
default=argparse.SUPPRESS)
chat.add_argument(
"--continue", "-c", dest="continue_last", nargs="?",
const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME",
)
return parser
class TestYoloEnvVar:
"""Verify --yolo sets HERMES_YOLO_MODE regardless of flag position.
This tests the actual cmd_chat logic pattern (getattr → os.environ).
"""
@pytest.fixture(autouse=True)
def _clean_env(self):
os.environ.pop("HERMES_YOLO_MODE", None)
yield
os.environ.pop("HERMES_YOLO_MODE", None)
def _simulate_cmd_chat_yolo_check(self, args):
"""Replicate the exact check from cmd_chat in main.py."""
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
def test_yolo_before_chat_sets_env(self):
parser = _build_parser()
args = parser.parse_args(["--yolo", "chat"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") == "1"
def test_yolo_after_chat_sets_env(self):
parser = _build_parser()
args = parser.parse_args(["chat", "--yolo"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") == "1"
def test_no_yolo_no_env(self):
parser = _build_parser()
args = parser.parse_args(["chat"])
self._simulate_cmd_chat_yolo_check(args)
assert os.environ.get("HERMES_YOLO_MODE") is None
class TestAcceptHooksOnAgentSubparsers:
"""Verify --accept-hooks is accepted at every agent-subcommand
position (before the subcommand, between group/subcommand, and
after the leaf subcommand) for gateway/cron/mcp/acp. Regression
against prior behaviour where the flag only worked on the root
parser and `chat`, so `hermes gateway run --accept-hooks` failed
with `unrecognized arguments`."""
@pytest.mark.parametrize("argv", [
["--accept-hooks", "gateway", "run", "--help"],
["gateway", "--accept-hooks", "run", "--help"],
["gateway", "run", "--accept-hooks", "--help"],
["--accept-hooks", "cron", "tick", "--help"],
["cron", "--accept-hooks", "tick", "--help"],
["cron", "tick", "--accept-hooks", "--help"],
["cron", "run", "--accept-hooks", "dummy-id", "--help"],
["--accept-hooks", "mcp", "serve", "--help"],
["mcp", "--accept-hooks", "serve", "--help"],
["mcp", "serve", "--accept-hooks", "--help"],
["acp", "--accept-hooks", "--help"],
])
def test_accepted_at_every_position(self, argv):
"""Invoking `hermes <argv>` must exit 0 (help) rather than
failing with `unrecognized arguments`."""
import subprocess
result = subprocess.run(
[sys.executable, "-m", "hermes_cli.main", *argv],
capture_output=True,
text=True,
timeout=15,
)
assert result.returncode == 0, (
f"argv={argv!r} returned {result.returncode}\n"
f"stdout: {result.stdout[:300]}\n"
f"stderr: {result.stderr[:300]}"
)
assert "unrecognized arguments" not in result.stderr