Skip to content

fix(backup): restore import members atomically so a failed import can't erase config - #80572

Closed
briandevans wants to merge 5 commits into
NousResearch:mainfrom
briandevans:fix/cli-atomic-import-write
Closed

fix(backup): restore import members atomically so a failed import can't erase config#80572
briandevans wants to merge 5 commits into
NousResearch:mainfrom
briandevans:fix/cli-atomic-import-write

Conversation

@briandevans

@briandevans briandevans commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

hermes import could destroy the file it was restoring.

Both restore sites in run_import wrote each zip member like this:

with zf.open(member) as src, open(target, "wb") as dst:
    dst.write(src.read())

open(target, "wb") truncates the user's existing file to zero before any replacement bytes exist. There is no os.replace, no fsync, and no backup. A Ctrl-C, an ENOSPC, a corrupt or truncated zip member, or a crash between the truncate and the write leaves config.yaml, .env, or an external provider config empty, with nothing behind it — during hermes import, which is the documented disaster-recovery path. The user is running it because they already lost something.

Two things make this worse than a normal non-atomic write:

  • Site 1 writes outside HERMES_HOME. The _external/ branch restores memory-provider state to its original home-relative location (e.g. ~/.honcho/config.json), so a failed import can zero a third-party config the user never associated with Hermes.
  • It is the normal path, not a rare state. Every member of every import goes through it.

Both sites now stage the member into the target's own directory, fsync, and publish with utils.atomic_replace. The target only ever moves from its old contents to the complete new contents.

This extends the module's own established idiom rather than introducing one. hermes_cli/backup.py already publishes atomically twice — os.replace(partial_path, final_path) in _atomic_output_path (:211) and os.replace(staging_dir, snap_dir) in the snapshot writer (:1337, from the recently landed aad8f741 "serialize and atomically publish snapshots"). run_import was the one path left overwriting user files in place. utils.atomic_write_text states the invariant directly in its docstring: it exists so that "every destructive file rewrite in the codebase shares one implementation."

Why utils.atomic_replace and not a bare os.replace — this is load-bearing. open(target, "wb") writes through a symlink, so a user who links ~/.hermes/config.yaml into a dotfiles repo keeps that link today. A naive mkstemp + os.replace would replace the symlink with a regular file and silently detach the deployment — that is #16743, a bug this repo has already been bitten by. atomic_replace resolves the link first, and falls back to copy/fsync/unlink on EXDEV/EBUSY for cross-device and bind-mount installs. Two of the added tests pin this; both go red if the helper is swapped for a raw os.replace.

Related Issue

No filed issue — found by inspection of the restore path. Refs #16743 (the symlink-detach hazard the helper is chosen to avoid).

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

hermes_cli/backup.py

  • Added _extract_member_atomically(): mkstemp in the target's own directory (never /tmp, so the EXDEV fallback stays the exception) → stream the member → fsyncatomic_replace. The temp file is unlinked on any failure, including KeyboardInterrupt, so a partial import leaves no residue.
  • Streams with shutil.copyfileobj instead of src.read(), which also stops a multi-gigabyte state.db member being held in memory in one piece.
  • Added _default_new_file_mode(): mkstemp creates at 0600, so without this every newly created file would be tightened to owner-only, breaking Docker/NAS installs that rely on broader permissions — the same hazard utils._restore_file_mode documents. Existing files keep their own mode (captured and applied to the temp fd before the replace, so the target never transits 0600). Resolved once per import, and the probe installs a restrictive mask rather than 0 so nothing created by another thread in that two-syscall window is world-writable.
  • Both run_import write sites call the helper. The os.chmod(...) calls at both sites are untouched — permission behaviour is byte-for-byte what it was.

tests/hermes_cli/test_backup.py

  • New TestImportAtomicWrites, 5 tests, covering both restore branches:
    • test_failed_member_leaves_existing_file_intact — a member whose stream dies mid-restore must not destroy the file it was replacing, and must leave no .config.yaml.* temp behind.
    • test_failed_external_member_leaves_existing_file_intact — same invariant on the _external/ branch.
    • test_symlinked_target_keeps_its_symlink / test_symlinked_external_target_keeps_its_symlink — the target keeps its symlink and the real file receives the content.
    • test_restore_preserves_existing_file_mode — staging through mkstemp does not tighten a 0644 file to 0600.

Acceptance criteria, all covered: (1) no window in which the target exists truncated or partially written; (2) a symlinked target survives; (3) a mid-write failure leaves the original intact with no stray temp; (4) existing permission behaviour unchanged.

How to Test

Reproduce the bug on main:

  1. printf 'model: keep-me\n' > $HERMES_HOME/config.yaml
  2. Build a backup zip whose config.yaml member is truncated/corrupt (or ^C during hermes import on a large archive).
  3. hermes import backup.zip --forceconfig.yaml is now 0 bytes. The original is gone.

With this PR, the same run reports the member in the warnings block and config.yaml still holds model: keep-me.

Automated, with the injected-failure direction verified in both directions:

pytest tests/hermes_cli/test_backup.py -q                      # 45 passed (40 existing + 5 new)
pytest tests/hermes_cli/test_backup.py::TestImportAtomicWrites # 5 passed

Regression guard, run explicitly:

  • Reverting only hermes_cli/backup.py to main and re-running: the two data-loss tests fail (the file is 0 bytes — the truncate landed, the write did not), the 3 guards pass. Restoring the fix: 5 passed.
  • Swapping atomic_replace for a bare os.replace: the two symlink tests fail. The guards are not vacuous.

Adjacent suites touching the same primitive, all green:

pytest tests/hermes_cli/test_atomic_json_write.py tests/hermes_cli/test_atomic_yaml_write.py \
       tests/hermes_cli/test_update_zip_atomic_replace.py tests/hermes_cli/test_update_zip_symlink_reject.py \
       tests/test_stale_utils_module_import.py -q               # 12 passed

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate — see Related / Positioning below
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass — not the full suite. Ran tests/hermes_cli/test_backup.py (45 passed) plus the five adjacent atomic-write/utils-import suites listed above (12 passed). Exact commands and counts are in "How to Test".
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4), Python 3.11.15

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — behaviour is documented in the new helpers' docstrings; no user-facing docs change, the CLI contract is unchanged
  • N/A — no config keys added or changed
  • N/A — no architecture or workflow change
  • I've considered cross-platform impact (Windows, macOS) — os.fchmod is Unix-only, so where it is absent the mode is applied to the temp file with a path-based os.chmod before the replace, with utils._restore_file_mode kept afterwards as the belt-and-braces path (mirrors utils.atomic_yaml_write after 43fc86562); _preserve_file_owner is a no-op off POSIX; atomic_replace already handles the Windows/bind-mount EBUSY path; the symlink, mode, and owner tests are skipif(os.name != "posix") and the no-fchmod test uses monkeypatch.delattr so the Windows branch is covered on CI
  • N/A — no tool descriptions or schemas changed

Related / Positioning

I checked the open queue by symbol and by changed path before opening this. Several PRs are adjacent; I want to be precise about which are disjoint and which is not, rather than claim a clean field.

Complementary, content-disjoint — no action needed:

Genuinely overlapping, and it does not fix this bug:

  • fix(security): harden backup import and OOXML extraction #61881 (harden backup import and OOXML extraction, currently CONFLICTING) replaces these same two statements with a _copy_zip_member() helper. That helper is:

    with zf.open(member) as src, open(target, "wb") as dst:
        shutil.copyfileobj(src, dst, length=_IMPORT_COPY_CHUNK_BYTES)

    It is still truncate-then-write — no temp file, no os.replace, no fsync, no symlink handling. It fixes the whole-member-in-memory problem (which this PR also fixes) but the data-loss window survives it unchanged. Its real value is elsewhere in its diff: zip-bomb limits, member-count/size/ratio caps, and an external-target allowlist, none of which this PR touches.

    So the two are not duplicates and neither is a subset of the other in intent — but they do collide textually on these two lines, and whichever lands second will need a trivial rebase. On the write statements themselves this PR is the superset: it carries fix(security): harden backup import and OOXML extraction #61881's streaming change and adds the atomicity, symlink preservation, and temp cleanup that it lacks. Happy to rebase onto it, or to fold the atomic helper into _copy_zip_member if you would rather take that one first — just say which.

I did not extend this to restore_quick_snapshot, which has the same class of non-atomic shutil.copy2 overwrite: it is a different entry point (/snapshot restore, not hermes import) and it is the function #46765 is actively editing. Happy to file it separately.

Follow-up commit

dfda12ea3 addresses the three inline findings on hermes_cli/backup.py; each thread has a reply with the reasoning. Two of them changed code. dc051c497 (current head) then scopes the helper's docstring to what it actually guarantees — see the triage-comment reply below on atomic_replace's cross-device fallback.

Owner preservation was missing, and that is the substantive one. tempfile.mkstemp + atomic_replace publishes a temp file owned by the writing user, so sudo hermes import was re-owning every restored file to root — on the disaster-recovery path, and on exactly the Docker/NAS volume-mounted installs utils._restore_file_owner was added for (551e5af50, "preserve owner on atomic writes (#56644)"). _extract_member_atomically now captures _preserve_file_owner(target) before staging and calls _restore_file_owner after the replace, ahead of the mode restore — chown clears setuid/setgid, so the mode has to go back last. This is the ordering atomic_yaml_write and atomic_json_write already use.

The mode was only applied before the replace on the os.fchmod branch. Platforms without fchmod fell through to a best-effort post-replace chmod, which left the published file at mkstemp's 0600 until that call landed — permanently if the process died in between — and made atomic_replace's EXDEV/EBUSY shutil.copystat fallback copy 0600 onto the target. The mode is now applied to the temp file on both branches, with the post-replace restore kept as belt-and-braces.

Both concerns now delegate to the shared utils helpers rather than being re-derived here_preserve_file_mode / _preserve_file_owner / _restore_file_mode / _restore_file_owner, the same set atomic_write_text and atomic_yaml_write use after 3556728a5 ("move mode+owner preservation into atomic_write_text") and 43fc86562 ("close the yaml 0600 transit window"). They are underscore-private, and I took importing them over re-implementing deliberately: same repo, backup.py already imports atomic_replace from utils, and a second copy of chown-then-chmod ordering is exactly the divergence those commits consolidated. If you would rather they were public, say the word and I will add a thin public wrapper in utils.py in this PR. The local import stat is gone as a result.

The third finding — os.umask mutating process-global state — I declined, with reasoning in the thread: CPython has no portable read-only umask query, the probe deliberately installs the restrictive 0o077 so anything a racing thread creates is owner-only rather than widened, the window is two syscalls, and it is resolved once per import rather than per member. The suggested alternative (create a throwaway file and stat it) adds I/O in the user's config directory on a recovery path and has its own race without removing the process-global one.

Two tests added to TestImportAtomicWrites, both mutation-checked in the final file location:

Test Red before Green after
test_restore_preserves_existing_file_owner chown_calls == [] [(config.yaml, 123, 456)]
test_mode_is_applied_before_the_replace_without_fchmod staged mode 0o600 staged mode 0o644

The owner test forces a uid/gid so it does not need root, and asserts the chown fires once — on the pre-existing file, not on the newly created member, which pins that the owner is genuinely captured rather than invented. The mode test uses monkeypatch.delattr(os, "fchmod") (the convention 43fc86562 established for this branch) and spies the temp file's mode at atomic_replace time rather than after, so a post-replace chmod cannot make it pass. Deleting only the _restore_file_owner call reds the first; deleting only the os.chmod(tmp_name, mode) line reds the second.

pytest tests/hermes_cli/test_backup.py                                        # 47 passed
pytest tests/hermes_cli/test_backup.py tests/test_atomic_replace_symlinks.py \
       tests/test_atomic_write_text_metadata.py                               # 67 passed, 1 skipped

…'t erase config

`hermes import` wrote every zip member with `open(target, "wb")` followed by
`dst.write(src.read())`, at both restore sites in `run_import`. Opening for
write truncates the user's existing file to zero *before* any replacement
bytes exist, so a Ctrl-C, an ENOSPC, a corrupt zip member, or a crash leaves
`config.yaml`, `.env`, or an external provider config (e.g.
`~/.honcho/config.json`) empty with nothing behind it — during the
disaster-recovery path the user is running precisely because they already
lost something. The `_external/` branch writes outside HERMES_HOME, into
third-party configs under the user's home, so the blast radius is not
confined to Hermes state.

Both sites now stage the member into the target's own directory, fsync it,
and publish with `utils.atomic_replace`, so the target only ever moves from
its old contents to the complete new contents.

`atomic_replace` rather than a bare `os.replace`: it resolves a symlinked
target first, so deployments that link `config.yaml` into a dotfiles repo
keep the link instead of having it silently swapped for a regular file
(NousResearch#16743), and it falls back to copy/fsync/unlink on EXDEV/EBUSY for
cross-device and bind-mount installs. Members stream through
`shutil.copyfileobj` instead of being read whole into memory. The temp file
is removed on any failure so a partial import leaves no residue, and
permission bits are carried across the replace so mkstemp's 0600 does not
silently tighten restored files.

This extends the module's own established idiom — `backup.py` already
publishes atomically via `os.replace` in `_atomic_output_path` and in the
snapshot writer — into the one path that still overwrote user files in place.
Copilot AI lite review requested due to automatic review settings August 6, 2026 19:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Fixes hermes import restore so zip members are written atomically (no truncate-then-write window), preventing failed imports from zeroing existing config/state files—including _external/ targets outside HERMES_HOME.

Changes:

  • Add _extract_member_atomically() to stage each member in-target-dir, fsync, then publish via utils.atomic_replace.
  • Add _default_new_file_mode() to preserve pre-existing and newly-created file permissions despite mkstemp(0600).
  • Add TestImportAtomicWrites to pin invariants for partial failures, symlink preservation, and mode preservation across both restore branches.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
hermes_cli/backup.py Reworks import extraction to stage + atomically publish, and adds logic to preserve file modes.
tests/hermes_cli/test_backup.py Adds regression tests ensuring failed restores don’t truncate files and symlink/mode behavior is preserved.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread hermes_cli/backup.py
Comment on lines +872 to +876
try:
current = os.umask(0o077)
os.umask(current)
except OSError:
return None

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thread-safety point is real, but I don't think either alternative is better here, so I've kept the probe. Reasoning, since it deserves more than "won't fix":

There is no read-only umask in CPython. os.umask is the only way to observe the process umask; there is no getumask. Linux exposes it via /proc/self/status (Umask:), but that is Linux-only and absent on macOS and BSD, so it cannot replace the probe — only add a platform-specific fast path.

The window is two syscalls, and it is deliberately fail-safe in the unsafe direction. The probe installs 0o077, not 0, precisely because of the race you're describing: a file another thread creates inside the window comes out owner-only, i.e. tighter than intended. The failure mode is a file that is too private, never one that is world-readable or world-writable. A 0 probe would have the security bug; this one has a cosmetic one.

It runs once per import, not once per member. _default_new_file_mode() is called a single time in run_import (hermes_cli/backup.py:1022) and the result is threaded through to every member, so the exposure is one two-syscall window per hermes import invocation — and hermes import is a single-threaded CLI command, not a service. There is no concurrent Hermes writer inside that window in practice.

The throwaway-file alternative trades the race for a worse one. Creating a file with open(..., 'xb'), stating it and unlinking it does I/O in the user's HERMES_HOME (or, on the _external/ branch, in a third-party config directory) on a disaster-recovery path where the directory may be read-only, full, or on a filesystem that ignores mode bits entirely — in which case the probe reports the wrong answer rather than failing. It also has its own race (another process observing or colliding with the throwaway) without removing the process-global umask mutation, since the umask is still what determines the resulting mode.

A module-level lock doesn't help either, because the races that matter are with code outside this module — any other thread in the process calling open() — and a lock in hermes_cli.backup cannot serialize those.

The alternative I would take if this window ever became a real concern is dropping the probe entirely and having _default_new_file_mode() return None, which the caller already handles by leaving mkstemp's mode alone. That is strictly safe but reintroduces the 0600 tightening on every newly created file that this PR exists to prevent, so it is a regression, not a fix.

Current head is dfda12ea3.

Comment thread hermes_cli/backup.py
Comment on lines +916 to +920
fd, tmp_name = tempfile.mkstemp(
dir=str(target.parent), prefix=f".{target.name[:80]}.", suffix=".partial"
)
try:
with os.fdopen(fd, "wb") as dst:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a reachable mismatch here, so I've left the diff alone.

utils.atomic_replace is annotated for exactly this call shape and normalizes both arguments itself:

def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:
    ...
    target_str = str(target)
    real_path = os.path.realpath(target_str) if os.path.islink(target_str) else target_str
    tmp_str = str(tmp_path)

(utils.py:91, 111-113). Everything downstream of those three lines is str, and the function's declared return is str — the resolved real path, not the symlink — which is the value that matters, since a symlinked config.yaml must have its mode and owner re-applied to the link target, not the link.

tmp_name is also handed to tempfile.mkstemp's own output and to os.unlink in the cleanup path, both of which are str-native, so converting it would add a Path() round-trip without removing one.

Where the mismatch would actually bite is on the receiving side, and dfda12ea3 (current head) does convert there: the replace result is now wrapped once as real_path = Path(atomic_replace(tmp_name, target)) (hermes_cli/backup.py:949) because _restore_file_owner / _restore_file_mode are annotated path: Path. That is the one boundary in this helper where the type was load-bearing.

Comment thread hermes_cli/backup.py Outdated
Comment on lines +932 to +937
real_path = atomic_replace(tmp_name, target)
if mode is not None and not hasattr(os, "fchmod"):
try:
os.chmod(real_path, mode)
except OSError:
pass

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and fixed in dfda12ea3 (current head).

The docstring on this function already claimed the mode is applied "before the replace so the target never transits through mkstemp's 0600" — but that was only true on the os.fchmod branch. You're right that the fallback contradicted it, and there is a second consequence beyond the transit window: atomic_replace's EXDEV/EBUSY fallback does shutil.copyfile + shutil.copystat(tmp, real) (utils.py:125-127), so on a cross-device or bind-mount install the temp file's 0600 was being copied straight onto the target, and only the swallowed post-replace chmod undid it.

_extract_member_atomically now applies the mode to the temp file on both branches — os.fchmod(dst.fileno(), mode) where it exists, os.chmod(tmp_name, mode) where it does not (hermes_cli/backup.py:933-942) — and keeps the post-replace restore as the belt-and-braces path. That last call is now utils._restore_file_mode rather than a local swallowing try, which also picks up the ordering constraint: _restore_file_owner runs first because chown clears setuid/setgid, so the mode restore is what puts those bits back. Same shape atomic_yaml_write took in 43fc86562 ("close the yaml 0600 transit window") and atomic_write_text in 3556728a5, rather than a third variant.

On surfacing chmod failures in errors: I kept them best-effort deliberately. The member has already been restored correctly at that point, and the failure mode we care about — the file being published at 0600 — is now closed at the temp-file step, so the post-replace call is redundant on POSIX and only load-bearing on Windows, where os.chmod is a near-no-op. Turning a cosmetic permission miss into a reported import error would make hermes import look partially failed when the restore actually succeeded. This also matches every other atomic writer in utils.py, which all swallow there.

Test: tests/hermes_cli/test_backup.py::TestImportAtomicWrites::test_mode_is_applied_before_the_replace_without_fchmod (test_backup.py:789). It uses monkeypatch.delattr(os, "fchmod") — the convention 43fc86562 established for this branch — and spies the temp file's mode at atomic_replace time rather than after, so it reads 0o600 without the fix and 0o644 with it. Mutation-checked: deleting only the os.chmod(tmp_name, mode) line reds it (assert [384] == [420]).

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 6, 2026
@spfcraze

spfcraze commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary:
The atomicity guarantee in the new _extract_member_atomically docstring — the target only ever moves from old contents to complete new contents — does not hold on atomic_replace's EXDEV/EBUSY fallback, which writes with shutil.copyfile and truncates the destination in place before any replacement bytes exist.

Problems:

  • The new helper stages each member in the target's directory, fsyncs, and publishes via atomic_replace, and its docstring states the target "only ever moves from its old contents to the complete new contents".
  • atomic_replace's fallback (utils.py:125) is shutil.copyfile(tmp_str, real_path) — copyfile opens the destination with wb, truncating it before copying; an ENOSPC or interrupt mid-copy leaves the resolved target truncated, the exact failure this PR fixes, on the fallback path.
  • The fallback is reachable for the deployment the PR names as a reason for choosing atomic_replace: a symlinked config.yaml whose real file lives on another filesystem (the temp is staged in the symlink's parent directory, so the rename crosses devices), and on Windows / bind-mount EBUSY. The new ENOSPC test exercises only the normal path.

Solution:
Apply the same stage-then-replace inside the fallback: copy tmp_str to a second temp file in real_path's directory, fsync, then os.replace — so the EXDEV/EBUSY path never truncates the destination in place.


Checked against 6ae228e — the PR head when this was written — and 0957277, main at the same moment.

…0 transit window

Follow-up on the atomic-import restore, delegating both metadata concerns to
the shared helpers instead of half-handling them locally.

Owner preservation was missing entirely. `tempfile.mkstemp` + `atomic_replace`
publishes a temp file owned by the *writing* user, so `sudo hermes import`
re-owned every restored file to root — on the disaster-recovery path, and on
exactly the Docker/NAS volume installs `utils._restore_file_owner` was added
for. `_extract_member_atomically` now captures `_preserve_file_owner(target)`
before staging and calls `_restore_file_owner` after the replace, before the
mode restore (chown clears setuid/setgid, so the mode has to go back last).

Mode handling was also only half applied before the replace: the `os.fchmod`
branch applied it to the temp fd, but the platforms without `fchmod` fell
through to a best-effort post-replace chmod, leaving the published file at
mkstemp's 0600 until that chmod landed — permanently if the process died in
between — and making `atomic_replace`'s EXDEV/EBUSY `shutil.copystat` fallback
copy 0600 onto the target. The mode is now applied to the temp file on both
branches, with the post-replace `_restore_file_mode` kept as the belt-and-
braces path.

This is the same shape `atomic_write_text` and `atomic_yaml_write` already
carry after 3556728 and 43fc865; capture and restore now reuse
`utils._preserve_file_mode` / `_preserve_file_owner` / `_restore_file_mode` /
`_restore_file_owner` rather than re-deriving them, which also drops the local
`import stat`.

Tests (tests/hermes_cli/test_backup.py, class TestImportAtomicWrites):
- test_restore_preserves_existing_file_owner — forces a uid/gid so it does not
  need root; asserts chown fires once, with the captured owner, on the
  pre-existing file only (a newly created member has no prior owner).
  Mutation-checked: dropping only the `_restore_file_owner` call reds it.
- test_mode_is_applied_before_the_replace_without_fchmod — `monkeypatch.delattr`
  on `os.fchmod`, spies the temp file's mode at replace time. Reads 0o600
  without the fix, 0o644 with it. Mutation-checked the same way.
…truncates

The atomicity claim in _extract_member_atomically's docstring holds on the
os.replace path but not on atomic_replace's EXDEV/EBUSY fallback, which uses
shutil.copyfile and so opens the destination 'wb'. That is pre-existing
behaviour shared by every atomic writer in the repo, and it is reachable here
for a symlinked target whose real file lives on another filesystem. Scope the
docstring to what the helper actually guarantees instead of overstating it;
the fallback itself is a utils.atomic_replace change.
@briandevans

Copy link
Copy Markdown
Contributor Author

This one is correct, and I've scoped the docstring to match in dc051c497 (current head) rather than leave it overstating the guarantee.

To be precise about where the gap lives: shutil.copyfile does open the destination "wb", so atomic_replace's EXDEV/EBUSY fallback truncates in place. That is pre-existing behaviour in utils.atomic_replace, not something this PR introduces — the same fallback backs atomic_write_text, atomic_json_write, and atomic_yaml_write, so on a cross-device or bind-mount target every atomic writer in the repo has the same window today. What this PR changes is the os.replace path, which is the path taken on a normal same-filesystem install; the fallback goes from "truncates in place" to "truncates in place", i.e. no regression, but no improvement either.

The reachability point is fair, though: staging in the symlink's parent directory means a symlinked config.yaml whose real file lives on another filesystem takes the fallback, and that is one of the deployments this PR names.

I have not fixed it here on purpose. The fix belongs in utils.atomic_replace — stage a second temp beside real_path, fsync, then os.replace — where all four callers get it at once, and doing it inside hermes_cli/backup.py would either duplicate the helper or quietly change shared behaviour for three unrelated call sites from inside a backup PR. Happy to open that as its own PR against utils.py with a test that forces the EXDEV branch, or to fold it in here if you would rather it landed together — just say which.

Head is now dc051c497; the three inline threads above were answered at dfda12ea3, which is still an ancestor of it.

@egilewski

Copy link
Copy Markdown
Contributor

suggesting changes

_extract_member_atomically() preserves the target's complete stat.S_IMODE value and reapplies it to the staged/replaced file. That includes S_ISUID and S_ISGID, not just ordinary read/write/execute permissions. On current main, overwriting an existing 04755 file clears the setuid bit and leaves it 0755; on this PR and its current-main replay, the same archive-controlled replacement remains 04755. Because a backup ZIP may contain arbitrary relative members under HERMES_HOME, and this path explicitly supports privileged imports for owner preservation, a privileged hermes import can publish imported executable bytes while retaining the old file's privilege bits.

Please preserve only ordinary permission bits for restored content (or explicitly clear setuid/setgid before publication) and add a regression covering a target with special mode bits. The existing 0644 preservation behavior can remain unchanged.

Security evidence:

  • trust boundary: backup ZIP members control replacement bytes, while a privileged import may restore onto pre-existing files and metadata.
  • source/sink/invariant: zf.open(member) feeds the staged file, then _preserve_file_mode and _restore_file_mode carry the old mode across atomic_replace; imported content must not retain setuid/setgid privilege bits.
  • current-main reproduction: replacing an existing 04755 target with archive-controlled content leaves it 0755.
  • PR-head or patch-replay validation: the exact head replayed cleanly onto current main, but the same replacement leaves the target 04755.
  • positive/negative cases: interrupted-member injection now preserves the original file with no partial temp, and all seven focused atomic-import tests pass; the special-bit case still fails the security invariant.
  • residual bypass search: both normal and _external/ restores call the same helper, and its mode value is never masked before pre-replace fchmod/chmod or post-replace restoration.
  • reviewer validation: the focused tests cover ordinary mode preservation but mask assertions to 0777, so they do not detect retained S_ISUID/S_ISGID bits.

Not checked:

  • Full test suite
  • CodeRabbit review

Signed: GPT-5.6-sol-xhigh in Codex

…files

``_extract_member_atomically`` carries the replaced file's permissions
across the publish so that routing through mkstemp does not change what
the caller would otherwise have produced. But ``_preserve_file_mode``
returns ``stat.S_IMODE``, which is all twelve bits, and this restore is
deliberate on both sides of the replace: the mode is fchmod'd onto the
temp before ``atomic_replace`` and re-applied afterwards because chown
clears the elevated bits. So a target sitting at 0o4755 comes out of
``hermes import`` still at 0o4755 — with contents supplied by the zip.

That is a regression introduced by the atomic rewrite rather than a
pre-existing one. The overwrite it replaced was an in-place
``open(target, "wb")``, and an in-place write by a process without
CAP_FSETID has the elevated bits stripped by the kernel, so the old path
left 0o4755 as 0o755.

The blast radius is not limited to Hermes' own state: the ``_external/``
branch of ``run_import`` publishes members anywhere under ``$HOME``, and
this is the path that documents ``sudo`` use so ownership survives a
restore. An archive that happens to contain a member matching some
existing privileged file would take over the identity that file runs as.

Mask the two bits off the preserved mode. The masking happens once,
before the temp file is chmod'd, so there is no transient elevation
either. The sticky bit is kept — it is inert on a regular file. The
ordinary permission bits are unaffected, so the Docker/NAS installs the
preservation exists for still get their broader modes back.

This is the one write path in the repo where the bytes are untrusted;
the ``utils`` writers that preserve the full mode re-serialize content
the process itself produced, and are correct as they stand.
… bits

Pre-creates a 0o6755 target, imports a member over it, and asserts the
published file is 0o755 with both elevated bits gone — plus that the
staged temp file never carried them either, so there is no window where
archive content sits behind an elevated mode.

The existing coverage in this class cannot see the failure: every mode
assertion masks with ``& 0o777``, which discards exactly the bits at
issue, and the fixtures chmod their targets to ordinary modes that never
had them set. Without the mask on the preserved mode this test reports
the published file still holding S_ISUID.

Skipped where the platform or filesystem refuses setuid on a user-owned
file, so the assertion never depends on running as root.
@briandevans

Copy link
Copy Markdown
Contributor Author

setuid/setgid are now masked off the preserved mode. Pushed.

This one is a regression this PR introduces, not a pre-existing one, and it is worth being exact about why.

_preserve_file_mode returns stat.S_IMODE(...) — all twelve bits, S_ISUID/S_ISGID/S_ISVTX included. This PR then re-applies them deliberately on both sides of the replace: os.fchmod(dst.fileno(), mode) before atomic_replace, and _restore_file_mode(real_path, mode) after _restore_file_owner, under a comment saying the mode restore is what puts the elevated bits back after chown clears them. So a target at 0o4755 comes back out of hermes import at 0o4755, with contents supplied by the zip.

Main does not behave that way. The overwrite this PR replaces is an in-place open(target, "wb") (backup.py:929, :975 on main), and an in-place write by a process without CAP_FSETID has the kernel strip setuid/setgid — main leaves 0o4755 as 0o755. Routing through mkstemp + os.replace is exactly what removes that implicit stripping, because the bits are then re-applied explicitly.

The reach is not limited to Hermes' own state: the _external/ branch of run_import publishes members anywhere under $HOME, subject only to the traversal check, and this is the path that documents sudo use so ownership survives a restore.

Fix: mask the two bits off the preserved mode once, before the temp file is chmod'd, so there is no transient elevation either. S_ISVTX is kept — inert on a regular file. Ordinary permission bits are untouched, so the Docker/NAS installs the preservation exists for still get their broader modes back. The docstring now states outright that this is not a faithful mode copy and why.

Deliberately not widened to utils.py. grep -n '_preserve_file_mode' utils.py on main returns five other call sites, all inside atomic_write_text, atomic_json_write, atomic_yaml_write, atomic_roundtrip_yaml_update and atomic_roundtrip_yaml_save — writers that re-serialize content the process itself produced, so preserving the caller's own bits is correct there. The untrusted-archive trust boundary is unique to this function, and that is the whole reason the mask belongs here rather than in the shared helper.

Commits: 21dbe75c5dd (fix) and d7a7e8350b9 (test), head d7a7e8350b9.

Regression:
tests/hermes_cli/test_backup.py::TestImportAtomicWrites::test_restore_does_not_carry_setuid_onto_archive_content

It pre-creates a 0o6755 target, imports a member over it, and asserts the published file is 0o755 with both bits gone and the staged temp file never elevated. The pre-existing assertions in that class cannot detect this — they all mask with & 0o777, which discards precisely the bits in question. Without the mask the test reports the published file still holding S_ISUID. Full file: 48 passed.

@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #86669. All five of your commits were cherry-picked onto current main with your authorship preserved in git log — thank you for the thorough work here, including the review-driven setuid/setgid masking and the sudo owner preservation.

@teknium1 teknium1 closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P1 High — major feature broken, no workaround sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants