diff --git a/cron/scheduler.py b/cron/scheduler.py index eb43196a7dd4..998af72d7727 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -41,6 +41,7 @@ from hermes_constants import get_hermes_home from hermes_cli._subprocess_compat import windows_hide_flags from hermes_cli.config import load_config, _expand_env_vars +from hermes_cli.fallback_config import get_fallback_chain from hermes_time import now as _hermes_now logger = logging.getLogger(__name__) @@ -236,7 +237,7 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None: "QQBOT_HOME_CHANNEL": "QQ_HOME_CHANNEL", } -from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run +from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch # Sentinel: when a cron agent has nothing new to report, it can start its # response with this marker to suppress delivery. Output is still saved @@ -1245,13 +1246,13 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option DeliveryRouter, DeliveryTarget, _looks_like_int, - _looks_like_telegram_private_chat_id, + looks_like_telegram_private_chat_id, ) is_private_dm_topic = ( platform == Platform.TELEGRAM and thread_id is not None - and _looks_like_telegram_private_chat_id(str(chat_id)) + and looks_like_telegram_private_chat_id(str(chat_id)) and _looks_like_int(str(thread_id)) ) if is_private_dm_topic: @@ -1968,6 +1969,52 @@ def _scan_assembled_cron_prompt( return assembled +def _guard_job_credential_exfil(job: dict) -> None: + """Fail closed if a job's stored provider/base_url pair would exfiltrate a + credential (F8 runtime backstop; CWE-200/CWE-522). + + The model-callable cron tool validates this on create/update, but a job + persisted before that guard — or written directly to the jobs store — + reaches the scheduler's provider-resolution sink unchecked. Re-validate the + EFFECTIVE stored pair with the same guard the tool uses, so a named + provider's stored key is never paired with an off-host base_url at fire + time. Raises ``RuntimeError`` (caught by the run_job failure path → the run + is aborted and reported) when the pair is unsafe; returns ``None`` otherwise. + + Fallback providers come from operator config, not the model-callable job, so + they are trusted and validated by the caller, not here. + """ + try: + from tools.cronjob_tools import _validate_cron_base_url + err = _validate_cron_base_url(job.get("provider"), job.get("base_url")) + except Exception as exc: + # Fail CLOSED: this is the last guard before provider resolution, so an + # unexpected validator/import error must not silently allow an unvetted + # pair through. A job that carries no base_url override cannot exfiltrate + # a stored credential via this path (there is nothing to validate, and + # the validator would return None), so it still runs — that keeps the + # overwhelmingly-common no-override jobs from wedging on an unrelated + # error. But any job that DID set a base_url is refused until the + # validator can actually vet the pair. Operator fallback providers come + # from config, not the job, so they are unaffected. + if job.get("base_url"): + err = ( + f"could not validate provider/base_url pair " + f"({exc.__class__.__name__}: {exc}); refusing to run a job with " + "an unverified base_url override" + ) + else: + err = None + if err: + job_id = job.get("id") + logger.error( + "Job '%s': refusing to run — unsafe provider/base_url pair could " + "exfiltrate a stored credential: %s", + job_id, err, + ) + raise RuntimeError(f"Cron job '{job_id}' blocked for safety: {err}") + + def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: """ Execute a single cron job. @@ -2225,12 +2272,23 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: try: # Re-read .env and config.yaml fresh every run so provider/key - # changes take effect without a gateway restart. - from dotenv import load_dotenv - try: - load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="utf-8") - except UnicodeDecodeError: - load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="latin-1") + # changes take effect without a gateway restart. Route through + # load_hermes_dotenv (not a bare load_dotenv) and reset the secret- + # source cache first: startup already applied external secrets and + # recorded this HERMES_HOME in _APPLIED_HOMES, so a naive reload would + # re-apply only the .env placeholder and never re-resolve a Bitwarden/ + # BSM-backed secret — leaving cron jobs 401'ing on the placeholder + # (#33465). Clearing the cache forces the re-pull; the resolved secret + # overrides the placeholder only when secrets.bitwarden.override_existing + # is set (mirrors startup), and the Bitwarden value-cache keeps the + # forced re-pull off the network. load_hermes_dotenv also handles the + # utf-8/latin-1 encoding fallback internally. + from hermes_cli.env_loader import ( + load_hermes_dotenv, + reset_secret_source_cache, + ) + reset_secret_source_cache() + load_hermes_dotenv(hermes_home=_get_hermes_home()) delivery_target = _resolve_delivery_target(job) if delivery_target: @@ -2344,6 +2402,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: format_runtime_provider_error, ) from hermes_cli.auth import AuthError + + # F8 runtime backstop: never resolve a stored provider/base_url pair that + # would ship a named provider's stored credential to an off-host endpoint + # (CWE-200/CWE-522). The cron tool validates this on create/update, but a + # job persisted before that guard — or written directly to the jobs store + # — reaches this sink unchecked. Fail closed before resolution so no + # off-host call is ever made with a stored key. + _guard_job_credential_exfil(job) + try: # Do not inject HERMES_INFERENCE_PROVIDER here. resolve_runtime_provider() # already prefers persisted config over stale shell/env overrides when @@ -2359,12 +2426,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: except AuthError as auth_exc: # Primary provider auth failed — try fallback chain before giving up. logger.warning("Job '%s': primary auth failed (%s), trying fallback", job_id, auth_exc) - fb = _cfg.get("fallback_providers") or _cfg.get("fallback_model") - fb_list = (fb if isinstance(fb, list) else [fb]) if fb else [] + fb_list = get_fallback_chain(_cfg) runtime = None for entry in fb_list: - if not isinstance(entry, dict): - continue try: fb_kwargs = {"requested": entry.get("provider")} if entry.get("base_url"): @@ -2436,7 +2500,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: f"(or pin the original values to keep them). See #44585." ) - fallback_model = _cfg.get("fallback_providers") or _cfg.get("fallback_model") or None + fallback_model = get_fallback_chain(_cfg) or None credential_pool = None runtime_provider = str(runtime.get("provider") or "").strip().lower() if runtime_provider: @@ -2770,6 +2834,20 @@ def run_one_job(job: dict, *, adapters=None, loop=None, verbose: bool = False) - failure is recorded via ``mark_job_run``), False only if processing raised. """ try: + # Pre-run dispatch claim (issue #38758): atomically commit a finite + # one-shot's dispatch BEFORE its side effect runs, so a tick that dies + # mid-execution (gateway kill, OOM, segfault, hard-timeout) cannot + # re-fire the job forever on restart. No-op for recurring jobs (they + # use advance_next_run) and infinite/no-repeat jobs. This lives here in + # the shared body so BOTH the built-in ticker and the external provider + # (Chronos fire_due) get at-most-times semantics. + if not claim_dispatch(job["id"]): + logger.info( + "Job '%s': one-shot dispatch limit reached — skipping", + job.get("name", job["id"]), + ) + return True # not an error — already handled/removed + success, output, final_response, error = run_job(job) output_file = save_job_output(job["id"], output) diff --git a/scripts/release.py b/scripts/release.py index 0af7fd8c37c8..3cbd7f220b24 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -46,12 +46,9 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { "290873280+rrevenanttt@users.noreply.github.com": "rrevenanttt", # PR #40773 salvage (close hardline rm bypass via quoted paths and ${HOME} brace form) - "290871358+Vesna-9@users.noreply.github.com": "Vesna-9", # PR #41274 salvage (collapse shell line continuations before dangerous/hardline pattern matching so `rm -rf \/` can't bypass the yolo-proof hardline floor) "jnibarger01@gmail.com": "jnibarger01", # PR #35130 salvage (ReDoS-bound threat-pattern filler + FTS5 query cap + V4A Move-File approval/traversal targets) "290868363+petrichor-op@users.noreply.github.com": "petrichor-op", # PR #41281 salvage (never persist ephemeral empty-response recovery scaffolding to the SQLite session store / JSON log; filter by flag not position) "283494121+redactdeveloper@users.noreply.github.com": "redactdeveloper", # PR #36897 salvage (route /sessions & /history through prompt_toolkit-safe print; filter doctor missing-key summary to CLI-enabled toolsets) - "charleneleong84@gmail.com": "charleneleong-ai", # PR #11736 salvage (classify Anthropic "out of extra usage" 400 as billing) - "janrenz@Mac.fritz.box": "janrenz", # PR #35862 salvage (prompt_caching.enabled escape hatch for strict providers) "syahidfrd@gmail.com": "syahidfrd", # PR #17059 salvage (tag unverified senders in Slack thread context to mitigate indirect prompt injection) "22971845+H2KFORGIVEN@users.noreply.github.com": "H2KFORGIVEN", # PR #22523 salvage (turn-pair preservation: never orphan the last user ask at head_end during compaction) "5823452+sgabel@users.noreply.github.com": "sgabel", # PR #13139 salvage (redact secrets in user-facing approval prompts) @@ -102,14 +99,12 @@ "nikshepsvn@gmail.com": "nikshepsvn", # PR #27426 salvage (two-layer guard against hallucinated acp_command crashing the gateway on hosts with no ACP CLI) "65363919+coygeek@users.noreply.github.com": "coygeek", # PR #37735 salvage (redact provider error text at api-server HTTP boundary; #37733) "moonsong@nousresearch.local": "Tranquil-Flow", # PR #52623 salvage (auxiliary Anthropic base_url host validation; #52608) - "baris@writeme.com": "isair", # PR #50124 salvage (periodic FTS5 segment merge to curb write-lock contention; #54752) "140971685+Dr1985@users.noreply.github.com": "Dr1985", # PR #42567 salvage (launchd supervision detection + status reporting; #42524) "8180647+herbalizer404@users.noreply.github.com": "herbalizer404", # PR #49076 + #51835 salvage (auxiliary compression fallback: 403/session-usage payment errors + honor fallback chain when aux provider auth unavailable) "pyxl-dev@users.noreply.github.com": "pyxl-dev", # PR #52230 salvage (include rate-limit in auxiliary capacity-error fallback gate; #52228) "yashiel@skyner.co.za": "yashiels", # PR #53284 salvage (discord markdown table-to-bullet conversion; #21168) "46495124+yungchentang@users.noreply.github.com": "yungchentang", # PR #53622 salvage (drain Telegram general send pool on pool timeout before retry; #53524) "15205536+595650661@users.noreply.github.com": "595650661", # PR #37851 salvage (classify MiniMax new_sensitive content filter → content_policy_blocked; #32421) - "qWaitCrypto@users.noreply.github.com": "qWaitCrypto", # PR #52534 salvage (preserve assistant tool_use cache_control marker in Anthropic conversion so cache breakpoints aren't dropped from the wire) "benbenwyb@gmail.com": "benbenlijie", # PR #47205 salvage (named custom-provider extra_body + Z.AI Coding overload adaptive backoff; #50663) "dana@added-value.co.il": "Danamove", # PR #46726 salvage (kill venv-resident pythonw gateway before recreating venv on Windows; #47036/#47557/#47910) "rcint@klaith.com": "rc-int", # PR #9126 salvage / co-author (cap subagent summary size vs parent context overflow) @@ -164,15 +159,13 @@ "yehaotian@xuanshudeMac-mini.local": "ArcanePivot", "dbeyer7@gmail.com": "benegessarit", "264773240+MrDiamondBallz@users.noreply.github.com": "MrDiamondBallz", + "claudlos@agentmail.to": "claudlos", # PR #52351 salvage (cron base_url exfil guard; #) "94890352+Adolanium@users.noreply.github.com": "Adolanium", "kenmege@yahoo.com": "Kenmege", "tianying.x@eukarya.io": "xtymac", "dkobi16@gmail.com": "Diyoncrz18", "arnaud@nolimitdevelopment.com": "ali-nld", "sswdarius@gmail.com": "necoweb3", - "3483421977@qq.com": "xy200303", # PR #40663 (approval shell-command-name deobfuscation) - "30854794+YLChen-007@users.noreply.github.com": "YLChen-007", # PR #26965 (approval remote command substitution) - "1078345+egilewski@users.noreply.github.com": "egilewski", # co-author, PR #40663 "peterhao@Peters-MacBook-Air.local": "pinguarmy", "joe.rinaldijohnson@shopify.com": "joerj123", "adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI", diff --git a/tests/cron/test_scheduler_provider.py b/tests/cron/test_scheduler_provider.py index 00b03e9b2bf0..348caa4adff8 100644 --- a/tests/cron/test_scheduler_provider.py +++ b/tests/cron/test_scheduler_provider.py @@ -571,3 +571,98 @@ def test_cron_status_reports_stalled_when_no_heartbeat(tmp_path, monkeypatch, ca out = capsys.readouterr().out assert "STALLED" in out assert "will fire automatically" not in out + + +# ── F8: runtime backstop — never resolve a stored pair that exfiltrates a key ── + + +class TestGuardJobCredentialExfil: + """run_job() must fail closed before provider resolution when a job's stored + provider/base_url pair would ship a named provider's stored credential to an + off-host endpoint — covering jobs persisted before the create/update guard + or written directly to the store (F8 stored-job path; CWE-200/CWE-522).""" + + def test_named_registry_provider_offhost_is_blocked(self): + import pytest + from cron.scheduler import _guard_job_credential_exfil + + job = {"id": "j1", "provider": "anthropic", + "base_url": "https://evil.example/v1"} + with pytest.raises(RuntimeError) as exc: + _guard_job_credential_exfil(job) + assert "blocked for safety" in str(exc.value) + + def test_named_custom_offhost_is_blocked(self, monkeypatch): + import pytest + import hermes_cli.runtime_provider as rp + from cron.scheduler import _guard_job_credential_exfil + + monkeypatch.setattr(rp, "has_named_custom_provider", lambda n: True) + monkeypatch.setattr( + rp, "_get_named_custom_provider", + lambda n: {"name": "legit", "base_url": "https://legit.example/v1", + "api_key": "sk-legit"}, + ) + job = {"id": "j2", "provider": "custom:legit", + "base_url": "https://evil.example/v1"} + with pytest.raises(RuntimeError): + _guard_job_credential_exfil(job) + + def test_named_custom_matching_host_is_allowed(self, monkeypatch): + import hermes_cli.runtime_provider as rp + from cron.scheduler import _guard_job_credential_exfil + + monkeypatch.setattr(rp, "has_named_custom_provider", lambda n: True) + monkeypatch.setattr( + rp, "_get_named_custom_provider", + lambda n: {"name": "legit", "base_url": "https://legit.example/v1", + "api_key": "sk-legit"}, + ) + job = {"id": "j3", "provider": "custom:legit", + "base_url": "https://legit.example/v1"} + assert _guard_job_credential_exfil(job) is None + + def test_bare_custom_is_allowed(self): + from cron.scheduler import _guard_job_credential_exfil + + job = {"id": "j4", "provider": "custom", + "base_url": "https://anything.example/v1"} + assert _guard_job_credential_exfil(job) is None + + def test_no_base_url_is_allowed(self): + from cron.scheduler import _guard_job_credential_exfil + + assert _guard_job_credential_exfil({"id": "j5", "provider": "anthropic"}) is None + assert _guard_job_credential_exfil({"id": "j6"}) is None + + def test_validator_exception_with_base_url_fails_closed(self, monkeypatch): + # If the validator/import unexpectedly raises, this last-resort backstop + # must NOT allow a base_url-bearing job through to provider resolution + # (it cannot prove the stored pair is safe). Regression for the + # fail-open `except Exception: err = None` path. + import pytest + import tools.cronjob_tools as ct + from cron.scheduler import _guard_job_credential_exfil + + def _boom(provider, base_url): + raise RuntimeError("validator blew up") + + monkeypatch.setattr(ct, "_validate_cron_base_url", _boom) + job = {"id": "j7", "provider": "custom:legit", + "base_url": "https://evil.example/v1"} + with pytest.raises(RuntimeError) as exc: + _guard_job_credential_exfil(job) + assert "blocked for safety" in str(exc.value) + + def test_validator_exception_without_base_url_still_allowed(self, monkeypatch): + # A job with no base_url override can't exfiltrate via this path, so a + # validator error must not wedge it — only base_url-bearing jobs fail + # closed. + import tools.cronjob_tools as ct + from cron.scheduler import _guard_job_credential_exfil + + def _boom(provider, base_url): + raise RuntimeError("validator blew up") + + monkeypatch.setattr(ct, "_validate_cron_base_url", _boom) + assert _guard_job_credential_exfil({"id": "j8", "provider": "anthropic"}) is None diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 08c82f375134..41aea33c7dc0 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -336,6 +336,81 @@ def test_update_runtime_overrides_can_set_and_clear(self): assert updated["job"]["provider"] == "openrouter" assert updated["job"]["base_url"] is None + @staticmethod + def _patch_named_legit(monkeypatch): + import hermes_cli.runtime_provider as rp + monkeypatch.setattr(rp, "has_named_custom_provider", lambda n: True) + monkeypatch.setattr( + rp, "_get_named_custom_provider", + lambda n: {"name": "legit", "base_url": "https://legit.example/v1", + "api_key": "sk-legit"}, + ) + + @staticmethod + def _save_legacy_unsafe_job(): + """Write a job with an unsafe named-provider + off-host base_url pair + DIRECTLY to the store, bypassing the create-time tool guard (mirrors a + job persisted before the guard existed).""" + from cron.jobs import save_jobs + save_jobs([ + { + "id": "legacyunsafe1", + "name": "legacy", + "prompt": "x", + "schedule": {"kind": "interval", "minutes": 5, "display": "every 5m"}, + "schedule_display": "every 5m", + "repeat": {"times": None, "completed": 0}, + "enabled": True, + "state": "scheduled", + "provider": "custom:legit", + "base_url": "https://evil.example/v1", + } + ]) + return "legacyunsafe1" + + def test_legacy_unsafe_job_blocked_on_unrelated_update(self, monkeypatch): + """F8 stored-job path: editing an UNRELATED field on a job that already + holds an unsafe provider/base_url pair must be rejected, so the pair + cannot be left active/schedulable by sidestepping validation.""" + self._patch_named_legit(monkeypatch) + job_id = self._save_legacy_unsafe_job() + + result = json.loads(cronjob(action="update", job_id=job_id, name="renamed")) + assert result["success"] is False + assert "not allowed" in json.dumps(result) + + # The rejected update must not have mutated the stored job at all. + from cron.jobs import get_job + stored = get_job(job_id) + assert stored["name"] == "legacy" + assert stored["base_url"] == "https://evil.example/v1" + + def test_legacy_unsafe_job_remediated_by_clearing_base_url(self, monkeypatch): + """The operator can still fix a legacy unsafe job in a single update by + clearing base_url (the effective pair becomes safe).""" + self._patch_named_legit(monkeypatch) + job_id = self._save_legacy_unsafe_job() + + result = json.loads( + cronjob(action="update", job_id=job_id, name="renamed", base_url="") + ) + assert result["success"] is True + assert result["job"]["base_url"] is None + assert result["job"]["name"] == "renamed" + + def test_legacy_unsafe_job_remediated_by_matching_host(self, monkeypatch): + """Repointing base_url at the named provider's own configured host also + remediates the job (no off-host exfil).""" + self._patch_named_legit(monkeypatch) + job_id = self._save_legacy_unsafe_job() + + result = json.loads( + cronjob(action="update", job_id=job_id, + base_url="https://legit.example/v1") + ) + assert result["success"] is True + assert result["job"]["base_url"] == "https://legit.example/v1" + def test_create_skill_backed_job(self): result = json.loads( cronjob( @@ -581,3 +656,51 @@ def test_gateway_origin_no_notice(self, monkeypatch): ) assert created["deliver"] == "origin" assert "local-only cron job" not in created["message"] + + +class TestValidateCronBaseUrl: + """The cron base_url guard must not let a NAMED custom provider's stored + credential be sent to an off-host endpoint (CWE-200/CWE-522).""" + + @staticmethod + def _v(*args): + from tools.cronjob_tools import _validate_cron_base_url + return _validate_cron_base_url(*args) + + @staticmethod + def _patch_named_legit(monkeypatch): + import hermes_cli.runtime_provider as rp + monkeypatch.setattr(rp, "has_named_custom_provider", lambda n: True) + monkeypatch.setattr( + rp, "_get_named_custom_provider", + lambda n: {"name": "legit", "base_url": "https://legit.example/v1", "api_key": "sk-legit"}, + ) + + def test_named_custom_offhost_base_url_blocked(self, monkeypatch): + self._patch_named_legit(monkeypatch) + err = self._v("custom:legit", "https://evil.example/v1") + assert err and "not allowed" in err + + def test_named_custom_matching_host_allowed(self, monkeypatch): + self._patch_named_legit(monkeypatch) + assert self._v("custom:legit", "https://legit.example/v1") is None + # subdomain of the configured host is still the provider's own endpoint + assert self._v("custom:legit", "https://eu.legit.example/v1") is None + + def test_named_custom_lookalike_host_blocked(self, monkeypatch): + self._patch_named_legit(monkeypatch) + assert self._v("custom:legit", "https://legit.example.attacker.test/v1") is not None + + def test_bare_custom_allows_any_base_url(self): + # Bare 'custom' is inline/host-derived BYOK — no stored secret to leak. + assert self._v("custom", "https://anything.example/v1") is None + + def test_no_base_url_is_allowed(self): + assert self._v("custom:legit", None) is None + + def test_named_registry_offhost_blocked(self): + # A named registry provider (stored key) + off-host override is refused. + assert self._v("anthropic", "https://evil.example/v1") is not None + + def test_base_url_without_provider_rejected(self): + assert self._v(None, "https://x.example/v1") is not None diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 999297c20bb5..02ac58f9c608 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -445,6 +445,86 @@ def _normalize_deliver_param(value: Any) -> Optional[str]: return text or None +def _validate_cron_base_url( + provider: Optional[Any], base_url: Optional[Any] +) -> Optional[str]: + """Reject pairing a named provider's stored credential with an off-host base_url. + + The cron tool is model-callable, so a prompt-injected job could set a real + provider plus an attacker ``base_url``; on fire the scheduler resolves that + provider's stored API key and sends it to the URL, exfiltrating the + credential (CWE-200/CWE-522). Allow a ``base_url`` override only when it + cannot leak a stored secret: no override at all, a configured custom/byok + provider that carries its own endpoint+key, or an override whose host + matches the named provider's own endpoint. + + Returns an error string if blocked, else None (valid). + """ + bu = _normalize_optional_job_value(base_url, strip_trailing_slash=True) + if not bu: + return None + prov = _normalize_optional_job_value(provider) + if not prov: + # A base_url with no explicit provider inherits the default/session + # provider's stored key — the same exfil primitive without naming a + # provider. Require an explicit (custom) provider for custom endpoints. + return ( + "base_url override requires an explicit provider. Set provider to a " + "configured custom provider to use a custom endpoint." + ) + try: + from hermes_cli.runtime_provider import ( + has_named_custom_provider, + resolve_requested_provider, + _get_named_custom_provider, + ) + from hermes_cli.auth import PROVIDER_REGISTRY + from utils import base_url_host_matches, base_url_hostname + except Exception: + # Can't resolve provider metadata -> fail closed. + return f"Unable to validate base_url override for provider {prov!r}; refused." + + if prov.lower() == "custom": + # Bare/inline 'custom' (and aliases that resolve to it) is pure BYOK: the + # runtime derives the key from a pool keyed by THIS base_url or from + # host-gated env vars, never an arbitrary stored secret. Safe to allow. + return None + if has_named_custom_provider(prov): + # A NAMED custom provider carries a STORED key, and + # _resolve_named_custom_runtime prefers the override base_url while still + # sending that stored key — so an off-host override exfiltrates it. + # Require the override host to match the provider's CONFIGURED endpoint. + try: + cp = _get_named_custom_provider(prov) + except Exception: + cp = None + cfg_host = base_url_hostname((cp or {}).get("base_url", "")) if cp else "" + if cfg_host and base_url_host_matches(bu, cfg_host): + return None + return ( + f"base_url {bu!r} is not allowed for provider {prov!r}. A named " + f"custom provider's stored credential may only be sent to its own " + f"configured endpoint ({cfg_host or 'unknown'})." + ) + try: + resolved = resolve_requested_provider(prov) + except Exception: + resolved = prov + pconfig = PROVIDER_REGISTRY.get(resolved) if isinstance(resolved, str) else None + known_host = base_url_hostname(getattr(pconfig, "inference_base_url", "") if pconfig else "") + if known_host and base_url_host_matches(bu, known_host): + return None + # Fail closed: any non-custom provider we cannot host-match to its own + # endpoint is refused. This covers named providers with a stored credential + # AND aliases/unknown names we can't resolve to a known host (e.g. "openai", + # "google"), which would otherwise pair a stored key with the override URL. + return ( + f"base_url {bu!r} is not allowed for provider {prov!r}. A named " + f"provider's stored credential may only be sent to its own endpoint; " + f'use a configured custom provider (provider="custom") for a custom base_url.' + ) + + def _validate_cron_script_path(script: Optional[str]) -> Optional[str]: """Validate a cron job script path at the API boundary. @@ -625,6 +705,12 @@ def cronjob( if script_error: return tool_error(script_error, success=False) + # Reject a model-supplied base_url that would route a named + # provider's stored credential to an attacker endpoint (F8). + base_url_error = _validate_cron_base_url(provider, base_url) + if base_url_error: + return tool_error(base_url_error, success=False) + # Validate context_from references existing jobs if context_from: from cron.jobs import get_job as _get_job @@ -779,6 +865,25 @@ def cronjob( updates["provider"] = _normalize_optional_job_value(provider) if base_url is not None: updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True) + # Re-validate the EFFECTIVE provider/base_url on EVERY update, not + # only when this update supplies provider/base_url. A job persisted + # before this guard (or written directly to the jobs store) may + # already hold an unsafe named-provider + off-host base_url pair; + # if we only checked when the update touches those axes, editing any + # unrelated field (name, schedule, ...) would succeed and leave that + # exfil-capable pair active and schedulable (F8). The effective pair + # merges this update's normalized values over the stored job; an + # operator can still remediate in the same update by clearing + # base_url or pointing provider/base_url at a safe pair. + eff_provider = ( + updates["provider"] if "provider" in updates else job.get("provider") + ) + eff_base_url = ( + updates["base_url"] if "base_url" in updates else job.get("base_url") + ) + base_url_error = _validate_cron_base_url(eff_provider, eff_base_url) + if base_url_error: + return tool_error(base_url_error, success=False) if script is not None: # Pass empty string to clear an existing script if script: