Skip to content

Commit 4ddb1cb

Browse files
fix(init): harden extension archive downloads
1 parent 5a44067 commit 4ddb1cb

3 files changed

Lines changed: 118 additions & 2 deletions

File tree

README.md

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

333333
## Available Slash Commands
334334

335-
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`.
335+
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.
336336

337337
### Core Commands
338338

src/specify_cli/__init__.py

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

680680

681+
MAX_EXTENSION_ARCHIVE_BYTES = 100 * 1024 * 1024
682+
DOWNLOAD_CHUNK_BYTES = 1024 * 1024
683+
684+
681685
def _install_extension_archive(
682686
manager: Any,
683687
archive_path: Path,
@@ -706,6 +710,10 @@ def _install_extension_archive(
706710
raise ValidationError(
707711
f"Unsafe link in TAR archive: {member.name}"
708712
)
713+
if not (member.isfile() or member.isdir()):
714+
raise ValidationError(
715+
f"Unsupported TAR member type in archive: {member.name}"
716+
)
709717
member_path = (temp_path / member.name).resolve()
710718
try:
711719
member_path.relative_to(temp_path_resolved)
@@ -775,7 +783,36 @@ def _download_and_install_extension_url(
775783

776784
try:
777785
with urllib.request.urlopen(source_url, timeout=60) as response:
778-
archive_path.write_bytes(response.read())
786+
content_length = response.headers.get("Content-Length")
787+
try:
788+
content_length_bytes = (
789+
int(content_length) if content_length is not None else None
790+
)
791+
except ValueError:
792+
content_length_bytes = None
793+
794+
if (
795+
content_length_bytes
796+
and content_length_bytes > MAX_EXTENSION_ARCHIVE_BYTES
797+
):
798+
raise ExtensionError(
799+
"Extension archive is too large; maximum allowed size "
800+
f"is {MAX_EXTENSION_ARCHIVE_BYTES // (1024 * 1024)} MiB."
801+
)
802+
803+
downloaded = 0
804+
with archive_path.open("wb") as fh:
805+
while True:
806+
chunk = response.read(DOWNLOAD_CHUNK_BYTES)
807+
if not chunk:
808+
break
809+
downloaded += len(chunk)
810+
if downloaded > MAX_EXTENSION_ARCHIVE_BYTES:
811+
raise ExtensionError(
812+
"Extension archive is too large; maximum allowed "
813+
f"size is {MAX_EXTENSION_ARCHIVE_BYTES // (1024 * 1024)} MiB."
814+
)
815+
fh.write(chunk)
779816
except urllib.error.URLError as exc:
780817
raise ExtensionError(
781818
f"Failed to download extension from {source_url}: {exc}"

tests/integrations/test_cli.py

Lines changed: 79 additions & 0 deletions
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

@@ -269,6 +272,82 @@ def test_extension_git_explicit_opt_in_works_with_no_git(self, tmp_path):
269272
assert (project / ".specify" / "extensions" / "git").exists()
270273
assert not (project / ".git").exists()
271274

275+
def test_tar_extension_archive_rejects_special_members(self, tmp_path):
276+
"""TAR extension archives reject non-file and non-directory members."""
277+
from specify_cli import _install_extension_archive
278+
from specify_cli.extensions import ValidationError
279+
280+
archive_path = tmp_path / "unsafe-extension.tar"
281+
manifest = b"extension:\n id: test-ext\n name: Test\n version: 1.0.0\n"
282+
283+
with tarfile.open(archive_path, "w") as tf:
284+
manifest_info = tarfile.TarInfo("extension.yml")
285+
manifest_info.size = len(manifest)
286+
tf.addfile(manifest_info, BytesIO(manifest))
287+
288+
fifo_info = tarfile.TarInfo("unsafe-fifo")
289+
fifo_info.type = tarfile.FIFOTYPE
290+
tf.addfile(fifo_info)
291+
292+
with pytest.raises(ValidationError, match="Unsupported TAR member type"):
293+
_install_extension_archive(object(), archive_path, "0.0.0")
294+
295+
def test_extension_url_downloads_in_bounded_chunks(self, tmp_path, monkeypatch):
296+
"""URL extension downloads stream to disk instead of reading all bytes."""
297+
import urllib.request
298+
import specify_cli
299+
300+
payload = b"archive-bytes"
301+
read_sizes = []
302+
303+
class FakeResponse:
304+
headers = {"Content-Length": str(len(payload))}
305+
306+
def __init__(self):
307+
self.offset = 0
308+
309+
def __enter__(self):
310+
return self
311+
312+
def __exit__(self, exc_type, exc, tb):
313+
return False
314+
315+
def read(self, size=-1):
316+
read_sizes.append(size)
317+
if self.offset >= len(payload):
318+
return b""
319+
end = min(self.offset + size, len(payload))
320+
chunk = payload[self.offset:end]
321+
self.offset = end
322+
return chunk
323+
324+
def fake_urlopen(url, timeout):
325+
assert url == "https://example.com/extension.zip"
326+
assert timeout == 60
327+
return FakeResponse()
328+
329+
def fake_install(manager, archive_path, speckit_version, priority=10):
330+
assert archive_path.read_bytes() == payload
331+
assert speckit_version == "0.0.0"
332+
assert priority == 10
333+
return "installed"
334+
335+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
336+
monkeypatch.setattr(specify_cli, "_install_extension_archive", fake_install)
337+
338+
result = specify_cli._download_and_install_extension_url(
339+
object(),
340+
tmp_path,
341+
"https://example.com/extension.zip",
342+
"0.0.0",
343+
)
344+
345+
assert result == "installed"
346+
assert read_sizes == [
347+
specify_cli.DOWNLOAD_CHUNK_BYTES,
348+
specify_cli.DOWNLOAD_CHUNK_BYTES,
349+
]
350+
272351

273352
class TestForceExistingDirectory:
274353
"""Tests for --force merging into an existing named directory."""

0 commit comments

Comments
 (0)