Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import os
import re
import shutil
import zipfile
from pathlib import Path
Expand Down Expand Up @@ -181,19 +182,24 @@ def extension_list(
all_extensions: bool = typer.Option(False, "--all", help="Show both installed and available"),
):
"""List installed extensions."""
from . import ExtensionManager
from . import ExtensionManager, ExtensionCatalog, ExtensionError

project_root = _require_specify_project()
manager = ExtensionManager(project_root)
installed = manager.list_installed()

if not installed and not (available or all_extensions):
# Default (no flags) lists installed; --all also lists installed.
# --available alone lists only catalog extensions, not installed.
show_installed = all_extensions or not available
show_available = available or all_extensions

if not installed and not show_available:
console.print("[yellow]No extensions installed.[/yellow]")
console.print("\nInstall an extension with:")
console.print(" specify extension add <extension-name>")
return

if installed:
if show_installed and installed:
console.print("\n[bold cyan]Installed Extensions:[/bold cyan]\n")

for ext in installed:
Expand All @@ -206,9 +212,36 @@ def extension_list(
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print()

if available or all_extensions:
console.print("\nInstall an extension:")
console.print(" [cyan]specify extension add <name>[/cyan]")
if show_available:
# Query the catalog and show extensions that are not already installed.
catalog = ExtensionCatalog(project_root)
installed_ids = {ext["id"] for ext in installed}

try:
results = catalog.search()
except ExtensionError as e:
console.print(f"\n[red]Error:[/red] Could not query extension catalog: {e}")
console.print("[dim]The catalog may be temporarily unavailable. Try again later.[/dim]")
raise typer.Exit(1)

available_exts = [ext for ext in results if ext.get("id") not in installed_ids]

console.print("\n[bold cyan]Available Extensions:[/bold cyan]\n")
if not available_exts:
console.print(" [dim]No additional extensions available in the catalog.[/dim]")
else:
for ext in available_exts:
verified_badge = " [green]✓ Verified[/green]" if ext.get("verified") else ""
console.print(f" [bold]{ext['name']}[/bold] (v{ext['version']}){verified_badge}")
console.print(f" [dim]{ext['id']}[/dim]")
console.print(f" {ext.get('description', '')}")
install_allowed = ext.get("_install_allowed", True)
if install_allowed:
console.print(f" [cyan]Install:[/cyan] specify extension add {ext['id']}")
else:
catalog_name = ext.get("_catalog_name", "")
console.print(f" [yellow]Discovery only — not installable from '{catalog_name}'[/yellow]")
console.print()


@catalog_app.command("list")
Expand Down Expand Up @@ -463,7 +496,13 @@ def extension_add(
# Download ZIP to temp location
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
download_dir.mkdir(parents=True, exist_ok=True)
zip_path = download_dir / f"{extension}-url-download.zip"
# Sanitize the extension label before using it in a filename:
# the raw argument may contain path separators (e.g. "../x") that
# would let the download escape download_dir and overwrite
# arbitrary files (path traversal). Keep only safe characters and
# fall back to a fixed stem if nothing usable remains.
safe_label = re.sub(r"[^A-Za-z0-9._-]", "_", extension).strip("._") or "extension"
zip_path = download_dir / f"{safe_label}-url-download.zip"

try:
from specify_cli.authentication.http import open_url as _open_url
Expand Down
69 changes: 69 additions & 0 deletions tests/test_extension_add_path_traversal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Security test for `specify extension add <label> --from <url>`.

The raw extension label is interpolated into the downloaded ZIP filename. A
label containing path separators (e.g. "../../etc/evil") must not let the
download escape the downloads cache directory. The handler sanitizes the
label before building the filename; this test asserts the resulting path
stays inside the downloads dir.
"""

import contextlib

import pytest
from typer.testing import CliRunner

from specify_cli import app
from specify_cli.extensions import ExtensionManager

runner = CliRunner()


@pytest.fixture
def project_dir(tmp_path):
proj_dir = tmp_path / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
return proj_dir


def test_add_from_url_sanitizes_traversal_label(project_dir, monkeypatch):
monkeypatch.chdir(project_dir)

captured = {}

@contextlib.contextmanager
def fake_open_url(url, timeout=60):
class _Resp:
def read(self):
return b"not-a-real-zip"
yield _Resp()

monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url)

def fake_install_from_zip(self, zip_path, *args, **kwargs):
captured["zip_path"] = zip_path
# Stop the flow before real install/registration runs.
raise RuntimeError("stop after capture")

monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install_from_zip)

malicious = "../../../etc/evil"
runner.invoke(
app,
["extension", "add", malicious, "--from", "https://example.com/payload.zip"],
obj={"project_root": project_dir},
input="y\n", # confirm the "Untrusted Source" prompt for --from URLs
)

zip_path = captured.get("zip_path")
assert zip_path is not None, "install_from_zip was never reached"

download_dir = (project_dir / ".specify" / "extensions" / ".cache" / "downloads").resolve()
resolved = zip_path.resolve()

# The download must stay inside the downloads cache dir...
assert resolved.parent == download_dir
# ...and the filename must not carry path separators from the raw label.
assert "/" not in zip_path.name
assert ".." not in zip_path.name
135 changes: 135 additions & 0 deletions tests/test_extension_list_available.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Behavior tests for `specify extension list --available/--all`.

These flags were documented from the original extension system (#1551) as
"Show available extensions from catalog", but the implementation was a static
hint that never queried the catalog. This suite covers the wired-up behavior:
the catalog is queried, already-installed IDs are filtered out, and a clear
error is surfaced when the catalog is unavailable.
"""

import pytest
from typer.testing import CliRunner

from specify_cli import app
from specify_cli.extensions import ExtensionManager, ExtensionCatalog, ExtensionError

runner = CliRunner()


@pytest.fixture
def project_dir(tmp_path):
"""Create a minimal spec-kit project directory."""
proj_dir = tmp_path / "project"
proj_dir.mkdir()
(proj_dir / ".specify").mkdir()
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
return proj_dir


def _catalog_entry(ext_id, name, version="1.0.0", verified=False, install_allowed=True, catalog_name="default"):
return {
"id": ext_id,
"name": name,
"version": version,
"description": f"{name} description",
"verified": verified,
"_install_allowed": install_allowed,
"_catalog_name": catalog_name,
}


def test_list_available_queries_catalog_and_filters_installed(project_dir, monkeypatch):
"""--available must query the catalog and drop already-installed IDs."""
monkeypatch.chdir(project_dir)

monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "already-installed"}])
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [
_catalog_entry("already-installed", "Already Installed"),
_catalog_entry("fresh-ext", "Fresh Ext", verified=True),
])

result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})

assert result.exit_code == 0
assert "Available Extensions:" in result.output
# Uninstalled catalog extension is shown...
assert "fresh-ext" in result.output
assert "✓ Verified" in result.output
assert "specify extension add fresh-ext" in result.output
# ...and the installed one is filtered out.
assert "already-installed" not in result.output


def test_list_available_marks_discovery_only_entries(project_dir, monkeypatch):
"""Entries whose catalog disallows install render a discovery-only note."""
monkeypatch.chdir(project_dir)

monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [])
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [
_catalog_entry("locked-ext", "Locked Ext", install_allowed=False, catalog_name="curated"),
])

result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})

assert result.exit_code == 0
assert "Discovery only" in result.output
assert "curated" in result.output
assert "specify extension add locked-ext" not in result.output


def test_list_available_empty_catalog_message(project_dir, monkeypatch):
"""An empty (post-filter) catalog reports no additional extensions."""
monkeypatch.chdir(project_dir)

monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [])
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [])

result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})

assert result.exit_code == 0
assert "Available Extensions:" in result.output
assert "No additional extensions available" in result.output


def test_list_available_catalog_error_exits(project_dir, monkeypatch):
"""A catalog failure surfaces a clear error and exits non-zero."""
monkeypatch.chdir(project_dir)

def _boom(self):
raise ExtensionError("catalog unreachable")

monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [])
monkeypatch.setattr(ExtensionCatalog, "search", _boom)

result = runner.invoke(app, ["extension", "list", "--available"], obj={"project_root": project_dir})

assert result.exit_code == 1
assert "Could not query extension catalog" in result.output
assert "catalog unreachable" in result.output


def test_list_all_shows_installed_and_available(project_dir, monkeypatch):
"""--all lists installed extensions and available catalog extensions."""
monkeypatch.chdir(project_dir)

monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{
"id": "my-ext",
"name": "My Ext",
"version": "2.0.0",
"description": "installed one",
"command_count": 1,
"hook_count": 0,
"priority": 10,
"enabled": True,
}])
monkeypatch.setattr(ExtensionCatalog, "search", lambda self: [
_catalog_entry("other-ext", "Other Ext"),
])

result = runner.invoke(app, ["extension", "list", "--all"], obj={"project_root": project_dir})

assert result.exit_code == 0
assert "Installed Extensions:" in result.output
assert "My Ext" in result.output
assert "Available Extensions:" in result.output
assert "other-ext" in result.output