diff --git a/skillopt_sleep/staging.py b/skillopt_sleep/staging.py index 615c5ac8..9c3542ee 100644 --- a/skillopt_sleep/staging.py +++ b/skillopt_sleep/staging.py @@ -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"]), @@ -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")) @@ -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") diff --git a/tests/test_sleep_adopt_skill_subset.py b/tests/test_sleep_adopt_skill_subset.py index f6c32af5..4b830e2f 100644 --- a/tests/test_sleep_adopt_skill_subset.py +++ b/tests/test_sleep_adopt_skill_subset.py @@ -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")