feat(cron): config-gated agent scheduling in cron context

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.
This commit is contained in:
Victor Kyriazakos
2026-08-10 20:26:48 +00:00
committed by Teknium
parent 4901564694
commit 6e76c2698c
4 changed files with 125 additions and 4 deletions
+12 -3
View File
@@ -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:
+8
View File
@@ -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
+104
View File
@@ -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
+1 -1
View File
@@ -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": {