mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 15:25:09 +00:00
* feat(skills)!: promote Skills Repository out of experimental The registry-backed Skills Repository (crewai skill create/publish/ install/list, @org/name refs, global cache) is now mainline: - CLI: `crewai skill ...` is a top-level group; the CREWAI_EXPERIMENTAL gate and the now-empty `crewai experimental` group are removed. - Runtime: registry.py, cache.py, and events.py move from crewai.experimental.skills into crewai.skills next to the loader; the require_experimental_skills() gate is gone. crewai.experimental.skills remains as a deprecated re-export shim. - Docs: concepts/skills now leads with the CLI workflow and documents the create -> publish -> install lifecycle. Linear: n/a (requested promotion) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): org-scoped publish only + docs in all languages Skills are always scoped to the publishing organization, like tools: drop the --public/--private flags from `crewai skill publish` and always send is_public=False to the registry. CLI tests assert the flag is rejected and the API never receives a public publish. Translate the new CLI-first Quick Start and the create -> publish -> install lifecycle section into ar, pt-BR, and ko concepts/skills docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): address review comments on the promotion PR - Back-compat shim now aliases the old submodules in sys.modules so `crewai.experimental.skills.registry/cache/events` imports (and patch targets) resolve to the real crewai.skills modules, not just the package-root re-exports. - `crewai skill publish` actually enforces the git-state check that --force claims to skip: unsynced repos block publishing (mirroring tool publish); standalone skill dirs outside any git repo publish without a check. - Explicit UTF-8 encoding on SKILL.md and cache-metadata reads/writes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): fail closed when git state cannot be validated on publish Follow deploy's pattern: construct git.Repository(fetch=False) and only treat "not a Git repository" as skippable — any other git error (fetch/auth/misconfiguration) now blocks publish with a --force escape hatch instead of silently bypassing the sync check. Also single-style imports in the shim test (CodeQL) with the dotted shim import covered via importlib. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): fetch before sync check on publish; bump mcp past advisories Publish now refreshes remote-tracking refs (repository.fetch()) before is_synced(), so ahead/behind is judged against the actual remote rather than stale local refs; a failing fetch blocks publish with the --force escape hatch. Adds a fail-closed test for fetch errors. Raise mcp to >=1.28.1,<2 (locks 1.28.1): the ~=1.26.0 pin blocked GHSA-hvrp-rf83-w775 / GHSA-jpw9-pfvf-9f58 (fixed 1.27.2) and GHSA-vj7q-gjh5-988w (fixed 1.28.1), which were failing pip-audit on this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Vinicius Brasil <vini@hey.com>
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""Regression tests for path-traversal-safe archive extraction.
|
|
|
|
Guards against symlink/hardlink-based path traversal in the fallback used on
|
|
Python versions without tarfile extraction filters. The filtered path relies on
|
|
`tarfile.extractall(..., filter="data")`; the fallback must provide the same
|
|
protection by validating link targets, not just member names.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from crewai_cli.skills.main import _safe_extractall
|
|
|
|
|
|
def _tar_from_members(build) -> tarfile.TarFile:
|
|
"""Build an in-memory tar archive via `build(tf)` and return it for reading."""
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w") as tf:
|
|
build(tf)
|
|
buf.seek(0)
|
|
return tarfile.open(fileobj=buf, mode="r")
|
|
|
|
|
|
def test_blocks_symlink_escaping_destination(tmp_path: Path) -> None:
|
|
"""A symlink whose target escapes dest, plus a file written through it,
|
|
must be rejected before anything is extracted."""
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
dest = tmp_path / "dest"
|
|
dest.mkdir()
|
|
|
|
def build(tf: tarfile.TarFile) -> None:
|
|
link = tarfile.TarInfo("link")
|
|
link.type = tarfile.SYMTYPE
|
|
link.linkname = str(outside) # absolute path outside dest
|
|
tf.addfile(link)
|
|
payload = b"pwned"
|
|
info = tarfile.TarInfo("link/evil.txt")
|
|
info.size = len(payload)
|
|
tf.addfile(info, io.BytesIO(payload))
|
|
|
|
with _tar_from_members(build) as tf:
|
|
with pytest.raises(ValueError, match="escaping destination"):
|
|
_safe_extractall(tf, dest)
|
|
|
|
assert not (outside / "evil.txt").exists()
|
|
|
|
|
|
def test_blocks_relative_symlink_escaping_destination(tmp_path: Path) -> None:
|
|
"""A relative symlink (../..) that escapes dest is also rejected."""
|
|
dest = tmp_path / "dest"
|
|
dest.mkdir()
|
|
|
|
def build(tf: tarfile.TarFile) -> None:
|
|
link = tarfile.TarInfo("sub/link")
|
|
link.type = tarfile.SYMTYPE
|
|
link.linkname = "../../outside" # escapes dest from sub/
|
|
tf.addfile(link)
|
|
|
|
with _tar_from_members(build) as tf:
|
|
with pytest.raises(ValueError, match="escaping destination"):
|
|
_safe_extractall(tf, dest)
|
|
|
|
|
|
def test_blocks_hardlink_escaping_destination(tmp_path: Path) -> None:
|
|
"""A hardlink whose target escapes dest is rejected."""
|
|
dest = tmp_path / "dest"
|
|
dest.mkdir()
|
|
|
|
def build(tf: tarfile.TarFile) -> None:
|
|
link = tarfile.TarInfo("escape")
|
|
link.type = tarfile.LNKTYPE
|
|
link.linkname = "../outside.txt" # escapes archive root
|
|
tf.addfile(link)
|
|
|
|
with _tar_from_members(build) as tf:
|
|
with pytest.raises(ValueError, match="escaping destination"):
|
|
_safe_extractall(tf, dest)
|
|
|
|
|
|
def test_blocks_special_tar_member(tmp_path: Path) -> None:
|
|
"""Special tar members such as FIFOs are rejected."""
|
|
dest = tmp_path / "dest"
|
|
dest.mkdir()
|
|
|
|
def build(tf: tarfile.TarFile) -> None:
|
|
fifo = tarfile.TarInfo("pipe")
|
|
fifo.type = tarfile.FIFOTYPE
|
|
tf.addfile(fifo)
|
|
|
|
with _tar_from_members(build) as tf:
|
|
with pytest.raises(ValueError, match="unsupported tar member"):
|
|
_safe_extractall(tf, dest)
|
|
|
|
|
|
def test_allows_benign_relative_symlink(tmp_path: Path) -> None:
|
|
"""A symlink that stays within dest is permitted."""
|
|
dest = tmp_path / "dest"
|
|
dest.mkdir()
|
|
|
|
def build(tf: tarfile.TarFile) -> None:
|
|
payload = b"hi"
|
|
info = tarfile.TarInfo("real.txt")
|
|
info.size = len(payload)
|
|
tf.addfile(info, io.BytesIO(payload))
|
|
link = tarfile.TarInfo("alias.txt")
|
|
link.type = tarfile.SYMTYPE
|
|
link.linkname = "real.txt" # stays inside dest
|
|
tf.addfile(link)
|
|
|
|
with _tar_from_members(build) as tf:
|
|
_safe_extractall(tf, dest)
|
|
|
|
assert (dest / "real.txt").read_bytes() == b"hi"
|
|
assert (dest / "alias.txt").is_symlink()
|
|
assert (dest / "alias.txt").readlink() == Path("real.txt")
|
|
|
|
|
|
def test_allows_benign_archive(tmp_path: Path) -> None:
|
|
"""An ordinary archive of regular files extracts correctly."""
|
|
dest = tmp_path / "dest"
|
|
dest.mkdir()
|
|
|
|
def build(tf: tarfile.TarFile) -> None:
|
|
for name, body in (("SKILL.md", b"# skill"), ("scripts/run.py", b"print(1)")):
|
|
payload = body
|
|
info = tarfile.TarInfo(name)
|
|
info.size = len(payload)
|
|
tf.addfile(info, io.BytesIO(payload))
|
|
|
|
with _tar_from_members(build) as tf:
|
|
_safe_extractall(tf, dest)
|
|
|
|
assert (dest / "SKILL.md").read_bytes() == b"# skill"
|
|
assert (dest / "scripts" / "run.py").read_bytes() == b"print(1)"
|