Compare commits

...

2 Commits

Author SHA1 Message Date
Teknium
f86815b7f6 fix: --yolo and other flags silently dropped when placed before 'chat' subcommand
When --yolo, -w, -s, -r, -c, and --pass-session-id exist on both the parent
parser and the 'chat' subparser with explicit defaults (default=False or
default=None), argparse's subparser initialization overwrites the parent's
parsed value. So 'hermes --yolo chat' silently drops --yolo, making it appear
broken.

Fix: use default=argparse.SUPPRESS on all duplicated arguments in the chat
subparser. SUPPRESS means 'don't set this attribute if the user didn't
explicitly provide it', so the parent parser's value survives through.

Affected flags: --yolo, --worktree/-w, --skills/-s, --pass-session-id,
--resume/-r, --continue/-c.

Adds 15 regression tests covering flag-before-subcommand, flag-after-subcommand,
no-subcommand, and env var propagation scenarios.
2026-04-04 16:53:23 -07:00
Teknium
55bbf8caba fix: include approval metadata in terminal tool results (#5141)
When a dangerous command is approved (gateway, CLI, or smart approval),
the terminal tool now includes an 'approval' field in the result JSON
so the model knows approval was requested and granted. Previously the
model only saw normal command output with no indication that approval
happened, causing it to hallucinate that the approval system didn't fire.

Changes:
- approval.py: Return user_approved/description in all 3 approval paths
  (gateway blocking, CLI interactive, smart approval)
- terminal_tool.py: Capture approval metadata and inject into both
  foreground and background command results
2026-04-04 16:33:20 -07:00
4 changed files with 199 additions and 10 deletions

View File

@@ -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(

View 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

View File

@@ -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}

View File

@@ -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