mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Reconcile every historical agent on preset removal; validate child skill dirs
Fixes 3 findings from the Copilot review on HEAD 31c9b97 (#2948):
1. presets/__init__.py: remove()'s command reconciliation only recreated
the surviving preset's content for the currently active agent, even
though the removed preset's registered_commands could span multiple
historical (now-inactive) agents recorded via prior rescaffolds. Now
remove() captures every historical agent registered_commands actually
targeted (before mutation) and passes it as extra_agents through
_reconcile_composed_commands -> _register_for_non_skill_agents /
_register_command_from_path -> registrar.register_commands_for_non_
skill_agents, so the active-only restriction for install/use is
preserved while post-removal reconciliation restores every touched
directory.
2. presets/__init__.py: the analogous gap existed for skills. _unregister_
skills() now returns {skills_dir: renderer_agent} for every directory it
actually restored, and _reconcile_skills() accepts extra_skills_dirs to
reconcile each of those directories (via a new apply_to_dir() helper),
not only the currently active skills directory. _register_skills() gained
optional target_dir/target_agent overrides (forcing
create_missing_skills off for non-active directories) so a historical
directory is only ever restored, never seeded with brand-new skills.
3. extensions/__init__.py: _extension_owned_skill_names() and both the
fast and fallback paths of _unregister_extension_skills() validated only
the parent skills_dir for symlink escape, then resolved
skills_dir / skill_name and checked containment relative to that
already-resolved parent. A per-skill child that is itself a symlink to
a different, legitimate skill directory within the same (safe) root
passed that containment check, so deleting/attributing through the
symlink name could destroy or misattribute an unrelated skill reached
only via the alias. All three call sites now run the shared
_validate_safe_shared_directory() component-wise check against the full
skills_dir / skill_name path (not just the parent) before any read or
delete, rejecting a symlinked child outright rather than following it,
even when its resolved target remains in-bounds.
Regression tests added (all confirmed red against pre-fix code, green
after):
- test_remove_reconciles_command_for_every_historical_agent
- test_remove_reconciles_skill_for_every_historical_agent
- test_extension_owned_skill_names_rejects_symlinked_child_skill_dir
- test_unregister_extension_skills_explicit_dir_rejects_symlinked_child
- test_unregister_extension_skills_fallback_rejects_symlinked_child
Tests: tests/test_presets.py (361), tests/test_extension_skills.py (69),
tests/test_extensions.py (338) all pass; tests/integrations (1768 passed,
1 skipped) pass; full suite 3902 passed / 74 skipped (90 pre-existing,
environment-only git-signing tests deselected — confirmed failing
identically on the pre-change baseline due to local 1Password SSH-agent
signing, unrelated to this change). ruff check clean on all changed files.
Assisted-by: GitHub Copilot (model: Claude Sonnet 5, autonomous)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import platform
|
|||||||
import re
|
import re
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
@@ -1083,6 +1083,7 @@ class CommandRegistrar:
|
|||||||
link_outputs: bool = False,
|
link_outputs: bool = False,
|
||||||
extension_id: Optional[str] = None,
|
extension_id: Optional[str] = None,
|
||||||
only_agent: Optional[str] = None,
|
only_agent: Optional[str] = None,
|
||||||
|
extra_agents: Optional[Iterable[str]] = None,
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Register commands for all non-skill agents in the project.
|
"""Register commands for all non-skill agents in the project.
|
||||||
|
|
||||||
@@ -1102,14 +1103,25 @@ class CommandRegistrar:
|
|||||||
only_agent: If set, restrict registration to this single agent
|
only_agent: If set, restrict registration to this single agent
|
||||||
(#2948). An agent name that matches no configured agent
|
(#2948). An agent name that matches no configured agent
|
||||||
(e.g. an empty string) yields no registrations at all.
|
(e.g. an empty string) yields no registrations at all.
|
||||||
|
extra_agents: Additional agent names to register for besides
|
||||||
|
``only_agent``. Used by post-removal reconciliation to also
|
||||||
|
restore surviving content into historical agent directories
|
||||||
|
a just-removed preset actually wrote to, not only the
|
||||||
|
currently active agent (#2948). Ignored when ``only_agent``
|
||||||
|
is ``None`` (already unrestricted).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary mapping agent names to list of registered commands
|
Dictionary mapping agent names to list of registered commands
|
||||||
"""
|
"""
|
||||||
results = {}
|
results = {}
|
||||||
self._ensure_configs()
|
self._ensure_configs()
|
||||||
|
extra_agents_set = frozenset(extra_agents) if extra_agents else frozenset()
|
||||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||||
if only_agent is not None and agent_name != only_agent:
|
if (
|
||||||
|
only_agent is not None
|
||||||
|
and agent_name != only_agent
|
||||||
|
and agent_name not in extra_agents_set
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if agent_config.get("extension") == "/SKILL.md":
|
if agent_config.get("extension") == "/SKILL.md":
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1290,10 +1290,19 @@ class ExtensionManager:
|
|||||||
sn_path = Path(skill_name)
|
sn_path = Path(skill_name)
|
||||||
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
||||||
continue
|
continue
|
||||||
|
skill_subdir = skills_dir / skill_name
|
||||||
|
# Validate every path component down to the skill's own
|
||||||
|
# subdirectory, not just the already-validated parent
|
||||||
|
# skills_dir: a per-skill child can itself be a symlink to
|
||||||
|
# another directory whose *resolved* target still lands
|
||||||
|
# inside this same (safe) skills root, which the previous
|
||||||
|
# resolve()+relative_to() containment check alone would
|
||||||
|
# not catch. Reject the symlink outright rather than
|
||||||
|
# following it, even when the target is otherwise
|
||||||
|
# in-bounds (#2948).
|
||||||
try:
|
try:
|
||||||
skill_subdir = (skills_dir / skill_name).resolve()
|
_validate_safe_shared_directory(self.project_root, skill_subdir)
|
||||||
skill_subdir.relative_to(skills_dir.resolve()) # raises if outside
|
except (ValueError, OSError):
|
||||||
except (OSError, ValueError):
|
|
||||||
continue
|
continue
|
||||||
if not skill_subdir.is_dir():
|
if not skill_subdir.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -1354,12 +1363,19 @@ class ExtensionManager:
|
|||||||
sn_path = Path(skill_name)
|
sn_path = Path(skill_name)
|
||||||
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
||||||
continue
|
continue
|
||||||
|
skill_subdir = skills_candidate / skill_name
|
||||||
|
# Validate every path component down to the skill's
|
||||||
|
# own subdirectory, not just the already-validated
|
||||||
|
# candidate parent: a per-skill child can itself be a
|
||||||
|
# symlink to another directory whose resolved target
|
||||||
|
# still lands inside this same candidate, which the
|
||||||
|
# previous resolve()+relative_to() containment check
|
||||||
|
# alone would not catch (#2948).
|
||||||
try:
|
try:
|
||||||
skill_subdir = (skills_candidate / skill_name).resolve()
|
_validate_safe_shared_directory(
|
||||||
skill_subdir.relative_to(
|
self.project_root, skill_subdir
|
||||||
skills_candidate.resolve()
|
)
|
||||||
) # raises if outside
|
except (ValueError, OSError):
|
||||||
except (OSError, ValueError):
|
|
||||||
continue
|
continue
|
||||||
if not skill_subdir.is_dir():
|
if not skill_subdir.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -1450,20 +1466,23 @@ class ExtensionManager:
|
|||||||
_validate_safe_shared_directory(self.project_root, skills_candidate)
|
_validate_safe_shared_directory(self.project_root, skills_candidate)
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
continue
|
continue
|
||||||
try:
|
|
||||||
resolved_candidate = skills_candidate.resolve()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
for skill_name in skill_names:
|
for skill_name in skill_names:
|
||||||
if skill_name in owned:
|
if skill_name in owned:
|
||||||
continue
|
continue
|
||||||
sn_path = Path(skill_name)
|
sn_path = Path(skill_name)
|
||||||
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
if sn_path.is_absolute() or len(sn_path.parts) != 1:
|
||||||
continue
|
continue
|
||||||
|
skill_subdir = skills_candidate / skill_name
|
||||||
|
# Validate every path component down to the skill's own
|
||||||
|
# subdirectory, not just the already-validated candidate
|
||||||
|
# parent: a per-skill child can itself be a symlink to
|
||||||
|
# another directory whose resolved target still lands
|
||||||
|
# inside this same candidate, which the previous
|
||||||
|
# resolve()+relative_to() containment check alone would
|
||||||
|
# not catch (#2948).
|
||||||
try:
|
try:
|
||||||
skill_subdir = (skills_candidate / skill_name).resolve()
|
_validate_safe_shared_directory(self.project_root, skill_subdir)
|
||||||
skill_subdir.relative_to(resolved_candidate) # raises if outside
|
except (ValueError, OSError):
|
||||||
except (OSError, ValueError):
|
|
||||||
continue
|
continue
|
||||||
if not skill_subdir.is_dir():
|
if not skill_subdir.is_dir():
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import zipfile
|
|||||||
import shutil
|
import shutil
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union
|
from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union, Set
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..agents import CommandRegistrar
|
from ..agents import CommandRegistrar
|
||||||
@@ -910,7 +910,9 @@ class PresetManager:
|
|||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
registrar.unregister_commands(registered_commands, self.project_root)
|
registrar.unregister_commands(registered_commands, self.project_root)
|
||||||
|
|
||||||
def _reconcile_composed_commands(self, command_names: List[str]) -> None:
|
def _reconcile_composed_commands(
|
||||||
|
self, command_names: List[str], extra_agents: Optional[Set[str]] = None
|
||||||
|
) -> None:
|
||||||
"""Re-resolve and re-register composed commands from the full stack.
|
"""Re-resolve and re-register composed commands from the full stack.
|
||||||
|
|
||||||
After install or remove, recompute the effective content for each
|
After install or remove, recompute the effective content for each
|
||||||
@@ -930,6 +932,13 @@ class PresetManager:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
command_names: List of command names to reconcile
|
command_names: List of command names to reconcile
|
||||||
|
extra_agents: Additional agent names to also reconcile besides
|
||||||
|
the currently active one. Populated by ``remove()`` with the
|
||||||
|
historical agents a just-removed preset's
|
||||||
|
``registered_commands`` actually targeted, so a surviving
|
||||||
|
lower-priority preset's content is restored there too — not
|
||||||
|
only for the currently active agent (#2948). Install/use
|
||||||
|
callers omit this, preserving pure active-only behavior.
|
||||||
"""
|
"""
|
||||||
if not command_names:
|
if not command_names:
|
||||||
return
|
return
|
||||||
@@ -996,7 +1005,7 @@ class PresetManager:
|
|||||||
if tmpl.get("name") == cmd_name and tmpl.get("type") == "command":
|
if tmpl.get("name") == cmd_name and tmpl.get("type") == "command":
|
||||||
self._register_for_non_skill_agents(
|
self._register_for_non_skill_agents(
|
||||||
registrar, [tmpl], manifest.id, pack_dir,
|
registrar, [tmpl], manifest.id, pack_dir,
|
||||||
only_agent=only_agent,
|
only_agent=only_agent, extra_agents=extra_agents,
|
||||||
)
|
)
|
||||||
registered = True
|
registered = True
|
||||||
break
|
break
|
||||||
@@ -1024,7 +1033,7 @@ class PresetManager:
|
|||||||
matching_cmds, ext_id, ext_dir,
|
matching_cmds, ext_id, ext_dir,
|
||||||
self.project_root,
|
self.project_root,
|
||||||
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
|
context_note=f"\n<!-- Extension: {ext_id} -->\n<!-- Config: .specify/extensions/{ext_id}/ -->\n",
|
||||||
only_agent=only_agent,
|
only_agent=only_agent, extra_agents=extra_agents,
|
||||||
)
|
)
|
||||||
registered = True
|
registered = True
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1036,7 +1045,7 @@ class PresetManager:
|
|||||||
self._register_command_from_path(
|
self._register_command_from_path(
|
||||||
registrar, cmd_name, top_path,
|
registrar, cmd_name, top_path,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
only_agent=only_agent,
|
only_agent=only_agent, extra_agents=extra_agents,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Composed command — resolve from full stack
|
# Composed command — resolve from full stack
|
||||||
@@ -1073,7 +1082,11 @@ class PresetManager:
|
|||||||
agent: cmd_names_to_unregister
|
agent: cmd_names_to_unregister
|
||||||
for agent in registrar.AGENT_CONFIGS
|
for agent in registrar.AGENT_CONFIGS
|
||||||
if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"
|
if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"
|
||||||
and (only_agent is None or agent == only_agent)
|
and (
|
||||||
|
only_agent is None
|
||||||
|
or agent == only_agent
|
||||||
|
or agent in (extra_agents or ())
|
||||||
|
)
|
||||||
},
|
},
|
||||||
self.project_root,
|
self.project_root,
|
||||||
)
|
)
|
||||||
@@ -1096,7 +1109,7 @@ class PresetManager:
|
|||||||
registrar,
|
registrar,
|
||||||
[{**tmpl, "file": f".composed/{cmd_name}.md"}],
|
[{**tmpl, "file": f".composed/{cmd_name}.md"}],
|
||||||
manifest.id, pack_dir,
|
manifest.id, pack_dir,
|
||||||
only_agent=only_agent,
|
only_agent=only_agent, extra_agents=extra_agents,
|
||||||
)
|
)
|
||||||
registered = True
|
registered = True
|
||||||
break
|
break
|
||||||
@@ -1118,7 +1131,7 @@ class PresetManager:
|
|||||||
self._register_command_from_path(
|
self._register_command_from_path(
|
||||||
registrar, cmd_name, composed_file,
|
registrar, cmd_name, composed_file,
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
only_agent=only_agent,
|
only_agent=only_agent, extra_agents=extra_agents,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _register_command_from_path(
|
def _register_command_from_path(
|
||||||
@@ -1128,6 +1141,7 @@ class PresetManager:
|
|||||||
cmd_path: Path,
|
cmd_path: Path,
|
||||||
source_id: str = "reconciled",
|
source_id: str = "reconciled",
|
||||||
only_agent: Optional[str] = None,
|
only_agent: Optional[str] = None,
|
||||||
|
extra_agents: Optional[Set[str]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register a single command from a file path (non-preset source).
|
"""Register a single command from a file path (non-preset source).
|
||||||
|
|
||||||
@@ -1140,6 +1154,8 @@ class PresetManager:
|
|||||||
cmd_path: Path to the command file
|
cmd_path: Path to the command file
|
||||||
source_id: Source attribution for rendered output
|
source_id: Source attribution for rendered output
|
||||||
only_agent: If set, restrict registration to this single agent (#2948).
|
only_agent: If set, restrict registration to this single agent (#2948).
|
||||||
|
extra_agents: Additional agent names to register for besides
|
||||||
|
``only_agent`` (post-removal reconciliation only, #2948).
|
||||||
"""
|
"""
|
||||||
if not cmd_path.exists():
|
if not cmd_path.exists():
|
||||||
return
|
return
|
||||||
@@ -1170,7 +1186,7 @@ class PresetManager:
|
|||||||
pass # best-effort alias loading
|
pass # best-effort alias loading
|
||||||
self._register_for_non_skill_agents(
|
self._register_for_non_skill_agents(
|
||||||
registrar, [cmd_tmpl], source_id, cmd_path.parent,
|
registrar, [cmd_tmpl], source_id, cmd_path.parent,
|
||||||
only_agent=only_agent,
|
only_agent=only_agent, extra_agents=extra_agents,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _register_for_non_skill_agents(
|
def _register_for_non_skill_agents(
|
||||||
@@ -1180,6 +1196,7 @@ class PresetManager:
|
|||||||
source_id: str,
|
source_id: str,
|
||||||
source_dir: Path,
|
source_dir: Path,
|
||||||
only_agent: Optional[str] = None,
|
only_agent: Optional[str] = None,
|
||||||
|
extra_agents: Optional[Set[str]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register commands for non-skill agents during reconciliation.
|
"""Register commands for non-skill agents during reconciliation.
|
||||||
|
|
||||||
@@ -1198,10 +1215,14 @@ class PresetManager:
|
|||||||
only_agent: If set, restrict registration to this single agent,
|
only_agent: If set, restrict registration to this single agent,
|
||||||
matching the active-only rule applied by ``_register_commands``
|
matching the active-only rule applied by ``_register_commands``
|
||||||
(#2948).
|
(#2948).
|
||||||
|
extra_agents: Additional agent names to register for besides
|
||||||
|
``only_agent``. Used by post-removal reconciliation to also
|
||||||
|
restore surviving content into historical agent directories
|
||||||
|
a just-removed preset actually wrote to (#2948).
|
||||||
"""
|
"""
|
||||||
registrar.register_commands_for_non_skill_agents(
|
registrar.register_commands_for_non_skill_agents(
|
||||||
commands, source_id, source_dir, self.project_root,
|
commands, source_id, source_dir, self.project_root,
|
||||||
only_agent=only_agent,
|
only_agent=only_agent, extra_agents=extra_agents,
|
||||||
)
|
)
|
||||||
|
|
||||||
class _FilteredManifest:
|
class _FilteredManifest:
|
||||||
@@ -1225,7 +1246,11 @@ class PresetManager:
|
|||||||
if t.get("name") in self._cmd_names
|
if t.get("name") in self._cmd_names
|
||||||
]
|
]
|
||||||
|
|
||||||
def _reconcile_skills(self, command_names: List[str]) -> None:
|
def _reconcile_skills(
|
||||||
|
self,
|
||||||
|
command_names: List[str],
|
||||||
|
extra_skills_dirs: Optional[Dict[Path, Optional[str]]] = None,
|
||||||
|
) -> None:
|
||||||
"""Re-register skills for commands whose winning layer changed.
|
"""Re-register skills for commands whose winning layer changed.
|
||||||
|
|
||||||
After a preset is removed, finds the next preset in the priority
|
After a preset is removed, finds the next preset in the priority
|
||||||
@@ -1234,52 +1259,66 @@ class PresetManager:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
command_names: List of command names to reconcile skills for
|
command_names: List of command names to reconcile skills for
|
||||||
|
extra_skills_dirs: Additional ``{skills_dir: renderer_agent}``
|
||||||
|
pairs to reconcile besides the currently active skills
|
||||||
|
directory. Populated by ``remove()`` from the directories
|
||||||
|
``_unregister_skills`` just restored to core/extension
|
||||||
|
content: a surviving lower-priority preset's override must
|
||||||
|
be re-applied to every one of those directories too, not
|
||||||
|
only the currently active agent's directory, or an
|
||||||
|
inactive integration is left with stale/missing content
|
||||||
|
even though the removed preset no longer wins there
|
||||||
|
either (#2948).
|
||||||
"""
|
"""
|
||||||
if not command_names:
|
if not command_names:
|
||||||
return
|
return
|
||||||
|
|
||||||
resolver = PresetResolver(self.project_root)
|
resolver = PresetResolver(self.project_root)
|
||||||
skills_dir = self._get_skills_dir()
|
active_skills_dir = self._get_skills_dir()
|
||||||
|
|
||||||
|
from .. import load_init_options
|
||||||
|
|
||||||
|
init_opts = load_init_options(self.project_root)
|
||||||
|
active_ai = init_opts.get("ai") if isinstance(init_opts, dict) else None
|
||||||
|
if not isinstance(active_ai, str) or not active_ai:
|
||||||
|
active_ai = None
|
||||||
|
|
||||||
# Cache registry once to avoid repeated filesystem reads
|
# Cache registry once to avoid repeated filesystem reads
|
||||||
presets_by_priority = list(self.registry.list_by_priority())
|
presets_by_priority = list(self.registry.list_by_priority())
|
||||||
|
|
||||||
# Group command names by winning preset to batch _register_skills calls
|
# Group command names by winning preset to batch _register_skills calls
|
||||||
# while only registering skills for the specific commands being reconciled.
|
# while only registering skills for the specific commands being
|
||||||
|
# reconciled. This resolution (which preset/content wins) is
|
||||||
|
# directory-independent, so it's computed once and then applied to
|
||||||
|
# every affected directory below.
|
||||||
preset_cmds: Dict[str, List[str]] = {}
|
preset_cmds: Dict[str, List[str]] = {}
|
||||||
non_preset_skills: List[tuple] = []
|
non_preset_skills: List[tuple] = []
|
||||||
|
managed_skill_names: set = set()
|
||||||
|
|
||||||
for cmd_name in command_names:
|
for cmd_name in command_names:
|
||||||
layers = resolver.collect_all_layers(cmd_name, "command")
|
layers = resolver.collect_all_layers(cmd_name, "command")
|
||||||
if not layers:
|
if not layers:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Re-create the skill directory only if it was previously managed
|
skill_name, _ = self._skill_names_for_command(cmd_name)
|
||||||
# (i.e., listed in some preset's registered_skills). This avoids
|
# Track whether any preset previously registered this skill
|
||||||
# creating new skill dirs that _register_skills would normally skip.
|
# (i.e., it was actively managed), so a not-yet-existing skill
|
||||||
if skills_dir:
|
# dir can be re-created per affected directory below.
|
||||||
skill_name, _ = self._skill_names_for_command(cmd_name)
|
for _pid, meta in presets_by_priority:
|
||||||
skill_subdir = skills_dir / skill_name
|
if not isinstance(meta, dict):
|
||||||
if not skill_subdir.exists():
|
continue
|
||||||
# Check if any preset previously registered this skill
|
recorded = meta.get("registered_skills", [])
|
||||||
was_managed = False
|
if isinstance(recorded, dict):
|
||||||
for _pid, meta in presets_by_priority:
|
in_any_agent = any(
|
||||||
if not isinstance(meta, dict):
|
skill_name in names
|
||||||
continue
|
for names in recorded.values()
|
||||||
recorded = meta.get("registered_skills", [])
|
if isinstance(names, list)
|
||||||
if isinstance(recorded, dict):
|
)
|
||||||
in_any_agent = any(
|
else:
|
||||||
skill_name in names
|
in_any_agent = skill_name in recorded
|
||||||
for names in recorded.values()
|
if in_any_agent:
|
||||||
if isinstance(names, list)
|
managed_skill_names.add(skill_name)
|
||||||
)
|
break
|
||||||
else:
|
|
||||||
in_any_agent = skill_name in recorded
|
|
||||||
if in_any_agent:
|
|
||||||
was_managed = True
|
|
||||||
break
|
|
||||||
if was_managed:
|
|
||||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
top_path = layers[0]["path"]
|
top_path = layers[0]["path"]
|
||||||
# Find the preset that owns the winning layer
|
# Find the preset that owns the winning layer
|
||||||
@@ -1293,39 +1332,44 @@ class PresetManager:
|
|||||||
if not found_preset:
|
if not found_preset:
|
||||||
# Winner is a non-preset source (core/extension/override).
|
# Winner is a non-preset source (core/extension/override).
|
||||||
# Track the winning layer path for skill restoration.
|
# Track the winning layer path for skill restoration.
|
||||||
skill_name, _ = self._skill_names_for_command(cmd_name)
|
|
||||||
non_preset_skills.append((skill_name, cmd_name, layers[0]))
|
non_preset_skills.append((skill_name, cmd_name, layers[0]))
|
||||||
|
|
||||||
# Restore skills for commands whose winner is non-preset.
|
core_ext_skills = [s for s in non_preset_skills if s[2]["source"] != "project override"]
|
||||||
if non_preset_skills and skills_dir:
|
override_skills = [s for s in non_preset_skills if s[2]["source"] == "project override"]
|
||||||
# Separate override-backed skills from core/extension-backed ones.
|
|
||||||
# _unregister_skills can rmtree the skill dir, so overrides must
|
|
||||||
# be handled directly (create dir + write) without that call.
|
|
||||||
core_ext_skills = []
|
|
||||||
override_skills = []
|
|
||||||
for item in non_preset_skills:
|
|
||||||
if item[2]["source"] == "project override":
|
|
||||||
override_skills.append(item)
|
|
||||||
else:
|
|
||||||
core_ext_skills.append(item)
|
|
||||||
|
|
||||||
|
def apply_to_dir(
|
||||||
|
skills_dir: Path, dir_agent: Optional[str], *, is_active: bool
|
||||||
|
) -> None:
|
||||||
|
# Re-create the skill directory only if it was previously
|
||||||
|
# managed (i.e., listed in some preset's registered_skills).
|
||||||
|
# This avoids creating new skill dirs that _register_skills
|
||||||
|
# would normally skip.
|
||||||
|
for skill_name in managed_skill_names:
|
||||||
|
skill_subdir = skills_dir / skill_name
|
||||||
|
if not skill_subdir.exists():
|
||||||
|
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Restore skills for commands whose winner is non-preset.
|
||||||
|
# _unregister_skills_in_dir can rmtree the skill dir, so
|
||||||
|
# overrides must be handled directly (create dir + write)
|
||||||
|
# without that call.
|
||||||
if core_ext_skills:
|
if core_ext_skills:
|
||||||
self._unregister_skills(
|
self._unregister_skills_in_dir(
|
||||||
[s[0] for s in core_ext_skills], self.presets_dir
|
[s[0] for s in core_ext_skills], skills_dir, dir_agent
|
||||||
)
|
)
|
||||||
|
|
||||||
for skill_name, cmd_name, top_layer in override_skills:
|
for skill_name, cmd_name, top_layer in override_skills:
|
||||||
skill_subdir = skills_dir / skill_name
|
skill_subdir = skills_dir / skill_name
|
||||||
# Same symlink guard as _register_skills's registration path
|
# Same symlink guard as _register_skills's registration path
|
||||||
# (#2948): mkdir(exist_ok=True) alone would silently follow an
|
# (#2948): mkdir(exist_ok=True) alone would silently follow
|
||||||
# existing symlinked subdirectory before writing SKILL.md
|
# an existing symlinked subdirectory before writing SKILL.md
|
||||||
# through it.
|
# through it.
|
||||||
if not self._validate_skill_subdir(skill_subdir, create=True):
|
if not self._validate_skill_subdir(skill_subdir, create=True):
|
||||||
continue
|
continue
|
||||||
skill_file = skill_subdir / "SKILL.md"
|
skill_file = skill_subdir / "SKILL.md"
|
||||||
try:
|
try:
|
||||||
from ..agents import CommandRegistrar
|
from ..agents import CommandRegistrar
|
||||||
from .. import SKILL_DESCRIPTIONS, load_init_options
|
from .. import SKILL_DESCRIPTIONS
|
||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
content = top_layer["path"].read_text(encoding="utf-8")
|
content = top_layer["path"].read_text(encoding="utf-8")
|
||||||
fm, body = registrar.parse_frontmatter(content)
|
fm, body = registrar.parse_frontmatter(content)
|
||||||
@@ -1336,9 +1380,8 @@ class PresetManager:
|
|||||||
short_name.replace(".", "-"),
|
short_name.replace(".", "-"),
|
||||||
f"Command: {short_name}",
|
f"Command: {short_name}",
|
||||||
)
|
)
|
||||||
init_opts = load_init_options(self.project_root)
|
selected_ai = dir_agent if isinstance(dir_agent, str) else ""
|
||||||
selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
|
if selected_ai:
|
||||||
if isinstance(selected_ai, str):
|
|
||||||
body = registrar.resolve_skill_placeholders(
|
body = registrar.resolve_skill_placeholders(
|
||||||
selected_ai, fm, body, self.project_root
|
selected_ai, fm, body, self.project_root
|
||||||
)
|
)
|
||||||
@@ -1346,10 +1389,9 @@ class PresetManager:
|
|||||||
body, registrar, selected_ai
|
body, registrar, selected_ai
|
||||||
)
|
)
|
||||||
from ..integrations import get_integration
|
from ..integrations import get_integration
|
||||||
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
|
integration = get_integration(selected_ai) if selected_ai else None
|
||||||
fm_data = registrar.build_skill_frontmatter(
|
fm_data = registrar.build_skill_frontmatter(
|
||||||
selected_ai if isinstance(selected_ai, str) else "",
|
selected_ai, skill_name, desc,
|
||||||
skill_name, desc,
|
|
||||||
f"override:{cmd_name}",
|
f"override:{cmd_name}",
|
||||||
)
|
)
|
||||||
registrar.apply_argument_hint(fm, fm_data, integration)
|
registrar.apply_argument_hint(fm, fm_data, integration)
|
||||||
@@ -1366,21 +1408,38 @@ class PresetManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # best-effort override skill restoration
|
pass # best-effort override skill restoration
|
||||||
|
|
||||||
# Register skills only for the specific commands being reconciled,
|
# Register skills only for the specific commands being
|
||||||
# not all commands in each winning preset's manifest.
|
# reconciled, not all commands in each winning preset's
|
||||||
for pack_id, cmds in preset_cmds.items():
|
# manifest.
|
||||||
pack_dir = self.presets_dir / pack_id
|
for pack_id, cmds in preset_cmds.items():
|
||||||
manifest_path = pack_dir / "preset.yml"
|
pack_dir = self.presets_dir / pack_id
|
||||||
if not manifest_path.exists():
|
manifest_path = pack_dir / "preset.yml"
|
||||||
continue
|
if not manifest_path.exists():
|
||||||
try:
|
continue
|
||||||
manifest = PresetManifest(manifest_path)
|
try:
|
||||||
except PresetValidationError:
|
manifest = PresetManifest(manifest_path)
|
||||||
continue
|
except PresetValidationError:
|
||||||
# Filter manifest to only the commands being reconciled
|
continue
|
||||||
cmds_set = set(cmds)
|
cmds_set = set(cmds)
|
||||||
filtered_manifest = self._FilteredManifest(manifest, cmds_set)
|
filtered_manifest = self._FilteredManifest(manifest, cmds_set)
|
||||||
self._register_skills(filtered_manifest, pack_dir)
|
if is_active:
|
||||||
|
# Preserve exact prior behaviour for the currently
|
||||||
|
# active directory (including the ability to create
|
||||||
|
# brand-new skill subdirectories when ai_skills is on).
|
||||||
|
self._register_skills(filtered_manifest, pack_dir)
|
||||||
|
else:
|
||||||
|
self._register_skills(
|
||||||
|
filtered_manifest, pack_dir,
|
||||||
|
target_dir=skills_dir, target_agent=dir_agent or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
if active_skills_dir:
|
||||||
|
apply_to_dir(active_skills_dir, active_ai, is_active=True)
|
||||||
|
|
||||||
|
for extra_dir, extra_agent in (extra_skills_dirs or {}).items():
|
||||||
|
if extra_dir == active_skills_dir:
|
||||||
|
continue # already reconciled above as the active directory
|
||||||
|
apply_to_dir(extra_dir, extra_agent, is_active=False)
|
||||||
|
|
||||||
def _get_skills_dir(self) -> Optional[Path]:
|
def _get_skills_dir(self) -> Optional[Path]:
|
||||||
"""Return the active skills directory for preset skill overrides.
|
"""Return the active skills directory for preset skill overrides.
|
||||||
@@ -1494,6 +1553,9 @@ class PresetManager:
|
|||||||
self,
|
self,
|
||||||
manifest: "PresetManifest",
|
manifest: "PresetManifest",
|
||||||
preset_dir: Path,
|
preset_dir: Path,
|
||||||
|
*,
|
||||||
|
target_dir: Optional[Path] = None,
|
||||||
|
target_agent: Optional[str] = None,
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Generate SKILL.md files for preset command overrides.
|
"""Generate SKILL.md files for preset command overrides.
|
||||||
|
|
||||||
@@ -1507,6 +1569,18 @@ class PresetManager:
|
|||||||
Args:
|
Args:
|
||||||
manifest: Preset manifest.
|
manifest: Preset manifest.
|
||||||
preset_dir: Installed preset directory.
|
preset_dir: Installed preset directory.
|
||||||
|
target_dir: Explicit skills directory to render into, instead
|
||||||
|
of resolving the currently active one. Used by
|
||||||
|
``_reconcile_skills`` to restore a surviving preset's
|
||||||
|
override into a historical (currently inactive) agent's
|
||||||
|
directory that removal of a higher-priority preset just
|
||||||
|
reverted (#2948).
|
||||||
|
target_agent: Explicit agent name to render for, paired with
|
||||||
|
``target_dir``. When set, skills are only ever restored
|
||||||
|
into already-tracked directories/names — brand-new skill
|
||||||
|
subdirectories are never created for a non-active,
|
||||||
|
explicitly targeted directory (that creation path is only
|
||||||
|
meaningful for the currently active agent).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
``{agent_name: [skill_name, ...]}`` for the single active
|
``{agent_name: [skill_name, ...]}`` for the single active
|
||||||
@@ -1535,7 +1609,7 @@ class PresetManager:
|
|||||||
if not filtered:
|
if not filtered:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
skills_dir = self._get_skills_dir()
|
skills_dir = target_dir if target_dir is not None else self._get_skills_dir()
|
||||||
if not skills_dir:
|
if not skills_dir:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -1546,10 +1620,16 @@ class PresetManager:
|
|||||||
init_opts = load_init_options(self.project_root)
|
init_opts = load_init_options(self.project_root)
|
||||||
if not isinstance(init_opts, dict):
|
if not isinstance(init_opts, dict):
|
||||||
init_opts = {}
|
init_opts = {}
|
||||||
selected_ai = init_opts.get("ai")
|
selected_ai = target_agent if target_agent is not None else init_opts.get("ai")
|
||||||
if not isinstance(selected_ai, str) or not selected_ai:
|
if not isinstance(selected_ai, str) or not selected_ai:
|
||||||
return {}
|
return {}
|
||||||
ai_skills_enabled = is_ai_skills_enabled(init_opts)
|
# A target_dir/target_agent call reconciles an explicitly-known,
|
||||||
|
# already-tracked directory (see _reconcile_skills) rather than the
|
||||||
|
# currently active agent, so ai_skills_enabled must not be derived
|
||||||
|
# from the *current* project-wide toggle for that other agent — it
|
||||||
|
# only controls whether brand-new skill subdirectories may be
|
||||||
|
# created below, which is only meaningful for the active agent.
|
||||||
|
ai_skills_enabled = target_agent is None and is_ai_skills_enabled(init_opts)
|
||||||
registrar = CommandRegistrar()
|
registrar = CommandRegistrar()
|
||||||
integration = get_integration(selected_ai)
|
integration = get_integration(selected_ai)
|
||||||
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
|
||||||
@@ -1846,7 +1926,7 @@ class PresetManager:
|
|||||||
self,
|
self,
|
||||||
registered_skills: Union[Dict[str, List[str]], List[str]],
|
registered_skills: Union[Dict[str, List[str]], List[str]],
|
||||||
preset_dir: Path,
|
preset_dir: Path,
|
||||||
) -> None:
|
) -> Dict[Path, Optional[str]]:
|
||||||
"""Restore original SKILL.md files after a preset is removed.
|
"""Restore original SKILL.md files after a preset is removed.
|
||||||
|
|
||||||
For each skill that was overridden by the preset, attempts to
|
For each skill that was overridden by the preset, attempts to
|
||||||
@@ -1867,9 +1947,17 @@ class PresetManager:
|
|||||||
``List[str]`` from a registry written before this
|
``List[str]`` from a registry written before this
|
||||||
provenance tracking existed.
|
provenance tracking existed.
|
||||||
preset_dir: The preset's installed directory (may already be deleted).
|
preset_dir: The preset's installed directory (may already be deleted).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{skills_dir: renderer_agent}`` for every directory actually
|
||||||
|
restored, so callers (e.g. ``remove()``) can hand these same
|
||||||
|
directories to :meth:`_reconcile_skills` — a surviving
|
||||||
|
lower-priority preset's override must be re-applied to every
|
||||||
|
directory this removal touched, not only the currently active
|
||||||
|
agent's directory (#2948).
|
||||||
"""
|
"""
|
||||||
if not registered_skills:
|
if not registered_skills:
|
||||||
return
|
return {}
|
||||||
|
|
||||||
if isinstance(registered_skills, dict):
|
if isinstance(registered_skills, dict):
|
||||||
from .. import load_init_options
|
from .. import load_init_options
|
||||||
@@ -1909,25 +1997,32 @@ class PresetManager:
|
|||||||
active_agent if active_agent in agents else sorted(agents)[0]
|
active_agent if active_agent in agents else sorted(agents)[0]
|
||||||
)
|
)
|
||||||
self._unregister_skills_in_dir(group["names"], skills_dir, renderer_agent)
|
self._unregister_skills_in_dir(group["names"], skills_dir, renderer_agent)
|
||||||
return
|
return {
|
||||||
|
skills_dir: (
|
||||||
|
active_agent if active_agent in group["agents"] else sorted(group["agents"])[0]
|
||||||
|
)
|
||||||
|
for skills_dir, group in groups.items()
|
||||||
|
}
|
||||||
|
|
||||||
# Legacy flat-list format: no record of which agent directory these
|
# Legacy flat-list format: no record of which agent directory these
|
||||||
# names were written under, so best-effort restore is limited to the
|
# names were written under, so best-effort restore is limited to the
|
||||||
# currently active agent's directory (the pre-provenance behaviour).
|
# currently active agent's directory (the pre-provenance behaviour).
|
||||||
skills_dir = self._get_skills_dir()
|
skills_dir = self._get_skills_dir()
|
||||||
if not skills_dir:
|
if not skills_dir:
|
||||||
return
|
return {}
|
||||||
from .. import load_init_options
|
from .. import load_init_options
|
||||||
|
|
||||||
init_opts = load_init_options(self.project_root)
|
init_opts = load_init_options(self.project_root)
|
||||||
if not isinstance(init_opts, dict):
|
if not isinstance(init_opts, dict):
|
||||||
init_opts = {}
|
init_opts = {}
|
||||||
selected_ai = init_opts.get("ai")
|
selected_ai = init_opts.get("ai")
|
||||||
|
selected_ai = selected_ai if isinstance(selected_ai, str) else None
|
||||||
self._unregister_skills_in_dir(
|
self._unregister_skills_in_dir(
|
||||||
registered_skills,
|
registered_skills,
|
||||||
skills_dir,
|
skills_dir,
|
||||||
selected_ai if isinstance(selected_ai, str) else None,
|
selected_ai,
|
||||||
)
|
)
|
||||||
|
return {skills_dir: selected_ai}
|
||||||
|
|
||||||
def _unregister_skills_in_dir(
|
def _unregister_skills_in_dir(
|
||||||
self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str]
|
self, skill_names: List[str], skills_dir: Path, selected_ai: Optional[str]
|
||||||
@@ -2274,6 +2369,27 @@ class PresetManager:
|
|||||||
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
|
||||||
pack_dir = self.presets_dir / pack_id
|
pack_dir = self.presets_dir / pack_id
|
||||||
|
|
||||||
|
# Record which historical agents this preset's registered_commands
|
||||||
|
# actually targeted, *before* any filtering below, so post-removal
|
||||||
|
# reconciliation can restore a surviving lower-priority preset's
|
||||||
|
# override into every one of those directories too — not only the
|
||||||
|
# currently active agent's. Without this, removing a preset that
|
||||||
|
# was rendered under a previously-active (now inactive) agent
|
||||||
|
# deletes that agent's command file via _unregister_commands below,
|
||||||
|
# but active-only reconciliation would only recreate the surviving
|
||||||
|
# winner for the current agent, leaving the inactive integration
|
||||||
|
# with a missing/stale file (#2948).
|
||||||
|
try:
|
||||||
|
from ..agents import CommandRegistrar as _CommandRegistrarForScope
|
||||||
|
except ImportError:
|
||||||
|
_CommandRegistrarForScope = None
|
||||||
|
affected_command_agents = {
|
||||||
|
agent_name
|
||||||
|
for agent_name in registered_commands
|
||||||
|
if _CommandRegistrarForScope is None
|
||||||
|
or _CommandRegistrarForScope.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
|
||||||
|
}
|
||||||
|
|
||||||
# Collect ALL command names before filtering for reconciliation,
|
# Collect ALL command names before filtering for reconciliation,
|
||||||
# so commands registered only for skill-based agents are also
|
# so commands registered only for skill-based agents are also
|
||||||
# reconciled. Every command-type template's primary name is added
|
# reconciled. Every command-type template's primary name is added
|
||||||
@@ -2304,8 +2420,9 @@ class PresetManager:
|
|||||||
# names from registered_commands are still unregistered.
|
# names from registered_commands are still unregistered.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
affected_skill_dirs: Dict[Path, Optional[str]] = {}
|
||||||
if registered_skills:
|
if registered_skills:
|
||||||
self._unregister_skills(registered_skills, pack_dir)
|
affected_skill_dirs = self._unregister_skills(registered_skills, pack_dir)
|
||||||
try:
|
try:
|
||||||
from ..agents import CommandRegistrar
|
from ..agents import CommandRegistrar
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -2330,8 +2447,12 @@ class PresetManager:
|
|||||||
# re-resolve from the remaining stack so the next layer takes effect.
|
# re-resolve from the remaining stack so the next layer takes effect.
|
||||||
if removed_cmd_names:
|
if removed_cmd_names:
|
||||||
try:
|
try:
|
||||||
self._reconcile_composed_commands(list(removed_cmd_names))
|
self._reconcile_composed_commands(
|
||||||
self._reconcile_skills(list(removed_cmd_names))
|
list(removed_cmd_names), extra_agents=affected_command_agents
|
||||||
|
)
|
||||||
|
self._reconcile_skills(
|
||||||
|
list(removed_cmd_names), extra_skills_dirs=affected_skill_dirs
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
import warnings
|
import warnings
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
|||||||
@@ -1824,6 +1824,149 @@ class TestExtensionSkillRegistration:
|
|||||||
"removed via an explicit symlinked directory argument"
|
"removed via an explicit symlinked directory argument"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_extension_owned_skill_names_rejects_symlinked_child_skill_dir(
|
||||||
|
self, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Provenance probing must reject a per-skill child directory that
|
||||||
|
is itself a symlink, even when its resolved target stays inside
|
||||||
|
the (real, non-symlinked) skills root.
|
||||||
|
|
||||||
|
Both ``_extension_owned_skill_names`` and
|
||||||
|
``_unregister_extension_skills`` previously only validated the
|
||||||
|
*parent* ``skills_dir`` for symlink escape, then resolved
|
||||||
|
``skills_dir / skill_name`` and checked containment relative to
|
||||||
|
the already-resolved parent. A child symlink whose target
|
||||||
|
resolves inside that same root passes that containment check, so
|
||||||
|
a corrupted or attacker-controlled registry entry naming a
|
||||||
|
symlink alias could cause a legitimate, unrelated skill directory
|
||||||
|
to be falsely attributed as extension-owned via the alias.
|
||||||
|
"""
|
||||||
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
# A real, legitimately marker-matching skill directory under its
|
||||||
|
# own name — this represents genuine extension-owned content.
|
||||||
|
real_skill_dir = skills_dir / "speckit-child-sym-real"
|
||||||
|
real_skill_dir.mkdir()
|
||||||
|
(real_skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"name: speckit-child-sym-real\n"
|
||||||
|
"description: real skill\n"
|
||||||
|
"metadata:\n"
|
||||||
|
" source: extension:child-sym-ext\n"
|
||||||
|
"---\n\n"
|
||||||
|
"real body\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _can_create_symlink(temp_dir):
|
||||||
|
pytest.skip("Current platform/user cannot create symlinks")
|
||||||
|
|
||||||
|
# A *different* registered name that is merely a symlink alias
|
||||||
|
# pointing at the real skill directory above — both still live
|
||||||
|
# inside the same, non-symlinked skills root.
|
||||||
|
alias_name = "speckit-child-sym-alias"
|
||||||
|
os.symlink(str(real_skill_dir), str(skills_dir / alias_name))
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
owned = manager._extension_owned_skill_names(
|
||||||
|
[alias_name], "child-sym-ext"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert owned == [], (
|
||||||
|
"a per-skill child directory that is itself a symlink must "
|
||||||
|
"never be followed for provenance attribution, even when its "
|
||||||
|
"resolved target remains inside the skills root"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unregister_extension_skills_explicit_dir_rejects_symlinked_child(
|
||||||
|
self, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Fast (explicit ``skills_dir``) removal path must refuse to
|
||||||
|
delete through a per-skill child directory that is itself a
|
||||||
|
symlink, even when the resolved target stays inside the skills
|
||||||
|
root — deleting the resolved target would destroy a legitimate,
|
||||||
|
differently-named skill directory via the alias.
|
||||||
|
"""
|
||||||
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
precious_skill_dir = skills_dir / "speckit-child-sym-precious"
|
||||||
|
precious_skill_dir.mkdir()
|
||||||
|
precious_skill_md = precious_skill_dir / "SKILL.md"
|
||||||
|
precious_skill_md.write_text(
|
||||||
|
"---\n"
|
||||||
|
"name: speckit-child-sym-precious\n"
|
||||||
|
"description: precious skill\n"
|
||||||
|
"metadata:\n"
|
||||||
|
" source: extension:child-sym-ext2\n"
|
||||||
|
"---\n\n"
|
||||||
|
"precious body\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _can_create_symlink(temp_dir):
|
||||||
|
pytest.skip("Current platform/user cannot create symlinks")
|
||||||
|
|
||||||
|
alias_name = "speckit-child-sym-alias2"
|
||||||
|
os.symlink(str(precious_skill_dir), str(skills_dir / alias_name))
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manager._unregister_extension_skills(
|
||||||
|
[alias_name], "child-sym-ext2", skills_dir=skills_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert precious_skill_dir.exists(), (
|
||||||
|
"the real skill directory reached only through a symlink "
|
||||||
|
"alias must survive removal of the alias name (#2948)"
|
||||||
|
)
|
||||||
|
assert precious_skill_md.exists(), (
|
||||||
|
"the real skill directory's SKILL.md must not be deleted via "
|
||||||
|
"a differently-named symlink alias"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unregister_extension_skills_fallback_rejects_symlinked_child(
|
||||||
|
self, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Fallback (unscoped, ``skills_dir=None``) removal scan must also
|
||||||
|
refuse to delete through a per-skill child symlink, mirroring the
|
||||||
|
explicit-dir fast path.
|
||||||
|
"""
|
||||||
|
skills_dir = project_dir / ".claude" / "skills"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
precious_skill_dir = skills_dir / "speckit-child-sym-precious3"
|
||||||
|
precious_skill_dir.mkdir()
|
||||||
|
precious_skill_md = precious_skill_dir / "SKILL.md"
|
||||||
|
precious_skill_md.write_text(
|
||||||
|
"---\n"
|
||||||
|
"name: speckit-child-sym-precious3\n"
|
||||||
|
"description: precious skill\n"
|
||||||
|
"metadata:\n"
|
||||||
|
" source: extension:child-sym-ext3\n"
|
||||||
|
"---\n\n"
|
||||||
|
"precious body\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _can_create_symlink(temp_dir):
|
||||||
|
pytest.skip("Current platform/user cannot create symlinks")
|
||||||
|
|
||||||
|
alias_name = "speckit-child-sym-alias3"
|
||||||
|
os.symlink(str(precious_skill_dir), str(skills_dir / alias_name))
|
||||||
|
|
||||||
|
manager = ExtensionManager(project_dir)
|
||||||
|
manager._unregister_extension_skills([alias_name], "child-sym-ext3")
|
||||||
|
|
||||||
|
assert precious_skill_dir.exists(), (
|
||||||
|
"the real skill directory reached only through a symlink "
|
||||||
|
"alias must survive the unscoped fallback removal scan (#2948)"
|
||||||
|
)
|
||||||
|
assert precious_skill_md.exists(), (
|
||||||
|
"the real skill directory's SKILL.md must not be deleted via "
|
||||||
|
"a differently-named symlink alias during fallback removal"
|
||||||
|
)
|
||||||
|
|
||||||
def test_existing_agent_command_path_file_is_not_detected(
|
def test_existing_agent_command_path_file_is_not_detected(
|
||||||
self, project_dir, temp_dir
|
self, project_dir, temp_dir
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -5295,6 +5295,157 @@ class TestPresetSkills:
|
|||||||
"inactive agent's directory (#2948)"
|
"inactive agent's directory (#2948)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_remove_reconciles_command_for_every_historical_agent(
|
||||||
|
self, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Removing a preset must reconcile every historical agent its
|
||||||
|
``registered_commands`` actually targeted, not only the currently
|
||||||
|
active one.
|
||||||
|
|
||||||
|
Preset B (lower precedence, survives) is installed while gemini is
|
||||||
|
active, then preset A (higher precedence) overrides the same
|
||||||
|
command while gemini is still active. Switching the active
|
||||||
|
integration to opencode and rescaffolding re-registers both
|
||||||
|
presets under opencode too, so preset A's ``registered_commands``
|
||||||
|
now spans two agents: gemini (now inactive) and opencode (active).
|
||||||
|
Removing A deletes its command file from *both* directories via
|
||||||
|
``_unregister_commands``, but active-only reconciliation used to
|
||||||
|
recreate the surviving preset B's content only for the active
|
||||||
|
agent (opencode), leaving gemini's directory with a stale/missing
|
||||||
|
file (#2948).
|
||||||
|
"""
|
||||||
|
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
||||||
|
gemini_dir = project_dir / ".gemini" / "commands"
|
||||||
|
gemini_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
preset_b_dir = self._create_command_preset(
|
||||||
|
temp_dir, "hist-preset-b", "speckit.specify",
|
||||||
|
"Preset B", "preset B body",
|
||||||
|
)
|
||||||
|
preset_a_dir = self._create_command_preset(
|
||||||
|
temp_dir, "hist-preset-a", "speckit.specify",
|
||||||
|
"Preset A", "preset A body",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = PresetManager(project_dir)
|
||||||
|
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
||||||
|
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
||||||
|
|
||||||
|
gemini_cmd_files = list(gemini_dir.glob("*specify*"))
|
||||||
|
assert gemini_cmd_files, "sanity: gemini should have the command file"
|
||||||
|
assert "preset A body" in gemini_cmd_files[0].read_text(), (
|
||||||
|
"sanity: preset A (higher precedence) should win initially"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Switch the active integration to opencode and rescaffold, mirroring
|
||||||
|
# `integration use opencode`. This merges opencode into both
|
||||||
|
# presets' registered_commands alongside the pre-existing gemini
|
||||||
|
# entry recorded while gemini was active.
|
||||||
|
self._write_init_options(project_dir, ai="opencode", ai_skills=False)
|
||||||
|
opencode_dir = project_dir / ".opencode" / "commands"
|
||||||
|
opencode_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
manager.register_enabled_presets_for_agent("opencode")
|
||||||
|
|
||||||
|
metadata_a = manager.registry.get("hist-preset-a")
|
||||||
|
assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, (
|
||||||
|
"sanity: preset A's registered_commands must span both the "
|
||||||
|
"historical (gemini) and currently active (opencode) agents"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.remove("hist-preset-a") is True
|
||||||
|
|
||||||
|
gemini_cmd_files = list(gemini_dir.glob("*specify*"))
|
||||||
|
opencode_cmd_files = list(opencode_dir.glob("*specify*"))
|
||||||
|
assert gemini_cmd_files, "gemini's command file must still exist after removal"
|
||||||
|
assert opencode_cmd_files, "opencode's command file must still exist after removal"
|
||||||
|
assert "preset B body" in gemini_cmd_files[0].read_text(), (
|
||||||
|
"removing the higher-precedence preset must restore the "
|
||||||
|
"surviving preset's content in the historical (inactive) "
|
||||||
|
"agent's directory too, not only the active agent's (#2948)"
|
||||||
|
)
|
||||||
|
assert "preset B body" in opencode_cmd_files[0].read_text(), (
|
||||||
|
"the surviving preset's content must also be restored for the "
|
||||||
|
"currently active agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remove_reconciles_skill_for_every_historical_agent(
|
||||||
|
self, project_dir, temp_dir
|
||||||
|
):
|
||||||
|
"""Removing a preset must reconcile every historical skills
|
||||||
|
directory its ``registered_skills`` actually targeted, not only
|
||||||
|
the currently active one.
|
||||||
|
|
||||||
|
Preset B (survives) is installed while claude is active, then
|
||||||
|
preset A (higher precedence) overrides the same command while
|
||||||
|
claude is still active. Switching to codex and rescaffolding
|
||||||
|
records codex too, so preset A's ``registered_skills`` spans both
|
||||||
|
claude (now inactive) and codex (active) directories. Removing A
|
||||||
|
restores both directories to core/extension via
|
||||||
|
``_unregister_skills``, but ``_reconcile_skills`` used to only
|
||||||
|
resolve/apply the surviving winner for the currently active
|
||||||
|
skills directory, leaving claude's directory reverted to
|
||||||
|
core/extension content instead of preset B's override (#2948).
|
||||||
|
"""
|
||||||
|
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
||||||
|
claude_skills_dir = project_dir / ".claude" / "skills"
|
||||||
|
|
||||||
|
# A core template fallback is required so unregistering the
|
||||||
|
# top-priority preset's SKILL.md restores core content rather than
|
||||||
|
# deleting the skill directory outright when no preset remains to
|
||||||
|
# apply on top of it (mirrors the pre-existing skills-reconciliation
|
||||||
|
# fixtures elsewhere in this file).
|
||||||
|
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||||
|
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||||
|
(core_cmds / "specify.md").write_text(
|
||||||
|
"---\ndescription: Core specify command\n---\n\nCore specify body\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
preset_b_dir = self._create_command_preset(
|
||||||
|
temp_dir, "hist-skill-preset-b", "speckit.specify",
|
||||||
|
"Preset B", "preset B body",
|
||||||
|
)
|
||||||
|
preset_a_dir = self._create_command_preset(
|
||||||
|
temp_dir, "hist-skill-preset-a", "speckit.specify",
|
||||||
|
"Preset A", "preset A body",
|
||||||
|
)
|
||||||
|
|
||||||
|
manager = PresetManager(project_dir)
|
||||||
|
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
||||||
|
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
||||||
|
|
||||||
|
claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
||||||
|
assert "preset:hist-skill-preset-a" in claude_skill_file.read_text(), (
|
||||||
|
"sanity: preset A (higher precedence) should win initially"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Switch the active integration to codex (a distinct skills
|
||||||
|
# directory) and rescaffold, mirroring `integration use codex`.
|
||||||
|
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
||||||
|
codex_skills_dir = project_dir / ".agents" / "skills"
|
||||||
|
manager.register_enabled_presets_for_agent("codex")
|
||||||
|
|
||||||
|
metadata_a = manager.registry.get("hist-skill-preset-a")
|
||||||
|
assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, (
|
||||||
|
"sanity: preset A's registered_skills must span both the "
|
||||||
|
"historical (claude) and currently active (codex) agents"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.remove("hist-skill-preset-a") is True
|
||||||
|
|
||||||
|
codex_skill_file = codex_skills_dir / "speckit-specify" / "SKILL.md"
|
||||||
|
assert claude_skill_file.exists(), "claude's skill file must still exist after removal"
|
||||||
|
assert codex_skill_file.exists(), "codex's skill file must still exist after removal"
|
||||||
|
assert "preset:hist-skill-preset-b" in claude_skill_file.read_text(), (
|
||||||
|
"removing the higher-precedence preset must restore the "
|
||||||
|
"surviving preset's override in the historical (inactive) "
|
||||||
|
"agent's directory too, not only the active agent's (#2948)"
|
||||||
|
)
|
||||||
|
assert "preset:hist-skill-preset-b" in codex_skill_file.read_text(), (
|
||||||
|
"the surviving preset's override must also be restored for "
|
||||||
|
"the currently active agent"
|
||||||
|
)
|
||||||
|
|
||||||
def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir):
|
def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir):
|
||||||
"""Restore must validate each per-skill subdirectory, not just its parent.
|
"""Restore must validate each per-skill subdirectory, not just its parent.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user