Skip to content
Merged
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
25 changes: 22 additions & 3 deletions docs/reference/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,25 @@ Changes the resolution priority of an extension. When multiple extensions provid

Extension catalogs control where `search` and `add` look for extensions. Catalogs are checked in priority order (lower number = higher precedence).

### Trust model: discovery-only vs. install sources

Catalogs come in two kinds, and the distinction is a **security boundary**, not a limitation:

- **Install sources** (`install_allowed: true`) — catalogs you trust as a place to install from. The built-in `default` (official) catalog is one, as is any catalog you author and vet yourself.
- **Discovery-only** catalogs (`install_allowed: false`) — searchable surfaces for *finding* extensions, but not installable. The built-in `community` catalog is discovery-only and is already active for `search` out of the box; you do not need to add it.

`community` is intentionally discovery-only because it is an open, unvetted list. Making everything in it one-command-installable would mean pulling arbitrary third-party code with no review.

> **Do not flip a discovery-only catalog to `install_allowed`.** That defeats the entire point of separating discovery from installation. There are two correct ways to install something you found via `community`:
>
> 1. **Install a single vetted extension directly** with `--from` (no catalog authoring needed). Get the candidate archive URL from `specify extension info <name>` — for a discovery-only entry it prints a "Candidate archive" URL. Review that release archive, then install it:
> ```bash
> specify extension info <name> # shows the candidate archive URL
> specify extension add <name> --from <archive-url>
> ```
> Treat the URL as untrusted until you have vetted it — it comes from an unvetted catalog.
> 2. **Curate your own catalog** you control and vet, and mark *that* catalog `install_allowed: true` — for when you want a governed, reusable install source (e.g. for an org).

### List Catalogs

```bash
Expand All @@ -114,7 +133,7 @@ specify extension catalog add <url>
| ------------------------------------ | -------------------------------------------------- |
| `--name <name>` | Required. Unique name for the catalog |
| `--priority <N>` | Priority (default: 10; lower = higher precedence) |
| `--install-allowed / --no-install-allowed` | Whether extensions can be installed from this catalog |
| `--install-allowed / --no-install-allowed` | Mark the catalog as a trusted install source. Only enable for a catalog you own and vet; leave off (the default) for discovery-only sources. Never enable it for an unvetted public catalog. |
| `--description <text>` | Optional description |

Adds a catalog to the project's `.specify/extension-catalogs.yml`.
Expand All @@ -134,9 +153,9 @@ Catalogs are resolved in this order (first match wins):
1. **Environment variable** — `SPECKIT_CATALOG_URL` overrides all catalogs
2. **Project config** — `.specify/extension-catalogs.yml`
3. **User config** — `~/.specify/extension-catalogs.yml`
4. **Built-in defaults** — official catalog + community catalog
4. **Built-in defaults** — official `default` catalog (install-allowed) + `community` catalog (discovery-only)

Example `.specify/extension-catalogs.yml`:
Example `.specify/extension-catalogs.yml` for a catalog you own and vet:

```yaml
catalogs:
Expand Down
114 changes: 96 additions & 18 deletions src/specify_cli/extensions/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,15 @@

catalog_app = typer.Typer(
name="catalog",
help="Manage extension catalogs",
help=(
"Manage extension catalogs.\n\n"
"Catalogs are either install sources (install_allowed) or discovery-only "
"search surfaces. The built-in 'community' catalog is discovery-only by "
"design: it is unvetted, so it is searchable but not installable. To install "
"something you found there, either use 'specify extension add <name> --from "
"<url>' after vetting it, or curate your own catalog you control. Never flip a "
"discovery-only catalog to install_allowed — that is the vetting boundary."
),
add_completion=False,
)
extension_app.add_typer(catalog_app, name="catalog")
Expand Down Expand Up @@ -71,6 +79,33 @@ def _display_project_path(*args, **kwargs):
return _f(*args, **kwargs)


def _command_safe_id(raw_id: object, placeholder: str = "<extension-id>") -> str:
"""Return an extension ID that is safe to embed in a suggested shell command.

Catalog entries (especially from discovery-only catalogs) are untrusted:
their keys are not validated during catalog merge, so an ``id`` like
``foo; rm -rf ~`` could otherwise be interpolated into a command we
explicitly encourage the user to copy and run. ``rich.markup.escape`` only
neutralizes Rich markup, not shell metacharacters, so it is not sufficient
here. Only emit the real ID when it matches the same
lowercase-alphanumeric-and-hyphen rule ``ExtensionManifest`` enforces
(``^[a-z0-9-]+$``); otherwise fall back to a literal placeholder so the
printed command never carries catalog-controlled shell text.

A leading hyphen is additionally rejected: an ID like ``--force`` satisfies
the pattern but Typer would parse it as an option rather than the positional
extension argument, yielding a non-copyable or option-altering command.
"""
from . import VALID_EXTENSION_ARTIFACT_NAME_PATTERN

text = str(raw_id)
if text.startswith("-"):
return placeholder
if VALID_EXTENSION_ARTIFACT_NAME_PATTERN.match(text):
return text
return placeholder


def _refresh_events_and_warn(project_root: Path) -> None:
"""Refresh native event config and surface failures (R3).

Expand Down Expand Up @@ -444,6 +479,14 @@ def catalog_list():
console.print(f" Install: {install_str}")
console.print()

if any(not entry.install_allowed for entry in active_catalogs):
console.print(
"[dim]Discovery-only catalogs are searchable but not installable by design "
"(unvetted sources). To install something you found in one, vet it and run "
"'specify extension add <name> --from <url>', or add it to a catalog you "
"control. Don't flip a discovery-only catalog to install_allowed.[/dim]\n"
)

config_path = project_root / ".specify" / "extension-catalogs.yml"
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
if os.environ.get("SPECKIT_CATALOG_URL"):
Expand Down Expand Up @@ -477,7 +520,11 @@ def catalog_add(
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
install_allowed: bool = typer.Option(
False, "--install-allowed/--no-install-allowed",
help="Allow extensions from this catalog to be installed",
help=(
"Mark this catalog as a trusted install source. Only enable this for a "
"catalog you own and vet; leave it off (the default) for discovery-only "
"search surfaces. Never enable it for an unvetted public catalog."
),
),
description: str = typer.Option("", "--description", help="Description of the catalog"),
):
Expand Down Expand Up @@ -903,8 +950,8 @@ def extension_add(
# Warn about untrusted sources — default-deny confirmation
console.print()
console.print(Panel(
f"[bold]You are installing an extension from an external URL that is not\n"
f"listed in any of your configured extension catalogs.[/bold]\n\n"
f"[bold]You are installing an extension directly from an external URL,\n"
f"bypassing your trusted (install-allowed) extension catalogs.[/bold]\n\n"
f"URL: {safe_url}\n\n"
f"Only install extensions from sources you trust.",
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
Expand Down Expand Up @@ -1007,13 +1054,25 @@ def extension_add(
# Enforce install_allowed policy
if not ext_info.get("_install_allowed", True):
catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community")))
resolved_id = _command_safe_id(ext_info["id"])
console.print(
f"[red]Error:[/red] '{safe_extension}' is available in the "
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
f"[red]Error:[/red] '{safe_extension}' was found in the "
f"'{catalog_name}' catalog, which is discovery-only — a search "
f"surface, not an install source."
)
console.print(
f"\nTo enable installation, add '{safe_extension}' to an approved catalog "
f"(install_allowed: true) in .specify/extension-catalogs.yml."
"\nDiscovery-only catalogs are intentionally not installable so "
"unvetted extensions can't be pulled in without review. Don't flip "
"such a catalog to install_allowed. Instead, once you've vetted this "
"extension:"
)
console.print(
f" • install it directly from its archive URL:\n"
f" specify extension add {resolved_id} --from <archive-url>"
)
console.print(
" • or add it to a catalog you curate and control "
"(install_allowed: true)."
)
raise typer.Exit(1)

Expand Down Expand Up @@ -1256,14 +1315,16 @@ def extension_search(
console.print(f" [dim]Repository:[/dim] {_escape_markup(str(ext['repository']))}")

# Install command (show warning if not installable)
safe_id = _escape_markup(str(ext['id']))
cmd_id = _command_safe_id(ext['id'])
if install_allowed:
console.print(f"\n [cyan]Install:[/cyan] specify extension add {safe_id}")
console.print(f"\n [cyan]Install:[/cyan] specify extension add {cmd_id}")
else:
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}' (discovery-only).")
console.print(
f" Once vetted, install it directly: specify extension add {cmd_id} --from <archive-url>"
)
console.print(
f" Add to an approved catalog with install_allowed: true, "
f"or install from an archive URL: specify extension add {safe_id} --from <archive-url>"
" Don't flip a discovery-only catalog to install_allowed — that's the vetting boundary."
)
console.print()

Expand Down Expand Up @@ -1485,22 +1546,39 @@ def _print_extension_info(ext_info: dict, manager):
is_installed = manager.registry.is_installed(ext_info['id'])
install_allowed = ext_info.get("_install_allowed", True)
safe_id = _escape_markup(str(ext_info['id']))
cmd_id = _command_safe_id(ext_info['id'])
if is_installed:
console.print("[green]✓ Installed[/green]")
metadata = manager.registry.get(ext_info['id'])
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {safe_id}")
console.print(f"\nTo remove: specify extension remove {cmd_id}")
elif install_allowed:
console.print("[yellow]Not installed[/yellow]")
console.print(f"\n[cyan]Install:[/cyan] specify extension add {safe_id}")
console.print(f"\n[cyan]Install:[/cyan] specify extension add {cmd_id}")
else:
catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community")))
console.print("[yellow]Not installed[/yellow]")
console.print(
f"\n[yellow]⚠[/yellow] '{safe_id}' is available in the '{catalog_name}' catalog "
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
f"with install_allowed: true to enable installation."
f"\n[yellow]⚠[/yellow] '{safe_id}' is in the '{catalog_name}' catalog, which is "
f"discovery-only (a search surface, not an install source)."
)
download_url = ext_info.get("download_url")
if download_url:
console.print(
f"Candidate archive (vet before installing): {_escape_markup(str(download_url))}"
)
Comment thread
mnriem marked this conversation as resolved.
console.print(
f"Once vetted, install directly: specify extension add {cmd_id} --from <archive-url>"
)
else:
console.print(
f"Once you've vetted its release archive, install directly: "
f"specify extension add {cmd_id} --from <archive-url>"
)
console.print(
"Discovery-only catalogs are intentionally not install sources — don't set "
"install_allowed on them."
)


Expand Down
Loading