test_agentpack.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  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. enforce_config_permissions,
  25. )
  26. from agentpaas.engine.agentpack_store import (
  27. install_from_zip,
  28. list_installed,
  29. get_installed,
  30. uninstall,
  31. AgentPackInstallError,
  32. )
  33. # ─────────────────────────────────────────────────────────────────────────────
  34. # Kernel: manifest validation
  35. # ─────────────────────────────────────────────────────────────────────────────
  36. _VALID = {
  37. "id": "research.top-journal-reviewer",
  38. "name": "Top Journal Reviewer",
  39. "version": "0.1.0",
  40. "domain": "research",
  41. "entrypoint": "agents/reviewer.yml",
  42. "permissions": {"network": False, "shell": False, "file_write": "workspace"},
  43. "audience": ["professor", "phd_student"],
  44. "model": {"recommended": ["claude-code/sonnet"]},
  45. }
  46. def test_manifest_valid():
  47. m = validate_manifest_dict(_VALID)
  48. assert m.id == "research.top-journal-reviewer"
  49. assert m.version == "0.1.0"
  50. assert m.permissions.file_write == "workspace"
  51. assert m.permissions.shell is False
  52. assert "professor" in m.audience
  53. assert m.model_recommended == ["claude-code/sonnet"]
  54. @pytest.mark.parametrize("missing", ["id", "name", "version", "domain", "entrypoint", "permissions"])
  55. def test_manifest_missing_required_field(missing):
  56. bad = dict(_VALID)
  57. bad.pop(missing)
  58. with pytest.raises(AgentPackError):
  59. validate_manifest_dict(bad)
  60. def test_manifest_bad_id_rejected():
  61. bad = dict(_VALID, id="Bad ID With Spaces!")
  62. with pytest.raises(AgentPackError):
  63. validate_manifest_dict(bad)
  64. def test_manifest_bad_version_rejected():
  65. bad = dict(_VALID, version="v1")
  66. with pytest.raises(AgentPackError):
  67. validate_manifest_dict(bad)
  68. def test_manifest_entrypoint_escape_rejected():
  69. bad = dict(_VALID, entrypoint="../../etc/passwd")
  70. with pytest.raises(AgentPackError):
  71. validate_manifest_dict(bad)
  72. def test_manifest_bad_file_write_scope_rejected():
  73. bad = dict(_VALID, permissions={"file_write": "everywhere"})
  74. with pytest.raises(AgentPackError):
  75. validate_manifest_dict(bad)
  76. def test_permissions_deny_by_default():
  77. # Empty permissions block → safe defaults.
  78. m = validate_manifest_dict(dict(_VALID, permissions={}))
  79. assert m.permissions.network is False
  80. assert m.permissions.shell is False
  81. assert m.permissions.file_write == "workspace"
  82. def test_permission_summary_zh():
  83. m = validate_manifest_dict(_VALID)
  84. s = m.permissions.summary_zh()
  85. assert "读取本地知识库" in s
  86. assert "不执行 shell" in s
  87. assert "不访问网络" in s
  88. def test_permissions_are_enforced_on_runtime_config():
  89. config = {
  90. "type": "react",
  91. "systemPrompt": "test",
  92. "mcp": {
  93. "localTools": [
  94. "Bash", "RunTests", "WebSearch", "ReadFile", "WriteFile",
  95. "KBSearch", "KBAdd", "terminate",
  96. ],
  97. "onlineTool": {"remote": ["dangerous_remote_tool"]},
  98. },
  99. }
  100. effective, report = enforce_config_permissions(config, PackPermissions(
  101. network=False, shell=False, file_write="workspace",
  102. read_knowledge=True, read_filesystem=False,
  103. ))
  104. assert effective["mcp"]["localTools"] == ["WriteFile", "KBSearch", "terminate"]
  105. assert "onlineTool" not in effective["mcp"]
  106. assert set(report["removed_tools"]) == {
  107. "Bash", "RunTests", "WebSearch", "ReadFile", "KBAdd",
  108. }
  109. assert effective["_agentpack_permissions"]["file_write"] == "workspace"
  110. def test_gateway_wraps_validated_builtin_tools_case_insensitively():
  111. from lambdagent.tool_gateway import GatedTool, GatewayPolicy, ToolGateway
  112. from lambdagent.validated_tool import ShellToolInput, ValidatedTool
  113. executed = []
  114. bash = ValidatedTool("Bash", lambda value: executed.append(value) or "ran", ShellToolInput)
  115. gated = ToolGateway(GatewayPolicy()).wrap(bash)
  116. assert isinstance(gated, GatedTool)
  117. result = gated.apply('{"command":"rm -rf /"}')
  118. assert result.startswith("[BLOCKED]")
  119. assert executed == []
  120. # ─────────────────────────────────────────────────────────────────────────────
  121. # Helpers to build pack zips
  122. # ─────────────────────────────────────────────────────────────────────────────
  123. def _make_pack_zip(tmp_path, manifest_dict, *, entrypoint_body="type: react\nname: x\nsystemPrompt: hi\n", nested=False, extra_members=None):
  124. """Build a .zip containing manifest.yml + the entrypoint config.
  125. If nested=True, wraps everything in a top-level dir."""
  126. prefix = "mypack/" if nested else ""
  127. zpath = tmp_path / "pack.zip"
  128. with zipfile.ZipFile(zpath, "w") as zf:
  129. zf.writestr(prefix + "manifest.yml", yaml.safe_dump(manifest_dict, allow_unicode=True))
  130. zf.writestr(prefix + manifest_dict["entrypoint"], entrypoint_body)
  131. for name, body in (extra_members or {}).items():
  132. zf.writestr(prefix + name, body)
  133. return str(zpath)
  134. # ─────────────────────────────────────────────────────────────────────────────
  135. # Store: install / list / get / uninstall
  136. # ─────────────────────────────────────────────────────────────────────────────
  137. def test_install_list_get_uninstall_roundtrip(tmp_path):
  138. packs_dir = tmp_path / "agentpacks"
  139. packs_dir.mkdir()
  140. zp = _make_pack_zip(tmp_path, _VALID)
  141. pack = install_from_zip(zp, str(packs_dir))
  142. assert pack.id == "research.top-journal-reviewer"
  143. assert pack.version == "0.1.0"
  144. assert os.path.isfile(os.path.join(pack.path, "manifest.yml"))
  145. assert os.path.isfile(os.path.join(pack.path, "agents", "reviewer.yml"))
  146. listed = list_installed(str(packs_dir))
  147. assert len(listed) == 1
  148. assert listed[0].id == pack.id
  149. got = get_installed(str(packs_dir), pack.id)
  150. assert got is not None and got.version == "0.1.0"
  151. n = uninstall(str(packs_dir), pack.id)
  152. assert n == 1
  153. assert list_installed(str(packs_dir)) == []
  154. def test_install_nested_pack_zip(tmp_path):
  155. """A zip with everything under a single top-level dir still installs."""
  156. packs_dir = tmp_path / "agentpacks"
  157. packs_dir.mkdir()
  158. zp = _make_pack_zip(tmp_path, _VALID, nested=True)
  159. pack = install_from_zip(zp, str(packs_dir))
  160. assert pack.id == "research.top-journal-reviewer"
  161. def test_install_rejects_missing_entrypoint(tmp_path):
  162. """Manifest references agents/reviewer.yml but the zip doesn't contain it."""
  163. packs_dir = tmp_path / "agentpacks"
  164. packs_dir.mkdir()
  165. zpath = tmp_path / "pack.zip"
  166. with zipfile.ZipFile(zpath, "w") as zf:
  167. zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
  168. # entrypoint deliberately absent
  169. with pytest.raises((AgentPackInstallError, AgentPackError)):
  170. install_from_zip(str(zpath), str(packs_dir))
  171. def test_install_refuses_third_party_shell(tmp_path):
  172. """SR-003: a pack declaring shell:true is refused unless allow_shell."""
  173. packs_dir = tmp_path / "agentpacks"
  174. packs_dir.mkdir()
  175. shell_manifest = dict(_VALID, id="research.shelly",
  176. permissions={"shell": True, "file_write": "workspace"})
  177. zp = _make_pack_zip(tmp_path, shell_manifest)
  178. with pytest.raises(AgentPackInstallError) as exc:
  179. install_from_zip(zp, str(packs_dir))
  180. assert "shell" in str(exc.value).lower()
  181. # With allow_shell it goes through.
  182. pack = install_from_zip(zp, str(packs_dir), allow_shell=True)
  183. assert pack.manifest.permissions.shell is True
  184. def test_install_rejects_zip_slip(tmp_path):
  185. """A malicious member with ../ must be rejected (SPEC §6 zip-slip)."""
  186. packs_dir = tmp_path / "agentpacks"
  187. packs_dir.mkdir()
  188. zpath = tmp_path / "evil.zip"
  189. with zipfile.ZipFile(zpath, "w") as zf:
  190. zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
  191. zf.writestr("agents/reviewer.yml", "type: react\n")
  192. zf.writestr("../../escape.txt", "pwned")
  193. with pytest.raises(AgentPackInstallError) as exc:
  194. install_from_zip(str(zpath), str(packs_dir))
  195. assert "escape" in str(exc.value).lower() or "unsafe" in str(exc.value).lower()
  196. def test_uninstall_nonexistent_returns_zero(tmp_path):
  197. packs_dir = tmp_path / "agentpacks"
  198. packs_dir.mkdir()
  199. assert uninstall(str(packs_dir), "does.not.exist") == 0
  200. # ─────────────────────────────────────────────────────────────────────────────
  201. # Built-in packs (FR-008) — regression guard: the 3 bundled research packs
  202. # must always load, validate, and compile.
  203. # ─────────────────────────────────────────────────────────────────────────────
  204. _BUNDLED_IDS = {
  205. "research.top-journal-reviewer",
  206. "research.literature-mapper",
  207. "research.grant-planner",
  208. }
  209. def _bundled_pack_dirs():
  210. import glob
  211. repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  212. return sorted(glob.glob(os.path.join(repo_root, "agentexample", "agentpacks", "*", "manifest.yml")))
  213. def test_bundled_packs_present():
  214. found = {load_manifest(os.path.dirname(p)).id for p in _bundled_pack_dirs()}
  215. assert _BUNDLED_IDS.issubset(found), f"missing bundled packs: {_BUNDLED_IDS - found}"
  216. @pytest.mark.parametrize("manifest_path", _bundled_pack_dirs())
  217. def test_bundled_pack_valid_and_compiles(manifest_path):
  218. """Each bundled pack: manifest validates, entrypoint exists, and the
  219. entrypoint config compiles via from_config (it's a real runnable agent)."""
  220. from lambdagent import from_config
  221. pack_dir = os.path.dirname(manifest_path)
  222. m = load_manifest(pack_dir)
  223. assert os.path.isfile(m.entrypoint_path()), m.entrypoint
  224. # Built-in packs are local-first: no network.
  225. assert m.permissions.network is False
  226. # The entrypoint must compile to a runnable term.
  227. term = from_config(m.entrypoint_path())
  228. assert term is not None
  229. # Policy: bundled packs default to no shell. The one exception — a
  230. # Claude-Code-style workspace assistant — may declare shell:true, but
  231. # ONLY if its agent guards it with dangerousCommandBlock (runtime safety
  232. # net for arbitrary Bash). 杜绝"内置包随便要 shell 又不设防护"。
  233. if m.permissions.shell:
  234. import yaml as _yaml
  235. cfg = _yaml.safe_load(open(m.entrypoint_path(), encoding="utf-8"))
  236. guard = cfg.get("guard") or {}
  237. assert guard.get("dangerousCommandBlock") is True, (
  238. f"bundled pack {m.id} 声明 shell:true 但未设 dangerousCommandBlock 防护"
  239. )
  240. # ─────────────────────────────────────────────────────────────────────────────
  241. # Phase G: agentpack → agent creation endpoint integration
  242. # ─────────────────────────────────────────────────────────────────────────────
  243. def test_create_agent_from_pack_endpoint(tmp_path, monkeypatch):
  244. """End-to-end: install a synthetic pack, POST /agentpacks/{id}/create-agent,
  245. verify the new agent row carries pack.path as agent_dir and pack.id as
  246. agent_template."""
  247. import json
  248. import secrets
  249. from fastapi.testclient import TestClient
  250. from agentpaas.api.app import app
  251. from agentpaas.api.middleware.auth import hash_key
  252. from agentpaas.config import settings
  253. from agentpaas.db.models import Database, gen_id, now_utc
  254. import agentpaas.db.session as _session_mod
  255. # Redirect settings.agentpacks_dir into tmp so we don't touch ~/...
  256. packs_dir = tmp_path / "agentpacks"
  257. packs_dir.mkdir()
  258. monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
  259. # Install a pack via the public function.
  260. pack_zip = _make_pack_zip(tmp_path, _VALID)
  261. pack = install_from_zip(pack_zip, str(packs_dir))
  262. assert pack.id == "research.top-journal-reviewer"
  263. # Fresh in-memory DB + a real tenant + key.
  264. prev_db = _session_mod._db
  265. _session_mod._db = Database("sqlite:///:memory:")
  266. db = _session_mod._db
  267. try:
  268. tid = gen_id("tn_")
  269. uid = gen_id("usr_")
  270. raw_key = f"ap_{secrets.token_hex(16)}"
  271. now = now_utc()
  272. db.execute(
  273. "INSERT INTO tenants (id, name, plan, status, created_at) "
  274. "VALUES (?, 'test', 'free', 'active', ?)", (tid, now),
  275. )
  276. db.execute(
  277. "INSERT INTO users (id, tenant_id, email, role, created_at) "
  278. "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now),
  279. )
  280. db.execute(
  281. "INSERT INTO api_keys "
  282. "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
  283. " rate_limit, status, created_at) "
  284. "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
  285. (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
  286. json.dumps(["agents:*", "keys:*"]), now),
  287. )
  288. db.commit()
  289. with TestClient(app) as client:
  290. r = client.post(
  291. f"/api/v1/agentpacks/{pack.id}/create-agent",
  292. json={"name": "My Reviewer", "description": "from a pack"},
  293. headers={"Authorization": f"Bearer {raw_key}"},
  294. )
  295. assert r.status_code == 201, r.text[:300]
  296. body = r.json()
  297. assert body["ok"] is True
  298. agent_id = body["agent_id"]
  299. assert body["pack"]["id"] == pack.id
  300. assert body["pack"]["version"] == pack.version
  301. # Verify DB state: agent_dir = pack.path, agent_template = pack.id.
  302. row = db.fetchone(
  303. "SELECT agent_dir, agent_template, name FROM agents WHERE id = ?",
  304. (agent_id,),
  305. )
  306. assert row["agent_dir"] == pack.path
  307. assert row["agent_template"] == pack.id
  308. assert row["name"] == "My Reviewer"
  309. # The initial version's config carries _config_dir = pack.path so
  310. # any sub-agent ref inside the pack resolves correctly.
  311. ver = db.fetchone(
  312. "SELECT config FROM agent_versions WHERE agent_id = ? AND version = 1",
  313. (agent_id,),
  314. )
  315. cfg = json.loads(ver["config"])
  316. assert cfg.get("_config_dir") == pack.path
  317. finally:
  318. _session_mod._db = prev_db
  319. def test_create_agent_from_unknown_pack_returns_404(tmp_path, monkeypatch):
  320. """POST against a pack_id that isn't installed → 404."""
  321. from fastapi.testclient import TestClient
  322. from agentpaas.api.app import app
  323. from agentpaas.config import settings
  324. monkeypatch.setattr(settings, "agentpacks_dir", str(tmp_path / "empty"))
  325. (tmp_path / "empty").mkdir()
  326. with TestClient(app) as client:
  327. # No auth — gate-blocking 401 is the first dependency that fires,
  328. # which is fine; the assertion we care about is "no 5xx, no
  329. # accidental success". 401 covers both paths cleanly.
  330. r = client.post(
  331. "/api/v1/agentpacks/does.not.exist/create-agent",
  332. json={"name": "x"},
  333. )
  334. assert r.status_code in (401, 404)