226 lines
7.1 KiB
Python
226 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
TMP = ROOT / "tests" / "tmp_security"
|
|
CROSS_PACKAGER = ROOT / "scripts" / "cross_packager.py"
|
|
INIT_SKILL = ROOT / "scripts" / "init_skill.py"
|
|
CHECK_UPDATE = ROOT / "scripts" / "check_update.py"
|
|
|
|
|
|
def run(cmd: list[str]) -> dict:
|
|
proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
|
|
payload = {}
|
|
if proc.stdout.strip():
|
|
payload = json.loads(proc.stdout)
|
|
return {
|
|
"returncode": proc.returncode,
|
|
"ok": proc.returncode == 0,
|
|
"stdout": proc.stdout,
|
|
"stderr": proc.stderr,
|
|
"payload": payload,
|
|
}
|
|
|
|
|
|
def write_skill(root: Path, *, interface_extra: str = "") -> Path:
|
|
skill = root / "secure-demo"
|
|
(skill / "agents").mkdir(parents=True, exist_ok=True)
|
|
(skill / "SKILL.md").write_text(
|
|
"---\nname: secure-demo\ndescription: Secure demo skill.\n---\n\n# Secure Demo\n",
|
|
encoding="utf-8",
|
|
)
|
|
interface_text = f"""interface:
|
|
display_name: "Secure Demo"
|
|
short_description: "Safe package demo"
|
|
default_prompt: "Use this demo."
|
|
compatibility:
|
|
canonical_format: "agent-skills"
|
|
adapter_targets:
|
|
- "openai"
|
|
- "generic"
|
|
activation:
|
|
mode: "manual"
|
|
paths: []
|
|
execution:
|
|
context: "inline"
|
|
shell: "bash"
|
|
trust:
|
|
source_tier: "local"
|
|
remote_inline_execution: "forbid"
|
|
remote_metadata_policy: "allow-metadata-only"
|
|
degradation:
|
|
openai: "metadata-adapter"
|
|
generic: "neutral-source"
|
|
{interface_extra}"""
|
|
(skill / "agents" / "interface.yaml").write_text(interface_text, encoding="utf-8")
|
|
return skill
|
|
|
|
|
|
def test_dangerous_output_dir() -> dict:
|
|
skill = write_skill(TMP / "dangerous-output")
|
|
result = run(
|
|
[
|
|
sys.executable,
|
|
str(CROSS_PACKAGER),
|
|
str(skill),
|
|
"--platform",
|
|
"generic",
|
|
"--output-dir",
|
|
str(ROOT),
|
|
]
|
|
)
|
|
passed = result["returncode"] == 2 and "Refusing dangerous output directory" in result["stdout"]
|
|
return {"name": "dangerous_output_dir_rejected", "passed": passed, **result}
|
|
|
|
|
|
def test_zip_skips_symlink() -> dict:
|
|
case_root = TMP / "symlink-zip"
|
|
skill = write_skill(case_root)
|
|
outside_secret = case_root / "outside-secret.txt"
|
|
outside_secret.write_text("SECRET_FROM_OUTSIDE", encoding="utf-8")
|
|
(skill / "linked-secret.txt").symlink_to(outside_secret)
|
|
out_dir = case_root / "dist"
|
|
result = run(
|
|
[
|
|
sys.executable,
|
|
str(CROSS_PACKAGER),
|
|
str(skill),
|
|
"--platform",
|
|
"generic",
|
|
"--output-dir",
|
|
str(out_dir),
|
|
"--zip",
|
|
]
|
|
)
|
|
zip_path = out_dir / "secure-demo.zip"
|
|
leaked = False
|
|
contains_link = False
|
|
if zip_path.exists():
|
|
with zipfile.ZipFile(zip_path) as zf:
|
|
for name in zf.namelist():
|
|
if name.endswith("linked-secret.txt"):
|
|
contains_link = True
|
|
leaked = zf.read(name).decode("utf-8") == "SECRET_FROM_OUTSIDE"
|
|
passed = result["ok"] and not contains_link and not leaked
|
|
return {"name": "zip_skips_symlink", "passed": passed, "contains_link": contains_link, "leaked": leaked, **result}
|
|
|
|
|
|
def test_output_dir_symlink_rejected() -> dict:
|
|
case_root = TMP / "symlink-output"
|
|
skill = write_skill(case_root)
|
|
real_out = case_root / "real-output"
|
|
real_out.mkdir(parents=True)
|
|
symlink_out = case_root / "linked-output"
|
|
symlink_out.symlink_to(real_out, target_is_directory=True)
|
|
result = run(
|
|
[
|
|
sys.executable,
|
|
str(CROSS_PACKAGER),
|
|
str(skill),
|
|
"--platform",
|
|
"generic",
|
|
"--output-dir",
|
|
str(symlink_out),
|
|
]
|
|
)
|
|
passed = result["returncode"] == 2 and "Refusing symlink output directory" in result["stdout"]
|
|
return {"name": "output_dir_symlink_rejected", "passed": passed, **result}
|
|
|
|
|
|
def test_init_rejects_path_traversal() -> dict:
|
|
case_root = TMP / "init-traversal"
|
|
output_dir = case_root / "container"
|
|
outside = case_root / "outside-target"
|
|
result = run(
|
|
[
|
|
sys.executable,
|
|
str(INIT_SKILL),
|
|
"../outside-target",
|
|
"--description",
|
|
"Traversal demo.",
|
|
"--output-dir",
|
|
str(output_dir),
|
|
]
|
|
)
|
|
passed = result["returncode"] == 2 and not outside.exists() and "Invalid skill name" in result["stdout"]
|
|
return {"name": "init_rejects_path_traversal", "passed": passed, "outside_exists": outside.exists(), **result}
|
|
|
|
|
|
def test_update_rejects_file_url() -> dict:
|
|
remote_version = TMP / "remote-version.txt"
|
|
remote_version.write_text("9.9.9\n", encoding="utf-8")
|
|
result = run(
|
|
[
|
|
sys.executable,
|
|
str(CHECK_UPDATE),
|
|
"--force",
|
|
"--no-cache",
|
|
"--version-url",
|
|
remote_version.as_uri(),
|
|
"--manifest-url",
|
|
remote_version.as_uri(),
|
|
]
|
|
)
|
|
passed = result["returncode"] == 2 and "Update URL scheme is not allowed: file" in result["stdout"]
|
|
return {"name": "update_rejects_file_url", "passed": passed, **result}
|
|
|
|
|
|
def test_openai_yaml_uses_safe_dump() -> dict:
|
|
case_root = TMP / "safe-yaml"
|
|
skill = write_skill(case_root)
|
|
interface_path = skill / "agents" / "interface.yaml"
|
|
payload = yaml.safe_load(interface_path.read_text(encoding="utf-8"))
|
|
payload["interface"]["display_name"] = 'Quoted "Demo"\nSecond line'
|
|
payload["interface"]["default_prompt"] = 'Use "quotes" and:\n- preserve YAML'
|
|
interface_path.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True), encoding="utf-8")
|
|
out_dir = case_root / "dist"
|
|
result = run(
|
|
[
|
|
sys.executable,
|
|
str(CROSS_PACKAGER),
|
|
str(skill),
|
|
"--platform",
|
|
"openai",
|
|
"--output-dir",
|
|
str(out_dir),
|
|
]
|
|
)
|
|
generated_yaml = out_dir / "targets" / "openai" / "agents" / "openai.yaml"
|
|
parsed = yaml.safe_load(generated_yaml.read_text(encoding="utf-8")) if generated_yaml.exists() else {}
|
|
passed = (
|
|
result["ok"]
|
|
and parsed.get("interface", {}).get("display_name") == payload["interface"]["display_name"]
|
|
and parsed.get("interface", {}).get("default_prompt") == payload["interface"]["default_prompt"]
|
|
)
|
|
return {"name": "openai_yaml_uses_safe_dump", "passed": passed, "parsed": parsed, **result}
|
|
|
|
|
|
def main() -> None:
|
|
if TMP.exists():
|
|
shutil.rmtree(TMP)
|
|
TMP.mkdir(parents=True, exist_ok=True)
|
|
cases = [
|
|
test_dangerous_output_dir(),
|
|
test_zip_skips_symlink(),
|
|
test_output_dir_symlink_rejected(),
|
|
test_init_rejects_path_traversal(),
|
|
test_update_rejects_file_url(),
|
|
test_openai_yaml_uses_safe_dump(),
|
|
]
|
|
report = {"ok": all(case["passed"] for case in cases), "cases": cases}
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
if not report["ok"]:
|
|
raise SystemExit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|