Skip to content

Commit 7a93e27

Browse files
mnriemCopilot
andcommitted
Add default-deny trust confirmation for URL extension installs at init
URL-based --extension installs now require explicit trust, matching the `extension add --from` posture. Interactive sessions show an "Untrusted Source" panel and prompt (default no); non-interactive sessions deny by default unless --trust-extension-urls is passed. Trust is resolved before the Live display since the prompt can't be answered under the spinner. - Add --trust-extension-urls option and _ext_spec_is_url / _confirm_extension_url_trust helpers - Skip (not abort) unconfirmed URL extensions, consistent with other non-fatal extension failures - Pass trust_extension_urls=False from the bundler init callback - Add tests for deny-by-default, interactive confirm, and trusted install Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8bc6802d-81b8-48f4-8f60-cba3aebc3bb3
1 parent e863e6c commit 7a93e27

3 files changed

Lines changed: 164 additions & 3 deletions

File tree

‎src/specify_cli/commands/bundle/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
120120
integration=integration,
121121
integration_options=None,
122122
extensions=None,
123+
trust_extension_urls=False,
123124
)
124125
except typer.Exit as exc:
125126
if exc.exit_code:

‎src/specify_cli/commands/init.py‎

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,58 @@ def _stdin_is_interactive() -> bool:
3030
return sys.stdin.isatty()
3131

3232

33+
def _ext_spec_is_url(ext_spec: str) -> bool:
34+
"""Return True when *ext_spec* is an http(s) URL rather than a name/path."""
35+
from urllib.parse import urlparse
36+
37+
try:
38+
return urlparse(ext_spec).scheme in ("http", "https")
39+
except ValueError:
40+
return False
41+
42+
43+
def _confirm_extension_url_trust(
44+
url_specs: list[str], *, trust_override: bool
45+
) -> dict[str, bool]:
46+
"""Resolve trust for each URL-based extension before the Live display.
47+
48+
URL installs pull an arbitrary external extension, so they get the same
49+
default-deny confirmation as ``extension add --from``. Returns a mapping of
50+
``url_spec -> approved``. With *trust_override* every URL is pre-approved.
51+
In a non-interactive session without the override, every URL is denied
52+
(the prompt cannot be answered), mirroring the default-deny posture.
53+
"""
54+
from rich.markup import escape as _escape_markup
55+
from rich.panel import Panel
56+
57+
approvals: dict[str, bool] = {}
58+
interactive = _stdin_is_interactive()
59+
for spec in url_specs:
60+
if trust_override:
61+
approvals[spec] = True
62+
continue
63+
if not interactive:
64+
approvals[spec] = False
65+
continue
66+
console.print()
67+
console.print(
68+
Panel(
69+
"[bold]You are installing an extension from an external URL that is not\n"
70+
"listed in any of your configured extension catalogs.[/bold]\n\n"
71+
f"URL: {_escape_markup(spec)}\n\n"
72+
"Only install extensions from sources you trust.",
73+
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
74+
border_style="yellow",
75+
padding=(1, 2),
76+
)
77+
)
78+
console.print()
79+
approvals[spec] = typer.confirm(
80+
f"Install extension from {spec}?", default=False
81+
)
82+
return approvals
83+
84+
3385
def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str:
3486
"""Install a single extension during ``specify init``.
3587
@@ -234,6 +286,11 @@ def init(
234286
"--extension",
235287
help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable.",
236288
),
289+
trust_extension_urls: bool = typer.Option(
290+
False,
291+
"--trust-extension-urls",
292+
help="Pre-authorize installing extensions from external URLs without the interactive trust prompt (required for non-interactive URL installs).",
293+
),
237294
):
238295
"""
239296
Initialize a new Specify project.
@@ -269,7 +326,7 @@ def init(
269326
specify init my-project --integration copilot --extension git # With bundled extension
270327
specify init my-project --extension git --extension selftest # Multiple extensions
271328
specify init my-project --extension ./my-extensions/custom-ext # Local path extension
272-
specify init my-project --extension https://example.com/extensions/my-ext.zip # URL extension
329+
specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive)
273330
"""
274331
# Lazy imports to avoid circular dependency — __init__.py imports this module
275332
from .. import (
@@ -522,6 +579,18 @@ def init(
522579

523580
tracker.add("final", "Finalize")
524581

582+
# Resolve trust for URL-based extensions BEFORE entering the Live
583+
# display: the confirmation prompt cannot be shown/answered underneath
584+
# the Rich Live spinner. URL installs are default-deny unless the user
585+
# confirms interactively or passes --trust-extension-urls.
586+
extension_url_approvals: dict[str, bool] = {}
587+
if extensions:
588+
url_specs = [e for e in extensions if _ext_spec_is_url(e)]
589+
if url_specs:
590+
extension_url_approvals = _confirm_extension_url_trust(
591+
url_specs, trust_override=trust_extension_urls
592+
)
593+
525594
# Disable transient mode on Windows: PowerShell 5.1's legacy console
526595
# hangs when Rich tries to restore cursor state via VT escape sequences.
527596
_transient = sys.platform != "win32"
@@ -741,6 +810,17 @@ def init(
741810
any_extension_installed = False
742811
for i, ext_spec in enumerate(extensions):
743812
tracker.start(f"extension-{i}")
813+
# Skip URL extensions the user did not confirm as trusted
814+
# (default-deny; resolved before the Live display).
815+
if _ext_spec_is_url(ext_spec) and not extension_url_approvals.get(
816+
ext_spec, False
817+
):
818+
tracker.error(
819+
f"extension-{i}",
820+
"skipped: untrusted URL not confirmed "
821+
"(use --trust-extension-urls)",
822+
)
823+
continue
744824
try:
745825
status_msg = _install_extension_during_init(
746826
project_path, ext_spec, speckit_ver

‎tests/integrations/test_cli.py‎

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2495,7 +2495,7 @@ def test_url_extension_rejects_non_https(self, tmp_path):
24952495
"""A non-HTTPS URL is rejected before any download; init is not aborted."""
24962496
project, result = self._run_init(
24972497
tmp_path,
2498-
["--extension", "http://example.com/ext.zip"],
2498+
["--extension", "http://example.com/ext.zip", "--trust-extension-urls"],
24992499
project_name="ext-http",
25002500
)
25012501

@@ -2505,6 +2505,86 @@ def test_url_extension_rejects_non_https(self, tmp_path):
25052505
# No extension directory should have been created for the bad URL.
25062506
assert not (project / ".specify" / "extensions" / "ext").exists()
25072507

2508+
def test_url_extension_skipped_without_trust(self, tmp_path):
2509+
"""Non-interactive URL install without --trust-extension-urls is denied."""
2510+
from unittest.mock import patch
2511+
2512+
with patch(
2513+
"specify_cli.commands.init._stdin_is_interactive", return_value=False
2514+
), patch("specify_cli.authentication.http.open_url") as mock_open:
2515+
project, result = self._run_init(
2516+
tmp_path,
2517+
["--extension", "https://example.com/git.zip"],
2518+
project_name="ext-url-denied",
2519+
)
2520+
2521+
assert result.exit_code == 0, f"init failed:\n{result.output}"
2522+
# Default-deny: no download attempted, nothing installed.
2523+
mock_open.assert_not_called()
2524+
normalized = _normalize_cli_output(result.output)
2525+
assert "untrusted url" in normalized.lower()
2526+
assert not (project / ".specify" / "extensions" / "git").exists()
2527+
2528+
def test_url_extension_interactive_confirm_installs(self, tmp_path):
2529+
"""An interactive 'yes' to the trust prompt allows the URL install."""
2530+
import io
2531+
2532+
from unittest.mock import patch
2533+
2534+
from specify_cli import _locate_bundled_extension
2535+
2536+
bundled_git = _locate_bundled_extension("git")
2537+
assert bundled_git is not None, "bundled git extension not found"
2538+
zip_bytes = self._zip_bytes_from_dir(bundled_git)
2539+
2540+
class FakeResponse(io.BytesIO):
2541+
def __enter__(self):
2542+
return self
2543+
2544+
def __exit__(self, exc_type, exc, tb):
2545+
return False
2546+
2547+
def _cache_dir_stand_in(project_root):
2548+
d = project_root / ".specify" / "extensions" / ".cache" / "downloads"
2549+
d.mkdir(parents=True, exist_ok=True)
2550+
return d
2551+
2552+
def _open_download_zip(project_root, download_dir, zip_filename):
2553+
target = download_dir / zip_filename
2554+
o_temporary = getattr(os, "O_TEMPORARY", 0)
2555+
if o_temporary:
2556+
return os.open(
2557+
target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600
2558+
)
2559+
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
2560+
try:
2561+
os.unlink(target)
2562+
except OSError:
2563+
os.close(fd)
2564+
raise
2565+
return fd
2566+
2567+
with patch(
2568+
"specify_cli.commands.init._stdin_is_interactive", return_value=True
2569+
), patch("typer.confirm", return_value=True), patch(
2570+
"specify_cli.authentication.http.open_url",
2571+
return_value=FakeResponse(zip_bytes),
2572+
), patch(
2573+
"specify_cli.extensions._commands._validate_safe_cache_dir",
2574+
side_effect=_cache_dir_stand_in,
2575+
), patch(
2576+
"specify_cli.extensions._commands._safe_open_download_zip",
2577+
side_effect=_open_download_zip,
2578+
):
2579+
project, result = self._run_init(
2580+
tmp_path,
2581+
["--extension", "https://example.com/git.zip"],
2582+
project_name="ext-url-confirm",
2583+
)
2584+
2585+
assert result.exit_code == 0, f"init failed:\n{result.output}"
2586+
assert (project / ".specify" / "extensions" / "git").exists()
2587+
25082588
def test_url_extension_installs_zip(self, tmp_path):
25092589
"""A successful HTTPS ZIP download installs via the shared hardened path."""
25102590
import io
@@ -2556,7 +2636,7 @@ def _open_download_zip(project_root, download_dir, zip_filename):
25562636
):
25572637
project, result = self._run_init(
25582638
tmp_path,
2559-
["--extension", "https://example.com/git.zip"],
2639+
["--extension", "https://example.com/git.zip", "--trust-extension-urls"],
25602640
project_name="ext-url",
25612641
)
25622642

0 commit comments

Comments
 (0)