From 6e76c2698cecd8a08a5397faa26afb741352a1e0 Mon Sep 17 00:00:00 2001 From: Victor Kyriazakos Date: Mon, 10 Aug 2026 20:26:48 +0000 Subject: [PATCH] feat(cron): config-gated agent scheduling in cron context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cron-spawned agents have the cronjob toolset unconditionally denied, so scheduled agents cannot create, tune, or remove jobs even when an operator wants exactly that (reconciler-style jobs that manage a team's cron table, follow-up one-shots scheduled from within scheduled work). The denial is loop-prevention policy, not a security boundary: an agent with the terminal toolset can already shell out to the CLI, so the workaround exists but skips every limit and accounting layer. Add cron.allow_agent_scheduling (config.yaml, default false — byte-exact current behavior). When enabled, only 'cronjob' leaves the cron-context denylist; 'messaging' and 'clarify' remain denied as interactivity constraints, and the user-level agent.disabled_toolsets layering is unchanged, so a user denylist entry still beats the gate. The cronjob tool description now states the real policy and the quota bounds instead of a blanket prohibition. --- cron/scheduler.py | 15 +++- hermes_cli/config_defaults.py | 8 ++ tests/cron/test_agent_scheduling_gate.py | 104 +++++++++++++++++++++++ tools/cronjob_tools.py | 2 +- 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 tests/cron/test_agent_scheduling_gate.py diff --git a/cron/scheduler.py b/cron/scheduler.py index 14cac7054f..51dcd3a35a 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -169,19 +169,28 @@ class CronPromptInjectionBlocked(Exception): def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]: """Toolsets a cron-spawned agent must never receive. - Four protected toolsets are always disabled in cron context: - - ``cronjob`` — would let a cron-spawned agent schedule more cron jobs + Three toolsets are always disabled in cron context regardless of config: - ``messaging`` — interactive, needs a live gateway session - ``clarify`` — interactive, blocks waiting for user input - ``memory`` — cron agents are constructed with ``skip_memory=True``, so exposing this tool only gives the model an unbacked tool that fails + ``cronjob`` is policy-denied by default (loop prevention, not a security + boundary) and config-gated: setting ``cron.allow_agent_scheduling: true`` + in config.yaml drops it from the base denylist so cron-spawned agents may + manage the user's cron table. The gate only removes the built-in policy + denial — it never overrides the user denylist below. + User-level ``agent.disabled_toolsets`` from config.yaml is layered on top so per-job ``enabled_toolsets`` cannot bypass policy that applies to ordinary agent runs (#25752 — LLM-supplied enabled_toolsets was widening past config.yaml's denylist). """ - disabled = ["cronjob", "messaging", "clarify", "memory"] + cron_cfg = (cfg or {}).get("cron") or {} + if cron_cfg.get("allow_agent_scheduling"): + disabled = ["messaging", "clarify", "memory"] + else: + disabled = ["cronjob", "messaging", "clarify", "memory"] agent_cfg = (cfg or {}).get("agent") or {} user_disabled = agent_cfg.get("disabled_toolsets") or [] for name in user_disabled: diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 18ac017853..f68c833f97 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -2255,6 +2255,14 @@ DEFAULT_CONFIG = { }, "cron": { + # Allow cron-spawned agents to use the cronjob toolset (create/edit/ + # remove scheduled jobs from within a cron run — the "cron-librarian" + # pattern). Off by default: the cronjob toolset is policy-denied in + # cron context to prevent unattended scheduling loops. Jobs created + # this way are user-owned in the same flat jobs table as every other + # job. Interactive toolsets (messaging/clarify) stay denied in cron + # context regardless of this setting. + "allow_agent_scheduling": False, # Pre-dispatch configuration validation (T1-26): before constructing # any agent machinery for a job, verify the provider API key resolves # (unless a fallback chain is configured), attached skills are ready diff --git a/tests/cron/test_agent_scheduling_gate.py b/tests/cron/test_agent_scheduling_gate.py new file mode 100644 index 0000000000..6e80dc6823 --- /dev/null +++ b/tests/cron/test_agent_scheduling_gate.py @@ -0,0 +1,104 @@ +"""Behavior contract for the cron.allow_agent_scheduling config gate. + +``_resolve_cron_disabled_toolsets`` decides which toolsets a cron-spawned +agent must never receive. Historically ``cronjob`` was hard-denied there as +loop-prevention policy. The ``cron.allow_agent_scheduling`` gate (config.yaml, +default off) makes that denial opt-out-able: + + - gate off / absent: byte-exact current behavior — ``cronjob`` denied. + - gate on: ``cronjob`` dropped from the base denylist; ``messaging`` and + ``clarify`` (interactivity constraints) and ``memory`` (cron agents run + with skip_memory=True) are ALWAYS denied regardless of the gate. + - user-level ``agent.disabled_toolsets`` still layers on top, so a user who + denies ``cronjob`` globally keeps it denied even with the gate on + (per-job enabled_toolsets can never widen past the config denylist). +""" + +import pytest + +from cron.scheduler import _resolve_cron_disabled_toolsets + + +# The toolsets that must be denied in cron context no matter what the +# agent-scheduling gate says: messaging/clarify are interactive-only, +# memory is unbacked in cron runs (skip_memory=True). +ALWAYS_DISABLED = ["messaging", "clarify", "memory"] + + +class TestGateOffDefault: + def test_empty_config_denies_cronjob(self): + assert _resolve_cron_disabled_toolsets({}) == [ + "cronjob", "messaging", "clarify", "memory", + ] + + def test_none_config_denies_cronjob(self): + assert _resolve_cron_disabled_toolsets(None) == [ + "cronjob", "messaging", "clarify", "memory", + ] + + def test_cron_section_present_but_gate_absent(self): + cfg = {"cron": {"preflight": True}} + assert _resolve_cron_disabled_toolsets(cfg) == [ + "cronjob", "messaging", "clarify", "memory", + ] + + def test_explicit_false_matches_default(self): + cfg = {"cron": {"allow_agent_scheduling": False}} + assert _resolve_cron_disabled_toolsets(cfg) == \ + _resolve_cron_disabled_toolsets({}) + + @pytest.mark.parametrize("falsy", [False, None, "", 0]) + def test_falsy_values_keep_gate_off(self, falsy): + cfg = {"cron": {"allow_agent_scheduling": falsy}} + disabled = _resolve_cron_disabled_toolsets(cfg) + assert "cronjob" in disabled + + +class TestGateOn: + def test_cronjob_dropped_from_denylist(self): + cfg = {"cron": {"allow_agent_scheduling": True}} + disabled = _resolve_cron_disabled_toolsets(cfg) + assert "cronjob" not in disabled + + def test_interactivity_and_memory_denials_survive_the_gate(self): + cfg = {"cron": {"allow_agent_scheduling": True}} + disabled = _resolve_cron_disabled_toolsets(cfg) + for name in ALWAYS_DISABLED: + assert name in disabled + + def test_user_denylist_wins_over_gate(self): + # A user who denies cronjob in agent.disabled_toolsets keeps it + # denied even with the gate on — the gate only removes the built-in + # policy denial, never the user's own config denylist. + cfg = { + "cron": {"allow_agent_scheduling": True}, + "agent": {"disabled_toolsets": ["cronjob"]}, + } + assert "cronjob" in _resolve_cron_disabled_toolsets(cfg) + + def test_unrelated_user_denylist_layers_without_reviving_cronjob(self): + cfg = { + "cron": {"allow_agent_scheduling": True}, + "agent": {"disabled_toolsets": ["browser"]}, + } + disabled = _resolve_cron_disabled_toolsets(cfg) + assert "browser" in disabled + assert "cronjob" not in disabled + + +class TestUserLayerUnchanged: + def test_user_denylist_still_layers_when_gate_off(self): + cfg = {"agent": {"disabled_toolsets": ["browser", "cronjob"]}} + disabled = _resolve_cron_disabled_toolsets(cfg) + assert "browser" in disabled + # No duplicate when the user names an already-denied toolset. + assert disabled.count("cronjob") == 1 + + def test_blank_and_whitespace_entries_ignored(self): + cfg = { + "cron": {"allow_agent_scheduling": True}, + "agent": {"disabled_toolsets": ["", " ", "browser"]}, + } + disabled = _resolve_cron_disabled_toolsets(cfg) + assert "browser" in disabled + assert "" not in disabled diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index de5ba5441b..4b4ec36460 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -1459,7 +1459,7 @@ NOTE: The agent's final response is auto-delivered to the target. Put the primar user-facing content in the final response. Cron jobs run autonomously with no user present — they cannot ask questions or request clarification. -Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""", +Scheduling from cron-run sessions is disabled by default and enabled via cron.allow_agent_scheduling in config.yaml. When enabled, jobs created from a cron run are user-owned in the same flat job table as every other job, and their delivery resolves to the creating job's own persistent target — never to the ephemeral cron-run session. Prefer updating an existing job (list first, then update by job_id) over creating near-duplicates.""", "parameters": { "type": "object", "properties": {