Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 92 additions & 14 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 claim_dispatch imported from cron.jobs but never defined — breaks entire cron module (bug)

cron/scheduler.py line 240 adds the module-level import from cron.jobs import get_due_jobs, mark_job_run, save_job_output, advance_next_run, claim_dispatch. The function claim_dispatch is called in run_one_job() at line 2844 to implement one-shot dispatch deduplication (NousResearch#38758). However, no function named claim_dispatch exists anywhere in cron/jobs.py or the entire codebase. The import will raise ImportError at module load time, breaking the entire cron scheduler.

Impact: All cron functionality is dead — no jobs will fire, no tick will run, the scheduler module cannot be imported at all.

💡 Suggestion: Add a claim_dispatch(job_id: str) -> bool function to cron/jobs.py that atomically checks and increments a dispatch counter for finite one-shot jobs, returning False when the dispatch limit is reached (so run_one_job can skip). The import and call sites in scheduler.py are correct; only the function definition is missing.

📋 Prompt for AI Agents

In cron/jobs.py, add a function claim_dispatch(job_id: str) -> bool that: (1) acquires the jobs file lock, (2) loads the job from the store, (3) for recurring jobs (schedule.kind in {cron, interval}) returns True immediately, (4) for infinite repeat (repeat.times is None) returns True, (5) for finite one-shots, checks repeat.completed < repeat.times: if so, increments repeat.completed, saves, returns True; otherwise returns False. This implements the at-most-N semantics for one-shot dispatch deduplication described in the run_one_job comment (issue NousResearch#38758).


# 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
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Import name mismatch: looks_like_telegram_private_chat_id not exported by gateway/delivery.py (bug)

cron/scheduler.py line 1249 imports looks_like_telegram_private_chat_id (public name, no underscore prefix) from gateway.delivery, and calls it at line 1255. However, gateway/delivery.py line 62 defines the function as _looks_like_telegram_private_chat_id (private, with underscore prefix). The public name is not exported anywhere in delivery.py. This causes ImportError when _deliver_result() executes on Telegram platform paths, breaking cron-to-Telegram private-DM delivery routes.

Impact: Cron jobs delivering results to Telegram private DM topics will fail to deliver, and the ImportError will cause the delivery path to raise an exception.

💡 Suggestion: Rename the function in gateway/delivery.py from _looks_like_telegram_private_chat_id to looks_like_telegram_private_chat_id (dropping the underscore prefix) at line 62, and update the two internal call sites at lines 470 and 493 to use the new public name.

Suggested change
looks_like_telegram_private_chat_id,
def looks_like_telegram_private_chat_id(chat_id: Optional[str]) -> bool:
📋 Prompt for AI Agents

In gateway/delivery.py line 62, rename def _looks_like_telegram_private_chat_id to def looks_like_telegram_private_chat_id (drop the underscore prefix). Then update the two internal references at lines 470 and 493 in the same file from _looks_like_telegram_private_chat_id to looks_like_telegram_private_chat_id. No changes needed in cron/scheduler.py — it already imports and calls the public name correctly at line 1249.

)

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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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"):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 1 addition & 8 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 \<newline>/` 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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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; #<salvagePR>)
"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",
Expand Down
95 changes: 95 additions & 0 deletions tests/cron/test_scheduler_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading