From 60c4bc96fd81b51277663a8283fa5eea2be8ab51 Mon Sep 17 00:00:00 2001 From: Yuyang Xu Date: Sun, 26 Apr 2026 15:26:14 -0400 Subject: [PATCH] fix(security): restore .env/auth.json/state.db with 0600 perms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes import` was creating secret files with the process umask (typically 0644) instead of 0600. zipfile.open() does not honor the Unix mode bits stored in zip member external_attr; the restore loop used open(target, "wb") which always falls back to umask. Threat: silent privilege downgrade after a routine restore on multi-user systems (shared dev boxes, CI runners, jump hosts) — any local user could read API keys and OAuth tokens from ~/.hermes/. Fix mirrors the convention already used at file creation (hermes_cli/auth.py: stat.S_IRUSR | stat.S_IWUSR for auth.json). The quick-snapshot restore path (restore_quick_snapshot) is unaffected — it uses shutil.copy2 which preserves perms via copystat(). Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/backup.py | 5 +++++ tests/hermes_cli/test_backup.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index 2a766f7502..20ddb3c87d 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -61,6 +61,9 @@ _EXCLUDED_NAMES = { "cron.pid", } +# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600. +_SECRET_FILE_NAMES = {".env", "auth.json", "state.db"} + def _should_exclude(rel_path: Path) -> bool: """Return True if *rel_path* (relative to hermes root) should be skipped.""" @@ -381,6 +384,8 @@ def run_import(args) -> None: target.parent.mkdir(parents=True, exist_ok=True) with zf.open(member) as src, open(target, "wb") as dst: dst.write(src.read()) + if target.name in _SECRET_FILE_NAMES: + os.chmod(target, 0o600) restored += 1 except (PermissionError, OSError) as exc: errors.append(f" {rel}: {exc}") diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 346c38dbe6..9a99a035fa 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -471,6 +471,32 @@ class TestImport: with pytest.raises(SystemExit): run_import(args) + @pytest.mark.skipif(os.name != "posix", reason="POSIX file permissions only") + def test_restores_secret_files_with_0600_perms(self, tmp_path, monkeypatch): + """Secret files must end up at 0600 after restore (zipfile drops mode bits).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + zip_path = tmp_path / "backup.zip" + self._make_backup_zip(zip_path, { + "config.yaml": "model: openrouter\n", + ".env": "OPENROUTER_API_KEY=sk-secret\n", + "auth.json": '{"providers": {"nous": "token"}}', + "state.db": b"SQLite format 3\x00", + "profiles/coder/.env": "ANTHROPIC_API_KEY=sk-ant-secret\n", + }) + + args = Namespace(zipfile=str(zip_path), force=True) + + from hermes_cli.backup import run_import + run_import(args) + + for rel in (".env", "auth.json", "state.db", "profiles/coder/.env"): + mode = (hermes_home / rel).stat().st_mode & 0o777 + assert mode == 0o600, f"{rel} restored with mode {oct(mode)}, expected 0o600" + # --------------------------------------------------------------------------- # Round-trip test