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
29 changes: 27 additions & 2 deletions cron/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
6 changes: 4 additions & 2 deletions tests/cron/test_cron_no_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ""}

Expand Down Expand Up @@ -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):
Expand Down
12 changes: 8 additions & 4 deletions tests/cron/test_cron_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down