Skip to content

Commit fa3a5c5

Browse files
mnriemCopilot
andauthored
Clarify extension catalog trust model in docs, help, and messaging (#4177)
* Clarify extension catalog trust model in docs, help, and messaging (#4176) Extension catalog management gave no explanation of why the community catalog is discovery-only, and the install-error text nudged users to flip a discovery catalog to install_allowed — exactly the wrong move. - Docs: add a "discovery-only vs. install sources" trust-model section, document `add --from <url>` as the lightweight vetted-install path, and stop implying you should make community installable. - Help: expand the `catalog` app and `--install-allowed` help to state the vetting intent instead of bare mechanics. - Messaging: rewrite the not-installable errors in `add`, `search`, and `info` to point at `--from` and self-curated catalogs, and to say explicitly not to flip a discovery-only catalog to install_allowed. - `catalog list` now prints trust-model guidance when a discovery-only catalog is active. - Tests cover the new list guidance (present/absent). Deliberately does not add a verb to toggle install_allowed on an existing catalog: discovery-only is a security boundary, not an inconvenience. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d * Address PR review: copy-pasteable install hint and accurate --from warning (#4176) - The discovery-only "install directly" hint used the user-typed argument, which can be a display name with spaces (resolved via search) and would break when copied as a shell command. Emit the resolved catalog ID (ext_info['id']) instead. Added a regression test. - The `--from` untrusted-source warning claimed the URL was "not listed in any of your configured extension catalogs", which is false for a URL copied from a discovery-only catalog — the exact flow this PR documents. Reword it to state the install is bypassing trusted (install-allowed) catalogs, which is accurate regardless of discovery-catalog membership. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d * Harden install hints against catalog-controlled IDs; expose archive URL (#4176) Second review round on #4177. Shell-safety: catalog entry IDs (especially from discovery-only catalogs) are not validated during catalog merge, and rich.markup.escape only neutralizes Rich markup, not shell metacharacters. A malicious ID like `foo; rm -rf ~` was interpolated into the `specify extension add ... --from` command we encourage the user to copy. Add `_command_safe_id`, which only emits an ID matching the manifest rule `^[a-z0-9-]+$` (via VALID_EXTENSION_ARTIFACT_NAME_PATTERN) and otherwise falls back to a literal `<extension-id>` placeholder. Applied to every suggested command in `add`, `search`, and `info`. Discoverability: the documented `--from <archive-url>` flow gave no CLI path to obtain the URL. `extension info` now prints the candidate `download_url` for a discovery-only entry (clearly flagged as needing vetting), and the docs show `extension info <name>` as the way to get the archive URL. Tests cover the resolved-ID hint, the unsafe-ID neutralization, and pass the full extensions + CLI suites (635). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d * Reject leading-hyphen catalog IDs; test info archive-URL branch (#4176) Third review round on #4177. _command_safe_id: an ID like `--force` satisfies the manifest character rule `^[a-z0-9-]+$` but Typer parses a leading hyphen as an option rather than the positional extension argument, so an untrusted catalog could still yield a non-copyable or option-altering suggested command. Reject a leading hyphen and fall back to the `<extension-id>` placeholder. Tests: cover the new `extension info` discovery-only branch that surfaces the candidate `download_url` (plus the no-URL fallback), and the leading-hyphen rejection. Full extensions suite green (528). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, supervised) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a86c498e-f129-4422-9983-d1a33513fd4d
1 parent e4895d1 commit fa3a5c5

3 files changed

Lines changed: 380 additions & 21 deletions

File tree

docs/reference/extensions.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ Changes the resolution priority of an extension. When multiple extensions provid
9696

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

99+
### Trust model: discovery-only vs. install sources
100+
101+
Catalogs come in two kinds, and the distinction is a **security boundary**, not a limitation:
102+
103+
- **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.
104+
- **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.
105+
106+
`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.
107+
108+
> **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`:
109+
>
110+
> 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:
111+
> ```bash
112+
> specify extension info <name> # shows the candidate archive URL
113+
> specify extension add <name> --from <archive-url>
114+
> ```
115+
> Treat the URL as untrusted until you have vetted it — it comes from an unvetted catalog.
116+
> 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).
117+
99118
### List Catalogs
100119
101120
```bash
@@ -114,7 +133,7 @@ specify extension catalog add <url>
114133
| ------------------------------------ | -------------------------------------------------- |
115134
| `--name <name>` | Required. Unique name for the catalog |
116135
| `--priority <N>` | Priority (default: 10; lower = higher precedence) |
117-
| `--install-allowed / --no-install-allowed` | Whether extensions can be installed from this catalog |
136+
| `--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. |
118137
| `--description <text>` | Optional description |
119138
120139
Adds a catalog to the project's `.specify/extension-catalogs.yml`.
@@ -134,9 +153,9 @@ Catalogs are resolved in this order (first match wins):
134153
1. **Environment variable** — `SPECKIT_CATALOG_URL` overrides all catalogs
135154
2. **Project config** — `.specify/extension-catalogs.yml`
136155
3. **User config** — `~/.specify/extension-catalogs.yml`
137-
4. **Built-in defaults** — official catalog + community catalog
156+
4. **Built-in defaults** — official `default` catalog (install-allowed) + `community` catalog (discovery-only)
138157
139-
Example `.specify/extension-catalogs.yml`:
158+
Example `.specify/extension-catalogs.yml` for a catalog you own and vet:
140159
141160
```yaml
142161
catalogs:

src/specify_cli/extensions/_commands.py

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@
4343

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

7381

82+
def _command_safe_id(raw_id: object, placeholder: str = "<extension-id>") -> str:
83+
"""Return an extension ID that is safe to embed in a suggested shell command.
84+
85+
Catalog entries (especially from discovery-only catalogs) are untrusted:
86+
their keys are not validated during catalog merge, so an ``id`` like
87+
``foo; rm -rf ~`` could otherwise be interpolated into a command we
88+
explicitly encourage the user to copy and run. ``rich.markup.escape`` only
89+
neutralizes Rich markup, not shell metacharacters, so it is not sufficient
90+
here. Only emit the real ID when it matches the same
91+
lowercase-alphanumeric-and-hyphen rule ``ExtensionManifest`` enforces
92+
(``^[a-z0-9-]+$``); otherwise fall back to a literal placeholder so the
93+
printed command never carries catalog-controlled shell text.
94+
95+
A leading hyphen is additionally rejected: an ID like ``--force`` satisfies
96+
the pattern but Typer would parse it as an option rather than the positional
97+
extension argument, yielding a non-copyable or option-altering command.
98+
"""
99+
from . import VALID_EXTENSION_ARTIFACT_NAME_PATTERN
100+
101+
text = str(raw_id)
102+
if text.startswith("-"):
103+
return placeholder
104+
if VALID_EXTENSION_ARTIFACT_NAME_PATTERN.match(text):
105+
return text
106+
return placeholder
107+
108+
74109
def _refresh_events_and_warn(project_root: Path) -> None:
75110
"""Refresh native event config and surface failures (R3).
76111
@@ -444,6 +479,14 @@ def catalog_list():
444479
console.print(f" Install: {install_str}")
445480
console.print()
446481

482+
if any(not entry.install_allowed for entry in active_catalogs):
483+
console.print(
484+
"[dim]Discovery-only catalogs are searchable but not installable by design "
485+
"(unvetted sources). To install something you found in one, vet it and run "
486+
"'specify extension add <name> --from <url>', or add it to a catalog you "
487+
"control. Don't flip a discovery-only catalog to install_allowed.[/dim]\n"
488+
)
489+
447490
config_path = project_root / ".specify" / "extension-catalogs.yml"
448491
user_config_path = Path.home() / ".specify" / "extension-catalogs.yml"
449492
if os.environ.get("SPECKIT_CATALOG_URL"):
@@ -477,7 +520,11 @@ def catalog_add(
477520
priority: int = typer.Option(10, "--priority", help="Priority (lower = higher priority)"),
478521
install_allowed: bool = typer.Option(
479522
False, "--install-allowed/--no-install-allowed",
480-
help="Allow extensions from this catalog to be installed",
523+
help=(
524+
"Mark this catalog as a trusted install source. Only enable this for a "
525+
"catalog you own and vet; leave it off (the default) for discovery-only "
526+
"search surfaces. Never enable it for an unvetted public catalog."
527+
),
481528
),
482529
description: str = typer.Option("", "--description", help="Description of the catalog"),
483530
):
@@ -903,8 +950,8 @@ def extension_add(
903950
# Warn about untrusted sources — default-deny confirmation
904951
console.print()
905952
console.print(Panel(
906-
f"[bold]You are installing an extension from an external URL that is not\n"
907-
f"listed in any of your configured extension catalogs.[/bold]\n\n"
953+
f"[bold]You are installing an extension directly from an external URL,\n"
954+
f"bypassing your trusted (install-allowed) extension catalogs.[/bold]\n\n"
908955
f"URL: {safe_url}\n\n"
909956
f"Only install extensions from sources you trust.",
910957
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
@@ -1007,13 +1054,25 @@ def extension_add(
10071054
# Enforce install_allowed policy
10081055
if not ext_info.get("_install_allowed", True):
10091056
catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community")))
1057+
resolved_id = _command_safe_id(ext_info["id"])
10101058
console.print(
1011-
f"[red]Error:[/red] '{safe_extension}' is available in the "
1012-
f"'{catalog_name}' catalog but installation is not allowed from that catalog."
1059+
f"[red]Error:[/red] '{safe_extension}' was found in the "
1060+
f"'{catalog_name}' catalog, which is discovery-only — a search "
1061+
f"surface, not an install source."
10131062
)
10141063
console.print(
1015-
f"\nTo enable installation, add '{safe_extension}' to an approved catalog "
1016-
f"(install_allowed: true) in .specify/extension-catalogs.yml."
1064+
"\nDiscovery-only catalogs are intentionally not installable so "
1065+
"unvetted extensions can't be pulled in without review. Don't flip "
1066+
"such a catalog to install_allowed. Instead, once you've vetted this "
1067+
"extension:"
1068+
)
1069+
console.print(
1070+
f" • install it directly from its archive URL:\n"
1071+
f" specify extension add {resolved_id} --from <archive-url>"
1072+
)
1073+
console.print(
1074+
" • or add it to a catalog you curate and control "
1075+
"(install_allowed: true)."
10171076
)
10181077
raise typer.Exit(1)
10191078

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

12581317
# Install command (show warning if not installable)
1259-
safe_id = _escape_markup(str(ext['id']))
1318+
cmd_id = _command_safe_id(ext['id'])
12601319
if install_allowed:
1261-
console.print(f"\n [cyan]Install:[/cyan] specify extension add {safe_id}")
1320+
console.print(f"\n [cyan]Install:[/cyan] specify extension add {cmd_id}")
12621321
else:
1263-
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
1322+
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}' (discovery-only).")
1323+
console.print(
1324+
f" Once vetted, install it directly: specify extension add {cmd_id} --from <archive-url>"
1325+
)
12641326
console.print(
1265-
f" Add to an approved catalog with install_allowed: true, "
1266-
f"or install from an archive URL: specify extension add {safe_id} --from <archive-url>"
1327+
" Don't flip a discovery-only catalog to install_allowed — that's the vetting boundary."
12671328
)
12681329
console.print()
12691330

@@ -1485,22 +1546,39 @@ def _print_extension_info(ext_info: dict, manager):
14851546
is_installed = manager.registry.is_installed(ext_info['id'])
14861547
install_allowed = ext_info.get("_install_allowed", True)
14871548
safe_id = _escape_markup(str(ext_info['id']))
1549+
cmd_id = _command_safe_id(ext_info['id'])
14881550
if is_installed:
14891551
console.print("[green]✓ Installed[/green]")
14901552
metadata = manager.registry.get(ext_info['id'])
14911553
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
14921554
console.print(f"[dim]Priority:[/dim] {priority}")
1493-
console.print(f"\nTo remove: specify extension remove {safe_id}")
1555+
console.print(f"\nTo remove: specify extension remove {cmd_id}")
14941556
elif install_allowed:
14951557
console.print("[yellow]Not installed[/yellow]")
1496-
console.print(f"\n[cyan]Install:[/cyan] specify extension add {safe_id}")
1558+
console.print(f"\n[cyan]Install:[/cyan] specify extension add {cmd_id}")
14971559
else:
14981560
catalog_name = _escape_markup(str(ext_info.get("_catalog_name", "community")))
14991561
console.print("[yellow]Not installed[/yellow]")
15001562
console.print(
1501-
f"\n[yellow]⚠[/yellow] '{safe_id}' is available in the '{catalog_name}' catalog "
1502-
f"but not in your approved catalog. Add it to .specify/extension-catalogs.yml "
1503-
f"with install_allowed: true to enable installation."
1563+
f"\n[yellow]⚠[/yellow] '{safe_id}' is in the '{catalog_name}' catalog, which is "
1564+
f"discovery-only (a search surface, not an install source)."
1565+
)
1566+
download_url = ext_info.get("download_url")
1567+
if download_url:
1568+
console.print(
1569+
f"Candidate archive (vet before installing): {_escape_markup(str(download_url))}"
1570+
)
1571+
console.print(
1572+
f"Once vetted, install directly: specify extension add {cmd_id} --from <archive-url>"
1573+
)
1574+
else:
1575+
console.print(
1576+
f"Once you've vetted its release archive, install directly: "
1577+
f"specify extension add {cmd_id} --from <archive-url>"
1578+
)
1579+
console.print(
1580+
"Discovery-only catalogs are intentionally not install sources — don't set "
1581+
"install_allowed on them."
15041582
)
15051583

15061584

0 commit comments

Comments
 (0)