Skip to content

Commit 796b4f4

Browse files
authored
fix: prevent extension command shadowing (#1994)
* fix: prevent extension command shadowing * Validate extension command namespaces * Reuse extension command name pattern
1 parent 6b1f45c commit 796b4f4

File tree

7 files changed

+403
-59
lines changed

7 files changed

+403
-59
lines changed

extensions/EXTENSION-API-REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ provides:
4444
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
4545
file: string # Required, relative path to command file
4646
description: string # Required
47-
aliases: [string] # Optional, array of alternate names
47+
aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands
4848

4949
config: # Optional, array of config files
5050
- name: string # Config file name

extensions/EXTENSION-DEVELOPMENT-GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ provides:
4141
- name: "speckit.my-ext.hello" # Must follow pattern: speckit.{ext-id}.{cmd}
4242
file: "commands/hello.md"
4343
description: "Say hello"
44-
aliases: ["speckit.hello"] # Optional aliases
44+
aliases: ["speckit.my-ext.hi"] # Optional aliases, same pattern
4545

4646
config: # Optional: Config files
4747
- name: "my-ext-config.yml"
@@ -186,7 +186,7 @@ What the extension provides.
186186
- `name`: Command name (must match `speckit.{ext-id}.{command}`)
187187
- `file`: Path to command file (relative to extension root)
188188
- `description`: Command description (optional)
189-
- `aliases`: Alternative command names (optional, array)
189+
- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`)
190190

191191
### Optional Fields
192192

extensions/EXTENSION-USER-GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,8 @@ Extensions add commands that appear in your AI agent (Claude Code):
214214
# In Claude Code
215215
> /speckit.jira.specstoissues
216216
217-
# Or use short alias (if provided)
218-
> /speckit.specstoissues
217+
# Or use a namespaced alias (if provided)
218+
> /speckit.jira.sync
219219
```
220220

221221
### Extension Configuration

extensions/RFC-EXTENSION-SYSTEM.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ provides:
223223
- name: "speckit.jira.specstoissues"
224224
file: "commands/specstoissues.md"
225225
description: "Create Jira hierarchy from spec and tasks"
226-
aliases: ["speckit.specstoissues"] # Alternate names
226+
aliases: ["speckit.jira.sync"] # Alternate names
227227

228228
- name: "speckit.jira.discover-fields"
229229
file: "commands/discover-fields.md"
@@ -1517,18 +1517,18 @@ specify extension add github-projects
15171517
/speckit.github.taskstoissues
15181518
```
15191519

1520-
**Compatibility shim** (if needed):
1520+
**Migration alias** (if needed):
15211521

15221522
```yaml
15231523
# extension.yml
15241524
provides:
15251525
commands:
15261526
- name: "speckit.github.taskstoissues"
15271527
file: "commands/taskstoissues.md"
1528-
aliases: ["speckit.taskstoissues"] # Backward compatibility
1528+
aliases: ["speckit.github.sync-taskstoissues"] # Alternate namespaced entry point
15291529
```
15301530

1531-
AI agent registers both names, so old scripts work.
1531+
AI agents register both names, so callers can migrate to the alternate alias without relying on deprecated global shortcuts like `/speckit.taskstoissues`.
15321532

15331533
---
15341534

extensions/template/extension.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ provides:
4747
- name: "speckit.my-extension.example"
4848
file: "commands/example.md"
4949
description: "Example command that demonstrates functionality"
50-
# Optional: Add aliases for shorter command names
51-
aliases: ["speckit.example"]
50+
# Optional: Add aliases in the same namespaced format
51+
aliases: ["speckit.my-extension.example-short"]
5252

5353
# ADD MORE COMMANDS: Copy this block for each command
5454
# - name: "speckit.my-extension.another-command"

src/specify_cli/extensions.py

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,49 @@
2525
from packaging import version as pkg_version
2626
from packaging.specifiers import SpecifierSet, InvalidSpecifier
2727

28+
_FALLBACK_CORE_COMMAND_NAMES = frozenset({
29+
"analyze",
30+
"checklist",
31+
"clarify",
32+
"constitution",
33+
"implement",
34+
"plan",
35+
"specify",
36+
"tasks",
37+
"taskstoissues",
38+
})
39+
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
40+
41+
42+
def _load_core_command_names() -> frozenset[str]:
43+
"""Discover bundled core command names from the packaged templates.
44+
45+
Prefer the wheel-time ``core_pack`` bundle when present, and fall back to
46+
the source checkout when running from the repository. If neither is
47+
available, use the baked-in fallback set so validation still works.
48+
"""
49+
candidate_dirs = [
50+
Path(__file__).parent / "core_pack" / "commands",
51+
Path(__file__).resolve().parent.parent.parent / "templates" / "commands",
52+
]
53+
54+
for commands_dir in candidate_dirs:
55+
if not commands_dir.is_dir():
56+
continue
57+
58+
command_names = {
59+
command_file.stem
60+
for command_file in commands_dir.iterdir()
61+
if command_file.is_file() and command_file.suffix == ".md"
62+
}
63+
if command_names:
64+
return frozenset(command_names)
65+
66+
return _FALLBACK_CORE_COMMAND_NAMES
67+
68+
69+
CORE_COMMAND_NAMES = _load_core_command_names()
70+
2871

2972
class ExtensionError(Exception):
3073
"""Base exception for extension-related errors."""
@@ -149,7 +192,7 @@ def _validate(self):
149192
raise ValidationError("Command missing 'name' or 'file'")
150193

151194
# Validate command name format
152-
if not re.match(r'^speckit\.[a-z0-9-]+\.[a-z0-9-]+$', cmd["name"]):
195+
if EXTENSION_COMMAND_NAME_PATTERN.match(cmd["name"]) is None:
153196
raise ValidationError(
154197
f"Invalid command name '{cmd['name']}': "
155198
"must follow pattern 'speckit.{extension}.{command}'"
@@ -446,6 +489,126 @@ def __init__(self, project_root: Path):
446489
self.extensions_dir = project_root / ".specify" / "extensions"
447490
self.registry = ExtensionRegistry(self.extensions_dir)
448491

492+
@staticmethod
493+
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
494+
"""Collect command and alias names declared by a manifest.
495+
496+
Performs install-time validation for extension-specific constraints:
497+
- commands and aliases must use the canonical `speckit.{extension}.{command}` shape
498+
- commands and aliases must use this extension's namespace
499+
- command namespaces must not shadow core commands
500+
- duplicate command/alias names inside one manifest are rejected
501+
502+
Args:
503+
manifest: Parsed extension manifest
504+
505+
Returns:
506+
Mapping of declared command/alias name -> kind ("command"/"alias")
507+
508+
Raises:
509+
ValidationError: If any declared name is invalid
510+
"""
511+
if manifest.id in CORE_COMMAND_NAMES:
512+
raise ValidationError(
513+
f"Extension ID '{manifest.id}' conflicts with core command namespace '{manifest.id}'"
514+
)
515+
516+
declared_names: Dict[str, str] = {}
517+
518+
for cmd in manifest.commands:
519+
primary_name = cmd["name"]
520+
aliases = cmd.get("aliases", [])
521+
522+
if aliases is None:
523+
aliases = []
524+
if not isinstance(aliases, list):
525+
raise ValidationError(
526+
f"Aliases for command '{primary_name}' must be a list"
527+
)
528+
529+
for kind, name in [("command", primary_name)] + [
530+
("alias", alias) for alias in aliases
531+
]:
532+
if not isinstance(name, str):
533+
raise ValidationError(
534+
f"{kind.capitalize()} for command '{primary_name}' must be a string"
535+
)
536+
537+
match = EXTENSION_COMMAND_NAME_PATTERN.match(name)
538+
if match is None:
539+
raise ValidationError(
540+
f"Invalid {kind} '{name}': "
541+
"must follow pattern 'speckit.{extension}.{command}'"
542+
)
543+
544+
namespace = match.group(1)
545+
if namespace != manifest.id:
546+
raise ValidationError(
547+
f"{kind.capitalize()} '{name}' must use extension namespace '{manifest.id}'"
548+
)
549+
550+
if namespace in CORE_COMMAND_NAMES:
551+
raise ValidationError(
552+
f"{kind.capitalize()} '{name}' conflicts with core command namespace '{namespace}'"
553+
)
554+
555+
if name in declared_names:
556+
raise ValidationError(
557+
f"Duplicate command or alias '{name}' in extension manifest"
558+
)
559+
560+
declared_names[name] = kind
561+
562+
return declared_names
563+
564+
def _get_installed_command_name_map(
565+
self,
566+
exclude_extension_id: Optional[str] = None,
567+
) -> Dict[str, str]:
568+
"""Return registered command and alias names for installed extensions."""
569+
installed_names: Dict[str, str] = {}
570+
571+
for ext_id in self.registry.keys():
572+
if ext_id == exclude_extension_id:
573+
continue
574+
575+
manifest = self.get_extension(ext_id)
576+
if manifest is None:
577+
continue
578+
579+
for cmd in manifest.commands:
580+
cmd_name = cmd.get("name")
581+
if isinstance(cmd_name, str):
582+
installed_names.setdefault(cmd_name, ext_id)
583+
584+
aliases = cmd.get("aliases", [])
585+
if not isinstance(aliases, list):
586+
continue
587+
588+
for alias in aliases:
589+
if isinstance(alias, str):
590+
installed_names.setdefault(alias, ext_id)
591+
592+
return installed_names
593+
594+
def _validate_install_conflicts(self, manifest: ExtensionManifest) -> None:
595+
"""Reject installs that would shadow core or installed extension commands."""
596+
declared_names = self._collect_manifest_command_names(manifest)
597+
installed_names = self._get_installed_command_name_map(
598+
exclude_extension_id=manifest.id
599+
)
600+
601+
collisions = [
602+
f"{name} (already provided by extension '{installed_names[name]}')"
603+
for name in sorted(declared_names)
604+
if name in installed_names
605+
]
606+
if collisions:
607+
raise ValidationError(
608+
"Extension commands conflict with installed extensions:\n- "
609+
+ "\n- ".join(collisions)
610+
)
611+
449612
@staticmethod
450613
def _load_extensionignore(source_dir: Path) -> Optional[Callable[[str, List[str]], Set[str]]]:
451614
"""Load .extensionignore and return an ignore function for shutil.copytree.
@@ -861,6 +1024,9 @@ def install_from_directory(
8611024
f"Use 'specify extension remove {manifest.id}' first."
8621025
)
8631026

1027+
# Reject manifests that would shadow core commands or installed extensions.
1028+
self._validate_install_conflicts(manifest)
1029+
8641030
# Install extension
8651031
dest_dir = self.extensions_dir / manifest.id
8661032
if dest_dir.exists():

0 commit comments

Comments
 (0)