mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Persist historical reconciliation ownership; defer destructive toggle cleanup; validate registry-provided skill names
Round 11 review findings (5 comments on HEAD ab6c28c), three root causes:
A) Historical-agent reconciliation wrote surviving content to disk but
discarded the returned per-agent write map, so the preset's own
registered_commands/registered_skills never learned about directories
reconciliation restored on its behalf. A later removal of that same
preset then orphaned those directories. Added
_merge_pack_registered_commands/_merge_pack_registered_skills and wired
them into _reconcile_composed_commands and _reconcile_skills's
apply_to_dir so every actual write is merged back into the winning
preset's registry metadata.
B) Command<->skills toggle on an already-active agent deleted the old
artifact before the replacement registration ran, in both
presets/__init__.py's register_enabled_presets_for_agent and
extensions/__init__.py's register_enabled_extensions_for_agent. If the
replacement step raised, both artifacts were lost. Deferred the
destructive cleanup until after the replacement phase completes
without raising (register-new-then-remove-old ordering); the mirror
skills->command direction was already safe since the new command file
is always registered unconditionally before any cleanup runs.
C) _unregister_skills_in_dir and _infer_legacy_skill_provenance joined a
registry-provided (untrusted) skill name directly onto a directory
before any name-shape validation. An absolute in-project name discards
the intended parent directory entirely (Path's "/" operator drops the
left side for an absolute right side), letting a corrupted registry
entry escape the intended skills subtree while still resolving inside
the project root - passing the existing containment/symlink check.
Added a centralized _is_safe_registry_skill_name guard (rejecting
non-strings, empty strings, absolute paths, multi-component paths, and
"."/".." ) and applied it before every path join derived from
registry-provided skill names in both functions. Also fixed
_infer_legacy_skill_provenance's unmatched-name fallback, which
previously still attributed rejected names to fallback_agent even
after the loop skipped them.
Added red-first regressions for all three root causes, covering: a
two-preset historical-command-agent survivor scenario, an analogous
skill-agent survivor scenario, injected skills-phase failure during a
preset command->skills toggle and the extension equivalent, a direct
unit test of the new name-safety guard, an absolute-path escape attempt
against _unregister_skills_in_dir, and a false-attribution attempt
against _infer_legacy_skill_provenance.
Tests: tests/test_presets.py (367 passed), tests/test_extension_skills.py
+ tests/test_extensions.py (408 passed), tests/integrations (1768
passed, 1 skipped), full suite tests -q deselecting the pre-existing
1Password-signing-affected tests/extensions/git/test_git_extension.py
(3909 passed, 74 skipped, 90 deselected). 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:
@@ -2001,6 +2001,10 @@ class ExtensionManager:
|
||||
# registration of the remaining enabled extensions for this agent.
|
||||
try:
|
||||
updates: Dict[str, Any] = {}
|
||||
# Set when a command -> skills toggle for this same agent
|
||||
# defers stale command-mode cleanup until the skills
|
||||
# replacement below confirms success (#2948).
|
||||
deferred_stale_commands: Optional[List[str]] = None
|
||||
|
||||
if agent_config and not skills_mode_active:
|
||||
registered = registrar.register_commands_for_agent(
|
||||
@@ -2022,28 +2026,27 @@ class ExtensionManager:
|
||||
updates["registered_commands"] = new_registered
|
||||
elif agent_config and skills_mode_active:
|
||||
# Toggled command -> skills for this same agent: the
|
||||
# commands phase above is skipped, but a command file
|
||||
# this extension previously wrote for this agent while
|
||||
# commands phase above is skipped. A command file this
|
||||
# extension previously wrote for this agent while
|
||||
# command mode was active is still on disk and still
|
||||
# tracked. Remove it narrowly for this agent so
|
||||
# command-mode and skills-mode artifacts stay mutually
|
||||
# exclusive, matching unregister_agent_artifacts's
|
||||
# per-agent command cleanup (#2948).
|
||||
# tracked, but it must NOT be removed yet — the skills
|
||||
# phase below is an independently fallible replacement
|
||||
# step, and deleting the old artifact before it
|
||||
# succeeds would leave neither the old command file nor
|
||||
# a new skill file if skills registration raises. The
|
||||
# actual removal is deferred until after the skills
|
||||
# phase below completes without raising (#2948).
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
if isinstance(registered_commands, dict) and registered_commands.get(
|
||||
agent_name
|
||||
):
|
||||
stale_commands = self._valid_name_list(
|
||||
deferred_stale_commands = self._valid_name_list(
|
||||
registered_commands.get(agent_name)
|
||||
)
|
||||
if stale_commands:
|
||||
registrar.unregister_commands(
|
||||
{agent_name: stale_commands}, self.project_root
|
||||
)
|
||||
new_registered = copy.deepcopy(registered_commands)
|
||||
new_registered.pop(agent_name, None)
|
||||
if new_registered != registered_commands:
|
||||
updates["registered_commands"] = new_registered
|
||||
else:
|
||||
deferred_stale_commands = None
|
||||
else:
|
||||
deferred_stale_commands = None
|
||||
|
||||
# Extension *skills* are only ever rendered for the active agent:
|
||||
# `_register_extension_skills` resolves the skills dir and
|
||||
@@ -2122,6 +2125,25 @@ class ExtensionManager:
|
||||
if remaining != existing_skills:
|
||||
updates["registered_skills"] = remaining
|
||||
|
||||
# The skills phase above completed without raising
|
||||
# (this ``else:`` is only reached on success), so a
|
||||
# deferred command -> skills toggle cleanup queued
|
||||
# above is now safe to apply: the replacement skill
|
||||
# registration is confirmed, so the stale
|
||||
# command-mode artifact can finally be removed
|
||||
# without risking a transient state where neither
|
||||
# artifact exists (#2948).
|
||||
if deferred_stale_commands:
|
||||
registrar.unregister_commands(
|
||||
{agent_name: deferred_stale_commands}, self.project_root
|
||||
)
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
if isinstance(registered_commands, dict):
|
||||
new_registered = copy.deepcopy(registered_commands)
|
||||
new_registered.pop(agent_name, None)
|
||||
if new_registered != registered_commands:
|
||||
updates["registered_commands"] = new_registered
|
||||
|
||||
if updates:
|
||||
self.registry.update(ext_id, updates)
|
||||
except Exception as ext_err:
|
||||
|
||||
@@ -785,17 +785,24 @@ class PresetManager:
|
||||
if not isinstance(existing_commands, dict):
|
||||
existing_commands = {}
|
||||
merged_commands = copy.deepcopy(existing_commands)
|
||||
# Toggled command -> skills for this same agent:
|
||||
# _register_commands's ai_skills guard just made this a
|
||||
# no-op, but the command file this preset wrote while
|
||||
# command mode was active is still on disk and still
|
||||
# tracked. Do NOT unregister it yet — _register_skills()
|
||||
# below is an independently fallible replacement step, and
|
||||
# deleting the old artifact before it succeeds would leave
|
||||
# neither the old command file nor a new skill file if
|
||||
# skills registration raises. The old artifact is only
|
||||
# removed after the skills phase below completes without
|
||||
# raising, preserving command/skill mutual exclusion while
|
||||
# never leaving a transient failure with nothing in place
|
||||
# (#2948).
|
||||
stale_command_names: Optional[List[str]] = None
|
||||
if registered_commands.get(agent_name):
|
||||
merged_commands[agent_name] = registered_commands[agent_name]
|
||||
elif ai_skills_now and merged_commands.get(agent_name):
|
||||
# Toggled command -> skills for this same agent:
|
||||
# _register_commands's ai_skills guard just made this a
|
||||
# no-op, but the command file this preset wrote while
|
||||
# command mode was active is still on disk and still
|
||||
# tracked. Unregister it narrowly for this agent so
|
||||
# command-mode and skills-mode artifacts stay mutually
|
||||
# exclusive (#2948).
|
||||
self._unregister_commands({agent_name: merged_commands.pop(agent_name)})
|
||||
stale_command_names = merged_commands[agent_name]
|
||||
# Persist the commands phase immediately, mirroring
|
||||
# install_from_directory(): _register_skills is an
|
||||
# independently fallible phase, and if it raises, the files
|
||||
@@ -843,7 +850,12 @@ class PresetManager:
|
||||
# directory once ai_skills is off, so _register_skills
|
||||
# is a no-op — but the SKILL.md this preset wrote while
|
||||
# skills mode was active is still tracked and still on
|
||||
# disk. Restore/remove it narrowly for this agent (#2948).
|
||||
# disk. Restore/remove it narrowly for this agent. This
|
||||
# direction is already register-new-then-remove-old:
|
||||
# _register_commands (the replacement) ran unconditionally
|
||||
# above and only reaches here once it has already
|
||||
# succeeded, so this cleanup happens only after the new
|
||||
# command artifact is confirmed in place (#2948).
|
||||
self._unregister_skills(
|
||||
{agent_name: merged_skills.pop(agent_name)}, pack_dir
|
||||
)
|
||||
@@ -861,6 +873,15 @@ class PresetManager:
|
||||
)
|
||||
if merged_skills != existing_skills or needs_migration:
|
||||
self.registry.update(pack_id, {"registered_skills": merged_skills})
|
||||
|
||||
# The skills phase above completed without raising, so the
|
||||
# replacement artifact is confirmed — now it's safe to
|
||||
# remove the stale command-mode artifact deferred earlier
|
||||
# (#2948).
|
||||
if stale_command_names:
|
||||
self._unregister_commands({agent_name: stale_command_names})
|
||||
merged_commands.pop(agent_name, None)
|
||||
self.registry.update(pack_id, {"registered_commands": merged_commands})
|
||||
except Exception as pack_err:
|
||||
from .. import _print_cli_warning
|
||||
|
||||
@@ -910,6 +931,50 @@ class PresetManager:
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands(registered_commands, self.project_root)
|
||||
|
||||
def _merge_pack_registered_commands(
|
||||
self, pack_id: str, written: Optional[Dict[str, List[str]]]
|
||||
) -> None:
|
||||
"""Merge actually-written agent command registrations into a preset's metadata.
|
||||
|
||||
Reconciliation (``_reconcile_composed_commands``) can write a
|
||||
preset's content into an agent directory the preset never wrote to
|
||||
before — most notably a historical (currently inactive) agent
|
||||
supplied via ``extra_agents`` when a higher-priority preset is
|
||||
removed. If that write isn't reflected back into the winning
|
||||
preset's own ``registered_commands``, the registry silently lies
|
||||
about which directories the preset owns: a later removal of this
|
||||
same preset only cleans up the agents it already knew about,
|
||||
orphaning the directory reconciliation just wrote to on its behalf
|
||||
(#2948).
|
||||
|
||||
Args:
|
||||
pack_id: The preset whose metadata should be updated.
|
||||
written: ``{agent_name: [cmd_name, ...]}`` actually written by
|
||||
the reconciliation call just made, exactly mirroring
|
||||
``CommandRegistrar.register_commands_for_non_skill_agents``'s
|
||||
return value. A falsy value is a no-op.
|
||||
"""
|
||||
if not written:
|
||||
return
|
||||
metadata = self.registry.get(pack_id)
|
||||
if metadata is None:
|
||||
return # pack_id no longer installed (e.g. removed mid-loop)
|
||||
existing_commands = metadata.get("registered_commands", {})
|
||||
if not isinstance(existing_commands, dict):
|
||||
existing_commands = {}
|
||||
merged_commands = copy.deepcopy(existing_commands)
|
||||
changed = False
|
||||
for agent_name, cmd_names in written.items():
|
||||
if not cmd_names:
|
||||
continue
|
||||
existing_names = merged_commands.get(agent_name, [])
|
||||
new_names = [n for n in cmd_names if n not in existing_names]
|
||||
if new_names:
|
||||
merged_commands[agent_name] = existing_names + new_names
|
||||
changed = True
|
||||
if changed:
|
||||
self.registry.update(pack_id, {"registered_commands": merged_commands})
|
||||
|
||||
def _reconcile_composed_commands(
|
||||
self, command_names: List[str], extra_agents: Optional[Set[str]] = None
|
||||
) -> None:
|
||||
@@ -1003,10 +1068,11 @@ class PresetManager:
|
||||
if manifest:
|
||||
for tmpl in manifest.templates:
|
||||
if tmpl.get("name") == cmd_name and tmpl.get("type") == "command":
|
||||
self._register_for_non_skill_agents(
|
||||
written = self._register_for_non_skill_agents(
|
||||
registrar, [tmpl], manifest.id, pack_dir,
|
||||
only_agent=only_agent, extra_agents=extra_agents,
|
||||
)
|
||||
self._merge_pack_registered_commands(manifest.id, written)
|
||||
registered = True
|
||||
break
|
||||
break
|
||||
@@ -1105,12 +1171,13 @@ class PresetManager:
|
||||
composed_dir.mkdir(parents=True, exist_ok=True)
|
||||
composed_file = composed_dir / f"{cmd_name}.md"
|
||||
composed_file.write_text(composed, encoding="utf-8")
|
||||
self._register_for_non_skill_agents(
|
||||
written = self._register_for_non_skill_agents(
|
||||
registrar,
|
||||
[{**tmpl, "file": f".composed/{cmd_name}.md"}],
|
||||
manifest.id, pack_dir,
|
||||
only_agent=only_agent, extra_agents=extra_agents,
|
||||
)
|
||||
self._merge_pack_registered_commands(manifest.id, written)
|
||||
registered = True
|
||||
break
|
||||
else:
|
||||
@@ -1142,7 +1209,7 @@ class PresetManager:
|
||||
source_id: str = "reconciled",
|
||||
only_agent: Optional[str] = None,
|
||||
extra_agents: Optional[Set[str]] = None,
|
||||
) -> None:
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register a single command from a file path (non-preset source).
|
||||
|
||||
Used by reconciliation when the winning layer is an extension,
|
||||
@@ -1156,9 +1223,14 @@ class PresetManager:
|
||||
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).
|
||||
|
||||
Returns:
|
||||
``{agent_name: [cmd_name, ...]}`` for every agent this call
|
||||
actually registered the command for (empty if the source path
|
||||
doesn't exist or nothing was written).
|
||||
"""
|
||||
if not cmd_path.exists():
|
||||
return
|
||||
return {}
|
||||
cmd_tmpl: Dict[str, Any] = {
|
||||
"name": cmd_name,
|
||||
"type": "command",
|
||||
@@ -1184,7 +1256,7 @@ class PresetManager:
|
||||
break
|
||||
except Exception:
|
||||
pass # best-effort alias loading
|
||||
self._register_for_non_skill_agents(
|
||||
return self._register_for_non_skill_agents(
|
||||
registrar, [cmd_tmpl], source_id, cmd_path.parent,
|
||||
only_agent=only_agent, extra_agents=extra_agents,
|
||||
)
|
||||
@@ -1197,7 +1269,7 @@ class PresetManager:
|
||||
source_dir: Path,
|
||||
only_agent: Optional[str] = None,
|
||||
extra_agents: Optional[Set[str]] = None,
|
||||
) -> None:
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for non-skill agents during reconciliation.
|
||||
|
||||
Skill-based agents (``/SKILL.md`` layout) are handled separately:
|
||||
@@ -1219,8 +1291,15 @@ class PresetManager:
|
||||
``only_agent``. Used by post-removal reconciliation to also
|
||||
restore surviving content into historical agent directories
|
||||
a just-removed preset actually wrote to (#2948).
|
||||
|
||||
Returns:
|
||||
``{agent_name: [cmd_name, ...]}`` for every agent this call
|
||||
actually registered a command for, mirroring
|
||||
``CommandRegistrar.register_commands_for_non_skill_agents``'s
|
||||
return value so callers can merge it into a preset's own
|
||||
``registered_commands`` tracking (#2948).
|
||||
"""
|
||||
registrar.register_commands_for_non_skill_agents(
|
||||
return registrar.register_commands_for_non_skill_agents(
|
||||
commands, source_id, source_dir, self.project_root,
|
||||
only_agent=only_agent, extra_agents=extra_agents,
|
||||
)
|
||||
@@ -1246,6 +1325,59 @@ class PresetManager:
|
||||
if t.get("name") in self._cmd_names
|
||||
]
|
||||
|
||||
def _merge_pack_registered_skills(
|
||||
self, pack_id: str, written: Optional[Dict[str, List[str]]]
|
||||
) -> None:
|
||||
"""Merge actually-written agent skill registrations into a preset's metadata.
|
||||
|
||||
Mirrors :meth:`_merge_pack_registered_commands` for the skills
|
||||
side: ``_reconcile_skills`` can render a preset's SKILL.md content
|
||||
into an agent directory the preset never wrote to before — most
|
||||
notably a historical (currently inactive) agent restored via
|
||||
``extra_skills_dirs`` when a higher-priority preset is removed. If
|
||||
that write isn't reflected back into the winning preset's own
|
||||
``registered_skills``, a later removal of this same preset only
|
||||
cleans up the agents it already knew about, orphaning the skill
|
||||
directory reconciliation just wrote to on its behalf (#2948).
|
||||
|
||||
Args:
|
||||
pack_id: The preset whose metadata should be updated.
|
||||
written: ``{agent_name: [skill_name, ...]}`` actually written
|
||||
by the ``_register_skills`` call just made. A falsy value
|
||||
is a no-op.
|
||||
"""
|
||||
if not written:
|
||||
return
|
||||
metadata = self.registry.get(pack_id)
|
||||
if metadata is None:
|
||||
return # pack_id no longer installed (e.g. removed mid-loop)
|
||||
raw_existing_skills = metadata.get("registered_skills")
|
||||
if isinstance(raw_existing_skills, list) and raw_existing_skills:
|
||||
# Legacy flat-list value: infer real per-agent ownership from
|
||||
# on-disk provenance rather than guessing (#2948).
|
||||
fallback_agent = next(iter(written)) if written else None
|
||||
existing_skills = self._infer_legacy_skill_provenance(
|
||||
[n for n in raw_existing_skills if isinstance(n, str)],
|
||||
pack_id,
|
||||
fallback_agent=fallback_agent,
|
||||
)
|
||||
else:
|
||||
existing_skills = self._normalize_registered_skills(raw_existing_skills)
|
||||
merged_skills = copy.deepcopy(existing_skills)
|
||||
changed = (
|
||||
isinstance(raw_existing_skills, list) and bool(raw_existing_skills)
|
||||
)
|
||||
for agent_name, skill_names in written.items():
|
||||
if not skill_names:
|
||||
continue
|
||||
existing_names = merged_skills.get(agent_name, [])
|
||||
new_names = [n for n in skill_names if n not in existing_names]
|
||||
if new_names:
|
||||
merged_skills[agent_name] = existing_names + new_names
|
||||
changed = True
|
||||
if changed:
|
||||
self.registry.update(pack_id, {"registered_skills": merged_skills})
|
||||
|
||||
def _reconcile_skills(
|
||||
self,
|
||||
command_names: List[str],
|
||||
@@ -1426,12 +1558,21 @@ class PresetManager:
|
||||
# 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)
|
||||
written = self._register_skills(filtered_manifest, pack_dir)
|
||||
else:
|
||||
self._register_skills(
|
||||
written = self._register_skills(
|
||||
filtered_manifest, pack_dir,
|
||||
target_dir=skills_dir, target_agent=dir_agent or "",
|
||||
)
|
||||
# The winning preset may not have previously written to
|
||||
# this directory's agent (most notably a historical agent
|
||||
# reconciliation just restored content into via
|
||||
# extra_skills_dirs). If that write isn't merged back into
|
||||
# the preset's own registered_skills, its registry entry
|
||||
# silently lies about which directories it owns and a
|
||||
# later removal of this same preset orphans the directory
|
||||
# reconciliation just wrote to on its behalf (#2948).
|
||||
self._merge_pack_registered_skills(pack_id, written)
|
||||
|
||||
if active_skills_dir:
|
||||
apply_to_dir(active_skills_dir, active_ai, is_active=True)
|
||||
@@ -1802,11 +1943,18 @@ class PresetManager:
|
||||
dir_to_agents.setdefault(skills_dir, []).append(agent_name)
|
||||
|
||||
marker = f"preset:{pack_id}"
|
||||
# Filter unsafe names once, up front, rather than only inside the
|
||||
# matching loop: any name skipped there would otherwise still
|
||||
# land in "unmatched" below and get blindly attributed to
|
||||
# fallback_agent anyway, defeating the guard entirely (#2948).
|
||||
safe_skill_names = [
|
||||
name for name in skill_names if self._is_safe_registry_skill_name(name)
|
||||
]
|
||||
inferred: Dict[str, List[str]] = {}
|
||||
matched_names: set = set()
|
||||
for resolved_dir, agents in dir_to_agents.items():
|
||||
canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0]
|
||||
for name in skill_names:
|
||||
for name in safe_skill_names:
|
||||
skill_subdir = resolved_dir / name
|
||||
if not self._validate_skill_subdir(skill_subdir, create=False):
|
||||
continue
|
||||
@@ -1828,7 +1976,7 @@ class PresetManager:
|
||||
inferred.setdefault(canonical_agent, []).append(name)
|
||||
matched_names.add(name)
|
||||
|
||||
unmatched = [name for name in skill_names if name not in matched_names]
|
||||
unmatched = [name for name in safe_skill_names if name not in matched_names]
|
||||
if unmatched and fallback_agent:
|
||||
fallback_names = inferred.setdefault(fallback_agent, [])
|
||||
for name in unmatched:
|
||||
@@ -1893,6 +2041,36 @@ class PresetManager:
|
||||
return None
|
||||
return skills_dir
|
||||
|
||||
@staticmethod
|
||||
def _is_safe_registry_skill_name(name: Any) -> bool:
|
||||
"""Validate a registry-provided skill name is a single safe path component.
|
||||
|
||||
``registered_skills`` entries are persisted registry data, not
|
||||
derived from the current preset manifest, so a corrupted or
|
||||
maliciously edited registry could contain an absolute path, a
|
||||
multi-segment path (containing ``/`` or ``\\``), or a traversal
|
||||
component (``"."``/``".."``) instead of a plain skill directory
|
||||
name. Any of these — if joined directly onto a skills directory —
|
||||
can escape the intended skill subtree while still resolving to a
|
||||
location inside the project root, which is enough to pass the
|
||||
parent-directory containment/symlink check alone (#2948). This
|
||||
centralizes the single boundary check every preset cleanup and
|
||||
provenance loop that consumes registry-provided skill names must
|
||||
apply before ever constructing a path from one.
|
||||
"""
|
||||
if not isinstance(name, str) or not name:
|
||||
return False
|
||||
if name in (".", ".."):
|
||||
return False
|
||||
candidate = Path(name)
|
||||
if candidate.is_absolute():
|
||||
return False
|
||||
if len(candidate.parts) != 1:
|
||||
return False
|
||||
if candidate.name != name:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _validate_skill_subdir(self, skill_subdir: Path, *, create: bool) -> bool:
|
||||
"""Validate a single skill's subdirectory is symlink-free.
|
||||
|
||||
@@ -2046,6 +2224,20 @@ class PresetManager:
|
||||
extension_restore_index = self._build_extension_skill_restore_index()
|
||||
|
||||
for skill_name in skill_names:
|
||||
# Guard against a corrupted/malicious registry entry: a
|
||||
# registered_skills name is persisted data, not derived from
|
||||
# the current manifest, so it must be validated as a single,
|
||||
# relative, non-"."/".." path component before ever being
|
||||
# joined onto skills_dir. Without this, an absolute name
|
||||
# discards skills_dir entirely (Path's "/" operator drops the
|
||||
# left side for an absolute right side) or a multi-component
|
||||
# name containing ".." can resolve to a different, unrelated
|
||||
# directory that still happens to be inside the project root
|
||||
# — passing the containment-only symlink guard below and
|
||||
# letting removal overwrite/delete it (#2948).
|
||||
if not self._is_safe_registry_skill_name(skill_name):
|
||||
continue
|
||||
|
||||
# Derive command name from skill name (speckit-specify -> specify)
|
||||
short_name = skill_name
|
||||
if short_name.startswith("speckit-"):
|
||||
|
||||
@@ -1310,6 +1310,62 @@ class TestExtensionSkillRegistration:
|
||||
skill_file = skills_dir / "speckit-toggle-ext-hello" / "SKILL.md"
|
||||
assert skill_file.exists(), "sanity: skills mode should write SKILL.md"
|
||||
|
||||
def test_toggle_command_to_skills_preserves_old_extension_command_on_skills_failure(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""An extension command->skills toggle must not destroy the old
|
||||
command artifact before the new skill registration has actually
|
||||
succeeded.
|
||||
|
||||
Mirrors the analogous preset-side fix
|
||||
(``test_toggle_command_to_skills_preserves_old_command_on_skills_failure``
|
||||
in ``tests/test_presets.py``): before the fix, the stale
|
||||
command-mode file/tracking was unregistered unconditionally as soon
|
||||
as the commands phase was skipped for ``skills_mode_active``,
|
||||
regardless of whether the subsequent, independently-fallible
|
||||
``_register_extension_skills()`` call actually succeeded. If skills
|
||||
raises, the exception handler just warns and continues, leaving
|
||||
neither the old command file nor a new skill file (#2948).
|
||||
"""
|
||||
_create_init_options(project_dir, ai="copilot", ai_skills=False)
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(
|
||||
_create_extension_dir(temp_dir, ext_id="toggle-fail-ext"), "0.1.0",
|
||||
register_commands=False,
|
||||
)
|
||||
manager.register_enabled_extensions_for_agent("copilot")
|
||||
|
||||
agents_dir = project_dir / ".github" / "agents"
|
||||
cmd_file = agents_dir / "speckit.toggle-fail-ext.hello.agent.md"
|
||||
assert cmd_file.exists(), "sanity: command mode should write .agent.md"
|
||||
metadata = manager.registry.get("toggle-fail-ext")
|
||||
assert metadata.get("registered_commands", {}).get("copilot"), (
|
||||
"sanity: the command-mode write should be tracked for copilot"
|
||||
)
|
||||
|
||||
# Toggle ai_skills on for the same active agent (copilot), but with
|
||||
# skills registration injected to fail.
|
||||
_create_init_options(project_dir, ai="copilot", ai_skills=True)
|
||||
|
||||
def _raise_register_extension_skills(*args, **kwargs):
|
||||
raise OSError("simulated extension skills-phase failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
manager, "_register_extension_skills", _raise_register_extension_skills
|
||||
)
|
||||
manager.register_enabled_extensions_for_agent("copilot")
|
||||
|
||||
assert cmd_file.exists(), (
|
||||
"the old command-mode artifact must survive when the "
|
||||
"replacement skills registration fails — deleting it before "
|
||||
"the new artifact is confirmed leaves neither in place (#2948)"
|
||||
)
|
||||
metadata = manager.registry.get("toggle-fail-ext")
|
||||
assert metadata.get("registered_commands", {}).get("copilot"), (
|
||||
"registered_commands must keep tracking copilot's still-live "
|
||||
"command file when the skills replacement failed (#2948)"
|
||||
)
|
||||
|
||||
def test_rescaffold_toggle_skills_to_command_removes_stale_extension_skill_file(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
|
||||
@@ -4425,6 +4425,67 @@ class TestPresetSkills:
|
||||
"sanity: the new skills-mode artifact should still be written"
|
||||
)
|
||||
|
||||
def test_toggle_command_to_skills_preserves_old_command_on_skills_failure(
|
||||
self, project_dir, temp_dir, monkeypatch
|
||||
):
|
||||
"""A command->skills toggle must not destroy the old command
|
||||
artifact before the new skill registration has actually succeeded.
|
||||
|
||||
Before the fix, the stale command-mode file/tracking was
|
||||
unregistered unconditionally as soon as ``_register_commands``'s
|
||||
``ai_skills`` guard made the commands phase a no-op — regardless
|
||||
of whether the subsequent, independently-fallible
|
||||
``_register_skills()`` call actually succeeded. If skills raises
|
||||
(e.g. a transient I/O error), the per-preset exception handler
|
||||
just logs and continues, leaving neither the old command file
|
||||
nor a new skill file — the preset's command override vanishes
|
||||
entirely from copilot until the next successful rescaffold
|
||||
(#2948).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="copilot", ai_skills=False)
|
||||
copilot_commands_dir = project_dir / ".github" / "agents"
|
||||
copilot_commands_dir.mkdir(parents=True)
|
||||
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir, "toggle-failure-preset", "speckit.specify",
|
||||
"Toggle failure test", "preset body",
|
||||
)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
cmd_file = copilot_commands_dir / "speckit.specify.agent.md"
|
||||
assert cmd_file.exists(), (
|
||||
"sanity: command mode should have written copilot's command file"
|
||||
)
|
||||
metadata = manager.registry.get("toggle-failure-preset")
|
||||
assert metadata["registered_commands"].get("copilot"), (
|
||||
"sanity: the command-mode write should be tracked for copilot"
|
||||
)
|
||||
|
||||
# Flip ai_skills on for the *same* active agent and rescaffold, as
|
||||
# `integration upgrade copilot` would, but with skills registration
|
||||
# injected to fail.
|
||||
self._write_init_options(project_dir, ai="copilot", ai_skills=True)
|
||||
|
||||
def _raise_register_skills(*args, **kwargs):
|
||||
raise OSError("simulated skills-phase failure")
|
||||
|
||||
monkeypatch.setattr(manager, "_register_skills", _raise_register_skills)
|
||||
manager.register_enabled_presets_for_agent("copilot")
|
||||
|
||||
assert cmd_file.exists(), (
|
||||
"the old command-mode artifact must survive when the "
|
||||
"replacement skills registration fails — deleting it before "
|
||||
"the new artifact is confirmed leaves neither in place (#2948)"
|
||||
)
|
||||
metadata = manager.registry.get("toggle-failure-preset")
|
||||
assert metadata["registered_commands"].get("copilot"), (
|
||||
"registered_commands must keep tracking copilot's still-live "
|
||||
"command file when the skills replacement failed, or a later "
|
||||
"removal/rescaffold will believe there is nothing to clean up "
|
||||
"even though the file is still on disk (#2948)"
|
||||
)
|
||||
|
||||
def test_rescaffold_toggle_skills_to_command_removes_stale_skill_file(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
@@ -5368,6 +5429,95 @@ class TestPresetSkills:
|
||||
"currently active agent"
|
||||
)
|
||||
|
||||
def test_remove_reconciliation_tracks_new_historical_agent_for_survivor(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Historical-agent reconciliation writes must be recorded in the
|
||||
surviving preset's own ``registered_commands``, not just written
|
||||
to disk and forgotten.
|
||||
|
||||
Preset A is installed while gemini is active, then survives to be
|
||||
active under opencode too (so A's ``registered_commands`` spans
|
||||
both gemini and opencode). Preset B is installed *only* while
|
||||
opencode is active — B's ``registered_commands`` is
|
||||
``{"opencode": [...]}`` and never mentions gemini. Removing A
|
||||
triggers reconciliation that writes B's content into gemini's
|
||||
directory (an agent B never wrote to before) via ``extra_agents``,
|
||||
but if that write isn't merged back into B's own
|
||||
``registered_commands``, B's registry entry still only says
|
||||
``{"opencode": [...]}`` even though B's content now lives in
|
||||
gemini's directory too. A later ``remove('b')`` then only cleans
|
||||
up opencode, leaving the gemini file — reconciled there entirely
|
||||
by side effect of removing A — as a permanent orphan with no
|
||||
preset tracking it (#2948).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="gemini", ai_skills=False)
|
||||
gemini_dir = project_dir / ".gemini" / "commands"
|
||||
gemini_dir.mkdir(parents=True)
|
||||
|
||||
preset_a_dir = self._create_command_preset(
|
||||
temp_dir, "orphan-preset-a", "speckit.specify",
|
||||
"Preset A", "preset A body",
|
||||
)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
||||
|
||||
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("orphan-preset-a")
|
||||
assert set(metadata_a.get("registered_commands", {})) == {"gemini", "opencode"}, (
|
||||
"sanity: preset A must be tracked under both agents"
|
||||
)
|
||||
|
||||
# Preset B is installed only now, while opencode is the sole
|
||||
# active agent — it never writes to or tracks gemini.
|
||||
preset_b_dir = self._create_command_preset(
|
||||
temp_dir, "orphan-preset-b", "speckit.specify",
|
||||
"Preset B", "preset B body",
|
||||
)
|
||||
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
||||
|
||||
metadata_b = manager.registry.get("orphan-preset-b")
|
||||
assert set(metadata_b.get("registered_commands", {})) == {"opencode"}, (
|
||||
"sanity: preset B must only be tracked for opencode before "
|
||||
"preset A is removed"
|
||||
)
|
||||
|
||||
assert manager.remove("orphan-preset-a") is True
|
||||
|
||||
# B is now written into gemini's directory as a side effect of
|
||||
# reconciling A's removal, via the historical-agent extra_agents
|
||||
# pass.
|
||||
gemini_cmd_files = list(gemini_dir.glob("*specify*"))
|
||||
assert gemini_cmd_files, "sanity: gemini's directory must have B's restored content"
|
||||
assert "preset B body" in gemini_cmd_files[0].read_text()
|
||||
|
||||
metadata_b = manager.registry.get("orphan-preset-b")
|
||||
assert set(metadata_b.get("registered_commands", {})) == {"gemini", "opencode"}, (
|
||||
"preset B's own registered_commands must be updated to "
|
||||
"include gemini once reconciliation actually writes content "
|
||||
"there on its behalf — otherwise B's registry entry silently "
|
||||
"lies about which directories it owns (#2948)"
|
||||
)
|
||||
|
||||
assert manager.remove("orphan-preset-b") is True
|
||||
|
||||
# No preset is installed any more, so gemini's file must have been
|
||||
# reconciled down to the core bundled template (or removed
|
||||
# entirely) — but it must NOT still contain B's stale content,
|
||||
# which would mean B's write there was never tracked for cleanup.
|
||||
remaining_gemini_files = list(gemini_dir.glob("*specify*"))
|
||||
for f in remaining_gemini_files:
|
||||
assert "preset B body" not in f.read_text(), (
|
||||
"removing preset B must clean up gemini's directory too, "
|
||||
"since B's registered_commands was updated to include it "
|
||||
"— otherwise B's stale content is orphaned there forever "
|
||||
"with no preset left to track or clean it up (#2948)"
|
||||
)
|
||||
|
||||
def test_remove_reconciles_skill_for_every_historical_agent(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
@@ -5446,6 +5596,107 @@ class TestPresetSkills:
|
||||
"the currently active agent"
|
||||
)
|
||||
|
||||
def test_remove_reconciliation_tracks_new_historical_skill_agent_for_survivor(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""Historical-agent skill reconciliation writes must be recorded in
|
||||
the surviving preset's own ``registered_skills``, mirroring
|
||||
``test_remove_reconciliation_tracks_new_historical_agent_for_survivor``
|
||||
for the command side.
|
||||
|
||||
Preset A is installed while claude is active, then survives to be
|
||||
active under codex too (so A's ``registered_skills`` spans both
|
||||
claude and codex). Preset B is installed *only* while codex is
|
||||
active — B's ``registered_skills`` is ``{"codex": [...]}`` and
|
||||
never mentions claude. Removing A triggers reconciliation that
|
||||
renders B's SKILL.md into claude's directory (an agent B never
|
||||
wrote to before) via ``extra_skills_dirs``, but if that write
|
||||
isn't merged back into B's own ``registered_skills``, a later
|
||||
``remove('b')`` only cleans up codex, leaving claude's SKILL.md —
|
||||
rendered there entirely by side effect of removing A — untracked
|
||||
by any preset (#2948).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
claude_skills_dir = project_dir / ".claude" / "skills"
|
||||
# Pre-create the skill so _register_commands/_register_skills find
|
||||
# an existing skill to overwrite (mirrors every other skill test
|
||||
# in this class — native skill agents only overwrite already
|
||||
# existing skill directories, they don't materialize brand-new
|
||||
# ones outside of active-agent creation).
|
||||
self._create_skill(claude_skills_dir, "speckit-specify")
|
||||
|
||||
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_a_dir = self._create_command_preset(
|
||||
temp_dir, "orphan-skill-preset-a", "speckit.specify",
|
||||
"Preset A", "preset A body",
|
||||
)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_a_dir, "0.1.5", priority=1)
|
||||
|
||||
self._write_init_options(project_dir, ai="codex", ai_skills=True)
|
||||
codex_skills_dir = project_dir / ".agents" / "skills"
|
||||
self._create_skill(codex_skills_dir, "speckit-specify")
|
||||
manager.register_enabled_presets_for_agent("codex")
|
||||
|
||||
metadata_a = manager.registry.get("orphan-skill-preset-a")
|
||||
assert set(metadata_a.get("registered_skills", {})) == {"claude", "codex"}, (
|
||||
"sanity: preset A must be tracked under both agents"
|
||||
)
|
||||
|
||||
# Preset B is installed only now, while codex is the sole active
|
||||
# agent — it never writes to or tracks claude.
|
||||
preset_b_dir = self._create_command_preset(
|
||||
temp_dir, "orphan-skill-preset-b", "speckit.specify",
|
||||
"Preset B", "preset B body",
|
||||
)
|
||||
manager.install_from_directory(preset_b_dir, "0.1.5", priority=10)
|
||||
|
||||
metadata_b = manager.registry.get("orphan-skill-preset-b")
|
||||
assert set(metadata_b.get("registered_skills", {})) == {"codex"}, (
|
||||
"sanity: preset B must only be tracked for codex before "
|
||||
"preset A is removed"
|
||||
)
|
||||
|
||||
assert manager.remove("orphan-skill-preset-a") is True
|
||||
|
||||
claude_skill_file = claude_skills_dir / "speckit-specify" / "SKILL.md"
|
||||
assert claude_skill_file.exists(), (
|
||||
"sanity: claude's skill file must have been restored by "
|
||||
"reconciliation"
|
||||
)
|
||||
assert "preset:orphan-skill-preset-b" in claude_skill_file.read_text(), (
|
||||
"sanity: claude's SKILL.md must reflect preset B's content "
|
||||
"after preset A is removed"
|
||||
)
|
||||
|
||||
metadata_b = manager.registry.get("orphan-skill-preset-b")
|
||||
assert set(metadata_b.get("registered_skills", {})) == {"claude", "codex"}, (
|
||||
"preset B's own registered_skills must be updated to include "
|
||||
"claude once reconciliation actually renders content there "
|
||||
"on its behalf — otherwise B's registry entry silently lies "
|
||||
"about which directories it owns (#2948)"
|
||||
)
|
||||
|
||||
assert manager.remove("orphan-skill-preset-b") is True
|
||||
|
||||
# No preset is installed any more, so claude's SKILL.md must have
|
||||
# been reconciled down to the core bundled template (or removed
|
||||
# entirely) — but it must NOT still contain B's stale content,
|
||||
# which would mean B's write there was never tracked for cleanup.
|
||||
if claude_skill_file.exists():
|
||||
assert "preset:orphan-skill-preset-b" not in claude_skill_file.read_text(), (
|
||||
"removing preset B must clean up claude's directory too, "
|
||||
"since B's registered_skills was updated to include it — "
|
||||
"otherwise B's stale content is orphaned there forever "
|
||||
"with no preset left to track or clean it up (#2948)"
|
||||
)
|
||||
|
||||
def test_symlinked_skill_subdir_rejected_on_restore(self, project_dir, temp_dir):
|
||||
"""Restore must validate each per-skill subdirectory, not just its parent.
|
||||
|
||||
@@ -5518,6 +5769,107 @@ class TestPresetSkills:
|
||||
"the symlink itself should be left alone"
|
||||
)
|
||||
|
||||
def test_is_safe_registry_skill_name_rejects_unsafe_values(self, project_dir):
|
||||
"""Unit-test the centralized registry skill-name boundary guard.
|
||||
|
||||
``registered_skills`` entries are persisted registry data, not
|
||||
manifest-derived, so every preset cleanup/provenance loop that
|
||||
joins one onto a directory must first reject: non-strings, empty
|
||||
strings, absolute paths, multi-component paths (containing ``/``),
|
||||
and the literal traversal components ``"."``/``".."`` — the last
|
||||
of which is *not* caught by a naive ``is_absolute() or
|
||||
len(parts) != 1`` check alone, since ``Path("..").parts`` is a
|
||||
single-element tuple (#2948).
|
||||
"""
|
||||
manager = PresetManager(project_dir)
|
||||
is_safe = manager._is_safe_registry_skill_name
|
||||
|
||||
assert is_safe("speckit-specify") is True
|
||||
assert is_safe("") is False
|
||||
assert is_safe(None) is False
|
||||
assert is_safe(123) is False
|
||||
assert is_safe(["speckit-specify"]) is False
|
||||
assert is_safe(".") is False
|
||||
assert is_safe("..") is False
|
||||
assert is_safe("/etc/passwd") is False
|
||||
assert is_safe(str(project_dir / "important-data")) is False
|
||||
assert is_safe("foo/bar") is False
|
||||
assert is_safe("foo/..") is False
|
||||
assert is_safe("../foo") is False
|
||||
|
||||
def test_unregister_skills_in_dir_rejects_absolute_registry_name(
|
||||
self, project_dir
|
||||
):
|
||||
"""A corrupted ``registered_skills`` entry with an absolute path must not escape.
|
||||
|
||||
``Path`` join with an absolute right-hand operand discards the
|
||||
left side entirely (``skills_dir / "/abs/path"`` == ``"/abs/path"``),
|
||||
so an absolute in-project path stored in the registry would bypass
|
||||
``skills_dir`` altogether if not rejected before the join (#2948).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
claude_skills_dir = project_dir / ".claude" / "skills"
|
||||
claude_skills_dir.mkdir(parents=True)
|
||||
|
||||
precious_dir = project_dir / "important-data"
|
||||
precious_dir.mkdir()
|
||||
precious_file = precious_dir / "SKILL.md"
|
||||
precious_file.write_text("precious-absolute-target-marker")
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
manager._unregister_skills_in_dir(
|
||||
[str(precious_dir)], claude_skills_dir, "claude"
|
||||
)
|
||||
|
||||
assert precious_dir.is_dir(), (
|
||||
"an absolute registry entry must not let cleanup escape "
|
||||
"skills_dir to an unrelated project directory (#2948)"
|
||||
)
|
||||
assert precious_file.read_text() == "precious-absolute-target-marker"
|
||||
|
||||
def test_infer_legacy_skill_provenance_rejects_absolute_registry_name(
|
||||
self, project_dir
|
||||
):
|
||||
"""Legacy provenance inference must reject an absolute registry name.
|
||||
|
||||
``_infer_legacy_skill_provenance`` receives its ``skill_names``
|
||||
directly from a legacy flat-list ``registered_skills`` value —
|
||||
registry data, not manifest-derived — and joins each name onto a
|
||||
candidate agent's resolved skills directory the same way
|
||||
``_unregister_skills_in_dir`` does. An absolute in-project name
|
||||
discards the candidate directory entirely (Python's ``/`` operator
|
||||
drops the left side for an absolute right side), so it can read
|
||||
an unrelated project directory's ``SKILL.md`` and, if its
|
||||
frontmatter happens to carry a matching preset source marker,
|
||||
falsely attribute an unrelated directory as this preset's own
|
||||
skill override under whichever agent is being probed (#2948).
|
||||
"""
|
||||
self._write_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
claude_skills_dir = project_dir / ".claude" / "skills"
|
||||
claude_skills_dir.mkdir(parents=True)
|
||||
|
||||
precious_dir = project_dir / "important-data"
|
||||
precious_dir.mkdir()
|
||||
(precious_dir / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"metadata:\n"
|
||||
" source: preset:some-pack\n"
|
||||
"---\n\n"
|
||||
"# Unrelated directory, not a real preset skill\n"
|
||||
)
|
||||
|
||||
manager = PresetManager(project_dir)
|
||||
inferred = manager._infer_legacy_skill_provenance(
|
||||
[str(precious_dir)], "some-pack", "claude"
|
||||
)
|
||||
|
||||
for names in inferred.values():
|
||||
assert str(precious_dir) not in names, (
|
||||
"an absolute registry entry must not be falsely attributed "
|
||||
"as preset-owned provenance by probing outside the "
|
||||
"intended skills subtree (#2948)"
|
||||
)
|
||||
|
||||
def test_copilot_skills_registration_restored_after_process_restart(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user