Skip to content

Commit ca3d27e

Browse files
mnriemCopilot
andcommitted
Require per-extension ownership for generic symlinks
Reject cross-extension retargeted symlinks in generic command and skill ownership checks, and preserve them during skill cleanup. Exercise refresh, forced reinstall, removal, and update rollback with identical target bytes. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9a8d2ef commit ca3d27e

4 files changed

Lines changed: 131 additions & 12 deletions

File tree

‎src/specify_cli/agents.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@ def _generic_owned_output(path: Path, source_id: str, project_root: Path) -> boo
634634
if path.stat().st_nlink > 1:
635635
return False
636636
if path.is_symlink() and not path.resolve().is_relative_to(
637-
(project_root / ".specify/extensions").resolve()
637+
(project_root / ".specify/extensions" / source_id).resolve()
638638
):
639639
return False
640640
relative = path.relative_to(project_root.resolve()).as_posix()

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,7 +1680,7 @@ def _replacement(match: re.Match[str]) -> str:
16801680
if selected_ai == "generic" and skill_dir_preexists:
16811681
metadata = self.registry.get(manifest.id) or {}
16821682
if skill_name not in self._generic_owned_names(
1683-
metadata, [skill_name], skills=True
1683+
metadata, [skill_name], skills=True, extension_id=manifest.id
16841684
):
16851685
continue
16861686
if skill_file.exists() or skill_file.is_symlink():
@@ -2082,7 +2082,8 @@ def _restore_generic_refresh_artifacts(
20822082
raise ExtensionError("Could not restore generic artifacts: " + "; ".join(errors))
20832083

20842084
def _generic_owned_names(
2085-
self, metadata: Dict[str, Any], names: List[str], *, skills: bool
2085+
self, metadata: Dict[str, Any], names: List[str], *,
2086+
skills: bool, extension_id: str,
20862087
) -> List[str]:
20872088
"""Keep customized or untracked generic artifacts out of cleanup."""
20882089
from ..integrations.generic import registration_directory
@@ -2105,7 +2106,7 @@ def _generic_owned_names(
21052106
if path.stat().st_nlink > 1:
21062107
continue
21072108
if path.is_symlink() and not path.resolve().is_relative_to(
2108-
self.extensions_dir.resolve()
2109+
(self.extensions_dir / extension_id).resolve()
21092110
):
21102111
continue
21112112
relative = path.relative_to(root).as_posix()
@@ -2125,7 +2126,9 @@ def _complete_generic_refresh(
21252126
"""Require each invocation to be newly written or still hash-owned."""
21262127
missing = sorted(set(expected) - set(registered))
21272128
retained = (
2128-
self._generic_owned_names(metadata, missing, skills=skills)
2129+
self._generic_owned_names(
2130+
metadata, missing, skills=skills, extension_id=extension_id
2131+
)
21292132
if missing else []
21302133
)
21312134
absent = sorted(set(missing) - set(retained))
@@ -2236,6 +2239,14 @@ def _unregister_extension_skills(
22362239
skill_names, extension_id, skills_dir=skills_dir
22372240
):
22382241
skill_file = skill_subdir / "SKILL.md"
2242+
if (
2243+
generic_hashes is not None
2244+
and skill_file.is_symlink()
2245+
and not skill_file.resolve().is_relative_to(
2246+
(self.extensions_dir / extension_id).resolve()
2247+
)
2248+
):
2249+
continue
22392250
if generic_hashes is not None and skill_file.is_relative_to(
22402251
self.project_root.resolve()
22412252
):
@@ -2510,7 +2521,8 @@ def install_from_directory(
25102521
)
25112522
owned = (
25122523
set(self._generic_owned_names(
2513-
self.registry.get(manifest.id) or {}, list(names), skills=skills,
2524+
self.registry.get(manifest.id) or {}, list(names),
2525+
skills=skills, extension_id=manifest.id,
25142526
))
25152527
if force and self.registry.is_installed(manifest.id) else set()
25162528
)
@@ -3696,7 +3708,7 @@ def unregister_agent_artifacts(
36963708
)
36973709
if agent_name == "generic":
36983710
command_names = self._generic_owned_names(
3699-
metadata, command_names, skills=False
3711+
metadata, command_names, skills=False, extension_id=ext_id
37003712
)
37013713
if command_names:
37023714
registrar.unregister_commands(
@@ -3713,7 +3725,7 @@ def unregister_agent_artifacts(
37133725
if registered_skills and not commands_only:
37143726
if agent_name == "generic":
37153727
registered_skills = self._generic_owned_names(
3716-
metadata, registered_skills, skills=True
3728+
metadata, registered_skills, skills=True, extension_id=ext_id
37173729
)
37183730
# Always pass the explicit, agent-scoped skills_dir — even
37193731
# when it doesn't currently exist on disk. This method must
@@ -4051,7 +4063,7 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
40514063
]
40524064
if agent_name == "generic":
40534065
to_remove = self._generic_owned_names(
4054-
metadata, to_remove, skills=True
4066+
metadata, to_remove, skills=True, extension_id=ext_id
40554067
)
40564068
if to_remove:
40574069
self._unregister_extension_skills(
@@ -4139,7 +4151,8 @@ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool
41394151
if fully_replaced:
41404152
if agent_name == "generic":
41414153
fully_replaced = self._generic_owned_names(
4142-
metadata, fully_replaced, skills=False
4154+
metadata, fully_replaced, skills=False,
4155+
extension_id=ext_id,
41434156
)
41444157
registrar.unregister_commands(
41454158
{agent_name: fully_replaced}, self.project_root

‎src/specify_cli/extensions/command_enable.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def extension_enable(
6666
if skills else set(manager._collect_manifest_command_names(manifest))
6767
)
6868
owned = set(manager._generic_owned_names(
69-
refreshed, list(expected), skills=skills,
69+
refreshed, list(expected), skills=skills, extension_id=extension_id,
7070
))
7171
missing = expected - owned
7272
if missing:

‎tests/integrations/test_integration_generic.py‎

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,8 @@ def test_generic_skill_symlinked_directory_is_not_owned(
169169
source.write_text(source.read_text(encoding="utf-8") + "\nnew source\n", encoding="utf-8")
170170
manager.register_enabled_extensions_for_agent("generic", force=True)
171171
assert manager._generic_owned_names(
172-
manager.registry.get("sample"), ["speckit-sample-run"], skills=True,
172+
manager.registry.get("sample"), ["speckit-sample-run"],
173+
skills=True, extension_id="sample",
173174
) == []
174175
elif operation == "force":
175176
with pytest.raises(ExtensionError, match="cannot be replaced safely"):
@@ -1453,6 +1454,71 @@ def install_update(self, _zip_path, speckit_version, *, catalog_name=None):
14531454
assert not artifact.exists()
14541455

14551456

1457+
def test_generic_update_rollback_preserves_cross_extension_skill_link(
1458+
tmp_path, generic_extension, monkeypatch,
1459+
):
1460+
from typer.testing import CliRunner
1461+
from specify_cli import app
1462+
1463+
project = generic_project(tmp_path, skills=True)
1464+
manager = ExtensionManager(project)
1465+
manager.install_from_directory(generic_extension, "1.0.0", link_commands=True)
1466+
artifact = project / ".custom/commands/speckit-sample-run/SKILL.md"
1467+
if not artifact.is_symlink():
1468+
pytest.skip("dev-mode symlinks are unavailable")
1469+
other_file = manager.extensions_dir / "other/file.md"
1470+
other_file.parent.mkdir()
1471+
original = artifact.read_bytes()
1472+
other_file.write_bytes(original)
1473+
artifact.unlink()
1474+
artifact.symlink_to(os.path.relpath(other_file, artifact.parent))
1475+
metadata = manager.registry.get("sample")
1476+
1477+
updated_source = tmp_path / "updated-source"
1478+
shutil.copytree(generic_extension, updated_source)
1479+
manifest_path = updated_source / "extension.yml"
1480+
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
1481+
manifest["extension"]["version"] = "2.0.0"
1482+
manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8")
1483+
archive = tmp_path / "sample-update.zip"
1484+
with zipfile.ZipFile(archive, "w") as zip_file:
1485+
for file in updated_source.rglob("*"):
1486+
if file.is_file():
1487+
zip_file.write(file, file.relative_to(updated_source))
1488+
1489+
original_unlink = Path.unlink
1490+
removed_user_link = []
1491+
1492+
def track_unlink(path, *args, **kwargs):
1493+
if path == artifact:
1494+
removed_user_link.append(path)
1495+
return original_unlink(path, *args, **kwargs)
1496+
1497+
monkeypatch.setattr(Path, "unlink", track_unlink)
1498+
with (
1499+
patch.object(Path, "cwd", return_value=project),
1500+
patch.object(ExtensionCatalog, "get_extension_info", return_value={
1501+
"id": "sample",
1502+
"name": "Sample",
1503+
"version": "2.0.0",
1504+
"_install_allowed": True,
1505+
}),
1506+
patch.object(ExtensionCatalog, "download_extension", return_value=archive),
1507+
patch.object(ExtensionManager, "install_from_zip", side_effect=RuntimeError("update failed")),
1508+
):
1509+
result = CliRunner().invoke(
1510+
app, ["extension", "update", "sample"], input="y\n",
1511+
)
1512+
1513+
assert result.exit_code == 1
1514+
assert "update failed" in result.output
1515+
assert not removed_user_link
1516+
assert artifact.is_symlink()
1517+
assert artifact.resolve() == other_file.resolve()
1518+
assert other_file.read_bytes() == original
1519+
assert ExtensionManager(project).registry.get("sample") == metadata
1520+
1521+
14561522
@pytest.mark.parametrize("skills", [False, True])
14571523
@pytest.mark.parametrize("collision", [False, True])
14581524
@pytest.mark.parametrize("multiple_prior_dirs", [False, True])
@@ -1723,6 +1789,46 @@ def test_generic_dev_extension_removes_links(tmp_path, generic_extension, skills
17231789
assert not artifact.is_symlink()
17241790

17251791

1792+
@pytest.mark.parametrize("skills", [False, True])
1793+
@pytest.mark.parametrize("operation", ["refresh", "force", "remove"])
1794+
def test_generic_retargeted_cross_extension_link_is_not_owned(
1795+
tmp_path, generic_extension, skills, operation, capsys,
1796+
):
1797+
project = generic_project(tmp_path, skills=skills)
1798+
manager = ExtensionManager(project)
1799+
manager.install_from_directory(generic_extension, "1.0.0", link_commands=True)
1800+
artifact = project / ".custom/commands" / (
1801+
"speckit-sample-run/SKILL.md" if skills else "speckit.sample.run.md"
1802+
)
1803+
if not artifact.is_symlink():
1804+
pytest.skip("dev-mode symlinks are unavailable")
1805+
other_file = manager.extensions_dir / "other" / "file.md"
1806+
other_file.parent.mkdir()
1807+
original = artifact.read_bytes()
1808+
other_file.write_bytes(original)
1809+
artifact.unlink()
1810+
artifact.symlink_to(os.path.relpath(other_file, artifact.parent))
1811+
metadata = manager.registry.get("sample")
1812+
1813+
if operation == "refresh":
1814+
source = manager.extensions_dir / "sample/commands/run.md"
1815+
source.write_text(source.read_text(encoding="utf-8") + "\nnew source\n", encoding="utf-8")
1816+
manager.register_enabled_extensions_for_agent("generic", force=skills)
1817+
warning = capsys.readouterr().out
1818+
assert "Missing" in warning and "invocation artifacts" in warning
1819+
assert manager.registry.get("sample") == metadata
1820+
elif operation == "force":
1821+
with pytest.raises(ExtensionError, match="cannot be replaced safely"):
1822+
manager.install_from_directory(generic_extension, "1.0.0", force=True)
1823+
assert manager.registry.get("sample") == metadata
1824+
else:
1825+
assert manager.remove("sample")
1826+
1827+
assert artifact.is_symlink()
1828+
assert artifact.resolve() == other_file.resolve()
1829+
assert other_file.read_bytes() == original
1830+
1831+
17261832
@pytest.mark.parametrize("skills", [False, True])
17271833
def test_generic_dev_extension_preserves_edited_link(tmp_path, generic_extension, skills):
17281834
project = generic_project(tmp_path, skills=skills)

0 commit comments

Comments
 (0)