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:
committed by
Teknium
parent
4901564694
commit
6e76c2698c
+12
-3
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user