The Remote Skills Directory That Quietly Copies Itself Back to Your Laptop


You run Hermes against a remote box over SSH because the work needs to happen where the code lives, and it has been fine for weeks. Then you notice your laptop’s ~/.hermes/skills/ is a lot bigger than it should be, and there is a .curator_backups directory in it that you never created. The numbers are not subtle: on the instance that reported this, 24 MB of old curator backups came back on every single sync-back, and the host’s own .hub/lock.json was silently replaced by a copy from the remote machine.

The two halves of the sync, and only one got the filter

An SSH terminal backend keeps the important files in step in both directions. On the way out it uploads your skills, so the remote agent works with the same skills you do. On the way back — when the session tears down — it applies whatever changed remotely.

The upload half is careful. Before walking the skills tree it prunes a fixed set of directories that a remote agent never reads:

.git  .github  .hub  .archive  .curator_backups  .locks
.venv  venv  node_modules  site-packages  __pycache__
.tox  .nox  .pytest_cache  .mypy_cache  .ruff_cache

In tools/credential_files.py, _walk_skill_tree prunes exactly that set before descending, and the docstring says why: these bookkeeping and dependency trees are not skill content, so walking them would cost time for nothing. That pruning is what keeps a multi-hundred-megabyte node_modules from being pushed to a remote host on every session.

The sync-back half has no equivalent filter. And that is the bug.

Why the extra files come back

Two pieces of ordinary-looking code combine badly:

  • _infer_host_path maps a remote file back to a host path by matching its directory prefix against files it already knows about. A remote path under a known directory simply gets the same substitution — /root/.hermes/skills/anything/x becomes ~/.hermes/skills/anything/x.
  • The files it knows about include ones sitting at the very top of the skills directory, such as .bundled_manifest and .usage.json. That is an ordinary result of syncing, and it is enough to make “the skills directory” a mapped prefix.

Once skills/ is a mapped prefix, everything found in the remote staging tree under it resolves to a host path — including skills/.hub/lock.json and skills/.curator_backups/*, the very trees the upload deliberately skipped. Those files were never uploaded by this session, so they have no recorded hash from the push. _apply_staged_file uses exactly that hash to decide whether a file changed:

pushed_hash = self._pushed_hashes.get(remote_path)
if pushed_hash is not None and _sha256_file(staged_file) == pushed_hash:
    return 0  # unchanged from push

No hash means “no evidence it is unchanged”, and the file is copied. Every teardown. And because the remote copies were not put there by you — they were left by an older build of Hermes or by Hermes running natively on the remote machine — they can be arbitrarily stale and arbitrarily large.

The reporter’s summary is the clearest version of the impact:

The host’s skills/.hub/lock.json is replaced by the remote copy, and the remote .curator_backups and node_modules trees are copied into the host’s skills directory. On our instance the remote held 24 MB of old curator backups that came back on every sync-back.

Why you should care

A replaced .hub/lock.json is not cosmetic. That lock is part of how the skills hub coordinates what is installed and being written; overwriting it with a remote machine’s stale copy is how you get a skills directory that believes it has packages it does not have, or that refuses work it should allow. Curator backups are memory of a state you deliberately moved past, and getting them handed back silently, forever, is the kind of thing you discover by disk usage rather than by error message.

Worth noting the scope: the same path inference runs for every backend that downloads in bulk, not only SSH. SSH is just where it was first reported, because SSH is where people keep a persistent remote whose ~/.hermes was written by a different Hermes than the one on the laptop.

Check your own machine

Look before you clean, on both sides:

ls -la ~/.hermes/skills/
du -sh ~/.hermes/skills/.curator_backups ~/.hermes/skills/.hub 2>/dev/null

On the host, .curator_backups is the tell — if it exists and you never ran a curator backup locally, it arrived over the wire. .hub/lock.json is harder to judge by eye; compare its modification time against your last session teardown, and compare it with the remote copy:

ssh your-remote 'ls -la ~/.hermes/skills/.hub/ ~/.hermes/skills/ | head -30'

The cleanest mitigation available today is to remove the offending trees on the remote, so there is nothing stale to copy back. List first, then delete only what you recognise as old duplicates — and do it when no session of yours is live on that machine:

ssh your-remote 'du -sh ~/.hermes/skills/.curator_backups 2>/dev/null; ls ~/.hermes/skills/.hub 2>/dev/null'
ssh your-remote 'rm -rf ~/.hermes/skills/.curator_backups ~/.hermes/skills/node_modules'

If you keep curator backups on the remote on purpose, keep the newest one and prune the rest rather than deleting the directory.

Status

This is tracked as issue #139364 (type/bug, backend/file-sync, P2), reported on October 15, 2026 against v0.21.7, with a self-contained regression test in the linked pull request. The proposed fix — PR #139365, keep skill bookkeeping trees out of sync-back — applies the same exclusion rule to the return trip. As of this writing the PR is open, not merged, so the behaviour is still on main.

If you use the SSH backend with a persistent remote, this is worth thirty seconds of checking regardless of when the fix ships: the trees should not be there, and now you know why they appeared.

The same remote backend has produced other surprises worth reading alongside this one: the SSH environment leakage fix and the OAuth over SSH guide.


Sources: issue #139364 and its linked fix PR #139365 on the Hermes Agent repository; the upload-side exclusion is _walk_skill_tree in tools/credential_files.py and the return path is _infer_host_path / _apply_staged_file in tools/environments/file_sync.py on origin/main; the excluded-directory list is EXCLUDED_SKILL_DIRS in agent/skill_utils.py.