Skip to content

Commit 9889c0b

Browse files
fix(init): harden extension archive downloads
Signed-off-by: Asish Kumar <officialasishkumar@gmail.com>
1 parent 9cc9be3 commit 9889c0b

File tree

3 files changed

+131
-3
lines changed

3 files changed

+131
-3
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ Community projects that extend, visualize, or build on Spec Kit:
348348

349349
## Available Slash Commands
350350

351-
After running `specify init`, your AI coding agent will have access to these slash commands for structured development. If you pass `--ai <agent> --ai-skills`, Spec Kit installs agent skills instead of slash-command prompt files; `--ai-skills` requires `--ai`.
351+
After running `specify init --integration <agent>`, your AI coding agent will have access to these slash commands for structured development. Integrations choose the appropriate command format for each agent, including native skills where supported. The legacy `--ai` and `--ai-skills` options remain available as deprecated aliases.
352352

353353
### Core Commands
354354

src/specify_cli/__init__.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,10 @@ def _validate_extension_url(url: str) -> None:
748748
)
749749

750750

751+
MAX_EXTENSION_ARCHIVE_BYTES = 100 * 1024 * 1024
752+
DOWNLOAD_CHUNK_BYTES = 1024 * 1024
753+
754+
751755
def _install_extension_archive(
752756
manager: Any,
753757
archive_path: Path,
@@ -776,6 +780,10 @@ def _install_extension_archive(
776780
raise ValidationError(
777781
f"Unsafe link in TAR archive: {member.name}"
778782
)
783+
if not (member.isfile() or member.isdir()):
784+
raise ValidationError(
785+
f"Unsupported TAR member type in archive: {member.name}"
786+
)
779787
member_path = (temp_path / member.name).resolve()
780788
try:
781789
member_path.relative_to(temp_path_resolved)
@@ -846,7 +854,36 @@ def _download_and_install_extension_url(
846854

847855
try:
848856
with urllib.request.urlopen(source_url, timeout=60) as response:
849-
archive_path.write_bytes(response.read())
857+
content_length = response.headers.get("Content-Length")
858+
try:
859+
content_length_bytes = (
860+
int(content_length) if content_length is not None else None
861+
)
862+
except ValueError:
863+
content_length_bytes = None
864+
865+
if (
866+
content_length_bytes
867+
and content_length_bytes > MAX_EXTENSION_ARCHIVE_BYTES
868+
):
869+
raise ExtensionError(
870+
"Extension archive is too large; maximum allowed size "
871+
f"is {MAX_EXTENSION_ARCHIVE_BYTES // (1024 * 1024)} MiB."
872+
)
873+
874+
downloaded = 0
875+
with archive_path.open("wb") as fh:
876+
while True:
877+
chunk = response.read(DOWNLOAD_CHUNK_BYTES)
878+
if not chunk:
879+
break
880+
downloaded += len(chunk)
881+
if downloaded > MAX_EXTENSION_ARCHIVE_BYTES:
882+
raise ExtensionError(
883+
"Extension archive is too large; maximum allowed "
884+
f"size is {MAX_EXTENSION_ARCHIVE_BYTES // (1024 * 1024)} MiB."
885+
)
886+
fh.write(chunk)
850887
except urllib.error.URLError as exc:
851888
raise ExtensionError(
852889
f"Failed to download extension from {display_url}: {exc}"

tests/integrations/test_cli.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import json
44
import os
5+
import tarfile
6+
from io import BytesIO
57

8+
import pytest
69
import yaml
710

811
from tests.conftest import strip_ansi
@@ -338,15 +341,27 @@ def test_extension_url_output_redacts_credentials_and_query(self, tmp_path, monk
338341
import specify_cli
339342
from specify_cli import app
340343

344+
payload = b"fake-archive"
345+
341346
class FakeResponse:
347+
headers = {"Content-Length": str(len(payload))}
348+
349+
def __init__(self):
350+
self.offset = 0
351+
342352
def __enter__(self):
343353
return self
344354

345355
def __exit__(self, exc_type, exc, tb):
346356
return False
347357

348358
def read(self, size=-1):
349-
return b"fake-archive"
359+
if self.offset >= len(payload):
360+
return b""
361+
end = min(self.offset + size, len(payload))
362+
chunk = payload[self.offset:end]
363+
self.offset = end
364+
return chunk
350365

351366
def fake_urlopen(*args, **kwargs):
352367
return FakeResponse()
@@ -373,6 +388,82 @@ def fake_urlopen(*args, **kwargs):
373388
assert "user:secret" not in result.output
374389
assert "token=abc123" not in result.output
375390

391+
def test_tar_extension_archive_rejects_special_members(self, tmp_path):
392+
"""TAR extension archives reject non-file and non-directory members."""
393+
from specify_cli import _install_extension_archive
394+
from specify_cli.extensions import ValidationError
395+
396+
archive_path = tmp_path / "unsafe-extension.tar"
397+
manifest = b"extension:\n id: test-ext\n name: Test\n version: 1.0.0\n"
398+
399+
with tarfile.open(archive_path, "w") as tf:
400+
manifest_info = tarfile.TarInfo("extension.yml")
401+
manifest_info.size = len(manifest)
402+
tf.addfile(manifest_info, BytesIO(manifest))
403+
404+
fifo_info = tarfile.TarInfo("unsafe-fifo")
405+
fifo_info.type = tarfile.FIFOTYPE
406+
tf.addfile(fifo_info)
407+
408+
with pytest.raises(ValidationError, match="Unsupported TAR member type"):
409+
_install_extension_archive(object(), archive_path, "0.0.0")
410+
411+
def test_extension_url_downloads_in_bounded_chunks(self, tmp_path, monkeypatch):
412+
"""URL extension downloads stream to disk instead of reading all bytes."""
413+
import urllib.request
414+
import specify_cli
415+
416+
payload = b"archive-bytes"
417+
read_sizes = []
418+
419+
class FakeResponse:
420+
headers = {"Content-Length": str(len(payload))}
421+
422+
def __init__(self):
423+
self.offset = 0
424+
425+
def __enter__(self):
426+
return self
427+
428+
def __exit__(self, exc_type, exc, tb):
429+
return False
430+
431+
def read(self, size=-1):
432+
read_sizes.append(size)
433+
if self.offset >= len(payload):
434+
return b""
435+
end = min(self.offset + size, len(payload))
436+
chunk = payload[self.offset:end]
437+
self.offset = end
438+
return chunk
439+
440+
def fake_urlopen(url, timeout):
441+
assert url == "https://example.com/extension.zip"
442+
assert timeout == 60
443+
return FakeResponse()
444+
445+
def fake_install(manager, archive_path, speckit_version, priority=10):
446+
assert archive_path.read_bytes() == payload
447+
assert speckit_version == "0.0.0"
448+
assert priority == 10
449+
return "installed"
450+
451+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
452+
monkeypatch.setattr(specify_cli, "_install_extension_archive", fake_install)
453+
454+
result = specify_cli._download_and_install_extension_url(
455+
object(),
456+
tmp_path,
457+
"https://example.com/extension.zip",
458+
"0.0.0",
459+
)
460+
461+
assert result == "installed"
462+
assert read_sizes == [
463+
specify_cli.DOWNLOAD_CHUNK_BYTES,
464+
specify_cli.DOWNLOAD_CHUNK_BYTES,
465+
]
466+
376467

377468
class TestForceExistingDirectory:
378469
"""Tests for --force merging into an existing named directory."""

0 commit comments

Comments
 (0)