Skip to content

Commit fc7b2e2

Browse files
marcelsafinCopilot
andcommitted
fix: guard skill subdirectories and active-agent scoping in preset reconciliation
Fix 4 issues from round-6 review of the active-only integration registration work (#2948): - remove(): removed_cmd_names only collected primary command names from registered_commands + manifest aliases, missing commands that were only ever registered via skills mode (ai_skills guard returns no command names for command-backed integrations in skills mode). This skipped reconciliation entirely when removing a higher-priority skills-mode preset, causing _unregister_skills() to fall back to core/extension content instead of the surviving lower-priority preset's override. Now every command template's primary name is added to removed_cmd_names unconditionally. - _reconcile_composed_commands(): the "composed is None" branch (fires when no replace-strategy layer remains for a command, e.g. after removing a wrap/append preset's base) called unregister_commands() across every configured non-skill agent, ignoring only_agent. This deleted historical artifacts from integrations that were never active for the preset. Now filtered by only_agent like the rest of the file. - Added _validate_skill_subdir() helper (reusing _ensure_safe_shared_directory/_validate_safe_shared_directory from shared_infra.py) and applied it at every site that reads or writes an individual skill subdirectory (_register_skills, _unregister_skills_in_dir, _reconcile_skills' override_skills restoration loop). _safe_skills_dir_for_agent only validated the parent skills directory; a symlinked leaf subdirectory (e.g. .claude/skills/speckit-specify) would slip past that check since is_dir()/exists() follow symlinks, letting write_text/rmtree operate through it to an arbitrary location outside the project. Added regression tests: removing a higher-priority skills-only preset restores the surviving lower-priority preset's content; composed-is-None unregistration only touches the active agent; symlinked skill subdirectory rejected on restore; symlinked skill subdirectory rejected on write. Targeted (934) and full (3934 passed, 109 skipped) test suites and ruff check pass clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e5d5553 commit fc7b2e2

2 files changed

Lines changed: 275 additions & 7 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -992,9 +992,18 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
992992
if isinstance(alias, str):
993993
cmd_names_to_unregister.append(alias)
994994
break
995+
# Mirror the active-only restriction used elsewhere in
996+
# this pass: without it, unregistering a stale composed
997+
# command would touch every non-skill agent's directory,
998+
# deleting historical artifacts from integrations that
999+
# were never active when this preset registered (#2948).
9951000
registrar.unregister_commands(
996-
{agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS
997-
if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"},
1001+
{
1002+
agent: cmd_names_to_unregister
1003+
for agent in registrar.AGENT_CONFIGS
1004+
if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"
1005+
and (only_agent is None or agent == only_agent)
1006+
},
9981007
self.project_root,
9991008
)
10001009
continue
@@ -1236,7 +1245,12 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
12361245

12371246
for skill_name, cmd_name, top_layer in override_skills:
12381247
skill_subdir = skills_dir / skill_name
1239-
skill_subdir.mkdir(parents=True, exist_ok=True)
1248+
# Same symlink guard as _register_skills's registration path
1249+
# (#2948): mkdir(exist_ok=True) alone would silently follow an
1250+
# existing symlinked subdirectory before writing SKILL.md
1251+
# through it.
1252+
if not self._validate_skill_subdir(skill_subdir, create=True):
1253+
continue
12401254
skill_file = skill_subdir / "SKILL.md"
12411255
try:
12421256
from ..agents import CommandRegistrar
@@ -1539,7 +1553,13 @@ def _register_skills(
15391553
skill_subdir = skills_dir / target_skill_name
15401554
if skill_subdir.exists() and not skill_subdir.is_dir():
15411555
continue
1542-
skill_subdir.mkdir(parents=True, exist_ok=True)
1556+
# Validate (and create, if missing) the skill's own
1557+
# subdirectory under the same symlink guard as its parent —
1558+
# is_dir() above follows symlinks, so a symlinked subdir
1559+
# with a real parent would otherwise slip through and have
1560+
# SKILL.md written through it to an arbitrary location (#2948).
1561+
if not self._validate_skill_subdir(skill_subdir, create=True):
1562+
continue
15431563
frontmatter_data = registrar.build_skill_frontmatter(
15441564
selected_ai,
15451565
target_skill_name,
@@ -1617,6 +1637,35 @@ def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]:
16171637
return None
16181638
return skills_dir
16191639

1640+
def _validate_skill_subdir(self, skill_subdir: Path, *, create: bool) -> bool:
1641+
"""Validate a single skill's subdirectory is symlink-free.
1642+
1643+
Unlike :meth:`_safe_skills_dir_for_agent` (which only validates the
1644+
*parent* skills directory), this validates the skill's own
1645+
subdirectory — e.g. ``.claude/skills/speckit-specify`` — so a
1646+
symlink planted at that level (with a safe parent) can't be used to
1647+
write or delete through to a location outside the project. Shared by
1648+
both the registration path (``create=True``, so a missing directory
1649+
is created component-by-component under the same guard) and the
1650+
restore/removal path (``create=False``, so a missing directory is
1651+
left for the caller's own existence check to skip). Returns
1652+
``False`` rather than raising when the path escapes the project
1653+
root or crosses a symlink.
1654+
"""
1655+
from ..shared_infra import _ensure_safe_shared_directory, _validate_safe_shared_directory
1656+
1657+
try:
1658+
if create:
1659+
_ensure_safe_shared_directory(
1660+
self.project_root, skill_subdir,
1661+
create=True, context="preset skill directory",
1662+
)
1663+
else:
1664+
_validate_safe_shared_directory(self.project_root, skill_subdir)
1665+
except (ValueError, OSError):
1666+
return False
1667+
return True
1668+
16201669
def _unregister_skills(
16211670
self,
16221671
registered_skills: Union[Dict[str, List[str]], List[str]],
@@ -1737,6 +1786,12 @@ def _unregister_skills_in_dir(
17371786
skill_file = skill_subdir / "SKILL.md"
17381787
if not skill_subdir.is_dir():
17391788
continue
1789+
# is_dir() follows symlinks, so a symlinked skill subdirectory
1790+
# (with a safe, non-symlinked parent) would otherwise slip past
1791+
# _safe_skills_dir_for_agent's parent-only check and have
1792+
# write_text/rmtree operate through it (#2948).
1793+
if not self._validate_skill_subdir(skill_subdir, create=False):
1794+
continue
17401795
if not skill_file.is_file():
17411796
# Only manage directories that contain the expected skill entrypoint.
17421797
continue
@@ -2020,9 +2075,15 @@ def remove(self, pack_id: str) -> bool:
20202075
pack_dir = self.presets_dir / pack_id
20212076

20222077
# Collect ALL command names before filtering for reconciliation,
2023-
# so commands registered only for skill-based agents are also reconciled.
2024-
# Also include aliases from the manifest as a safety net for registries
2025-
# populated by older versions that may not track aliases.
2078+
# so commands registered only for skill-based agents are also
2079+
# reconciled. Every command-type template's primary name is added
2080+
# unconditionally (not just aliases) since ai_skills-mode presets
2081+
# never populate registered_commands for command-backed
2082+
# integrations (see _register_commands's ai_skills guard) — without
2083+
# this, removing a skills-mode preset that overrides a command no
2084+
# other preset registered "the normal way" would skip reconciliation
2085+
# entirely and _unregister_skills would restore core/extension
2086+
# content instead of a surviving lower-priority preset's override.
20262087
removed_cmd_names = set()
20272088
for cmd_names in registered_commands.values():
20282089
removed_cmd_names.update(cmd_names)
@@ -2032,6 +2093,9 @@ def remove(self, pack_id: str) -> bool:
20322093
manifest = PresetManifest(manifest_path)
20332094
for tmpl in manifest.templates:
20342095
if tmpl.get("type") == "command":
2096+
name = tmpl.get("name")
2097+
if isinstance(name, str):
2098+
removed_cmd_names.add(name)
20352099
for alias in tmpl.get("aliases", []):
20362100
if isinstance(alias, str):
20372101
removed_cmd_names.add(alias)

tests/test_presets.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4510,6 +4510,210 @@ def test_shared_skills_dir_restored_once_using_active_agent(
45104510
assert "preset:shared-dir-preset" not in content
45114511
assert "Core specify body" in content
45124512

4513+
def test_remove_higher_priority_skills_only_preset_restores_lower_preset(
4514+
self, project_dir, temp_dir
4515+
):
4516+
"""Removing a skills-mode preset must reconcile against the surviving
4517+
stack, not fall back to core/extension content.
4518+
4519+
Copilot in skills mode never populates ``registered_commands`` for
4520+
its overrides (``_register_commands``'s ``ai_skills`` guard skips
4521+
command-file registration entirely), so with two presets overriding
4522+
the same command, the removed preset's command name was never added
4523+
to ``removed_cmd_names`` and reconciliation was skipped outright.
4524+
``_unregister_skills`` then restored straight to core/extension
4525+
content instead of resolving the lower-priority preset that should
4526+
now win (#2948).
4527+
"""
4528+
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
4529+
4530+
preset_a_dir = self._create_command_preset(
4531+
temp_dir, "skills-preset-a", "speckit.specify",
4532+
"Preset A", "preset A body",
4533+
)
4534+
preset_b_dir = self._create_command_preset(
4535+
temp_dir, "skills-preset-b", "speckit.specify",
4536+
"Preset B", "preset B body",
4537+
)
4538+
4539+
manager = PresetManager(project_dir)
4540+
# Lower priority number = higher precedence.
4541+
manager.install_from_directory(preset_a_dir, "0.1.5", priority=5)
4542+
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
4543+
4544+
skills_dir = project_dir / ".github" / "skills"
4545+
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
4546+
assert "preset:skills-preset-a" in skill_file.read_text(), (
4547+
"sanity: the higher-precedence preset should win initially"
4548+
)
4549+
4550+
assert manager.remove("skills-preset-a") is True
4551+
4552+
content = skill_file.read_text()
4553+
assert "preset:skills-preset-b" in content, (
4554+
"removing the higher-precedence skills-mode preset must "
4555+
"restore the surviving lower-precedence preset's override, "
4556+
"not fall back to core/extension content (#2948)"
4557+
)
4558+
assert "preset:skills-preset-a" not in content
4559+
4560+
def test_composed_none_unregister_respects_active_agent(
4561+
self, project_dir, temp_dir
4562+
):
4563+
"""Unregistering a stale composed command must only touch the
4564+
active agent's directory, not every configured non-skill agent.
4565+
4566+
When a wrap preset's base layer is removed, ``resolve_content`` can
4567+
no longer find a replace layer to compose onto and returns
4568+
``None``, triggering the "composed is None" branch of
4569+
``_reconcile_composed_commands``. Before the fix, that
4570+
unregistration mapping covered every configured non-skill agent
4571+
regardless of ``only_agent``, deleting historical artifacts from
4572+
integrations that were never active for this preset (#2948).
4573+
"""
4574+
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
4575+
gemini_commands_dir = project_dir / ".gemini" / "commands"
4576+
gemini_commands_dir.mkdir(parents=True)
4577+
4578+
# A made-up command name with no bundled/core equivalent, so the
4579+
# *only* base layer is the "compose-base" preset installed below —
4580+
# once it's removed, no base remains for the wrap preset to compose
4581+
# onto.
4582+
cmd_name = "speckit.fake-compose-test"
4583+
base_dir = self._create_command_preset(
4584+
temp_dir, "compose-base", cmd_name, "Base", "base body",
4585+
)
4586+
manager = PresetManager(project_dir)
4587+
manager.install_from_directory(base_dir, "0.1.5", priority=10)
4588+
4589+
wrap_dir = temp_dir / "compose-wrap"
4590+
wrap_dir.mkdir()
4591+
(wrap_dir / "commands").mkdir()
4592+
(wrap_dir / "commands" / f"{cmd_name}.md").write_text(
4593+
"---\ndescription: Wrap\nstrategy: wrap\n---\n\n"
4594+
"wrap start\n{CORE_TEMPLATE}\nwrap end\n"
4595+
)
4596+
manifest_data = {
4597+
"schema_version": "1.0",
4598+
"preset": {
4599+
"id": "compose-wrap",
4600+
"name": "compose-wrap",
4601+
"version": "1.0.0",
4602+
"description": "Test",
4603+
},
4604+
"requires": {"speckit_version": ">=0.1.0"},
4605+
"provides": {
4606+
"templates": [
4607+
{
4608+
"type": "command",
4609+
"name": cmd_name,
4610+
"file": f"commands/{cmd_name}.md",
4611+
"strategy": "wrap",
4612+
}
4613+
]
4614+
},
4615+
}
4616+
with open(wrap_dir / "preset.yml", "w") as f:
4617+
yaml.dump(manifest_data, f)
4618+
manager.install_from_directory(wrap_dir, "0.1.5", priority=5)
4619+
4620+
claude_dir = project_dir / ".gemini" / "commands"
4621+
cmd_file = claude_dir / f"{cmd_name}.toml"
4622+
assert cmd_file.exists(), (
4623+
"sanity: the composed command should register for the active agent"
4624+
)
4625+
4626+
# Simulate a pre-existing artifact for an inactive agent, predating
4627+
# this preset entirely — active-only unregistration must never
4628+
# touch it.
4629+
opencode_dir = project_dir / ".opencode" / "commands"
4630+
opencode_dir.mkdir(parents=True, exist_ok=True)
4631+
opencode_stale_file = opencode_dir / f"{cmd_name}.md"
4632+
opencode_stale_file.write_text("stale opencode content\n")
4633+
4634+
assert manager.remove("compose-base") is True
4635+
4636+
assert not cmd_file.exists(), (
4637+
"sanity: the active agent's now-uncomposable command file must "
4638+
"be unregistered"
4639+
)
4640+
assert opencode_stale_file.read_text() == "stale opencode content\n", (
4641+
"unregistering a stale composed command must not touch an "
4642+
"inactive agent's directory (#2948)"
4643+
)
4644+
4645+
def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir):
4646+
"""Restore must validate each per-skill subdirectory, not just its parent.
4647+
4648+
``_safe_skills_dir_for_agent`` only validates the parent skills
4649+
directory (e.g. ``.claude/skills``); a symlink planted one level
4650+
deeper at the individual skill's own subdirectory (e.g.
4651+
``.claude/skills/speckit-specify``) has a perfectly safe parent and
4652+
would otherwise slip past that check, since ``is_dir()`` follows
4653+
symlinks. Restoration must refuse to write/rmtree through it (#2948).
4654+
"""
4655+
self._write_init_options(project_dir, ai="claude", ai_skills=True)
4656+
claude_skills_dir = project_dir / ".claude" / "skills"
4657+
claude_skills_dir.mkdir(parents=True)
4658+
4659+
outside_target = temp_dir / "outside-skill-subdir"
4660+
outside_target.mkdir()
4661+
sentinel = outside_target / "SKILL.md"
4662+
sentinel.write_text("do-not-touch")
4663+
(claude_skills_dir / "speckit-specify").symlink_to(
4664+
outside_target, target_is_directory=True
4665+
)
4666+
4667+
manager = PresetManager(project_dir)
4668+
manager._unregister_skills_in_dir(
4669+
["speckit-specify"], claude_skills_dir, "claude"
4670+
)
4671+
4672+
assert sentinel.read_text() == "do-not-touch", (
4673+
"restoration must not follow a symlinked skill subdirectory "
4674+
"to write/delete outside the project (#2948)"
4675+
)
4676+
assert (claude_skills_dir / "speckit-specify").is_symlink(), (
4677+
"the symlink itself should be left alone, not rmtree'd through"
4678+
)
4679+
4680+
def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir):
4681+
"""Registration must validate each per-skill subdirectory before writing.
4682+
4683+
A symlink planted at an individual skill's own subdirectory (safe
4684+
parent, unsafe leaf) would otherwise pass the existing
4685+
``skill_subdir.exists() and not skill_subdir.is_dir()`` guard
4686+
(``is_dir()`` follows symlinks) and have ``SKILL.md`` written
4687+
through it to an arbitrary location (#2948).
4688+
"""
4689+
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
4690+
copilot_commands_dir = project_dir / ".github" / "agents"
4691+
copilot_commands_dir.mkdir(parents=True)
4692+
skills_dir = project_dir / ".github" / "skills"
4693+
skills_dir.mkdir(parents=True)
4694+
4695+
outside_target = temp_dir / "outside-skill-write-target"
4696+
outside_target.mkdir()
4697+
(skills_dir / "speckit-specify").symlink_to(
4698+
outside_target, target_is_directory=True
4699+
)
4700+
4701+
preset_dir = self._create_command_preset(
4702+
temp_dir, "symlink-write-preset", "speckit.specify",
4703+
"Symlink write test", "preset body",
4704+
)
4705+
4706+
manager = PresetManager(project_dir)
4707+
manager.install_from_directory(preset_dir, "0.1.5")
4708+
4709+
assert not (outside_target / "SKILL.md").exists(), (
4710+
"registration must not follow a symlinked skill subdirectory "
4711+
"to write outside the project (#2948)"
4712+
)
4713+
assert (skills_dir / "speckit-specify").is_symlink(), (
4714+
"the symlink itself should be left alone"
4715+
)
4716+
45134717
def test_copilot_skills_registration_restored_after_process_restart(
45144718
self, project_dir, temp_dir
45154719
):

0 commit comments

Comments
 (0)