Skip to content
Open
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
24 changes: 19 additions & 5 deletions hermes_cli/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,23 @@ def _format_size(nbytes: int) -> str:
return f"{nbytes:.1f} TB"


def _tighten_file_permissions(path: Path) -> None:
"""Restrict file to owner-only read/write (POSIX 0600).

On Windows, ``os.chmod`` with Unix permission bits is a no-op β€” it
silently succeeds without changing anything. NTFS ACLs already restrict
newly-created files to the owning user / SYSTEM / Administrators, so
the explicit chmod is redundant there. Skip it to avoid a silent
no-op that looks like it worked but didn't (#56923).
"""
if sys.platform == "win32":
return
try:
os.chmod(path, 0o600)
except OSError:
pass


def run_backup(args) -> None:
"""Create a zip backup of the Hermes home directory."""
hermes_root = get_default_hermes_root()
Expand Down Expand Up @@ -600,10 +617,7 @@ def run_import(args) -> None:
dst.write(src.read())
# External provider configs commonly hold credentials.
if target.suffix in {".json", ".env", ".conf"} or target.name in _SECRET_FILE_NAMES:
try:
os.chmod(target, 0o600)
except OSError:
pass
_tighten_file_permissions(target)
restored += 1
restored_external += 1
except (PermissionError, OSError) as exc:
Expand Down Expand Up @@ -645,7 +659,7 @@ def run_import(args) -> None:
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)
_tighten_file_permissions(target)
restored += 1
except (PermissionError, OSError) as exc:
errors.append(f" {rel}: {exc}")
Expand Down
8 changes: 6 additions & 2 deletions tests/hermes_cli/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import os
import sqlite3
import sys
import zipfile
from argparse import Namespace
from pathlib import Path
Expand Down Expand Up @@ -2467,8 +2468,11 @@ def test_import_restores_external_to_home_relative_location(self, tmp_path, monk
restored = dst_home / ".honcho" / "config.json"
assert restored.exists()
assert restored.read_text() == '{"peer":"bob"}'
# Credential-shaped file tightened.
assert (restored.stat().st_mode & 0o777) == 0o600
# Credential-shaped file tightened (POSIX only; Windows NTFS ACLs
# already restrict to owner/SYSTEM/Administrators β€” os.chmod is a
# no-op there, so we skip the mode-bit assertion).
if sys.platform != "win32":
assert (restored.stat().st_mode & 0o777) == 0o600
# External state did NOT leak into HERMES_HOME.
assert not (hermes_home / "_external").exists()

Expand Down
Loading