mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 01:21:43 +00:00
Adds a floor below --yolo: a tiny set of commands so catastrophic they should never run via the agent, regardless of --yolo, gateway /yolo, approvals.mode=off, or cron approve mode. Opting into yolo is trusting the agent with your files and services — not trusting it to wipe the disk or power the box off. The list is deliberately small (12 patterns), covering only unrecoverable ops: - rm -rf targeting /, /home, /etc, /usr, /var, /boot, /bin, /sbin, /lib, ~, $HOME - mkfs (any variant) - dd + redirection to raw block devices (/dev/sd*, /dev/nvme*, etc.) - fork bomb - kill -1 / kill -9 -1 - shutdown, reboot, halt, poweroff, init 0/6, telinit 0/6, systemctl poweroff/reboot/halt/kexec Recoverable-but-costly commands (git reset --hard, rm -rf /tmp/x, chmod -R 777, curl | sh) stay in DANGEROUS_PATTERNS where yolo can still pass them through — that's what yolo is for. Container backends (docker/singularity/modal/daytona) continue to bypass both hardline and dangerous checks, since nothing they do can touch the host. Inspired by Mercury Agent's permission-hardened blocklist.
258 lines
12 KiB
Python
258 lines
12 KiB
Python
"""Tests for approvals.cron_mode — configurable approval behavior for cron jobs."""
|
|
|
|
import os
|
|
import pytest
|
|
|
|
import tools.approval as approval_module
|
|
from tools.approval import (
|
|
_get_cron_approval_mode,
|
|
check_all_command_guards,
|
|
check_dangerous_command,
|
|
detect_dangerous_command,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_approval_state():
|
|
approval_module._permanent_approved.clear()
|
|
approval_module.clear_session("default")
|
|
approval_module.clear_session("test-session")
|
|
yield
|
|
approval_module._permanent_approved.clear()
|
|
approval_module.clear_session("default")
|
|
approval_module.clear_session("test-session")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_cron_approval_mode() config parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCronApprovalModeParsing:
|
|
def test_default_is_deny(self):
|
|
"""When no config is set, cron_mode defaults to 'deny'."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {}}):
|
|
assert _get_cron_approval_mode() == "deny"
|
|
|
|
def test_explicit_deny(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "deny"}}):
|
|
assert _get_cron_approval_mode() == "deny"
|
|
|
|
def test_explicit_approve(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "approve"}}):
|
|
assert _get_cron_approval_mode() == "approve"
|
|
|
|
def test_off_maps_to_approve(self):
|
|
"""'off' is an alias for 'approve' (matches --yolo semantics)."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "off"}}):
|
|
assert _get_cron_approval_mode() == "approve"
|
|
|
|
def test_allow_maps_to_approve(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "allow"}}):
|
|
assert _get_cron_approval_mode() == "approve"
|
|
|
|
def test_yes_maps_to_approve(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "yes"}}):
|
|
assert _get_cron_approval_mode() == "approve"
|
|
|
|
def test_case_insensitive(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "APPROVE"}}):
|
|
assert _get_cron_approval_mode() == "approve"
|
|
|
|
def test_unknown_value_defaults_to_deny(self):
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "maybe"}}):
|
|
assert _get_cron_approval_mode() == "deny"
|
|
|
|
def test_config_load_failure_defaults_to_deny(self):
|
|
"""If config loading fails entirely, default to deny (safe)."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", side_effect=RuntimeError("config broken")):
|
|
assert _get_cron_approval_mode() == "deny"
|
|
|
|
def test_yaml_boolean_false_maps_to_deny(self):
|
|
"""YAML 1.1 parses bare 'off' as False. Ensure it maps to deny."""
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": False}}):
|
|
# str(False) = "False", which is not in the approve set, so deny
|
|
assert _get_cron_approval_mode() == "deny"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_dangerous_command() with cron session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCronDenyMode:
|
|
"""When HERMES_CRON_SESSION is set and cron_mode=deny, dangerous commands are blocked."""
|
|
|
|
def test_dangerous_command_blocked_in_cron_deny_mode(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert not result["approved"]
|
|
assert "BLOCKED" in result["message"]
|
|
assert "cron_mode" in result["message"]
|
|
|
|
def test_safe_command_allowed_in_cron_deny_mode(self, monkeypatch):
|
|
"""Non-dangerous commands still work even with cron_mode=deny."""
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
result = check_dangerous_command("ls -la", "local")
|
|
assert result["approved"]
|
|
|
|
def test_multiple_dangerous_patterns_blocked(self, monkeypatch):
|
|
"""All dangerous patterns are blocked, not just rm."""
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
dangerous_commands = [
|
|
"rm -rf /",
|
|
"chmod 777 /etc/passwd",
|
|
"mkfs.ext4 /dev/sda1",
|
|
"dd if=/dev/zero of=/dev/sda",
|
|
]
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
for cmd in dangerous_commands:
|
|
is_dangerous, _, _ = detect_dangerous_command(cmd)
|
|
if is_dangerous:
|
|
result = check_dangerous_command(cmd, "local")
|
|
assert not result["approved"], f"Should be blocked: {cmd}"
|
|
assert "BLOCKED" in result["message"]
|
|
|
|
def test_block_message_includes_description(self, monkeypatch):
|
|
"""The block message should mention what pattern was matched."""
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert not result["approved"]
|
|
# Should contain the description of what was flagged
|
|
assert "dangerous" in result["message"].lower() or "delete" in result["message"].lower()
|
|
|
|
|
|
class TestCronApproveMode:
|
|
"""When HERMES_CRON_SESSION is set and cron_mode=approve, dangerous commands pass through."""
|
|
|
|
def test_dangerous_command_allowed_in_cron_approve_mode(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="approve"):
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert result["approved"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# check_all_command_guards() with cron session
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCronDenyModeAllGuards:
|
|
"""The combined guard function also respects cron_mode."""
|
|
|
|
def test_dangerous_command_blocked_in_combined_guard(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
|
|
assert not result["approved"]
|
|
assert "BLOCKED" in result["message"]
|
|
|
|
def test_safe_command_allowed_in_combined_guard(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
result = check_all_command_guards("echo hello", "local")
|
|
assert result["approved"]
|
|
|
|
def test_combined_guard_approve_mode(self, monkeypatch):
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_EXEC_ASK", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="approve"):
|
|
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
|
|
assert result["approved"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge cases: cron mode interaction with other approval mechanisms
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCronModeInteractions:
|
|
"""Cron mode should NOT interfere with other approval bypass mechanisms."""
|
|
|
|
def test_container_env_still_auto_approves(self, monkeypatch):
|
|
"""Docker/sandbox environments bypass approvals regardless of cron_mode."""
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
result = check_dangerous_command("rm -rf /", "docker")
|
|
assert result["approved"]
|
|
|
|
def test_yolo_overrides_cron_deny(self, monkeypatch):
|
|
"""--yolo still bypasses cron_mode=deny for dangerous (non-hardline) commands."""
|
|
monkeypatch.setenv("HERMES_CRON_SESSION", "1")
|
|
monkeypatch.setenv("HERMES_YOLO_MODE", "1")
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
with mock_patch("tools.approval._get_cron_approval_mode", return_value="deny"):
|
|
# Use a dangerous-but-not-hardline command — `rm -rf /` is now
|
|
# hardline-blocked regardless of yolo (see test_hardline_blocklist.py).
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert result["approved"]
|
|
|
|
def test_non_cron_non_interactive_still_auto_approves(self, monkeypatch):
|
|
"""Non-cron, non-interactive sessions (e.g. scripted usage) still auto-approve."""
|
|
monkeypatch.delenv("HERMES_CRON_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_INTERACTIVE", raising=False)
|
|
monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False)
|
|
monkeypatch.delenv("HERMES_YOLO_MODE", raising=False)
|
|
|
|
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
|
|
assert result["approved"]
|