NousResearch/hermes-agent · error · ValueError

Invalid cron job id for output path: {job_id!r}

Error message

Invalid cron job id for output path: {job_id!r}

What it means

Security ValueError from _job_output_dir in cron/jobs.py: the job id is used directly as a filesystem path component under the cron OUTPUT_DIR, so ids containing '/', '\\', '.', '..', that are empty, or that are absolute/UNC/drive-prefixed are rejected to prevent output writes and deletes from escaping the cron output sandbox. This variant catches separators, empty/dot ids.

Source

Thrown at cron/jobs.py:391

# Fields on a cron job that must never change after creation. ``id`` is used
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
# into output writes/deletes.
_IMMUTABLE_JOB_FIELDS = frozenset({"id"})


def _job_output_dir(job_id: str) -> Path:
    """Resolve a job's output directory, rejecting any path-escape attempt.

    Job IDs are filesystem path components under ``OUTPUT_DIR``. A legacy or
    crafted ID containing ``..``, absolute paths, or nested separators would
    allow output writes/deletes to escape the cron output sandbox. Reject
    anything that isn't a single safe path component.
    """
    text = str(job_id or "").strip()
    if not text or text in {".", ".."} or "/" in text or "\\" in text:
        raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
    if Path(text).is_absolute() or Path(text).drive:
        raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
    return _current_cron_store().output_dir / text


def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
    """Normalize legacy/single-skill and multi-skill inputs into a unique ordered list."""
    if skills is None:
        raw_items = [skill] if skill else []
    elif isinstance(skills, str):
        raw_items = [skills]
    else:
        raw_items = list(skills)

    normalized: List[str] = []
    for item in raw_items:
        text = str(item or "").strip()
        if text and text not in normalized:

View on GitHub (pinned to c896c09c42)

Solutions

  1. Pass the plain alphanumeric job id (as returned by create_job), not a path or composite string
  2. Sanitize before calling: reject ids containing os.sep, '..', or empty strings
  3. If a legacy store really contains such ids, re-create those jobs to obtain clean ids

Example fix

# before
jobs.get_job_output('../config')
# ValueError: Invalid cron job id for output path: '../config'

# after
jobs.get_job_output('a1b2c3d4')
Defensive patterns

Strategy: validation

Validate before calling

import re

_SAFE_ID = re.compile(r"^[A-Za-z0-9_-]+$")

def is_safe_job_id(job_id: str) -> bool:
    t = str(job_id or "").strip()
    return bool(_SAFE_ID.match(t)) and t not in {".", ".."}

Type guard

from pathlib import Path

def is_safe_job_id(job_id: str) -> bool:
    t = str(job_id or "").strip()
    if not t or t in {".", ".."} or "/" in t or "\\" in t:
        return False
    return not (Path(t).is_absolute() or Path(t).drive)

Try / catch

try:
    out = jobs.get_job_output(job_id)
except ValueError as e:
    if str(e).startswith("Invalid cron job id"):
        raise KeyError(f"no such job: {job_id!r} (bad id)") from e
    raise

Prevention

When it happens

Trigger: Calling a job-output API (list/save/delete output) with job_id='../hermes', job_id='a/b', job_id='', or job_id='.' — typically from hand-crafted input, a migrated legacy store with unusual ids, or unvalidated caller data.

Common situations: Passing a composite key or file path as the job id; a client joining values with '/'; legacy jobs created before ids were constrained; fuzzing/probing attempts on the path boundary.

Related errors


AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14). Data as JSON: /api/errors/ac8ef9aacf0286ae. Report an issue: GitHub.