feat: configurable approval mode for cron jobs (approvals.cron_mode)

Add approvals.cron_mode config option that controls how cron jobs handle
dangerous commands. Previously, cron jobs silently auto-approved all
dangerous commands because there was no user present to approve them.

Now the behavior is configurable:
  - deny (default): block dangerous commands and return a message telling
    the agent to find an alternative approach. The agent loop continues —
    it just can't use that specific command.
  - approve: auto-approve all dangerous commands (previous behavior).

When a command is blocked, the agent receives the same response format as
a user denial in the CLI — exit_code=-1, status=blocked, with a message
explaining why and pointing to the config option. This keeps the agent
loop running and encourages it to adapt.

Implementation:
  - config.py: add approvals.cron_mode to DEFAULT_CONFIG
  - scheduler.py: set HERMES_CRON_SESSION=1 env var before agent runs
  - approval.py: both check_command_approval() and check_all_command_guards()
    now check for cron sessions and apply the configured mode
  - 21 new tests covering config parsing, deny/approve behavior, and
    interaction with other bypass mechanisms (yolo, containers)
This commit is contained in:
Teknium
2026-03-29 11:37:06 -07:00
committed by Teknium
parent b02833f32d
commit 762f7e9796
4 changed files with 308 additions and 0 deletions

View File

@@ -532,6 +532,19 @@ def _get_approval_timeout() -> int:
return 60
def _get_cron_approval_mode() -> str:
"""Read the cron approval mode from config. Returns 'deny' or 'approve'."""
try:
from hermes_cli.config import load_config
config = load_config()
mode = str(config.get("approvals", {}).get("cron_mode", "deny")).lower().strip()
if mode in ("approve", "off", "allow", "yes"):
return "approve"
return "deny"
except Exception:
return "deny"
def _smart_approve(command: str, description: str) -> str:
"""Use the auxiliary LLM to assess risk and decide approval.
@@ -614,6 +627,19 @@ def check_dangerous_command(command: str, env_type: str,
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":
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."
),
}
return {"approved": True, "message": None}
if is_gateway or os.getenv("HERMES_EXEC_ASK"):
@@ -712,6 +738,22 @@ def check_all_command_guards(command: str, env_type: str,
# Preserve the existing non-interactive behavior: outside CLI/gateway/ask
# flows, we do not block on approvals and we skip external guard work.
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."
),
}
return {"approved": True, "message": None}
# --- Phase 1: Gather findings from both checks ---