Skip to content

Commit 9a8d2ef

Browse files
mnriemCopilot
andcommitted
Reject hard-linked generic outputs during refresh
Treat generated files with multiple hard links as unowned before command or skill refresh, so a matching hash cannot authorize changes to unrelated linked files. Cover both layouts and successful refresh after unlinking. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 71815df commit 9a8d2ef

3 files changed

Lines changed: 38 additions & 0 deletions

File tree

‎src/specify_cli/agents.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,8 @@ def _generic_owned_output(path: Path, source_id: str, project_root: Path) -> boo
631631
hashes = metadata.get("generic_artifact_hashes", {}) if metadata else {}
632632
if not isinstance(hashes, dict) or not path.is_file():
633633
return False
634+
if path.stat().st_nlink > 1:
635+
return False
634636
if path.is_symlink() and not path.resolve().is_relative_to(
635637
(project_root / ".specify/extensions").resolve()
636638
):

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2102,6 +2102,8 @@ def _generic_owned_names(
21022102
continue
21032103
if not path.is_file():
21042104
continue
2105+
if path.stat().st_nlink > 1:
2106+
continue
21052107
if path.is_symlink() and not path.resolve().is_relative_to(
21062108
self.extensions_dir.resolve()
21072109
):

‎tests/integrations/test_integration_generic.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,40 @@ def test_generic_extension_preserves_modified_artifact(tmp_path, generic_extensi
113113
assert artifact.read_text(encoding="utf-8").endswith("user edit\n")
114114

115115

116+
@pytest.mark.parametrize("skills", [False, True])
117+
def test_generic_refresh_rejects_hard_linked_artifact(
118+
tmp_path, generic_extension, skills, capsys,
119+
):
120+
project = generic_project(tmp_path, skills=skills)
121+
manager = ExtensionManager(project)
122+
manager.install_from_directory(generic_extension, "1.0.0")
123+
artifact = project / ".custom/commands" / (
124+
"speckit-sample-run/SKILL.md" if skills else "speckit.sample.run.md"
125+
)
126+
unrelated = project / "unrelated.md"
127+
try:
128+
os.link(artifact, unrelated)
129+
except OSError:
130+
pytest.skip("hard links are unavailable")
131+
original = artifact.read_bytes()
132+
metadata = manager.registry.get("sample")
133+
source = manager.extensions_dir / "sample/commands/run.md"
134+
source.write_text(source.read_text(encoding="utf-8") + "\nnew source\n", encoding="utf-8")
135+
136+
manager.register_enabled_extensions_for_agent("generic", force=skills)
137+
138+
warning = capsys.readouterr().out
139+
assert "Missing" in warning and "invocation artifacts" in warning
140+
assert artifact.read_bytes() == original
141+
assert unrelated.read_bytes() == original
142+
assert manager.registry.get("sample") == metadata
143+
144+
unrelated.unlink()
145+
manager.register_enabled_extensions_for_agent("generic", force=skills)
146+
assert b"new source" in artifact.read_bytes()
147+
assert manager.remove("sample")
148+
149+
116150
@pytest.mark.parametrize("operation", ["resync", "remove", "force"])
117151
def test_generic_skill_symlinked_directory_is_not_owned(
118152
tmp_path, generic_extension, operation,

0 commit comments

Comments
 (0)