Skip to content

fix(curator-backup): reject non-file tar members in rollback pre-check - #32523

Closed
akaponym-byte wants to merge 1 commit into
NousResearch:mainfrom
akaponym-byte:fix/curator-backup-reject-symlink-tar-members
Closed

akaponym-byte wants to merge 1 commit into
NousResearch:mainfrom
akaponym-byte:fix/curator-backup-reject-symlink-tar-members

Conversation

@akaponym-byte

Copy link
Copy Markdown

Summary

The rollback path in agent/curator_backup.py uses
tf.extractall(..., filter="data") when available, with an explicit
except TypeError fallback to plain tf.extractall(...) on older
interpreters. The pre-check above the call refused tar members whose
name was absolute or contained .., but not members whose type was
unsafe — so a tar entry like:

TarInfo(name="leak", type=SYMTYPE, linkname="/etc/passwd")

…slipped through the pre-check.

filter="data" itself rejects this on Python 3.12+ and on 3.11.4+ (PEP
706 was backported there), but pyproject.toml declares python>=3.11
and the except TypeError branch is reachable on 3.11.0–3.11.3 where
the filter kwarg is unknown. On those interpreters the fallback
materialises the symlink via os.symlink(linkname, targetpath), and
the agent reads the link target's content on the next skill_view.

A malicious tarball under `/.hermes/skills/.curator_backups/` (e.g. a
co-tenant on a shared host with write access to the home, a user
tricked into importing a third-party 'snapshot', or a write-anywhere
primitive surfaced by another skill) is enough to plant
`skills/leak → /etc/passwd` or `
/.ssh/id_rsa` ahead of the next
`hermes curator rollback`.

Fix

Extend the existing pre-check loop with a member-type whitelist —
refuse anything that isn't `isfile()` or `isdir()`. This mirrors
`hermes_cli/profiles._safe_extract_profile_archive`, which already
applies the same gate for profile imports. Same shape as c26af46
('reject symlinks in skill bundles before install'), one code path
over.

Test plan

  • New `test_rollback_rejects_symlink_member` forces the legacy
    fallback (monkeypatches `tarfile.TarFile.extractall` to raise
    `TypeError` on `filter='data'`), plants a victim file outside the
    skills tree, and asserts the symlink is never materialised. Without
    the patch the assertion `not leak.is_symlink()` fails.
  • `pytest tests/agent/test_curator_backup.py` — 26 passed.
  • `pytest tests/agent/test_curator*.py` — 105 passed (no
    regressions in sibling curator suites).
  • `ruff check agent/curator_backup.py tests/agent/test_curator_backup.py` — clean.

The rollback path in agent/curator_backup.py uses ``tf.extractall(filter='data')``
when available, with a fallback to plain ``tf.extractall()`` on older
interpreters.  The pre-check above the call refused tar members whose
``name`` was absolute or contained ``..`` parents, but not members whose
*type* was unsafe — so a symlink with a benign-looking name and an
attacker-controlled ``linkname`` would slip past.

``filter='data'`` itself catches this on Python 3.12+ and on 3.11.4+ (PEP
706 was backported there), but ``pyproject.toml`` declares ``python>=3.11``
and the explicit ``except TypeError`` branch is only reachable on
3.11.0–3.11.3 where the kwarg is unknown.  On those interpreters the
fallback materialises the symlink via ``os.symlink(linkname, targetpath)``
and the agent then reads the link target's content on the next
``skill_view``.

A malicious tarball under ``~/.hermes/skills/.curator_backups/`` (a co-tenant
with write access to the home, a user tricked into importing a third-party
"snapshot", or a write-anywhere primitive in another skill) is enough to
plant ``skills/leak -> /etc/passwd`` or ``~/.ssh/id_rsa`` ahead of the next
``hermes curator rollback``.

The fix mirrors ``hermes_cli/profiles._safe_extract_profile_archive``:
extend the pre-check loop to refuse anything that isn't ``isfile()`` or
``isdir()`` — rejecting symlinks, hardlinks, devices and FIFOs at the
same gate that already rejects ``/`` and ``..``.  This is the same shape
as c26af46 ("reject symlinks in skill bundles before install"), one
code path over.

Regression test forces the legacy fallback (monkeypatches ``extractall`` to
raise ``TypeError`` on ``filter='data'``) to exercise the path that 3.11.0–
3.11.3 hits unconditionally, plants a victim file outside the skills tree,
and asserts the symlink is never materialised.  Without the fix the
fallback succeeds and the assertion ``not leak.is_symlink()`` fails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@alt-glitch

Copy link
Copy Markdown

Duplicate of #23796 which already addresses the same curator_backup.py rollback symlink/hardlink member-type whitelist fix for #23794.

@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint duplicate This issue or pull request already exists labels May 26, 2026
@akaponym-byte

Copy link
Copy Markdown
Author

Confirmed duplicate of #23796 — closing.

#23796 covers the same isfile()/isdir() member-type whitelist as this PR, plus:

  • Cross-platform path validation via PurePosixPath + PureWindowsPath (backslash and drive-letter checks)
  • Resolved-target containment (target.resolve().relative_to(destination_root)) — catches the case where a member with a safe name resolves through a symlinked parent
  • _EXCLUDE_TOP_LEVEL enforcement on archive entries
  • TOCTOU mitigation by caching members = tf.getmembers() once and threading it through both validation and extractall(members=members, ...)

Test there also exercises the legacy 3.11 fallback path and additionally asserts a symlink-mediated file escape, which is strictly stronger than mine.

Sorry for the noise — I searched merged PRs for adjacent fixes but didn't think to check open ones with the same scope.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint duplicate This issue or pull request already exists P2 Medium — degraded but workaround exists type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants