hermes-agent/tests/hermes_cli/test_web_server_cron_profiles.py
zapabob 2c3ca475c0 fix(cron): reject id mutation + validate output paths under OUTPUT_DIR
Two defense-in-depth fixes on cron output path handling:

1. cron/jobs.py:update_job() rejects mutation of the immutable 'id' field
   (raises ValueError). Dashboard PUT /api/cron/jobs/{id} converts this to
   HTTP 400. Without this, an attacker who can reach the update endpoint
   could rename a job's id to '../escape' and move its output directory
   outside OUTPUT_DIR.

2. cron/jobs.py:_job_output_dir() validates job IDs before composing
   paths: rejects '.', '..', '/', '\\', absolute paths, and Windows drive
   prefixes. Used by save_job_output() and remove_job() so legacy unsafe
   IDs (from before this guard) fail closed rather than half-applying a
   shutil.rmtree or output write outside the sandbox.

Tests:
  - update_job rejects {'id': '../escape'} without renaming
  - remove_job(legacy '../escape' id) raises ValueError without deleting
    files outside OUTPUT_DIR or removing the job from the store
  - save_job_output rejects '..', './escape', 'nested/escape',
    absolute paths
  - dashboard PUT /api/cron/jobs/{id} with {'id': '../escape'} returns
    400, job list unchanged

Salvaged from PR #29826 by @zapabob. Simplified implementation:
- Dropped a 23-line _validate_job_output_id() helper using Path.parts
  semantics. The inline check (path separators + dot-components +
  is_absolute) is shorter and behaviorally identical.
- Dropped the secondary OUTPUT_DIR.resolve()/relative_to() check —
  redundant once we reject any path separator at the input boundary.
- Dropped the _docs/2026-05-21_cron-output-path-hardening_codex.md
  planning artifact (we don't check planning docs into the repo).

Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
2026-05-25 01:15:24 -07:00

199 lines
6.7 KiB
Python

"""Regression tests for dashboard cron job profile routing."""
import pytest
from fastapi import HTTPException
@pytest.fixture()
def isolated_profiles(tmp_path, monkeypatch):
"""Give profile discovery an isolated default home with one named profile."""
from hermes_cli import profiles
default_home = tmp_path / ".hermes"
profiles_root = default_home / "profiles"
worker_home = profiles_root / "worker_alpha"
for home in (default_home, worker_home):
(home / "cron").mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text("model: test-model\n", encoding="utf-8")
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
return {"default": default_home, "worker_alpha": worker_home}
def test_call_cron_for_profile_routes_storage_and_restores_globals(isolated_profiles):
from cron import jobs as cron_jobs
from hermes_cli import web_server
old_cron_dir = cron_jobs.CRON_DIR
old_jobs_file = cron_jobs.JOBS_FILE
old_output_dir = cron_jobs.OUTPUT_DIR
job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="run scheduled task",
schedule="every 1h",
name="worker-alpha-scan",
)
assert job["profile"] == "worker_alpha"
assert job["profile_name"] == "worker_alpha"
assert job["hermes_home"] == str(isolated_profiles["worker_alpha"])
assert job["is_default_profile"] is False
assert (isolated_profiles["worker_alpha"] / "cron" / "jobs.json").exists()
assert not (isolated_profiles["default"] / "cron" / "jobs.json").exists()
assert cron_jobs.CRON_DIR == old_cron_dir
assert cron_jobs.JOBS_FILE == old_jobs_file
assert cron_jobs.OUTPUT_DIR == old_output_dir
@pytest.mark.asyncio
async def test_list_cron_jobs_all_includes_default_and_named_profiles(isolated_profiles):
from hermes_cli import web_server
default_job = web_server._call_cron_for_profile(
"default",
"create_job",
prompt="default heartbeat",
schedule="every 2h",
name="default-heartbeat",
)
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="worker heartbeat",
schedule="every 3h",
name="worker-alpha-heartbeat",
)
jobs = await web_server.list_cron_jobs(profile="all")
by_id = {job["id"]: job for job in jobs}
assert set(by_id) >= {default_job["id"], worker_job["id"]}
assert by_id[default_job["id"]]["profile"] == "default"
assert by_id[default_job["id"]]["is_default_profile"] is True
assert by_id[default_job["id"]]["hermes_home"] == str(isolated_profiles["default"])
assert by_id[worker_job["id"]]["profile"] == "worker_alpha"
assert by_id[worker_job["id"]]["is_default_profile"] is False
assert by_id[worker_job["id"]]["hermes_home"] == str(isolated_profiles["worker_alpha"])
@pytest.mark.asyncio
async def test_list_cron_jobs_specific_profile_filters_results(isolated_profiles):
from hermes_cli import web_server
web_server._call_cron_for_profile(
"default",
"create_job",
prompt="default only",
schedule="every 2h",
name="default-only",
)
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="worker only",
schedule="every 3h",
name="worker-only",
)
jobs = await web_server.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in jobs] == [worker_job["id"]]
assert jobs[0]["profile"] == "worker_alpha"
@pytest.mark.asyncio
async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_profiles):
from hermes_cli import web_server
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="named-profile-job",
)
paused = await web_server.pause_cron_job(worker_job["id"])
assert paused["profile"] == "worker_alpha"
assert paused["enabled"] is False
default_jobs = await web_server.list_cron_jobs(profile="default")
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
assert default_jobs == []
assert len(worker_jobs) == 1
assert worker_jobs[0]["id"] == worker_job["id"]
assert worker_jobs[0]["enabled"] is False
@pytest.mark.asyncio
async def test_update_cron_job_rejects_id_mutation(isolated_profiles):
"""Dashboard surfaces a 400 (not a 500 or silent rename) when an
id-mutation attempt is rejected by cron/jobs.update_job."""
from hermes_cli import web_server
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="managed by named profile",
schedule="every 1h",
name="immutable-id-job",
)
with pytest.raises(HTTPException) as exc:
await web_server.update_cron_job(
worker_job["id"],
web_server.CronJobUpdate(updates={"id": "../escape"}),
profile="worker_alpha",
)
assert exc.value.status_code == 400
assert "id" in exc.value.detail
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in worker_jobs] == [worker_job["id"]]
@pytest.mark.asyncio
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
from hermes_cli import web_server
default_job = web_server._call_cron_for_profile(
"default",
"create_job",
prompt="same-ish default",
schedule="every 1h",
name="shared-name",
)
worker_job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="same-ish worker",
schedule="every 1h",
name="shared-name-worker",
)
deleted = await web_server.delete_cron_job(worker_job["id"], profile="worker_alpha")
assert deleted == {"ok": True}
remaining_default = await web_server.list_cron_jobs(profile="default")
remaining_worker = await web_server.list_cron_jobs(profile="worker_alpha")
assert [job["id"] for job in remaining_default] == [default_job["id"]]
assert remaining_worker == []
@pytest.mark.asyncio
async def test_cron_profile_validation_errors(isolated_profiles):
from hermes_cli import web_server
with pytest.raises(HTTPException) as bad_name:
await web_server.list_cron_jobs(profile="../bad")
assert bad_name.value.status_code == 400
with pytest.raises(HTTPException) as missing:
await web_server.list_cron_jobs(profile="missing_profile")
assert missing.value.status_code == 404