diff --git a/cron/scheduler.py b/cron/scheduler.py index 6b511d38b77..37c250b67d0 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -57,6 +57,29 @@ class CronPromptInjectionBlocked(Exception): """ +def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]: + """Toolsets a cron-spawned agent must never receive. + + Three protected toolsets are always disabled in cron context: + - ``cronjob`` — would let a cron-spawned agent schedule more cron jobs + - ``messaging`` — interactive, needs a live gateway session + - ``clarify`` — interactive, blocks waiting for user input + + 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"] + agent_cfg = (cfg or {}).get("agent") or {} + user_disabled = agent_cfg.get("disabled_toolsets") or [] + for name in user_disabled: + name = str(name).strip() + if name and name not in disabled: + disabled.append(name) + return disabled + + def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: """Resolve the toolset list for a cron job. @@ -1574,7 +1597,7 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]: provider_sort=pr.get("sort"), openrouter_min_coding_score=(_cfg.get("openrouter") or {}).get("min_coding_score"), enabled_toolsets=_resolve_cron_enabled_toolsets(job, _cfg), - disabled_toolsets=["cronjob", "messaging", "clarify"], + disabled_toolsets=_resolve_cron_disabled_toolsets(_cfg), quiet_mode=True, # Cron jobs should always inherit the user's SOUL.md identity from # HERMES_HOME. When a workdir is configured, also inject project diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 62bc6b688a0..94587fccedd 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1021,6 +1021,42 @@ class TestRunJobSessionPersistence: kwargs = mock_agent_cls.call_args.kwargs assert kwargs["enabled_toolsets"] == ["web", "terminal", "file"] + def test_run_job_disabled_toolsets_layer_user_config_on_baseline(self, tmp_path): + """agent.disabled_toolsets must be honoured in cron — issue #25752. + + The bug: per-job enabled_toolsets was returned verbatim, letting an + LLM-supplied cronjob() call re-enable tools the operator had globally + disabled. The fix: ALWAYS include agent.disabled_toolsets in the + disabled_toolsets passed to AIAgent, on top of the cron baseline + (cronjob/messaging/clarify). AIAgent's disabled_toolsets takes + precedence over enabled_toolsets, so this stops the bypass. + """ + (tmp_path / "config.yaml").write_text( + "agent:\n" + " disabled_toolsets:\n" + " - terminal\n" + " - file\n", + encoding="utf-8", + ) + job = { + "id": "policy-job", + "name": "test", + "prompt": "hello", + "enabled_toolsets": ["web", "terminal", "file"], + } + fake_db, patches = self._make_run_job_patches(tmp_path) + with patches[0], patches[1], patches[2], patches[3], patches[4], \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + run_job(job) + + kwargs = mock_agent_cls.call_args.kwargs + assert set(kwargs["disabled_toolsets"]) >= { + "cronjob", "messaging", "clarify", "terminal", "file", + } + def test_run_job_enabled_toolsets_resolves_from_platform_config_when_not_set(self, tmp_path): """When a job has no explicit enabled_toolsets, the scheduler now resolves them from ``hermes tools`` platform config for ``cron``