Skip to content

fix(profiles): support nested owned paths, correctly distinguish stale distributed files from user additions - #75351

Closed
ygd58 wants to merge 1 commit into
NousResearch:mainfrom
ygd58:fix/distribution-owned-paths-enforcement-v2
Closed

ygd58 wants to merge 1 commit into
NousResearch:mainfrom
ygd58:fix/distribution-owned-paths-enforcement-v2

Conversation

@ygd58

@ygd58 ygd58 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Context

Supersedes #74409 per @teknium1's review -- two real gaps, plus three additional bugs surfaced during implementation.

Fixes

  1. Nested paths. Rewrote to resolve each owned_paths() entry directly as a manifest-relative path (e.g. skills/research, cron/digest.json) against staged/target, instead of a root-name comparison that could only ever match top-level entries.
  2. Stale vs. user files. Added installed_files tracking to the manifest; on update, diffs against the previous installation's own tracked list and surgically removes only files that were tracked as distributed before and aren't part of this revision.

Bugs found and fixed along the way

  • An upfront rmtree for a whole-directory owned entry would wipe out user additions before the diff could run -- switched to non-destructive merge-copy everywhere.
  • distribution.yaml is itself a default owned path, so the copy loop was clobbering the tracking info before it could be read -- moved the read to the top of the function.
  • Walking the destination (not the source) to determine "what did this payload install" incorrectly counted pre-existing (including stale) files.
  • Skipping config.yaml's copy for preserve_config caused it to look stale and get deleted -- now explicitly counted as installed.

Tests

6 new tests plus 2 pre-existing tests updated to opt their directories into distribution_owned (they predated any allowlist enforcement).

49/49 pass in the full tests/hermes_cli/test_profile_distribution.py file.

…e distributed files from user additions

Follow-up per review of NousResearch#74409.

Two real gaps in the original allowlist enforcement:

1. The root-name comparison (checking staged.iterdir() entry names
   against owned_paths()) could only ever match root-level entries, so
   documented nested forms like skills/research/ and cron/digest.json
   were silently skipped -- their source content never reached the
   target at all. Rewrote to resolve each owned_paths() entry directly
   as a manifest-relative path against staged/target, supporting any
   depth.

2. dirs_exist_ok=True (needed to fix a separate bug this rewrite
   introduced along the way -- see below) retains files from an older
   distribution revision that were removed from a newer one, which
   contradicts the documented "replaced from the new clone" contract
   and doesn't distinguish that from a genuine target-only user file.
   Added a new manifest field, installed_files, tracking every file
   path this function writes; on the next update it diffs against the
   PREVIOUS installation's own tracked list and surgically removes only
   files that were tracked as distributed before and aren't part of
   this revision -- a file never tracked (a user addition placed inside
   an owned directory) is never touched, regardless of whether it lives
   under the default whole-directory "skills" or a narrow nested
   "skills/research" entry.

Three additional bugs surfaced and fixed during implementation and
testing (documented directly in code comments at each site):

- An upfront rmtree(dest) for a whole-directory owned entry (e.g. the
  default "skills") would wipe out a user addition living inside that
  directory before the installed_files diff could even run --
  switched to a non-destructive dirs_exist_ok=True merge-copy
  everywhere, relying entirely on the surgical stale-file prune
  (rather than directory deletion) to handle real removals.
- distribution.yaml (MANIFEST_FILENAME) is itself one of the default
  owned paths, so the owned-path copy loop was overwriting target's
  manifest with the raw staged source's version (no installed_files)
  BEFORE the stale-file diff read it -- moved the previous-installed
  read to the very top of the function, before any copying.
- Walking dest.rglob() after a merge-copy to determine "what did this
  payload just install" incorrectly counted pre-existing files
  (including genuinely stale ones) that were already physically
  present in the destination -- switched to walking src.rglob() (the
  staged source itself) instead.
- Skipping config.yaml's copy due to preserve_config caused the
  stale-file prune to see it as removed-from-this-revision and delete
  the user's own config -- now explicitly counted as installed even
  when its copy is skipped.

Added 6 new tests (nested directory/file owned entries, narrow-entry
sibling isolation, stale-file removal on update, target-only file
survival inside an owned directory, and installed_files tracking), and
updated two pre-existing tests that assumed unrestricted copying (from
before any allowlist enforcement existed) to explicitly opt their
directories into distribution_owned, matching the now-correctly-
enforced allowlist.

49/49 pass in the full tests/hermes_cli/test_profile_distribution.py
file (0 pre-existing failures once updated for the new allowlist
semantics).
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard area/profiles Multi-profile isolation, HERMES_HOME scoping sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 31, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Related: #74414 covers the profile-distribution allowlist path. This PR additionally implements nested owned paths and per-revision installed-file tracking to preserve user additions while pruning only stale distributed files.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for closing the allowlist gap and preserving target-only additions. The premise is verified: current main's hermes_cli/profile_distribution.py:563-590 copies every staged root entry without consulting distribution_owned, despite the selective-update contract in website/docs/reference/profile-commands.md:431-433.

Problems

  • Blocking: hermes_cli/profile_distribution.py:626 only strips slashes before joining an owned path to staged and target at lines 633 and 644. skills/../../auth.json bypasses the top-level exclusion check and escapes both roots.
  • Blocking: installed_files similarly preserves .. at lines 222-226, and stale cleanup joins and unlinks it at lines 674-678. A persisted traversal entry can delete outside the profile.

Suggested changes

  • Reject non-normalized, non-relative manifest paths (including . and ..) for both owned and tracked file lists before any filesystem operation, and add traversal regression tests.

This is an automated hermes-sweeper review.

shutil.copy2(staged / ENV_TEMPLATE_FILENAME, target / ENV_EXAMPLE_FILENAME)

for owned_path in owned:
owned_path = owned_path.strip().strip("/")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please validate this as a normalized manifest-relative path before joining it to either root. skills/../../auth.json passes the top-level skills check, but makes src and dest escape staged and target; validate persisted installed_files similarly before stale pruning.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 31, 2026
@ygd58

ygd58 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Both blocking security issues confirmed and fixed in #75494. Added _is_safe_manifest_relative_path() rejecting empty strings, absolute paths, backslashes, Windows drive forms, and any "."/".." component anywhere in the path -- applied at the owned_path loop, the stale-file prune loop, and DistributionManifest.from_dict() itself (defense-in-depth, filtering at parse time).

Verified this was genuinely exploitable: temporarily reverted the fix and confirmed skills/../../auth.json actually writes into the shared profiles/ directory, one level above every profiles own target_dir. Restored the fix and confirmed its blocked, with the rejection explicitly logged.

Added the requested traversal regression tests (crafted owned_path, corrupted persisted installed_files entry, and parse-time filtering). 53/53 pass in the full file. Closing this in favor of #75494.

@ygd58 ygd58 closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/profiles Multi-profile isolation, HERMES_HOME scoping comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants