fix(sleep): confine per-skill adoption to the staged skills roots - #241
Merged
Yifan Yang (Yif-Yang) merged 1 commit intoAug 20, 2026
Merged
Conversation
_safe_live_path proves a target is absolute, traversal-free and *.md, but accepts any such path on the machine; containment was never checked at adopt time. A tampered manifest live_skill_path with self-consistent pins therefore redirected the write onto an arbitrary existing-directory target. Record the resolved skills roots in the manifest at stage time and re-check each live target against them in adopt_skills, after the existing realpath(live) == live identity check so a symlinked ancestor cannot fake containment. Manifests without recorded roots fail closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #212 (merged as faf4700).
The gap
_safe_live_pathis the only filter on a per-skill live target at adopt time. It proves the path is absolute, traversal-free,~-free, control-char-free and*.md— but it accepts any such path on the machine.skill_proposal_rowsaddsbasename == "SKILL.md"andbasename(parent) == skill_name, which is a shape check, not a location check._path_is_withinalready exists instaging.pyand_contained_skill_fileinskill_resolver.pydoes containment correctly — but neither ever reaches the live write target.staging.pydoes not importskill_resolver.So a
manifest.jsonwhoselive_skill_path/live_realpath/live_sha256are edited self-consistently passes every existing check and redirects the adopt write onto an arbitrary file. I reproduced both cases against the merged code:(Creating a whole new directory tree is already refused:
live parent directory for 'alpha' does not exist. The blast radius is "any existing directory", not "anywhere".)This is not remote-triggerable — it needs local write access to the staging manifest — but the manifest is explicitly treated as untrusted everywhere else in this file (schema versioning, fail-closed parsing, hash pinning, symlink rejection). Containment was the one property left unenforced.
The fix
write_stagingrecords the resolved skills roots in the manifest asskill_roots. The cycle passesskill_search_roots(cfg). Low-level callers that don't know the roots get them derived from the just-resolved<root>/<name>/SKILL.mdpaths — at stage time, from paths we resolved ourselves, never from the manifest that will later be trusted.staged_skill_rootsreads them back with validation. A manifest without recorded roots fails closed with an explicit "discard and restage" message, so nights staged by the previous version cannot silently adopt unconfined._adopt_live_target_okre-checks each live target against those roots — placed after the existingrealpath(live) == liveidentity check, so a symlinked ancestor cannot fake containment. Ordering matters here and the comment says so.Validation
1348 passed, 10 skipped, 269 subtests— full suite on this branch (1344 on main before it; +4 new).TestAdoptionIsConfinedToTheStagedRootscovers both reproduced exploits (asserting the victim file is byte-identical / never created), the missing-roots fail-closed path, and that ordinary in-root adoption still succeeds.ruff checkclean on all three changed files.Thanks Bogdan (Dan) Baciu (@bogdanbaciu21) — the surrounding symlink/TOCTOU/WAL hardening in #212 is genuinely strong work; this closes the one remaining property.
🤖 Generated with Claude Code