Files
hermes-agent/tests/cron/test_file_permissions.py
T
teknium1 b78abb4710 fix(config): stop forcing 0700 on HERMES_HOME inside containers
Every start (and every `ensure_hermes_home` from a sibling CLI invocation)
chmod'd the data directory to 0700, wiping group/other bits and the ACL
mask on a bind mount shared with other containers (hermes-webui, Nix
desktop + dashboard). _secure_file already skipped containers for this
reason; _secure_dir did not.

In a container the directory mode is now left to the operator unless
HERMES_HOME_MODE is set explicitly, which is still applied. cron/jobs.py
had its own 0700/0600 copies that bypassed the managed/container rules;
they now delegate to the shared helpers so cron/output stops re-locking
the mount as well.

Fixes #10757
2026-09-12 08:43:43 -07:00

147 lines
5.6 KiB
Python

"""Tests for file permissions hardening on sensitive files."""
import os
import stat
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
class TestCronFilePermissions(unittest.TestCase):
"""Verify cron files get secure permissions."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.cron_dir = Path(self.tmpdir) / "cron"
self.output_dir = self.cron_dir / "output"
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
@patch("cron.jobs.CRON_DIR")
@patch("cron.jobs.OUTPUT_DIR")
@patch("cron.jobs.JOBS_FILE")
def test_ensure_dirs_sets_0700(self, mock_jobs_file, mock_output, mock_cron):
mock_cron.__class__ = Path
# Use real paths
cron_dir = Path(self.tmpdir) / "cron"
output_dir = cron_dir / "output"
with patch("cron.jobs.CRON_DIR", cron_dir), \
patch("cron.jobs.OUTPUT_DIR", output_dir):
from cron.jobs import ensure_dirs
ensure_dirs()
cron_mode = stat.S_IMODE(os.stat(cron_dir).st_mode)
output_mode = stat.S_IMODE(os.stat(output_dir).st_mode)
self.assertEqual(cron_mode, 0o700)
self.assertEqual(output_mode, 0o700)
@patch("cron.jobs.CRON_DIR")
@patch("cron.jobs.OUTPUT_DIR")
@patch("cron.jobs.JOBS_FILE")
def test_save_jobs_sets_0600(self, mock_jobs_file, mock_output, mock_cron):
cron_dir = Path(self.tmpdir) / "cron"
output_dir = cron_dir / "output"
jobs_file = cron_dir / "jobs.json"
with patch("cron.jobs.CRON_DIR", cron_dir), \
patch("cron.jobs.OUTPUT_DIR", output_dir), \
patch("cron.jobs.JOBS_FILE", jobs_file):
from cron.jobs import save_jobs
save_jobs([{"id": "test", "prompt": "hello"}])
file_mode = stat.S_IMODE(os.stat(jobs_file).st_mode)
self.assertEqual(file_mode, 0o600)
def test_save_job_output_sets_0600(self):
output_dir = Path(self.tmpdir) / "output"
with patch("cron.jobs.OUTPUT_DIR", output_dir), \
patch("cron.jobs.CRON_DIR", Path(self.tmpdir)), \
patch("cron.jobs.ensure_dirs"):
output_dir.mkdir(parents=True, exist_ok=True)
from cron.jobs import save_job_output
output_file = save_job_output("test-job", "test output content")
file_mode = stat.S_IMODE(os.stat(output_file).st_mode)
self.assertEqual(file_mode, 0o600)
# Job output dir should also be 0700
job_dir = output_dir / "test-job"
dir_mode = stat.S_IMODE(os.stat(job_dir).st_mode)
self.assertEqual(dir_mode, 0o700)
class TestConfigFilePermissions(unittest.TestCase):
"""Verify config files get secure permissions."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_save_config_sets_0600(self):
config_path = Path(self.tmpdir) / "config.yaml"
with patch("hermes_cli.config.get_config_path", return_value=config_path), \
patch("hermes_cli.config.ensure_hermes_home"):
from hermes_cli.config import save_config
save_config({"model": "test/model"})
file_mode = stat.S_IMODE(os.stat(config_path).st_mode)
self.assertEqual(file_mode, 0o600)
def test_save_env_value_sets_0600(self):
env_path = Path(self.tmpdir) / ".env"
with patch("hermes_cli.config.get_env_path", return_value=env_path), \
patch("hermes_cli.config.ensure_hermes_home"):
from hermes_cli.config import save_env_value
save_env_value("TEST_KEY", "test_value")
file_mode = stat.S_IMODE(os.stat(env_path).st_mode)
self.assertEqual(file_mode, 0o600)
def test_ensure_hermes_home_sets_0700(self):
home = Path(self.tmpdir) / ".hermes"
with patch("hermes_cli.config.get_hermes_home", return_value=home):
from hermes_cli.config import ensure_hermes_home
ensure_hermes_home()
home_mode = stat.S_IMODE(os.stat(home).st_mode)
self.assertEqual(home_mode, 0o700)
for subdir in ("cron", "sessions", "logs", "memories"):
subdir_mode = stat.S_IMODE(os.stat(home / subdir).st_mode)
self.assertEqual(subdir_mode, 0o700, f"{subdir} should be 0700")
class TestSecureHelpers(unittest.TestCase):
"""Test the _secure_file and _secure_dir helpers."""
def test_secure_file_nonexistent_no_error(self):
from cron.jobs import _secure_file
_secure_file(Path("/nonexistent/path/file.json")) # Should not raise
def test_secure_dir_preserves_operator_mode_in_container(self):
"""A bind-mounted data dir shared with sibling containers must keep the operator's mode;
an explicit HERMES_HOME_MODE is still honored (#10757)."""
from cron.jobs import _secure_dir
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp) / "cron"
d.mkdir()
os.chmod(d, 0o755)
with patch.dict(os.environ, {"HERMES_CONTAINER": "1"}, clear=False):
os.environ.pop("HERMES_HOME_MODE", None)
_secure_dir(d)
self.assertEqual(stat.S_IMODE(os.stat(d).st_mode), 0o755)
os.environ["HERMES_HOME_MODE"] = "0701"
_secure_dir(d)
self.assertEqual(stat.S_IMODE(os.stat(d).st_mode), 0o701)
if __name__ == "__main__":
unittest.main()