diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index d21aa7453f..f2b01787d8 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -689,6 +689,29 @@ class TestValidateCronBaseUrl: self._patch_named_legit(monkeypatch) # named custom provider arm refused_off_origin("custom:legit", "legit.example") + def test_bare_custom_sends_a_stored_key_only_to_a_configured_origin(self, monkeypatch): + # Whatever key the real resolver would attach to a bare-custom base_url, the guard lets + # the job through only at the origin the operator configured. No key: still pure BYOK. + from hermes_cli.config import get_config_path + from hermes_cli.runtime_provider import resolve_runtime_provider + + configured, stored = "https://api.acmellm.example/v1", "sk-stored-acmellm-key" + monkeypatch.setenv("ACMELLM_API_KEY", stored) + get_config_path().write_text( + f"custom_providers:\n - name: acme\n base_url: {configured}\n", encoding="utf-8") + + def attached(bu): + return resolve_runtime_provider(requested="custom", explicit_base_url=bu)["api_key"] + + for bu in ("http://api.acmellm.example:8080/v1", "http://api.acmellm.example/v1", + "https://api.acmellm.example:8443/v1", "https://eu.acmellm.example/v1"): + assert attached(bu) == stored, bu + err = self._v("custom", bu) + assert err and "not allowed" in err, bu + assert self._v("custom", configured) is None + keyless = "http://192.0.2.10:8080/v1" + assert attached(keyless) != stored and self._v("custom", keyless) 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 diff --git a/tools/cronjob_job_args.py b/tools/cronjob_job_args.py index 8ff209688c..e40e4174e0 100644 --- a/tools/cronjob_job_args.py +++ b/tools/cronjob_job_args.py @@ -320,11 +320,38 @@ def _same_origin(base_url: str, configured: str) -> bool: return bool(want[1]) and got == want +def _custom_stored_key_error(bu: str) -> Optional[str]: + """Bare 'custom' is BYOK only while the runtime attaches no stored key. The resolver picks + the host-gated env keys by HOSTNAME, so a base_url that would receive one must be an origin + the operator or the provider registry names; pool and ``model.key_env`` keys already match + their configured URL exactly.""" + from agent.credential_pool import _iter_custom_providers + from hermes_cli import runtime_provider as rp + from hermes_cli.auth import PROVIDER_REGISTRY + from hermes_cli.runtime_provider_custom import _DIRECT_API_BASE_URLS + from hermes_constants import OPENROUTER_BASE_URL + + if not any(rp.has_usable_secret(key) for key in rp._host_gated_env_key_candidates(bu, ollama=True)): + return None + configured = [ + rp._get_model_config().get("base_url"), rp.get_secret_str("OPENAI_BASE_URL", ""), + OPENROUTER_BASE_URL, *_DIRECT_API_BASE_URLS.values(), + *(getattr(p, "inference_base_url", "") for p in PROVIDER_REGISTRY.values()), + *(entry.get("base_url") for _, entry in _iter_custom_providers()), + ] + if any(_same_origin(bu, str(url)) for url in configured if url): + return None + return ( + f"base_url {bu!r} is not allowed for provider 'custom'. A stored API key matches its " + f"hostname, and a stored credential may only be sent to a configured endpoint (same " + f"scheme, host and port); configure the endpoint as a custom provider to use it.") + + 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-origin base_url (a - prompt-injected job could exfil the key). Allowed: no override; bare 'custom' (pure BYOK, - key derived from the base_url); an override with the same origin as the named provider's + """Reject pairing a stored credential with an off-origin base_url (a prompt-injected job + could exfil the key). Allowed: no override; bare 'custom' while no stored key would go with + it, or at a configured origin; an override with the same origin as the named provider's own endpoint. Everything else fails closed. Returns an error string if blocked, else None.""" bu = _normalize_optional_job_value(base_url, strip_trailing_slash=True) if not bu: @@ -343,8 +370,8 @@ def _validate_cron_base_url( except Exception: return f"Unable to validate base_url override for provider {prov!r}; refused." - if prov.lower() == "custom": # pure BYOK: key keyed by THIS base_url, never a stored secret - return None + if prov.lower() == "custom": + return _custom_stored_key_error(bu) if has_named_custom_provider(prov): # A NAMED custom provider's STORED key is still sent to an override base_url. try: diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index eb12e77741..42bf085f11 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -1354,7 +1354,7 @@ If a hand edit leaves `jobs.json` malformed, the scheduler repairs it on the nex Jobs may store `model` and `provider` as `null`. When those fields are omitted, Hermes resolves them at execution time from the global configuration. They only appear in the job record when a per-job override is set. -A per-job `base_url` override needs an explicit `provider`. For a provider with a stored key (a named custom provider or a built-in one), the override must have the same origin as that provider's configured endpoint: the same scheme, host and port. Another scheme, port or subdomain is refused, so the stored key is only ever sent where you configured it. A bare `provider: custom` takes any `base_url`, because no stored key goes with it. +A per-job `base_url` override needs an explicit `provider`. For a provider with a stored key (a named custom provider or a built-in one), the override must have the same origin as that provider's configured endpoint: the same scheme, host and port. Another scheme, port or subdomain is refused, so the stored key is only ever sent where you configured it. A bare `provider: custom` takes any `base_url` that no stored key goes with. When a stored key matches the URL's hostname (for example `DEEPSEEK_API_KEY` for `api.deepseek.com`), the same rule applies: the `base_url` must have the origin of an endpoint you configured or of a built-in provider. The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md index 3c1ecd4225..cf93e7daf5 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/cron.md @@ -746,7 +746,7 @@ cronjob(action="create", name="daily-digest", 任务可能将 `model` 和 `provider` 存储为 `null`。省略这些字段时,Hermes 在执行时从全局配置中解析它们。只有设置了单任务覆盖时,这些字段才会出现在任务记录中。 -单任务的 `base_url` 覆盖必须同时指定 `provider`。对于保存了密钥的 provider(具名自定义 provider 或内置 provider),覆盖地址必须与该 provider 配置的端点同源:协议、主机和端口都相同。其他协议、端口或子域名都会被拒绝,因此保存的密钥只会发往你配置的地址。单独的 `provider: custom` 可以使用任意 `base_url`,因为它不携带保存的密钥。 +单任务的 `base_url` 覆盖必须同时指定 `provider`。对于保存了密钥的 provider(具名自定义 provider 或内置 provider),覆盖地址必须与该 provider 配置的端点同源:协议、主机和端口都相同。其他协议、端口或子域名都会被拒绝,因此保存的密钥只会发往你配置的地址。单独的 `provider: custom` 可以使用任何不会携带保存密钥的 `base_url`。如果某个保存的密钥与该 URL 的主机名匹配(例如 `api.deepseek.com` 对应 `DEEPSEEK_API_KEY`),则适用同样的规则:`base_url` 必须与你配置的端点或内置 provider 的端点同源。 存储使用原子文件写入,因此中断的写入不会留下部分写入的任务文件。