mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-04 16:14:09 +00:00
Fixes #37748. Sort skill package archive entries by relative POSIX archive name so generated `.skill` bundles are reproducible regardless of filesystem traversal order. Verification: - `PYTHONDONTWRITEBYTECODE=1 python3 skills/skill-creator/scripts/test_package_skill.py` - `git diff --check origin/main...HEAD` - GitHub CI run 26690938925 on `43a0fdf7175f33a5c74bc7ff92723ebf5efc4df9`: all checks passed except repeated unrelated no-output timeouts in `checks-node-agentic-commands-doctor` and `checks-node-core-runtime-infra-state` after visible tests passed.
200 lines
7.6 KiB
Python
200 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Regression tests for skill packaging security behavior.
|
|
"""
|
|
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
import zipfile
|
|
from pathlib import Path
|
|
from unittest import TestCase, main
|
|
from unittest.mock import patch
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
if str(SCRIPT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
|
|
fake_quick_validate = types.ModuleType("quick_validate")
|
|
fake_quick_validate.validate_skill = lambda _path: (True, "Skill is valid!")
|
|
original_quick_validate = sys.modules.get("quick_validate")
|
|
sys.modules["quick_validate"] = fake_quick_validate
|
|
|
|
import package_skill as package_skill_module
|
|
|
|
package_skill = package_skill_module.package_skill
|
|
|
|
if original_quick_validate is not None:
|
|
sys.modules["quick_validate"] = original_quick_validate
|
|
else:
|
|
sys.modules.pop("quick_validate", None)
|
|
|
|
|
|
class TestPackageSkillSecurity(TestCase):
|
|
def setUp(self):
|
|
self.temp_dir = Path(tempfile.mkdtemp(prefix="test_skill_"))
|
|
|
|
def tearDown(self):
|
|
import shutil
|
|
|
|
if self.temp_dir.exists():
|
|
shutil.rmtree(self.temp_dir)
|
|
|
|
def create_skill(self, name="test-skill"):
|
|
skill_dir = self.temp_dir / name
|
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
(skill_dir / "SKILL.md").write_text("---\nname: test-skill\ndescription: test\n---\n")
|
|
(skill_dir / "script.py").write_text("print('ok')\n")
|
|
return skill_dir
|
|
|
|
def test_packages_normal_files(self):
|
|
skill_dir = self.create_skill("normal-skill")
|
|
out_dir = self.temp_dir / "out"
|
|
out_dir.mkdir()
|
|
|
|
result = package_skill(str(skill_dir), str(out_dir))
|
|
|
|
self.assertIsNotNone(result)
|
|
skill_file = out_dir / "normal-skill.skill"
|
|
self.assertTrue(skill_file.exists())
|
|
with zipfile.ZipFile(skill_file, "r") as archive:
|
|
names = set(archive.namelist())
|
|
self.assertIn("normal-skill/SKILL.md", names)
|
|
self.assertIn("normal-skill/script.py", names)
|
|
|
|
def test_skips_symlink_to_external_file(self):
|
|
skill_dir = self.create_skill("symlink-file-skill")
|
|
outside = self.temp_dir / "outside-secret.txt"
|
|
outside.write_text("super-secret\n")
|
|
link = skill_dir / "loot.txt"
|
|
out_dir = self.temp_dir / "out"
|
|
out_dir.mkdir()
|
|
|
|
try:
|
|
link.symlink_to(outside)
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("symlink unsupported on this platform")
|
|
|
|
result = package_skill(str(skill_dir), str(out_dir))
|
|
self.assertIsNotNone(result)
|
|
skill_file = out_dir / "symlink-file-skill.skill"
|
|
self.assertTrue(skill_file.exists())
|
|
with zipfile.ZipFile(skill_file, "r") as archive:
|
|
names = set(archive.namelist())
|
|
self.assertIn("symlink-file-skill/SKILL.md", names)
|
|
self.assertIn("symlink-file-skill/script.py", names)
|
|
self.assertNotIn("symlink-file-skill/loot.txt", names)
|
|
|
|
def test_skips_symlink_directory(self):
|
|
skill_dir = self.create_skill("symlink-dir-skill")
|
|
outside_dir = self.temp_dir / "outside"
|
|
outside_dir.mkdir()
|
|
(outside_dir / "secret.txt").write_text("secret\n")
|
|
link = skill_dir / "docs"
|
|
out_dir = self.temp_dir / "out"
|
|
out_dir.mkdir()
|
|
|
|
try:
|
|
link.symlink_to(outside_dir, target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
self.skipTest("symlink unsupported on this platform")
|
|
|
|
result = package_skill(str(skill_dir), str(out_dir))
|
|
self.assertIsNotNone(result)
|
|
skill_file = out_dir / "symlink-dir-skill.skill"
|
|
with zipfile.ZipFile(skill_file, "r") as archive:
|
|
names = set(archive.namelist())
|
|
self.assertIn("symlink-dir-skill/SKILL.md", names)
|
|
self.assertIn("symlink-dir-skill/script.py", names)
|
|
self.assertNotIn("symlink-dir-skill/docs/secret.txt", names)
|
|
|
|
def test_rejects_resolved_path_outside_skill_root(self):
|
|
skill_dir = self.create_skill("escape-skill")
|
|
out_dir = self.temp_dir / "out"
|
|
out_dir.mkdir()
|
|
|
|
original_within = package_skill_module._is_within
|
|
|
|
def fake_is_within(path_obj: Path, root: Path):
|
|
if path_obj.name == "script.py":
|
|
return False
|
|
return original_within(path_obj, root)
|
|
|
|
with patch.object(package_skill_module, "_is_within", fake_is_within):
|
|
result = package_skill(str(skill_dir), str(out_dir))
|
|
|
|
self.assertIsNone(result)
|
|
|
|
def test_allows_nested_regular_files(self):
|
|
skill_dir = self.create_skill("nested-skill")
|
|
nested = skill_dir / "lib" / "helpers"
|
|
nested.mkdir(parents=True, exist_ok=True)
|
|
(nested / "util.py").write_text("def run():\n return 1\n")
|
|
out_dir = self.temp_dir / "out"
|
|
out_dir.mkdir()
|
|
|
|
result = package_skill(str(skill_dir), str(out_dir))
|
|
|
|
self.assertIsNotNone(result)
|
|
skill_file = out_dir / "nested-skill.skill"
|
|
with zipfile.ZipFile(skill_file, "r") as archive:
|
|
names = set(archive.namelist())
|
|
self.assertIn("nested-skill/lib/helpers/util.py", names)
|
|
|
|
def test_skips_output_archive_when_output_dir_is_skill_dir(self):
|
|
skill_dir = self.create_skill("self-output-skill")
|
|
|
|
result = package_skill(str(skill_dir), str(skill_dir))
|
|
|
|
self.assertIsNotNone(result)
|
|
skill_file = skill_dir / "self-output-skill.skill"
|
|
self.assertTrue(skill_file.exists())
|
|
with zipfile.ZipFile(skill_file, "r") as archive:
|
|
names = set(archive.namelist())
|
|
self.assertIn("self-output-skill/SKILL.md", names)
|
|
self.assertIn("self-output-skill/script.py", names)
|
|
self.assertNotIn("self-output-skill/self-output-skill.skill", names)
|
|
|
|
def test_archive_entry_order_is_deterministic(self):
|
|
skill_dir = self.create_skill("order-skill")
|
|
# Files across multiple levels, created in non-sorted order, so the
|
|
# filesystem/rglob enumeration order differs from a lexicographic sort.
|
|
(skill_dir / "zeta.md").write_text("z\n")
|
|
(skill_dir / "yankee.txt").write_text("y\n")
|
|
alpha = skill_dir / "alpha"
|
|
alpha.mkdir()
|
|
(alpha / "delta.txt").write_text("d\n")
|
|
(alpha / "bravo.txt").write_text("b\n")
|
|
nested = skill_dir / "zlib"
|
|
nested.mkdir()
|
|
(nested / "november.txt").write_text("n\n")
|
|
# "alpha-x.txt" discriminates entry-name ordering from Path-object
|
|
# ordering: "-" (0x2d) sorts before "/" (0x2f) in the archive entry
|
|
# name, but Path part-tuple ordering places it after the "alpha/" dir.
|
|
(skill_dir / "alpha-x.txt").write_text("x\n")
|
|
out_dir = self.temp_dir / "out"
|
|
out_dir.mkdir()
|
|
|
|
result = package_skill(str(skill_dir), str(out_dir))
|
|
|
|
self.assertIsNotNone(result)
|
|
skill_file = out_dir / "order-skill.skill"
|
|
with zipfile.ZipFile(skill_file, "r") as archive:
|
|
names = [name for name in archive.namelist() if not name.endswith("/")]
|
|
# Entries must be ordered by their archive entry name, regardless of
|
|
# filesystem enumeration or OS path-flavour, so archives are reproducible.
|
|
self.assertEqual(names, sorted(names))
|
|
# Lock the entry-name contract: "alpha-x.txt" precedes "alpha/bravo.txt"
|
|
# (Path-object sorting would invert these).
|
|
self.assertLess(
|
|
names.index("order-skill/alpha-x.txt"),
|
|
names.index("order-skill/alpha/bravo.txt"),
|
|
)
|
|
# Ensure the fixture actually spans multiple directories/files.
|
|
self.assertIn("order-skill/zlib/november.txt", names)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|