diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index c20160c167..ef74c3b86d 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -1185,17 +1185,31 @@ def _get_installed_command_name_map( return installed_names def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None: - """Reject installs that would shadow core or installed extension commands.""" + """Reject installs that would shadow core or installed extension commands. + + Primary command names are already namespace-checked against + ``CORE_COMMAND_NAMES`` in ``_collect_manifest_command_names``, but + aliases are intentionally free-form (see the comment there) and so + can only be caught here, by comparing declared names directly + against the fully-qualified core command names (``speckit.``) + rather than relying on ``_get_installed_command_name_map``, which + only knows about installed extensions. + """ declared_names = self._collect_manifest_command_names(manifest) installed_names = self._get_installed_command_name_map( exclude_extension_id=manifest.id ) + core_command_names = {f"speckit.{name}" for name in CORE_COMMAND_NAMES} + + collisions = [] + for name in sorted(declared_names): + if name in installed_names: + collisions.append( + f"{name} (already provided by extension '{installed_names[name]}')" + ) + elif name in core_command_names: + collisions.append(f"{name} (conflicts with core command)") - collisions = [ - f"{name} (already provided by extension '{installed_names[name]}')" - for name in sorted(declared_names) - if name in installed_names - ] if collisions: raise ValidationError( "Extension commands conflict with installed extensions:\n- " diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 800f5ce00e..b825039b85 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -3126,6 +3126,48 @@ def test_install_rejects_command_collision_with_installed_extension(self, temp_d with pytest.raises(ValidationError, match="already provided by extension 'ext-one'"): manager.install_from_directory(second_dir, "0.1.0", register_commands=False) + def test_install_rejects_alias_shadowing_core_command(self, temp_dir, project_dir): + """An alias equal to a core command's qualified name must not install. + + Regression test for #4555: a primary name is namespace-checked + against CORE_COMMAND_NAMES, but aliases are intentionally free-form + and previously went unchecked against core commands entirely, so an + extension could claim e.g. 'speckit.taskstoissues' as an alias and + shadow the core command of the same name. + """ + import yaml + + ext_dir = temp_dir / "probe-ext" + ext_dir.mkdir() + (ext_dir / "commands").mkdir() + + manifest_data = { + "schema_version": "1.0", + "extension": { + "id": "probe", + "name": "Probe", + "version": "1.0.0", + "description": "Test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + { + "name": "speckit.probe.taskstoissues", + "file": "commands/cmd.md", + "aliases": ["speckit.taskstoissues"], + } + ] + }, + } + + (ext_dir / "extension.yml").write_text(yaml.dump(manifest_data)) + (ext_dir / "commands" / "cmd.md").write_text("---\ndescription: Test\n---\n\nBody") + + manager = ExtensionManager(project_dir) + with pytest.raises(ValidationError, match="conflicts with core command"): + manager.install_from_directory(ext_dir, "0.1.0", register_commands=False) + def test_remove_extension(self, extension_dir, project_dir): """Test removing an installed extension.""" manager = ExtensionManager(project_dir)