mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-02 08:47:26 +08:00
Compare commits
2 Commits
feat/fix-p
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86815b7f6 | ||
|
|
55bbf8caba |
@@ -4033,7 +4033,7 @@ For more help on a command:
|
||||
chat_parser.add_argument(
|
||||
"-s", "--skills",
|
||||
action="append",
|
||||
default=None,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Preload one or more skills for the session (repeat flag or comma-separate)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
@@ -4055,6 +4055,7 @@ For more help on a command:
|
||||
chat_parser.add_argument(
|
||||
"--resume", "-r",
|
||||
metavar="SESSION_ID",
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
@@ -4062,14 +4063,14 @@ For more help on a command:
|
||||
dest="continue_last",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
default=argparse.SUPPRESS,
|
||||
metavar="SESSION_NAME",
|
||||
help="Resume a session by name, or the most recent if no name given"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--worktree", "-w",
|
||||
action="store_true",
|
||||
default=False,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Run in an isolated git worktree (for parallel agents on the same repo)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
@@ -4088,13 +4089,13 @@ For more help on a command:
|
||||
chat_parser.add_argument(
|
||||
"--yolo",
|
||||
action="store_true",
|
||||
default=False,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Bypass all dangerous command approval prompts (use at your own risk)"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--pass-session-id",
|
||||
action="store_true",
|
||||
default=False,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Include the session ID in the agent's system prompt"
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
|
||||
172
tests/hermes_cli/test_argparse_flag_propagation.py
Normal file
172
tests/hermes_cli/test_argparse_flag_propagation.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""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 TestFlagBeforeSubcommand:
|
||||
"""Flags placed before 'chat' must propagate through."""
|
||||
|
||||
def test_yolo_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo", "chat"])
|
||||
assert getattr(args, "yolo", False) is True
|
||||
|
||||
def test_worktree_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-w", "chat"])
|
||||
assert getattr(args, "worktree", False) is True
|
||||
|
||||
def test_skills_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-s", "myskill", "chat"])
|
||||
assert getattr(args, "skills", None) == ["myskill"]
|
||||
|
||||
def test_pass_session_id_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--pass-session-id", "chat"])
|
||||
assert getattr(args, "pass_session_id", False) is True
|
||||
|
||||
def test_resume_before_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["-r", "abc123", "chat"])
|
||||
assert getattr(args, "resume", None) == "abc123"
|
||||
|
||||
|
||||
class TestFlagAfterSubcommand:
|
||||
"""Flags placed after 'chat' must still work."""
|
||||
|
||||
def test_yolo_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "--yolo"])
|
||||
assert getattr(args, "yolo", False) is True
|
||||
|
||||
def test_worktree_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-w"])
|
||||
assert getattr(args, "worktree", False) is True
|
||||
|
||||
def test_skills_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-s", "myskill"])
|
||||
assert getattr(args, "skills", None) == ["myskill"]
|
||||
|
||||
def test_resume_after_chat(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat", "-r", "abc123"])
|
||||
assert getattr(args, "resume", None) == "abc123"
|
||||
|
||||
|
||||
class TestNoSubcommandDefaults:
|
||||
"""When no subcommand is given, flags must work and defaults must hold."""
|
||||
|
||||
def test_yolo_no_subcommand(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["--yolo"])
|
||||
assert args.yolo is True
|
||||
assert args.command is None
|
||||
|
||||
def test_defaults_no_flags(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args([])
|
||||
assert getattr(args, "yolo", False) is False
|
||||
assert getattr(args, "worktree", False) is False
|
||||
assert getattr(args, "skills", None) is None
|
||||
assert getattr(args, "resume", None) is None
|
||||
|
||||
def test_defaults_chat_no_flags(self):
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(["chat"])
|
||||
# With SUPPRESS, these fall through to parent defaults
|
||||
assert getattr(args, "yolo", False) is False
|
||||
assert getattr(args, "worktree", False) is False
|
||||
assert getattr(args, "skills", None) is None
|
||||
|
||||
|
||||
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
|
||||
@@ -724,7 +724,8 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
logger.debug("Smart approval: auto-approved '%s' (%s)",
|
||||
command[:60], combined_desc_for_llm)
|
||||
return {"approved": True, "message": None,
|
||||
"smart_approved": True}
|
||||
"smart_approved": True,
|
||||
"description": combined_desc_for_llm}
|
||||
elif verdict == "deny":
|
||||
combined_desc_for_llm = "; ".join(desc for _, desc, _ in warnings)
|
||||
return {
|
||||
@@ -819,7 +820,8 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
approve_permanent(key)
|
||||
save_permanent_allowlist(_permanent_approved)
|
||||
|
||||
return {"approved": True, "message": None}
|
||||
return {"approved": True, "message": None,
|
||||
"user_approved": True, "description": combined_desc}
|
||||
|
||||
# Fallback: no gateway callback registered (e.g. cron, batch).
|
||||
# Return approval_required for backward compat.
|
||||
@@ -865,4 +867,5 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
approve_permanent(key)
|
||||
save_permanent_allowlist(_permanent_approved)
|
||||
|
||||
return {"approved": True, "message": None}
|
||||
return {"approved": True, "message": None,
|
||||
"user_approved": True, "description": combined_desc}
|
||||
|
||||
@@ -1058,6 +1058,7 @@ def terminal_tool(
|
||||
|
||||
# Pre-exec security checks (tirith + dangerous command detection)
|
||||
# Skip check if force=True (user has confirmed they want to run it)
|
||||
approval_note = None
|
||||
if not force:
|
||||
approval = _check_all_guards(command, env_type)
|
||||
if not approval["approved"]:
|
||||
@@ -1084,6 +1085,13 @@ def terminal_tool(
|
||||
"error": approval.get("message", fallback_msg),
|
||||
"status": "blocked"
|
||||
}, ensure_ascii=False)
|
||||
# Track whether approval was explicitly granted by the user
|
||||
if approval.get("user_approved"):
|
||||
desc = approval.get("description", "flagged as dangerous")
|
||||
approval_note = f"Command required approval ({desc}) and was approved by the user."
|
||||
elif approval.get("smart_approved"):
|
||||
desc = approval.get("description", "flagged as dangerous")
|
||||
approval_note = f"Command was flagged ({desc}) and auto-approved by smart approval."
|
||||
|
||||
# Prepare command for execution
|
||||
if background:
|
||||
@@ -1121,6 +1129,8 @@ def terminal_tool(
|
||||
"exit_code": 0,
|
||||
"error": None,
|
||||
}
|
||||
if approval_note:
|
||||
result_data["approval"] = approval_note
|
||||
|
||||
# Transparent timeout clamping note
|
||||
max_timeout = effective_timeout
|
||||
@@ -1232,11 +1242,14 @@ def terminal_tool(
|
||||
from agent.redact import redact_sensitive_text
|
||||
output = redact_sensitive_text(output.strip()) if output else ""
|
||||
|
||||
return json.dumps({
|
||||
result_dict = {
|
||||
"output": output,
|
||||
"exit_code": returncode,
|
||||
"error": None
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
if approval_note:
|
||||
result_dict["approval"] = approval_note
|
||||
return json.dumps(result_dict, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
Reference in New Issue
Block a user