Skip to content

fix(skills,pairing): traversal guard + hash symmetry + list_pending lock (#6656)#30715

Merged
teknium1 merged 3 commits into
mainfrom
hermes/hermes-37ccebe4
May 23, 2026
Merged

fix(skills,pairing): traversal guard + hash symmetry + list_pending lock (#6656)#30715
teknium1 merged 3 commits into
mainfrom
hermes/hermes-37ccebe4

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Salvages @aaronlab's PR #6656 — three independent fixes bundled together. All three are real, well-targeted, and small. Salvaged onto current main with the cherry-pick + a follow-up commit that completes a half-finished symmetry fix and adds the regression tests the original PR was missing.

What it fixes

Fix 1 — Skill uninstall path traversal (highest impact)

entry["install_path"] comes from ~/.hermes/skills/.hub/lock.json. If a malicious skill ever gets installed (or lock.json is otherwise corrupted), an entry like install_path: "../../" makes SKILLS_DIR / "../../" resolve to a parent dir, and shutil.rmtree recursively obliterates it. Now guarded with Path.resolve().is_relative_to(SKILLS_DIR.resolve()) — catches ../, absolute paths, and symlink-escape via realpath normalisation.

Fix 2 — Bundle / disk content hash filename inclusion

bundle_content_hash() previously mixed file CONTENTS only — swapping SKILL.mdscripts/run.sh contents produced an identical digest. The contributor patched this in the bundle hash but missed the on-disk equivalent content_hash() in tools/skills_guard.py. The two functions need to stay symmetric (one operates on a bundle, one on the disk layout, and check_for_skill_updates compares them). Without the symmetric fix, 2 existing tests in test_skills_hub.py started failing because every clean install reported as drifted. Follow-up commit fixes the disk side and documents the symmetry invariant.

Fix 3 — PairingStore.list_pending TOCTOU lock

list_pending() called _cleanup_expired() (which writes the JSON file) without holding self._lock, racing with concurrent generate_code / approve_code which DO hold it. PR was filed before our hash-pairing-codes work; conflict resolved by keeping main's hashed-entry schema and applying the contributor's lock wrap.

Changes

  • tools/skills_hub.py (+9 -2, @aaronlab): realpath guard in uninstall_skill, filename prefix in bundle_content_hash.
  • gateway/pairing.py (+1 indented, @aaronlab): with self._lock: wrap on list_pending.
  • tools/skills_guard.py (+13 -1, follow-up): make content_hash filename-sensitive to stay symmetric with bundle_content_hash. Symmetry invariant documented in docstring.
  • tests/tools/test_pr_6656_regressions.py (+287, follow-up): 10 new regression tests covering all three fixes (parent-segment traversal, absolute paths, symlink escape, legitimate uninstall, in-memory filename swap, identity, disk-side filename swap, bundle↔disk symmetry, list_pending lock source-grep contract, smoke).
  • scripts/release.py (+1 -1, follow-up): AUTHOR_MAP correction — 1115117931@qq.com mapped to aaronagent (not a real GitHub login) → fixed to aaronlab.

Validation

Scenario Before After
uninstall_skill on lock entry install_path="../../" rmtree obliterates parent refused with error
Bundle with swapped SKILL.md ↔ run.sh contents same hash different hashes
Disk and bundle hash for same skill matched (before contributor change) → mismatched (after contributor change only) → MATCHED (after this PR's follow-up) matched
list_pending racing with approve_code TOCTOU window both serialised under self._lock

tests/tools/test_pr_6656_regressions.py — 10/10 new tests pass.
Full skill + pairing surface (8 test files, 287 tests) — all green, no regressions.

Sibling-site audit

Checked other shutil.rmtree call sites in skills_hub.py. The two not patched by this PR (quarantine_bundle and install_from_quarantine) construct paths from _validate_skill_name / _validate_bundle_rel_path outputs which are already safe. The lock.json-derived install_path in uninstall_skill was the only unguarded site.

Attribution

@aaronlab did all three fixes in one commit. Cherry-picked with original authorship (date 2026-04-09 preserved). The follow-up commit (completing the hash symmetry + tests + AUTHOR_MAP fix) is attributed to Teknium. Rebase-merge keeps both visible in git log.

Infographic

PR 6656 skill hub safety audit

aaronlab and others added 3 commits May 22, 2026 19:51
…ding, hash file paths

- skills_hub: validate that uninstall_skill's install_path resolves
  inside SKILLS_DIR before calling shutil.rmtree, preventing recursive
  deletion of arbitrary directories via poisoned lock.json entries
- skills_hub: include file paths (not just contents) in
  bundle_content_hash so swapping filenames between files changes the
  hash, strengthening update-detection integrity
- pairing: wrap list_pending() in self._lock so _cleanup_expired() file
  writes don't race with concurrent generate_code()/approve_code() calls

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… bundle_content_hash)

PR #6656 added rel_path + \x00 prefixing to ``bundle_content_hash`` so a
filename swap between two files in a bundle changes the digest. But it
only patched the in-memory side — ``content_hash`` in ``tools/skills_guard.py``
(the on-disk equivalent) still hashed file contents only.

These two functions need to stay symmetric: ``check_for_skill_updates``
compares the disk hash of an installed skill against the bundle hash
of the upstream copy. With the asymmetric fix, every clean install
showed as drifted because the digests no longer matched
(2 existing tests in ``test_skills_hub.py`` started failing as soon as
the contributor's change landed).

Apply the same ``rel_path + \x00 + content`` shape to the disk-side
function. Both functions now produce the same digest for the same skill
content laid out two ways. Documented the symmetry invariant in the
docstring so a future change to either function knows to touch both.

Also adds tests/tools/test_pr_6656_regressions.py with 10 regression
tests covering all three fixes salvaged in PR #6656:
  - uninstall_skill path traversal (4 cases: parent segments, absolute
    paths, symlink escape, legitimate skill)
  - bundle_content_hash filename swap detection (4 cases: in-memory
    swap, identity, disk-side swap, bundle↔disk symmetry)
  - list_pending lock contract (2 cases: source-grep contract, smoke)

Also fixes AUTHOR_MAP entry for @aaronlab — their commit email
(1115117931@qq.com) maps to "aaronagent" which isn't a real GitHub
login, so changelog @mentions would 404.
@teknium1 teknium1 merged commit 7f7245b into main May 23, 2026
22 checks passed
@teknium1 teknium1 deleted the hermes/hermes-37ccebe4 branch May 23, 2026 02:59
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-37ccebe4 vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8992 on HEAD, 8991 on base (🆕 +1)

🆕 New issues (1):

Rule Count
unresolved-import 1
First entries
tests/tools/test_pr_6656_regressions.py:27: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues: none

Unchanged: 4770 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P1 High — major feature broken, no workaround tool/skills Skills system (list, view, manage) comp/gateway Gateway runner, session dispatch, delivery labels May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround tool/skills Skills system (list, view, manage) type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants