Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,7 @@ def _ensure_hermes_home_managed(home: Path):
#
# cron_mode — what to do when a cron job hits a dangerous command:
# deny — block the command and let the agent find another way (default, safe)
# allowlist — allow only command_allowlist entries, block everything else
# approve — auto-approve all dangerous commands in cron jobs
"approvals": {
"mode": "manual",
Expand Down
84 changes: 84 additions & 0 deletions tests/tools/test_cron_approval_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ def test_explicit_approve(self):
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "approve"}}):
assert _get_cron_approval_mode() == "approve"

def test_explicit_allowlist(self):
from unittest.mock import patch as mock_patch
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "allowlist"}}):
assert _get_cron_approval_mode() == "allowlist"

def test_allow_list_alias(self):
from unittest.mock import patch as mock_patch
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"cron_mode": "allow-list"}}):
assert _get_cron_approval_mode() == "allowlist"

def test_off_maps_to_approve(self):
"""'off' is an alias for 'approve' (matches --yolo semantics)."""
from unittest.mock import patch as mock_patch
Expand Down Expand Up @@ -153,6 +163,22 @@ def test_block_message_includes_description(self, monkeypatch):
# Should contain the description of what was flagged
assert "dangerous" in result["message"].lower() or "delete" in result["message"].lower()

def test_deny_mode_blocks_even_permanent_allowlist(self, monkeypatch):
"""cron_mode=deny should remain stricter than command_allowlist."""
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)

_, pattern_key, _ = detect_dangerous_command("rm -rf /tmp/stuff")
approval_module.approve_permanent(pattern_key)

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"]


class TestCronApproveMode:
"""When HERMES_CRON_SESSION is set and cron_mode=approve, dangerous commands pass through."""
Expand All @@ -169,6 +195,36 @@ def test_dangerous_command_allowed_in_cron_approve_mode(self, monkeypatch):
assert result["approved"]


class TestCronAllowlistMode:
"""When cron_mode=allowlist, only command_allowlist entries pass."""

def test_allowlisted_dangerous_command_allowed(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)

_, pattern_key, _ = detect_dangerous_command("rm -rf /tmp/stuff")
approval_module.approve_permanent(pattern_key)

from unittest.mock import patch as mock_patch
with mock_patch("tools.approval._get_cron_approval_mode", return_value="allowlist"):
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
assert result["approved"]

def test_unlisted_dangerous_command_blocked(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="allowlist"):
result = check_dangerous_command("rm -rf /tmp/stuff", "local")
assert not result["approved"]
assert "command_allowlist" in result["message"]


# ---------------------------------------------------------------------------
# check_all_command_guards() with cron session
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -213,6 +269,34 @@ def test_combined_guard_approve_mode(self, monkeypatch):
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
assert result["approved"]

def test_combined_guard_allowlist_mode_allows_listed_pattern(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)

_, pattern_key, _ = detect_dangerous_command("rm -rf /tmp/stuff")
approval_module.approve_permanent(pattern_key)

from unittest.mock import patch as mock_patch
with mock_patch("tools.approval._get_cron_approval_mode", return_value="allowlist"):
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
assert result["approved"]

def test_combined_guard_allowlist_mode_blocks_unlisted_pattern(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="allowlist"):
result = check_all_command_guards("rm -rf /tmp/stuff", "local")
assert not result["approved"]
assert "command_allowlist" in result["message"]


# ---------------------------------------------------------------------------
# Edge cases: cron mode interaction with other approval mechanisms
Expand Down
72 changes: 50 additions & 22 deletions tools/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,11 +728,13 @@ def _get_approval_timeout() -> int:


def _get_cron_approval_mode() -> str:
"""Read the cron approval mode from config. Returns 'deny' or 'approve'."""
"""Read the cron approval mode from config."""
try:
from hermes_cli.config import load_config
config = load_config()
mode = str(cfg_get(config, "approvals", "cron_mode", default="deny")).lower().strip()
if mode in ("allowlist", "allow-list"):
return "allowlist"
if mode in ("approve", "off", "allow", "yes"):
return "approve"
return "deny"
Expand Down Expand Up @@ -825,28 +827,41 @@ def check_dangerous_command(command: str, env_type: str,
return {"approved": True, "message": None}

session_key = get_current_session_key()
if is_approved(session_key, pattern_key):
return {"approved": True, "message": None}

is_cli = os.getenv("HERMES_INTERACTIVE")
is_gateway = os.getenv("HERMES_GATEWAY_SESSION")

if not is_cli and not is_gateway:
# Cron sessions: respect cron_mode config
if os.getenv("HERMES_CRON_SESSION"):
if _get_cron_approval_mode() == "deny":
cron_mode = _get_cron_approval_mode()
if cron_mode == "approve":
return {"approved": True, "message": None}
if cron_mode == "allowlist" and is_approved(session_key, pattern_key):
return {"approved": True, "message": None}
if cron_mode in ("deny", "allowlist"):
allow_hint = (
"Add this command pattern to command_allowlist or rewrite the job "
"to avoid the dangerous command."
if cron_mode == "allowlist"
else (
"Find an alternative approach that avoids this command. "
"To allow dangerous commands in cron jobs, set "
"approvals.cron_mode: approve in config.yaml."
)
)
return {
"approved": False,
"message": (
f"BLOCKED: Command flagged as dangerous ({description}) "
"but cron jobs run without a user present to approve it. "
"Find an alternative approach that avoids this command. "
"To allow dangerous commands in cron jobs, set "
"approvals.cron_mode: approve in config.yaml."
f"{allow_hint}"
),
}
return {"approved": True, "message": None}

if is_approved(session_key, pattern_key):
return {"approved": True, "message": None}

if is_gateway or os.getenv("HERMES_EXEC_ASK"):
submit_pending(session_key, {
"command": command,
Expand Down Expand Up @@ -954,20 +969,33 @@ def check_all_command_guards(command: str, env_type: str,
if not is_cli and not is_gateway and not is_ask:
# Cron sessions: respect cron_mode config
if os.getenv("HERMES_CRON_SESSION"):
if _get_cron_approval_mode() == "deny":
# Run detection to get a description for the block message
is_dangerous, _pk, description = detect_dangerous_command(command)
if is_dangerous:
return {
"approved": False,
"message": (
f"BLOCKED: Command flagged as dangerous ({description}) "
"but cron jobs run without a user present to approve it. "
"Find an alternative approach that avoids this command. "
"To allow dangerous commands in cron jobs, set "
"approvals.cron_mode: approve in config.yaml."
),
}
cron_mode = _get_cron_approval_mode()
if cron_mode == "approve":
return {"approved": True, "message": None}
# Run detection to get a description for the block message
is_dangerous, pattern_key, description = detect_dangerous_command(command)
if is_dangerous:
session_key = get_current_session_key()
if cron_mode == "allowlist" and is_approved(session_key, pattern_key):
return {"approved": True, "message": None}
allow_hint = (
"Add this command pattern to command_allowlist or rewrite the job "
"to avoid the dangerous command."
if cron_mode == "allowlist"
else (
"Find an alternative approach that avoids this command. "
"To allow dangerous commands in cron jobs, set "
"approvals.cron_mode: approve in config.yaml."
)
)
return {
"approved": False,
"message": (
f"BLOCKED: Command flagged as dangerous ({description}) "
"but cron jobs run without a user present to approve it. "
f"{allow_hint}"
),
}
return {"approved": True, "message": None}

# --- Phase 1: Gather findings from both checks ---
Expand Down
Loading