hermes-agent/tests/agent/test_shell_hooks_consent.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

224 lines
7.9 KiB
Python

"""Consent-flow tests for the shell-hook allowlist.
Covers the prompt/non-prompt decision tree: TTY vs non-TTY, and the
three accept-hooks channels (--accept-hooks, HERMES_ACCEPT_HOOKS env,
hooks_auto_accept: config key).
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from agent import shell_hooks
@pytest.fixture(autouse=True)
def _isolated_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_home"))
monkeypatch.delenv("HERMES_ACCEPT_HOOKS", raising=False)
shell_hooks.reset_for_tests()
yield
shell_hooks.reset_for_tests()
def _write_hook_script(tmp_path: Path) -> Path:
script = tmp_path / "hook.sh"
script.write_text("#!/usr/bin/env bash\nprintf '{}\\n'\n")
script.chmod(0o755)
return script
# ── TTY prompt flow ───────────────────────────────────────────────────────
class TestTTYPromptFlow:
def test_first_use_prompts_and_approves(self, tmp_path):
from hermes_cli import plugins
script = _write_hook_script(tmp_path)
plugins._plugin_manager = plugins.PluginManager()
with patch("sys.stdin") as mock_stdin, patch("builtins.input", return_value="y"):
mock_stdin.isatty.return_value = True
registered = shell_hooks.register_from_config(
{"hooks": {"on_session_start": [{"command": str(script)}]}},
accept_hooks=False,
)
assert len(registered) == 1
entry = shell_hooks.allowlist_entry_for("on_session_start", str(script))
assert entry is not None
assert entry["event"] == "on_session_start"
assert entry["command"] == str(script)
def test_first_use_prompts_and_rejects(self, tmp_path):
from hermes_cli import plugins
script = _write_hook_script(tmp_path)
plugins._plugin_manager = plugins.PluginManager()
with patch("sys.stdin") as mock_stdin, patch("builtins.input", return_value="n"):
mock_stdin.isatty.return_value = True
registered = shell_hooks.register_from_config(
{"hooks": {"on_session_start": [{"command": str(script)}]}},
accept_hooks=False,
)
assert registered == []
assert shell_hooks.allowlist_entry_for(
"on_session_start", str(script),
) is None
def test_subsequent_use_does_not_prompt(self, tmp_path):
"""After the first approval, re-registration must be silent."""
from hermes_cli import plugins
script = _write_hook_script(tmp_path)
plugins._plugin_manager = plugins.PluginManager()
# First call: TTY, approved.
with patch("sys.stdin") as mock_stdin, patch("builtins.input", return_value="y"):
mock_stdin.isatty.return_value = True
shell_hooks.register_from_config(
{"hooks": {"on_session_start": [{"command": str(script)}]}},
accept_hooks=False,
)
# Reset registration set but keep the allowlist on disk.
shell_hooks.reset_for_tests()
# Second call: TTY, input() must NOT be called.
with patch("sys.stdin") as mock_stdin, patch(
"builtins.input", side_effect=AssertionError("should not prompt"),
):
mock_stdin.isatty.return_value = True
registered = shell_hooks.register_from_config(
{"hooks": {"on_session_start": [{"command": str(script)}]}},
accept_hooks=False,
)
assert len(registered) == 1
# ── non-TTY flow ──────────────────────────────────────────────────────────
class TestNonTTYFlow:
def test_no_tty_no_flag_skips_registration(self, tmp_path):
from hermes_cli import plugins
script = _write_hook_script(tmp_path)
plugins._plugin_manager = plugins.PluginManager()
with patch("sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
registered = shell_hooks.register_from_config(
{"hooks": {"on_session_start": [{"command": str(script)}]}},
accept_hooks=False,
)
assert registered == []
def test_no_tty_with_env_accepts(self, tmp_path, monkeypatch):
from hermes_cli import plugins
script = _write_hook_script(tmp_path)
plugins._plugin_manager = plugins.PluginManager()
monkeypatch.setenv("HERMES_ACCEPT_HOOKS", "1")
with patch("sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
registered = shell_hooks.register_from_config(
{"hooks": {"on_session_start": [{"command": str(script)}]}},
accept_hooks=False,
)
assert len(registered) == 1
# ── Allowlist + revoke + mtime ────────────────────────────────────────────
class TestAllowlistOps:
def test_tilde_path_approval_records_resolvable_mtime(self, tmp_path, monkeypatch):
"""If the command uses ~ the approval must still find the file."""
monkeypatch.setenv("HOME", str(tmp_path))
target = tmp_path / "hook.sh"
target.write_text("#!/usr/bin/env bash\n")
target.chmod(0o755)
shell_hooks._record_approval("on_session_start", "~/hook.sh")
entry = shell_hooks.allowlist_entry_for(
"on_session_start", "~/hook.sh",
)
assert entry is not None
# Must not be None — the tilde was expanded before stat().
assert entry["script_mtime_at_approval"] is not None
def test_duplicate_approval_replaces_mtime(self, tmp_path):
"""Re-approving the same pair refreshes the approval timestamp."""
script = _write_hook_script(tmp_path)
shell_hooks._record_approval("on_session_start", str(script))
original_entry = shell_hooks.allowlist_entry_for(
"on_session_start", str(script),
)
assert original_entry is not None
# Touch the script to bump its mtime then re-approve.
import os
import time
new_mtime = original_entry.get("script_mtime_at_approval")
time.sleep(0.01)
os.utime(script, None) # current time
shell_hooks._record_approval("on_session_start", str(script))
# Exactly one entry per (event, command).
approvals = shell_hooks.load_allowlist().get("approvals", [])
matching = [
e for e in approvals
if e.get("event") == "on_session_start"
and e.get("command") == str(script)
]
assert len(matching) == 1
# ── hooks_auto_accept config parsing ──────────────────────────────────────
class TestHooksAutoAcceptParsing:
"""Regression guard: YAML-string values must not silently auto-accept.
``bool("false")`` is ``True`` in Python, so the old ``return bool(cfg_val)``
path treated ``hooks_auto_accept: "false"`` (quoted YAML string) as a
truthy opt-in, silently bypassing user consent for every shell hook.
"""
def test_bool_true_accepts(self):
assert shell_hooks._resolve_effective_accept(
{"hooks_auto_accept": True}, accept_hooks_arg=False,
) is True
def test_none_rejects(self):
assert shell_hooks._resolve_effective_accept(
{"hooks_auto_accept": None}, accept_hooks_arg=False,
) is False
def test_integer_ignored(self):
# Only bool and str are honored; anything else (including 1) is False.
assert shell_hooks._resolve_effective_accept(
{"hooks_auto_accept": 1}, accept_hooks_arg=False,
) is False