Skip to content

Commit d6e0773

Browse files
iamaeroplaneclaude
andcommitted
fix: address code review feedback
- Use Path.anchor to reject drive-relative/UNC paths in script validation, not just os.path.isabs + normpath - chmod only adds execute bits (0o111) and is gated to POSIX - Command filter treats missing extensions dir as empty (filters out all extension-scoped commands), matching preset behavior - list_available() rejects unsupported template_type with ValueError - CLI list-templates validates --type before calling resolver Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent de2d9e6 commit d6e0773

3 files changed

Lines changed: 36 additions & 15 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2743,7 +2743,14 @@ def preset_list_templates(
27432743
),
27442744
):
27452745
"""List all available templates from the resolution stack."""
2746-
from .presets import PresetResolver
2746+
from .presets import PresetResolver, VALID_PRESET_TEMPLATE_TYPES
2747+
2748+
if template_type not in VALID_PRESET_TEMPLATE_TYPES:
2749+
console.print(
2750+
f"[red]Error:[/red] Invalid template type '{template_type}'. "
2751+
f"Must be one of: {', '.join(sorted(VALID_PRESET_TEMPLATE_TYPES))}"
2752+
)
2753+
raise typer.Exit(1)
27472754

27482755
project_root = Path.cwd()
27492756

src/specify_cli/extensions.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,16 @@ def _validate(self):
171171
"must be lowercase alphanumeric with hyphens only"
172172
)
173173

174-
# Validate file path safety: must be relative, no parent traversal
174+
# Validate file path safety: must be relative, no anchored/drive
175+
# paths, and no parent traversal components
175176
file_path = script["file"]
176-
normalized = os.path.normpath(file_path)
177-
if os.path.isabs(normalized) or normalized.startswith(".."):
177+
p = Path(file_path)
178+
if p.is_absolute() or p.anchor:
179+
raise ValidationError(
180+
f"Invalid script file path '{file_path}': "
181+
"must be a relative path within the extension directory"
182+
)
183+
if ".." in p.parts:
178184
raise ValidationError(
179185
f"Invalid script file path '{file_path}': "
180186
"must be a relative path within the extension directory"
@@ -622,11 +628,12 @@ def install_from_directory(
622628
ignore_fn = self._load_extensionignore(source_dir)
623629
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
624630

625-
# Set execute permissions on extension scripts
626-
for script in manifest.scripts:
627-
script_path = dest_dir / script["file"]
628-
if script_path.exists() and script_path.suffix == ".sh":
629-
script_path.chmod(script_path.stat().st_mode | 0o755)
631+
# Set execute permissions on extension scripts (POSIX only)
632+
if os.name == "posix":
633+
for script in manifest.scripts:
634+
script_path = dest_dir / script["file"]
635+
if script_path.exists() and script_path.suffix == ".sh":
636+
script_path.chmod(script_path.stat().st_mode | 0o111)
630637

631638
# Register commands with AI agents
632639
registered_commands = {}
@@ -1092,16 +1099,16 @@ def _filter_commands_for_installed_extensions(
10921099
Extension-specific commands are only kept if the target extension
10931100
directory exists under .specify/extensions/.
10941101
1095-
If the extensions directory does not exist, no filtering is applied.
1102+
If the extensions directory does not exist, it is treated as empty
1103+
and all extension-scoped commands are filtered out (matching the
1104+
preset filtering behavior at presets.py:518-529).
10961105
10971106
Note: This method is not applied during extension self-registration
10981107
(all commands in an extension's own manifest are always registered).
10991108
It is designed for cross-boundary filtering, e.g. when presets provide
11001109
commands for extensions that may not be installed.
11011110
"""
11021111
extensions_dir = project_root / ".specify" / "extensions"
1103-
if not extensions_dir.is_dir():
1104-
return commands
11051112
filtered = []
11061113
for cmd in commands:
11071114
parts = cmd["name"].split(".")

src/specify_cli/presets.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1681,7 +1681,16 @@ def list_available(
16811681
16821682
Returns:
16831683
List of dicts with 'name', 'path', and 'source' keys, sorted by name.
1684+
1685+
Raises:
1686+
ValueError: If template_type is not one of "template", "command", "script"
16841687
"""
1688+
if template_type not in VALID_PRESET_TEMPLATE_TYPES:
1689+
raise ValueError(
1690+
f"Invalid template type '{template_type}': "
1691+
f"must be one of {sorted(VALID_PRESET_TEMPLATE_TYPES)}"
1692+
)
1693+
16851694
seen: set[str] = set()
16861695
results: List[Dict[str, str]] = []
16871696

@@ -1691,10 +1700,8 @@ def list_available(
16911700
subdirs = ["templates", ""]
16921701
elif template_type == "command":
16931702
subdirs = ["commands"]
1694-
elif template_type == "script":
1703+
else: # script
16951704
subdirs = ["scripts"]
1696-
else:
1697-
subdirs = [""]
16981705

16991706
def _collect(directory: Path, source: str):
17001707
"""Collect template files from a directory."""

0 commit comments

Comments
 (0)