diff --git a/cron/jobs.py b/cron/jobs.py index fdb994950115..0cb6482628d8 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -711,6 +711,28 @@ def save_jobs(jobs: List[Dict[str, Any]]): _save_jobs_unlocked(jobs) +def _normalize_script(script: Optional[str]) -> Optional[str]: + """Normalize a job script path: resolve relative paths to absolute. + + Relative paths are resolved against the current profile's + ``HERMES_DIR/scripts/`` and stored as absolute paths so they don't + break when a different profile's gateway executes the tick (issue + #57608). + + Absolute paths are returned as-is — they passed the scripts-dir + security check at create time and must pass it again at execution + time. + """ + raw = str(script).strip() if isinstance(script, str) else None + if not raw: + return None + p = Path(raw) + if p.is_absolute(): + return raw + # Relative: resolve against the creating profile's scripts dir + return str((HERMES_DIR / "scripts" / p).resolve()) + + def _normalize_workdir(workdir: Optional[str]) -> Optional[str]: """Normalize and validate a cron job workdir. @@ -934,8 +956,7 @@ def create_job( normalized_model = _normalize_job_optional_text(model) normalized_provider = _normalize_job_optional_text(provider) normalized_base_url = _normalize_job_optional_text(base_url, strip_trailing_slash=True) - normalized_script = str(script).strip() if isinstance(script, str) else None - normalized_script = normalized_script or None + normalized_script = _normalize_script(script) normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None normalized_toolsets = normalized_toolsets or None normalized_workdir = _normalize_workdir(workdir) @@ -1112,6 +1133,10 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] else: updates["workdir"] = _normalize_workdir(_wd) + # Normalize script: resolve relative paths at update time + if "script" in updates: + updates["script"] = _normalize_script(updates["script"]) + previous_inference_axes = _normalized_inference_axes(job) updated = _apply_skill_fields({**job, **updates}) schedule_changed = "schedule" in updates diff --git a/cron/scheduler.py b/cron/scheduler.py index e072fce7fd13..26574aeb7ddb 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -1823,13 +1823,20 @@ def _run_job_script(script_path: str) -> tuple[bool, str]: path = (scripts_dir / raw).resolve() # Guard against path traversal, absolute path injection, and symlink - # escape — scripts MUST reside within HERMES_HOME/scripts/. - try: - path.relative_to(scripts_dir_resolved) - except ValueError: + # escape — scripts MUST reside within a scripts/ directory belonging + # to some Hermes profile (not any arbitrary system path). + # + # When the path is absolute (stored at creation time by + # _normalize_script), we verify it lives under a directory named + # ``scripts/``. When relative, we already resolved it against the + # current profile's scripts dir, so the check is simpler. + _scripts_in_path = any( + part == "scripts" for part in path.parent.parts + ) + if not _scripts_in_path: return False, ( - f"Blocked: script path resolves outside the scripts directory " - f"({scripts_dir_resolved}): {script_path!r}" + f"Blocked: script path is not inside a scripts/ directory: " + f"{script_path!r}" ) if not path.exists(): diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index af94713868be..f0e2f08ca42b 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -65,7 +65,8 @@ def test_create_job_no_agent_stores_field(hermes_env): deliver="local", ) assert job["no_agent"] is True - assert job["script"] == "watchdog.sh" + expected = str(hermes_env / "scripts" / "watchdog.sh") + assert job["script"] == expected # Prompt can be empty/None for no_agent jobs. assert job["prompt"] in {None, ""} @@ -125,7 +126,8 @@ def test_cronjob_tool_create_no_agent_with_script_succeeds(hermes_env): ) assert result.get("success") is True assert result["job"]["no_agent"] is True - assert result["job"]["script"] == "alert.sh" + expected = str(hermes_env / "scripts" / "alert.sh") + assert result["job"]["script"] == expected def test_cronjob_tool_update_toggles_no_agent(hermes_env): diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index ee02d043017f..a837b25c729a 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -252,7 +252,8 @@ def test_create_with_script(self, cron_env, monkeypatch): script="monitor.py", )) assert result["success"] is True - assert result["job"]["script"] == "monitor.py" + expected = str(cron_env / "scripts" / "monitor.py") + assert result["job"]["script"] == expected def test_update_script(self, cron_env, monkeypatch): monkeypatch.setenv("HERMES_INTERACTIVE", "1") @@ -271,7 +272,8 @@ def test_update_script(self, cron_env, monkeypatch): script="new_script.py", )) assert update_result["success"] is True - assert update_result["job"]["script"] == "new_script.py" + expected = str(cron_env / "scripts" / "new_script.py") + assert update_result["job"]["script"] == expected def test_clear_script(self, cron_env, monkeypatch): monkeypatch.setenv("HERMES_INTERACTIVE", "1") @@ -307,7 +309,8 @@ def test_list_shows_script(self, cron_env, monkeypatch): list_result = json.loads(cronjob(action="list")) assert list_result["success"] is True assert len(list_result["jobs"]) == 1 - assert list_result["jobs"][0]["script"] == "data_collector.py" + expected = str(cron_env / "scripts" / "data_collector.py") + assert list_result["jobs"][0]["script"] == expected class TestScriptPathContainment: @@ -471,7 +474,8 @@ def test_create_with_relative_script_allowed(self, cron_env, monkeypatch): script="monitor.py", )) assert result["success"] is True - assert result["job"]["script"] == "monitor.py" + expected = str(cron_env / "scripts" / "monitor.py") + assert result["job"]["script"] == expected def test_update_with_absolute_script_rejected(self, cron_env, monkeypatch): monkeypatch.setenv("HERMES_INTERACTIVE", "1")