Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions skillopt_sleep/staging.py
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,24 @@ def write_staging(
manifest["skill_roots"] = list(dict.fromkeys(recorded_roots))
if legacy:
manifest["legacy"] = legacy
# Legacy adoption also writes a managed SKILL.md. Record the roots that
# were trusted when the night was staged so a later manifest edit cannot
# redirect that write to an arbitrary absolute path.
if "skill" in legacy:
recorded_roots = [
os.path.abspath(os.path.expanduser(str(root)))
for root in skill_roots
if isinstance(root, str) and str(root).strip()
]
if not recorded_roots:
skill_path = str(legacy["skill"].get("live_path") or "")
recorded_roots = [
os.path.dirname(os.path.dirname(skill_path))
if skill_path else ""
]
manifest["skill_roots"] = list(dict.fromkeys(
root for root in recorded_roots if root
))
artifacts: List[tuple[str, str]] = [
(
os.path.join(out, row["proposed_file"]),
Expand Down Expand Up @@ -3026,6 +3044,7 @@ def adopt(staging_dir: str) -> List[str]:
initial_rows = _legacy_rows(initial_manifest)
if not initial_rows:
return []
skill_roots = staged_skill_roots(staging_dir) if "skill" in initial_rows else []
initial_paths: List[str] = []
for row in initial_rows.values():
live = _safe_live_path(row.get("live_path"))
Expand Down Expand Up @@ -3088,6 +3107,16 @@ def adopt(staging_dir: str) -> List[str]:
expected_realpath,
expected_basename=expected_basename,
)
if label == "skill" and not _live_target_within_roots(live, skill_roots):
raise StagingError(
"legacy skill path is outside the skills roots recorded "
f"when this night was staged: {live}"
)
elif label == "skill" and not _live_target_within_roots(live, skill_roots):
raise StagingError(
"legacy skill path is outside the skills roots recorded "
f"when this night was staged: {live}"
)
staged = os.path.join(staging_dir, expected_file)
if _is_link_or_junction(staged) or not os.path.isfile(staged):
raise StagingError(f"legacy {label} proposal is missing or a symlink")
Expand Down
18 changes: 18 additions & 0 deletions tests/test_sleep_adopt_skill_subset.py
Original file line number Diff line number Diff line change
Expand Up @@ -2074,6 +2074,24 @@ def test_legacy_manifest_is_pinned_and_adoption_has_a_receipt(self):
["skill", "memory"],
)

def test_legacy_skill_retarget_outside_staged_roots_is_refused(self):
with tempfile.TemporaryDirectory() as tmp:
staging, skill, _memory = self._legacy_night(tmp)
outside = os.path.join(_canonical(tmp), "outside", "skill", "SKILL.md")
_write(outside, "# skill v1\n")
manifest_path = os.path.join(staging, "manifest.json")
with open(manifest_path, encoding="utf-8") as handle:
manifest = json.load(handle)
manifest["legacy"]["skill"]["live_path"] = outside
manifest["legacy"]["skill"]["live_realpath"] = outside
with open(manifest_path, "w", encoding="utf-8") as handle:
json.dump(manifest, handle)

with self.assertRaisesRegex(StagingError, "outside the skills roots"):
adopt(staging)
self.assertEqual(_read(outside), "# skill v1\n")
self.assertEqual(_read(skill), "# skill v1\n")

def test_legacy_missing_targets_can_share_one_new_parent(self):
with tempfile.TemporaryDirectory() as tmp:
live_root = os.path.join(_canonical(tmp), "new-live")
Expand Down