test_agentpack.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. """
  2. Tests for the AgentPack feature (M2 Phase D).
  3. Covers:
  4. - lambdagent.agentpack: manifest validation (kernel, pure)
  5. - agentpaas.engine.agentpack_store: safe install / list / uninstall
  6. - zip-slip defense
  7. - SR-003 shell refusal for third-party packs
  8. Spec: docs/AGENTPACK_SPEC.md.
  9. """
  10. from __future__ import annotations
  11. import os
  12. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  13. os.environ.setdefault("AGENTPAAS_TESTING", "1")
  14. import io
  15. import zipfile
  16. import pytest
  17. import yaml
  18. from lambdagent.agentpack import (
  19. AgentPackManifest,
  20. PackPermissions,
  21. AgentPackError,
  22. load_manifest,
  23. validate_manifest_dict,
  24. )
  25. from agentpaas.engine.agentpack_store import (
  26. install_from_zip,
  27. list_installed,
  28. get_installed,
  29. uninstall,
  30. AgentPackInstallError,
  31. )
  32. # ─────────────────────────────────────────────────────────────────────────────
  33. # Kernel: manifest validation
  34. # ─────────────────────────────────────────────────────────────────────────────
  35. _VALID = {
  36. "id": "research.top-journal-reviewer",
  37. "name": "Top Journal Reviewer",
  38. "version": "0.1.0",
  39. "domain": "research",
  40. "entrypoint": "agents/reviewer.yml",
  41. "permissions": {"network": False, "shell": False, "file_write": "workspace"},
  42. "audience": ["professor", "phd_student"],
  43. "model": {"recommended": ["claude-code/sonnet"]},
  44. }
  45. def test_manifest_valid():
  46. m = validate_manifest_dict(_VALID)
  47. assert m.id == "research.top-journal-reviewer"
  48. assert m.version == "0.1.0"
  49. assert m.permissions.file_write == "workspace"
  50. assert m.permissions.shell is False
  51. assert "professor" in m.audience
  52. assert m.model_recommended == ["claude-code/sonnet"]
  53. @pytest.mark.parametrize("missing", ["id", "name", "version", "domain", "entrypoint", "permissions"])
  54. def test_manifest_missing_required_field(missing):
  55. bad = dict(_VALID)
  56. bad.pop(missing)
  57. with pytest.raises(AgentPackError):
  58. validate_manifest_dict(bad)
  59. def test_manifest_bad_id_rejected():
  60. bad = dict(_VALID, id="Bad ID With Spaces!")
  61. with pytest.raises(AgentPackError):
  62. validate_manifest_dict(bad)
  63. def test_manifest_bad_version_rejected():
  64. bad = dict(_VALID, version="v1")
  65. with pytest.raises(AgentPackError):
  66. validate_manifest_dict(bad)
  67. def test_manifest_entrypoint_escape_rejected():
  68. bad = dict(_VALID, entrypoint="../../etc/passwd")
  69. with pytest.raises(AgentPackError):
  70. validate_manifest_dict(bad)
  71. def test_manifest_bad_file_write_scope_rejected():
  72. bad = dict(_VALID, permissions={"file_write": "everywhere"})
  73. with pytest.raises(AgentPackError):
  74. validate_manifest_dict(bad)
  75. def test_permissions_deny_by_default():
  76. # Empty permissions block → safe defaults.
  77. m = validate_manifest_dict(dict(_VALID, permissions={}))
  78. assert m.permissions.network is False
  79. assert m.permissions.shell is False
  80. assert m.permissions.file_write == "workspace"
  81. def test_permission_summary_zh():
  82. m = validate_manifest_dict(_VALID)
  83. s = m.permissions.summary_zh()
  84. assert "读取本地知识库" in s
  85. assert "不执行 shell" in s
  86. assert "不访问网络" in s
  87. # ─────────────────────────────────────────────────────────────────────────────
  88. # Helpers to build pack zips
  89. # ─────────────────────────────────────────────────────────────────────────────
  90. def _make_pack_zip(tmp_path, manifest_dict, *, entrypoint_body="type: react\nname: x\nsystemPrompt: hi\n", nested=False, extra_members=None):
  91. """Build a .zip containing manifest.yml + the entrypoint config.
  92. If nested=True, wraps everything in a top-level dir."""
  93. prefix = "mypack/" if nested else ""
  94. zpath = tmp_path / "pack.zip"
  95. with zipfile.ZipFile(zpath, "w") as zf:
  96. zf.writestr(prefix + "manifest.yml", yaml.safe_dump(manifest_dict, allow_unicode=True))
  97. zf.writestr(prefix + manifest_dict["entrypoint"], entrypoint_body)
  98. for name, body in (extra_members or {}).items():
  99. zf.writestr(prefix + name, body)
  100. return str(zpath)
  101. # ─────────────────────────────────────────────────────────────────────────────
  102. # Store: install / list / get / uninstall
  103. # ─────────────────────────────────────────────────────────────────────────────
  104. def test_install_list_get_uninstall_roundtrip(tmp_path):
  105. packs_dir = tmp_path / "agentpacks"
  106. packs_dir.mkdir()
  107. zp = _make_pack_zip(tmp_path, _VALID)
  108. pack = install_from_zip(zp, str(packs_dir))
  109. assert pack.id == "research.top-journal-reviewer"
  110. assert pack.version == "0.1.0"
  111. assert os.path.isfile(os.path.join(pack.path, "manifest.yml"))
  112. assert os.path.isfile(os.path.join(pack.path, "agents", "reviewer.yml"))
  113. listed = list_installed(str(packs_dir))
  114. assert len(listed) == 1
  115. assert listed[0].id == pack.id
  116. got = get_installed(str(packs_dir), pack.id)
  117. assert got is not None and got.version == "0.1.0"
  118. n = uninstall(str(packs_dir), pack.id)
  119. assert n == 1
  120. assert list_installed(str(packs_dir)) == []
  121. def test_install_nested_pack_zip(tmp_path):
  122. """A zip with everything under a single top-level dir still installs."""
  123. packs_dir = tmp_path / "agentpacks"
  124. packs_dir.mkdir()
  125. zp = _make_pack_zip(tmp_path, _VALID, nested=True)
  126. pack = install_from_zip(zp, str(packs_dir))
  127. assert pack.id == "research.top-journal-reviewer"
  128. def test_install_rejects_missing_entrypoint(tmp_path):
  129. """Manifest references agents/reviewer.yml but the zip doesn't contain it."""
  130. packs_dir = tmp_path / "agentpacks"
  131. packs_dir.mkdir()
  132. zpath = tmp_path / "pack.zip"
  133. with zipfile.ZipFile(zpath, "w") as zf:
  134. zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
  135. # entrypoint deliberately absent
  136. with pytest.raises((AgentPackInstallError, AgentPackError)):
  137. install_from_zip(str(zpath), str(packs_dir))
  138. def test_install_refuses_third_party_shell(tmp_path):
  139. """SR-003: a pack declaring shell:true is refused unless allow_shell."""
  140. packs_dir = tmp_path / "agentpacks"
  141. packs_dir.mkdir()
  142. shell_manifest = dict(_VALID, id="research.shelly",
  143. permissions={"shell": True, "file_write": "workspace"})
  144. zp = _make_pack_zip(tmp_path, shell_manifest)
  145. with pytest.raises(AgentPackInstallError) as exc:
  146. install_from_zip(zp, str(packs_dir))
  147. assert "shell" in str(exc.value).lower()
  148. # With allow_shell it goes through.
  149. pack = install_from_zip(zp, str(packs_dir), allow_shell=True)
  150. assert pack.manifest.permissions.shell is True
  151. def test_install_rejects_zip_slip(tmp_path):
  152. """A malicious member with ../ must be rejected (SPEC §6 zip-slip)."""
  153. packs_dir = tmp_path / "agentpacks"
  154. packs_dir.mkdir()
  155. zpath = tmp_path / "evil.zip"
  156. with zipfile.ZipFile(zpath, "w") as zf:
  157. zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
  158. zf.writestr("agents/reviewer.yml", "type: react\n")
  159. zf.writestr("../../escape.txt", "pwned")
  160. with pytest.raises(AgentPackInstallError) as exc:
  161. install_from_zip(str(zpath), str(packs_dir))
  162. assert "escape" in str(exc.value).lower() or "unsafe" in str(exc.value).lower()
  163. def test_uninstall_nonexistent_returns_zero(tmp_path):
  164. packs_dir = tmp_path / "agentpacks"
  165. packs_dir.mkdir()
  166. assert uninstall(str(packs_dir), "does.not.exist") == 0
  167. # ─────────────────────────────────────────────────────────────────────────────
  168. # Built-in packs (FR-008) — regression guard: the 3 bundled research packs
  169. # must always load, validate, and compile.
  170. # ─────────────────────────────────────────────────────────────────────────────
  171. _BUNDLED_IDS = {
  172. "research.top-journal-reviewer",
  173. "research.literature-mapper",
  174. "research.grant-planner",
  175. }
  176. def _bundled_pack_dirs():
  177. import glob
  178. repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  179. return sorted(glob.glob(os.path.join(repo_root, "agentexample", "agentpacks", "*", "manifest.yml")))
  180. def test_bundled_packs_present():
  181. found = {load_manifest(os.path.dirname(p)).id for p in _bundled_pack_dirs()}
  182. assert _BUNDLED_IDS.issubset(found), f"missing bundled packs: {_BUNDLED_IDS - found}"
  183. @pytest.mark.parametrize("manifest_path", _bundled_pack_dirs())
  184. def test_bundled_pack_valid_and_compiles(manifest_path):
  185. """Each bundled pack: manifest validates, entrypoint exists, and the
  186. entrypoint config compiles via from_config (it's a real runnable agent)."""
  187. from lambdagent import from_config
  188. pack_dir = os.path.dirname(manifest_path)
  189. m = load_manifest(pack_dir)
  190. assert os.path.isfile(m.entrypoint_path()), m.entrypoint
  191. # Built-in research packs are local-first: no shell, no network.
  192. assert m.permissions.shell is False
  193. assert m.permissions.network is False
  194. # The entrypoint must compile to a runnable term.
  195. term = from_config(m.entrypoint_path())
  196. assert term is not None