diff --git a/lib/cli/src/crewai_cli/skills/main.py b/lib/cli/src/crewai_cli/skills/main.py index 07ccef377..7e3b2cef2 100644 --- a/lib/cli/src/crewai_cli/skills/main.py +++ b/lib/cli/src/crewai_cli/skills/main.py @@ -233,8 +233,8 @@ class SkillCommand(BaseCommand, PlusAPIMixin): f"[bold blue]Publishing skill [bold]{name}[/bold] v{version} to {effective_org}...[/bold blue]" ) - archive_bytes = self._build_skill_zip() - encoded_file = "data:application/zip;base64," + base64.b64encode( + archive_bytes = self._build_skill_tarball() + encoded_file = "data:application/x-gzip;base64," + base64.b64encode( archive_bytes ).decode("utf-8") @@ -341,17 +341,17 @@ class SkillCommand(BaseCommand, PlusAPIMixin): with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf: _safe_extract_zip(zf, dest) - def _build_skill_zip(self) -> bytes: - """Build an in-memory ZIP of SKILL.md + scripts/ + references/ + assets/.""" + def _build_skill_tarball(self) -> bytes: + """Build an in-memory .tar.gz of SKILL.md + scripts/ + references/ + assets/.""" buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: - zf.write("SKILL.md") + with tarfile.open(fileobj=buf, mode="w:gz") as tf: + tf.add("SKILL.md") for folder in ("scripts", "references", "assets"): folder_path = Path(folder) if folder_path.is_dir(): for fpath in sorted(folder_path.rglob("*")): if fpath.is_file(): - zf.write(fpath) + tf.add(str(fpath)) return buf.getvalue() def _parse_frontmatter(self, content: str) -> dict[str, str]: diff --git a/lib/crewai/src/crewai/skills/cache.py b/lib/crewai/src/crewai/skills/cache.py index d2cc6fe9a..ef0f25201 100644 --- a/lib/crewai/src/crewai/skills/cache.py +++ b/lib/crewai/src/crewai/skills/cache.py @@ -12,6 +12,7 @@ import logging from pathlib import Path import tarfile from typing import TypedDict +import zipfile _logger = logging.getLogger(__name__) @@ -71,12 +72,16 @@ class SkillCacheManager: import io - with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf: - try: - tf.extractall(skill_dir, filter="data") - except TypeError: - # Python < 3.12 doesn't support filter= keyword; fall back safely - _safe_extractall(tf, skill_dir) + # Try tar.gz first, fall back to zip + try: + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tf: + try: + tf.extractall(skill_dir, filter="data") + except TypeError: + _safe_extractall(tf, skill_dir) + except tarfile.TarError: + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as zf: + _safe_extract_zip(zf, skill_dir) meta: SkillMetadata = { "org": org, @@ -131,3 +136,13 @@ def _safe_extractall(tf: tarfile.TarFile, dest: Path) -> None: if not member_path.is_relative_to(dest_resolved): raise ValueError(f"Blocked path traversal attempt: {member.name!r}") tf.extractall(dest) # noqa: S202 + + +def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None: + """Path-traversal-safe ZIP extraction.""" + dest_resolved = dest.resolve() + for member in zf.namelist(): + member_path = (dest / member).resolve() + if not member_path.is_relative_to(dest_resolved): + raise ValueError(f"Blocked path traversal attempt: {member!r}") + zf.extractall(dest) # noqa: S202