Ver Fonte

feat(M2): Phase D — AgentPack format + install mechanism

CR-20260607-001 rev.2 §6.3 (FR-006/007/008) + §7.3 (SR-003). 可下载/安装/
版本化的智能体包。kernel 校验 + agentpaas 安装两层, 19 个新 test, 171 全过。

## 设计 (docs/AGENTPACK_SPEC.md, NEW)

AgentPack = agent-template (agent_dir + entrypoint) 的薄封装 + manifest。
不引入新 runtime — entrypoint 解析成普通 lambdagent config, 走同一
from_config()/instance 机制。刻意避免 "agent vs instance vs pack" 概念
碎片化 (CR review 指出的风险)。

安装布局: <data_dir>/agentpacks/<id>/<version>/

## kernel 层 (CR §10.1 — lambdagent 内核)

lambdagent/agentpack.py (NEW, ~270 LOC), 纯逻辑无 HTTP/无副作用:
- AgentPackManifest.from_dict: 校验 id (regex) / version (semver) /
  entrypoint (相对路径 + 禁 ../ 逃逸) / 必填字段
- PackPermissions (SR-003): network/shell/file_write(none|workspace|
  knowledge)/read_knowledge/read_filesystem, deny-by-default; summary_zh()
  生成 FR-007 安装对话的中文权限摘要
- load_manifest(path): 从盘读 + 校验 entrypoint 存在且不逃逸 (yaml.safe_load)
导出 +5 symbol (152 → 157)。

## agentpaas 层 (CR §10.2)

- engine/agentpack_store.py (NEW): install_from_zip / list_installed /
  get_installed / uninstall
  * zip-slip 防御: _safe_extract 拒绝逃逸成员 (SPEC §6)
  * SR-003: 第三方 shell:true 包默认拒绝, 需 allow_shell
  * 支持 zip 根 或 单层嵌套目录 两种布局
- api/v1/agentpacks.py (NEW): GET list / GET {id} / POST install
  (loopback-only, 同 data-dir 守门) / DELETE {id}
- config.py: 加 agentpacks_dir (<data_dir>/agentpacks)
- app.py: mount agentpacks router (91 routes)

## 测试 (tests/test_agentpack.py, NEW, 19 test)

kernel (13): valid / 6×missing-field / bad-id / bad-version /
  entrypoint-escape / bad-file_write / deny-by-default / summary_zh
store (6): install-list-get-uninstall roundtrip / nested-zip /
  missing-entrypoint / 第三方shell拒绝(+allow_shell通过) / zip-slip拒绝 /
  uninstall-nonexistent

## 验证
  $ pytest tests/ -q  →  171 passed
  agentpack routes mounted: list/install/{id}

## 已知 doc drift (非阻塞)
- README "152 exports" → 实际 157, 下次文档同步时更新

下一步 Phase F: 把 agentexample 的 research67/physics67 reviewer 包装成
3 个内置 agent pack (各加 manifest.yml)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kenny67nju há 3 meses atrás
pai
commit
c550f15

+ 2 - 1
agentpaas/src/agentpaas/api/app.py

@@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 from agentpaas.config import settings
-from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router
+from agentpaas.api.v1 import agents, auth, admin, billing, traces, jobs, discovery, metrics, status, analyze, setup as setup_router, providers_api, templates_api, knowledge as knowledge_router, agentpacks
 from agentpaas.observability.logging import logger
 
 app = FastAPI(
@@ -201,6 +201,7 @@ app.include_router(setup_router.router, prefix="/api/v1")
 app.include_router(providers_api.router, prefix="/api/v1")
 app.include_router(templates_api.router, prefix="/api/v1")
 app.include_router(knowledge_router.router, prefix="/api/v1")
+app.include_router(agentpacks.router, prefix="/api/v1")
 
 
 @app.on_event("startup")

+ 118 - 0
agentpaas/src/agentpaas/api/v1/agentpacks.py

@@ -0,0 +1,118 @@
+"""
+api.v1.agentpacks — AgentPack install / list / uninstall (M2 Phase D).
+
+Spec: docs/AGENTPACK_SPEC.md. Kernel manifest logic: lambdagent.agentpack.
+Filesystem ops: agentpaas.engine.agentpack_store.
+
+  GET    /agentpacks            list installed packs
+  GET    /agentpacks/{id}       detail (manifest + permission summary)
+  POST   /agentpacks/install    install from a local .zip path
+  DELETE /agentpacks/{id}       uninstall (all versions, or ?version=)
+
+Install is loopback-only in desktop mode (the zip is a local-filesystem
+concern, same posture as /setup/data-dir); auth-gated in lab/paas.
+"""
+from __future__ import annotations
+
+from typing import Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel, Field
+
+from agentpaas.api.deps import get_tenant
+from agentpaas.api.middleware.auth import TenantContext
+from agentpaas.config import settings
+from agentpaas.engine.agentpack_store import (
+    install_from_zip,
+    list_installed,
+    get_installed,
+    uninstall,
+    AgentPackInstallError,
+)
+from lambdagent.agentpack import AgentPackError
+
+router = APIRouter(prefix="/agentpacks", tags=["agentpacks"])
+
+
+def _pack_json(p) -> dict:
+    m = p.manifest
+    return {
+        "id": p.id,
+        "version": p.version,
+        "name": p.name,
+        "domain": p.domain,
+        "path": p.path,
+        "description": m.description,
+        "audience": m.audience,
+        "permissions": m.permissions.to_dict(),
+        "permission_summary": m.permissions.summary_zh(),
+        "model_recommended": m.model_recommended,
+        "entrypoint": m.entrypoint,
+    }
+
+
+class InstallRequest(BaseModel):
+    zip_path: str = Field(..., description="Local filesystem path to the .zip")
+    allow_shell: bool = Field(
+        default=False,
+        description="Permit a pack declaring shell:true (third-party packs "
+                    "are refused shell otherwise)",
+    )
+
+
+@router.get("")
+async def list_agentpacks(tenant: TenantContext = Depends(get_tenant)):
+    """List installed AgentPacks."""
+    packs = list_installed(settings.agentpacks_dir)
+    return {"agentpacks": [_pack_json(p) for p in packs], "count": len(packs)}
+
+
+@router.get("/{pack_id}")
+async def get_agentpack(
+    pack_id: str,
+    version: Optional[str] = None,
+    tenant: TenantContext = Depends(get_tenant),
+):
+    """Detail for one installed pack (newest version unless ?version=)."""
+    p = get_installed(settings.agentpacks_dir, pack_id, version)
+    if not p:
+        raise HTTPException(status_code=404, detail="AgentPack not found")
+    return _pack_json(p)
+
+
+@router.post("/install")
+async def install_agentpack(req: InstallRequest, request: Request):
+    """Install an AgentPack from a local .zip path.
+
+    Loopback-only — installing from a local file path is a local-machine
+    operation; a remote caller has no business pointing the server at the
+    host filesystem. (lab/paas operators install via the host CLI / a
+    trusted deploy pipeline, not this endpoint.)
+    """
+    client_host = (request.client.host if request.client else "") or ""
+    if client_host not in ("127.0.0.1", "::1", "localhost"):
+        raise HTTPException(status_code=404, detail="Not Found")
+
+    try:
+        pack = install_from_zip(
+            req.zip_path,
+            settings.agentpacks_dir,
+            allow_shell=req.allow_shell,
+        )
+    except (AgentPackInstallError, AgentPackError) as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+    return {"ok": True, "installed": _pack_json(pack)}
+
+
+@router.delete("/{pack_id}", status_code=200)
+async def uninstall_agentpack(
+    pack_id: str,
+    version: Optional[str] = None,
+    tenant: TenantContext = Depends(get_tenant),
+):
+    """Uninstall a pack (all versions, or a specific ?version=)."""
+    removed = uninstall(settings.agentpacks_dir, pack_id, version)
+    if removed == 0:
+        raise HTTPException(status_code=404, detail="AgentPack not found")
+    return {"ok": True, "removed_versions": removed}

+ 8 - 1
agentpaas/src/agentpaas/config.py

@@ -126,9 +126,16 @@ class AgentPaaSConfig:
             "AGENTPAAS_LOGS_DIR",
             os.path.join(self.data_dir, "logs")
         )
+        # M2 Phase D: installed AgentPacks live here, one dir per id/version
+        # (docs/AGENTPACK_SPEC.md §1).
+        self.agentpacks_dir: str = os.getenv(
+            "AGENTPAAS_AGENTPACKS_DIR",
+            os.path.join(self.data_dir, "agentpacks")
+        )
 
         # Auto-create essential directories on startup
-        for _d in (self.data_dir, self.instances_dir, self.knowledge_bases_dir, self.logs_dir):
+        for _d in (self.data_dir, self.instances_dir, self.knowledge_bases_dir,
+                   self.logs_dir, self.agentpacks_dir):
             os.makedirs(_d, exist_ok=True)
 
         # ── Database — SQLite for dev/single-user, PostgreSQL for prod ──

+ 194 - 0
agentpaas/src/agentpaas/engine/agentpack_store.py

@@ -0,0 +1,194 @@
+"""
+agentpaas.engine.agentpack_store — install / list / uninstall AgentPacks.
+
+The agentpaas-side half of the AgentPack feature (CR §10.1 / §10.2). The
+kernel half (manifest parsing + permission model) lives in
+lambdagent.agentpack; this module handles the filesystem: safe unzip,
+validation via the kernel, and the on-disk layout
+`<agentpacks_dir>/<id>/<version>/` (docs/AGENTPACK_SPEC.md §1/§4).
+
+Security (SPEC §6):
+  - zip-slip guard: reject any archive member that escapes the temp dir
+  - manifest validated by lambdagent.load_manifest (semver, id, entrypoint
+    existence + escape guard)
+  - third-party packs declaring shell:true are refused unless allow_shell
+  - yaml.safe_load only (inside the kernel)
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+import zipfile
+from dataclasses import dataclass
+from typing import List, Optional
+
+from lambdagent.agentpack import (
+    AgentPackManifest,
+    AgentPackError,
+    load_manifest,
+)
+
+
+class AgentPackInstallError(Exception):
+    """Raised when an AgentPack cannot be installed."""
+
+
+@dataclass
+class InstalledPack:
+    id: str
+    version: str
+    name: str
+    domain: str
+    path: str
+    manifest: AgentPackManifest
+
+
+def _safe_extract(zf: zipfile.ZipFile, dest: str) -> None:
+    """Extract a zip, rejecting path-traversal (zip-slip) members."""
+    dest_abs = os.path.abspath(dest)
+    for member in zf.namelist():
+        # Resolve the target path and ensure it stays inside dest.
+        target = os.path.abspath(os.path.join(dest, member))
+        if target != dest_abs and not target.startswith(dest_abs + os.sep):
+            raise AgentPackInstallError(
+                f"unsafe zip member escapes extraction dir: {member!r}"
+            )
+    zf.extractall(dest)
+
+
+def _find_pack_root(extracted_dir: str) -> str:
+    """A zip may contain the pack at its root OR nested one level
+    (e.g. `mypack/manifest.yml`). Return the dir that holds manifest.yml."""
+    if os.path.isfile(os.path.join(extracted_dir, "manifest.yml")):
+        return extracted_dir
+    # Look one level down.
+    entries = [
+        os.path.join(extracted_dir, e) for e in os.listdir(extracted_dir)
+    ]
+    subdirs = [e for e in entries if os.path.isdir(e)]
+    if len(subdirs) == 1 and os.path.isfile(os.path.join(subdirs[0], "manifest.yml")):
+        return subdirs[0]
+    raise AgentPackInstallError(
+        "manifest.yml not found at zip root or single top-level dir"
+    )
+
+
+def install_from_zip(
+    zip_path: str,
+    agentpacks_dir: str,
+    *,
+    allow_shell: bool = False,
+    trusted: bool = False,
+) -> InstalledPack:
+    """Install an AgentPack from a local .zip file.
+
+    Args:
+        zip_path: path to the .zip
+        agentpacks_dir: install root (config.agentpacks_dir)
+        allow_shell: permit a pack that declares shell:true (SR-003 — third
+            party packs are refused shell unless explicitly allowed)
+        trusted: first-party / built-in pack (bypasses the shell refusal)
+
+    Returns the InstalledPack. Raises AgentPackInstallError / AgentPackError.
+    """
+    if not os.path.isfile(zip_path):
+        raise AgentPackInstallError(f"zip not found: {zip_path}")
+    if not zipfile.is_zipfile(zip_path):
+        raise AgentPackInstallError(f"not a valid zip file: {zip_path}")
+
+    tmp = tempfile.mkdtemp(prefix="agentpack_")
+    try:
+        with zipfile.ZipFile(zip_path) as zf:
+            _safe_extract(zf, tmp)
+
+        pack_root = _find_pack_root(tmp)
+        manifest = load_manifest(pack_root)  # validates everything (SPEC §2)
+
+        # SR-003: refuse third-party shell unless allowed.
+        if manifest.permissions.shell and not (allow_shell or trusted):
+            raise AgentPackInstallError(
+                f"pack {manifest.id!r} requests shell access; refusing a "
+                f"third-party pack with shell:true. Re-install with "
+                f"allow_shell=True if you trust it."
+            )
+
+        dest = os.path.join(agentpacks_dir, manifest.id, manifest.version)
+        if os.path.exists(dest):
+            shutil.rmtree(dest)  # reinstall same version → overwrite
+        os.makedirs(os.path.dirname(dest), exist_ok=True)
+        shutil.move(pack_root, dest)
+
+        # Reload from the final location so root_dir points at the install.
+        final = load_manifest(dest)
+        return InstalledPack(
+            id=final.id, version=final.version, name=final.name,
+            domain=final.domain, path=dest, manifest=final,
+        )
+    finally:
+        shutil.rmtree(tmp, ignore_errors=True)
+
+
+def list_installed(agentpacks_dir: str) -> List[InstalledPack]:
+    """Enumerate installed packs (one InstalledPack per id/version).
+    Skips dirs whose manifest fails to load (logs nothing — caller may)."""
+    out: List[InstalledPack] = []
+    if not os.path.isdir(agentpacks_dir):
+        return out
+    for pack_id in sorted(os.listdir(agentpacks_dir)):
+        id_dir = os.path.join(agentpacks_dir, pack_id)
+        if not os.path.isdir(id_dir):
+            continue
+        for version in sorted(os.listdir(id_dir)):
+            vdir = os.path.join(id_dir, version)
+            if not os.path.isdir(vdir):
+                continue
+            try:
+                m = load_manifest(vdir)
+            except (AgentPackError, OSError):
+                continue
+            out.append(InstalledPack(
+                id=m.id, version=m.version, name=m.name,
+                domain=m.domain, path=vdir, manifest=m,
+            ))
+    return out
+
+
+def get_installed(agentpacks_dir: str, pack_id: str,
+                  version: Optional[str] = None) -> Optional[InstalledPack]:
+    """Return one installed pack by id (+ optional version; newest if None)."""
+    matches = [p for p in list_installed(agentpacks_dir) if p.id == pack_id]
+    if not matches:
+        return None
+    if version:
+        for p in matches:
+            if p.version == version:
+                return p
+        return None
+    # Newest by version string sort (semver-ish; good enough for v0.1).
+    return sorted(matches, key=lambda p: p.version)[-1]
+
+
+def uninstall(agentpacks_dir: str, pack_id: str,
+              version: Optional[str] = None) -> int:
+    """Remove a pack (one version, or all versions if version is None).
+    Returns the number of version dirs removed."""
+    id_dir = os.path.join(agentpacks_dir, pack_id)
+    if not os.path.isdir(id_dir):
+        return 0
+    removed = 0
+    if version:
+        vdir = os.path.join(id_dir, version)
+        if os.path.isdir(vdir):
+            shutil.rmtree(vdir)
+            removed = 1
+        # Clean up the now-empty id dir.
+        if os.path.isdir(id_dir) and not os.listdir(id_dir):
+            shutil.rmtree(id_dir)
+    else:
+        removed = len([
+            v for v in os.listdir(id_dir)
+            if os.path.isdir(os.path.join(id_dir, v))
+        ])
+        shutil.rmtree(id_dir)
+    return removed

+ 172 - 0
docs/AGENTPACK_SPEC.md

@@ -0,0 +1,172 @@
+# AgentPack Specification v0.1
+
+**Status:** Draft
+**Date:** 2026-06-07
+**Implements:** CR-20260607-001 rev.2 §6.3 (FR-006/007/008) + §7.3 (SR-003)
+
+An **AgentPack** is a downloadable, installable, versioned bundle of one or
+more agents plus their prompts, knowledge, and metadata. It is the unit a
+ResearchAgent Desktop user installs to gain a new capability ("top journal
+reviewer", "literature mapper", …).
+
+AgentPacks are a thin packaging layer over the existing agent-template
+concept (`agent_dir` with `agent-config.yml` + optional `agents/`
+sub-agents). A pack does NOT introduce a new runtime — `entrypoint` resolves
+to an ordinary lambdagent config that runs through the same
+`from_config()` / instance mechanism as any other agent. This deliberately
+avoids the "agent vs instance vs pack" concept fragmentation flagged in the
+CR review.
+
+## 1. Directory layout
+
+```
+<pack-id>/
+  manifest.yml          REQUIRED — pack metadata + permissions
+  agents/               REQUIRED — agent configs (entrypoint lives here or at root)
+    <entrypoint>.yml
+    <sub-agent>.yml ...
+  prompts/              OPTIONAL — extracted reusable prompt fragments
+  knowledge/            OPTIONAL — bundled reference material (guides, rubrics)
+  examples/             OPTIONAL — sample inputs / expected outputs
+  README.md             OPTIONAL — human description
+```
+
+When installed, a pack lives at:
+
+```
+<data_dir>/agentpacks/<pack-id>/<version>/
+```
+
+(`<data_dir>` = `~/LambdAgentDesktop` in desktop mode, per FR-002.)
+
+Multiple versions of the same pack may coexist; the newest is used unless an
+agent pins a version.
+
+## 2. manifest.yml schema
+
+```yaml
+# ── Identity (all REQUIRED) ──
+id: research.top-journal-reviewer   # reverse-dotted, [a-z0-9.-], unique
+name: Top Journal Reviewer          # human-readable
+version: 0.1.0                       # semver
+domain: research                    # research | medical | general | <free>
+entrypoint: agents/reviewer.yml      # path (relative to pack root) to the
+                                     # lambdagent config that runs first
+
+# ── Audience (OPTIONAL, informational) ──
+audience:
+  - professor
+  - phd_student
+
+# ── Description (OPTIONAL) ──
+description: >
+  Generates a top-journal-grade peer review with acceptance estimate
+  and an actionable revision checklist.
+
+# ── Permissions (REQUIRED — see §3) ──
+permissions:
+  network: false              # outbound network access
+  shell: false                # shell command execution
+  file_write: workspace       # none | workspace | knowledge
+  read_knowledge: true        # read the user's knowledge base
+  read_filesystem: false      # read files outside KB + workspace
+
+# ── Model recommendation (OPTIONAL, informational) ──
+model:
+  recommended:
+    - claude-code/sonnet
+    - anthropic/claude-sonnet
+    - ollama/qwen
+
+# ── Provenance (OPTIONAL) ──
+author: kenny67nju
+homepage: https://github.com/kenny67nju/lambdagentpaas
+license: BUSL-1.1
+```
+
+### Field rules
+
+| Field | Required | Validation |
+|---|---|---|
+| `id` | yes | matches `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`; reverse-dotted recommended |
+| `name` | yes | non-empty string |
+| `version` | yes | semver `MAJOR.MINOR.PATCH` |
+| `domain` | yes | non-empty string (enum suggested, not enforced) |
+| `entrypoint` | yes | relative path; must exist after install; must NOT escape pack root |
+| `permissions` | yes | object; see §3 |
+| `audience` | no | list of strings |
+| `description` | no | string |
+| `model.recommended` | no | list of `provider/model` strings |
+
+## 3. Permission model (SR-003)
+
+Every pack MUST declare its permissions. The installer shows a plain-language
+summary before installing (FR-007). The runtime enforces them.
+
+| Permission | Type | Default | Meaning |
+|---|---|---|---|
+| `network` | bool | `false` | Pack agents may make outbound network / web calls |
+| `shell` | bool | `false` | Pack agents may execute shell commands |
+| `file_write` | enum | `workspace` | Where agents may write: `none` / `workspace` (run dir only) / `knowledge` (also the KB) |
+| `read_knowledge` | bool | `true` | Agents may read the user's knowledge base |
+| `read_filesystem` | bool | `false` | Agents may read files outside KB + workspace |
+
+### MVP enforcement rules
+
+- **Third-party packs default to `shell: false`** and the installer **refuses**
+  a third-party pack that declares `shell: true` unless the user passes an
+  explicit `--allow-shell` override (CR §SR-003). Built-in / first-party packs
+  (shipped in `agentexample/`) may declare `shell: true`.
+- A pack declaring a permission it doesn't list is treated as that permission
+  being `false` / `none` (deny by default).
+- The runtime maps permissions onto the tool registry: `shell: false` removes
+  `Bash`; `network: false` removes `WebSearch`/`WebFetch`; `file_write` scopes
+  the working dir; etc. (Enforcement wiring is incremental — v0.1 validates +
+  surfaces the declaration; full tool-registry gating lands alongside Phase F.)
+
+## 4. Lifecycle
+
+### Install (FR-007)
+
+1. Source: local `.zip` OR GitHub Release URL (Q6 — GitHub Releases is the
+   v1 registry).
+2. Unzip to a temp dir; locate `manifest.yml`.
+3. Validate manifest (§2 rules). Reject on any error.
+4. Verify `entrypoint` exists and stays within the pack root (no `../` escape).
+5. Show permission summary (FR-007):
+   > 此智能体包将读取本地知识库,写入 workspace,不执行 shell,不访问网络。
+6. On confirm: move into `<data_dir>/agentpacks/<id>/<version>/`.
+
+### List
+
+Enumerate `<data_dir>/agentpacks/*/*/manifest.yml`, return id/name/version/
+domain/permissions per installed pack.
+
+### Uninstall
+
+Remove `<data_dir>/agentpacks/<id>/<version>/` (or all versions of an id).
+
+### Use
+
+Creating an agent "from a pack" sets the agent's `agent_dir` to the pack's
+installed path; `entrypoint` is the config `from_config()` compiles. The pack
+is otherwise an ordinary agent template.
+
+## 5. Built-in packs (FR-008)
+
+Shipped in-repo under `agentexample/`, each gaining a `manifest.yml`:
+
+| Pack id | Source dir | Function |
+|---|---|---|
+| `research.literature-mapper` | research67 | PDF set → 文献地图 + 方法谱系 + BibTeX |
+| `research.top-journal-reviewer` | physics67 reviewer | 论文 → 顶刊审稿意见 + 接收概率 + 修改清单 |
+| `research.grant-planner` | research67 | 方向 + 材料 → 立项依据 + 创新点 + 技术路线 |
+
+## 6. Security notes
+
+- Pack zips are untrusted input. Unzip MUST guard against path traversal
+  (zip-slip): reject any member whose resolved path escapes the temp dir.
+- `pickle`-based knowledge artifacts are forbidden in packs (audit #17).
+- Manifest parsing uses `yaml.safe_load` only.
+- The `entrypoint` and any `config:` sub-agent references are resolved
+  relative to the pack root and may not escape it.

+ 9 - 0
lambdagent/src/lambdagent/__init__.py

@@ -127,7 +127,16 @@ from .agentruntime.recursive_engine import RecursiveEngine
 from .agentruntime.cek_engine import CEKEngine
 from .agentruntime.runtime import Runtime, RuntimeResult
 
+# M2 Phase D: AgentPack manifest + permissions (CR-20260607-001 §6.3/§7.3)
+from .agentpack import (
+    AgentPackManifest, PackPermissions, AgentPackError,
+    load_manifest, validate_manifest_dict,
+)
+
 __all__ = [
+    # AgentPack (M2)
+    "AgentPackManifest", "PackPermissions", "AgentPackError",
+    "load_manifest", "validate_manifest_dict",
     # 元层级
     "Term", "Context", "TraceEntry",
     # Enhanced trace system

+ 236 - 0
lambdagent/src/lambdagent/agentpack.py

@@ -0,0 +1,236 @@
+"""
+lambdagent.agentpack — AgentPack manifest parsing, validation, permissions.
+
+Spec: docs/AGENTPACK_SPEC.md (CR-20260607-001 rev.2 §6.3 / §7.3).
+
+This is the kernel-side half of the AgentPack feature (CR §10.1): pure,
+testable, no HTTP and no filesystem mutation beyond reading a manifest.
+The install / unzip / HTTP layer lives in agentpaas.api.v1.agentpacks.
+
+An AgentPack is a thin packaging layer over the existing agent-template
+concept — `entrypoint` resolves to an ordinary lambdagent config. This
+module only deals with the manifest (metadata + permissions); running the
+pack is the same `from_config()` path as any other agent.
+"""
+from __future__ import annotations
+
+import os
+import re
+from dataclasses import dataclass, field
+from typing import Any, Dict, List
+
+from .core import LambdagentError
+
+
+class AgentPackError(LambdagentError):
+    """Raised on an invalid or unsafe AgentPack manifest."""
+
+
+# ── Permission model (SR-003) ────────────────────────────────────────────
+
+_FILE_WRITE_SCOPES = ("none", "workspace", "knowledge")
+
+
+@dataclass
+class PackPermissions:
+    """Declared capabilities of a pack. Deny-by-default: any field absent
+    from the manifest is the safe value (False / 'none')."""
+    network: bool = False
+    shell: bool = False
+    file_write: str = "workspace"      # none | workspace | knowledge
+    read_knowledge: bool = True
+    read_filesystem: bool = False
+
+    @classmethod
+    def from_dict(cls, d: Dict[str, Any]) -> "PackPermissions":
+        if not isinstance(d, dict):
+            raise AgentPackError("manifest.permissions must be a mapping")
+        fw = d.get("file_write", "workspace")
+        if fw not in _FILE_WRITE_SCOPES:
+            raise AgentPackError(
+                f"permissions.file_write must be one of {_FILE_WRITE_SCOPES}, "
+                f"got {fw!r}"
+            )
+        return cls(
+            network=bool(d.get("network", False)),
+            shell=bool(d.get("shell", False)),
+            file_write=fw,
+            read_knowledge=bool(d.get("read_knowledge", True)),
+            read_filesystem=bool(d.get("read_filesystem", False)),
+        )
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "network": self.network,
+            "shell": self.shell,
+            "file_write": self.file_write,
+            "read_knowledge": self.read_knowledge,
+            "read_filesystem": self.read_filesystem,
+        }
+
+    def summary_zh(self) -> str:
+        """Plain-language permission summary for the install dialog (FR-007)."""
+        parts: List[str] = []
+        parts.append("读取本地知识库" if self.read_knowledge else "不读取知识库")
+        fw = {
+            "none": "不写入任何文件",
+            "workspace": "写入 workspace",
+            "knowledge": "写入 workspace 和知识库",
+        }[self.file_write]
+        parts.append(fw)
+        parts.append("执行 shell" if self.shell else "不执行 shell")
+        parts.append("访问网络" if self.network else "不访问网络")
+        if self.read_filesystem:
+            parts.append("读取知识库以外的文件")
+        return "此智能体包将" + ",".join(parts) + "。"
+
+
+# ── Manifest ──────────────────────────────────────────────────────────────
+
+_ID_RE = re.compile(r"^[a-z0-9]([a-z0-9.\-]*[a-z0-9])?$")
+_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+([.\-+][0-9A-Za-z.\-]+)?$")
+
+
+@dataclass
+class AgentPackManifest:
+    id: str
+    name: str
+    version: str
+    domain: str
+    entrypoint: str
+    permissions: PackPermissions
+    audience: List[str] = field(default_factory=list)
+    description: str = ""
+    model_recommended: List[str] = field(default_factory=list)
+    author: str = ""
+    homepage: str = ""
+    license: str = ""
+    # Absolute path to the pack root once loaded from disk (else "").
+    root_dir: str = ""
+
+    @classmethod
+    def from_dict(cls, data: Dict[str, Any], root_dir: str = "") -> "AgentPackManifest":
+        if not isinstance(data, dict):
+            raise AgentPackError("manifest must be a YAML mapping")
+
+        def _req(key: str) -> Any:
+            if key not in data or data[key] in (None, ""):
+                raise AgentPackError(f"manifest missing required field: {key!r}")
+            return data[key]
+
+        pid = str(_req("id"))
+        if not _ID_RE.match(pid):
+            raise AgentPackError(
+                f"manifest.id {pid!r} invalid — must match {_ID_RE.pattern}"
+            )
+
+        version = str(_req("version"))
+        if not _SEMVER_RE.match(version):
+            raise AgentPackError(
+                f"manifest.version {version!r} is not semver MAJOR.MINOR.PATCH"
+            )
+
+        entrypoint = str(_req("entrypoint"))
+        # zip-slip / escape guard: entrypoint must be relative and stay inside.
+        norm = os.path.normpath(entrypoint)
+        if os.path.isabs(norm) or norm.startswith(".."):
+            raise AgentPackError(
+                f"manifest.entrypoint {entrypoint!r} must be a relative path "
+                f"inside the pack (no absolute paths, no '..')"
+            )
+
+        perms = PackPermissions.from_dict(_req("permissions"))
+
+        model_block = data.get("model", {}) or {}
+        model_recommended = []
+        if isinstance(model_block, dict):
+            model_recommended = list(model_block.get("recommended", []) or [])
+
+        audience = data.get("audience", []) or []
+        if not isinstance(audience, list):
+            raise AgentPackError("manifest.audience must be a list")
+
+        return cls(
+            id=pid,
+            name=str(_req("name")),
+            version=version,
+            domain=str(_req("domain")),
+            entrypoint=norm,
+            permissions=perms,
+            audience=[str(a) for a in audience],
+            description=str(data.get("description", "") or ""),
+            model_recommended=[str(m) for m in model_recommended],
+            author=str(data.get("author", "") or ""),
+            homepage=str(data.get("homepage", "") or ""),
+            license=str(data.get("license", "") or ""),
+            root_dir=root_dir,
+        )
+
+    def entrypoint_path(self) -> str:
+        """Absolute path to the entrypoint config (requires root_dir set)."""
+        if not self.root_dir:
+            raise AgentPackError("manifest has no root_dir; load from disk first")
+        return os.path.join(self.root_dir, self.entrypoint)
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "id": self.id,
+            "name": self.name,
+            "version": self.version,
+            "domain": self.domain,
+            "entrypoint": self.entrypoint,
+            "permissions": self.permissions.to_dict(),
+            "audience": self.audience,
+            "description": self.description,
+            "model": {"recommended": self.model_recommended},
+            "author": self.author,
+            "homepage": self.homepage,
+            "license": self.license,
+        }
+
+
+def load_manifest(path: str) -> AgentPackManifest:
+    """Load + validate a manifest.yml from disk.
+
+    `path` may point at a manifest.yml file OR a pack directory containing
+    one. Returns a validated AgentPackManifest with root_dir set. Raises
+    AgentPackError on any problem (missing file, bad YAML, schema violation,
+    entrypoint escape, or — when loaded from disk — a missing entrypoint).
+    """
+    import yaml
+
+    if os.path.isdir(path):
+        manifest_path = os.path.join(path, "manifest.yml")
+        root_dir = os.path.abspath(path)
+    else:
+        manifest_path = path
+        root_dir = os.path.abspath(os.path.dirname(path))
+
+    if not os.path.isfile(manifest_path):
+        raise AgentPackError(f"manifest.yml not found: {manifest_path}")
+
+    try:
+        with open(manifest_path, encoding="utf-8") as f:
+            data = yaml.safe_load(f)
+    except yaml.YAMLError as e:
+        raise AgentPackError(f"manifest.yml is not valid YAML: {e}")
+
+    manifest = AgentPackManifest.from_dict(data or {}, root_dir=root_dir)
+
+    # When loaded from disk, the entrypoint must actually exist + stay inside.
+    ep = os.path.abspath(manifest.entrypoint_path())
+    if not (ep == root_dir or ep.startswith(root_dir + os.sep)):
+        raise AgentPackError(
+            f"entrypoint {manifest.entrypoint!r} escapes the pack root"
+        )
+    if not os.path.isfile(ep):
+        raise AgentPackError(
+            f"entrypoint config not found: {manifest.entrypoint}"
+        )
+    return manifest
+
+
+def validate_manifest_dict(data: Dict[str, Any]) -> AgentPackManifest:
+    """Validate a manifest given as a dict (no disk access, no entrypoint
+    existence check). Useful for tests + pre-publish linting."""
+    return AgentPackManifest.from_dict(data)

+ 213 - 0
tests/test_agentpack.py

@@ -0,0 +1,213 @@
+"""
+Tests for the AgentPack feature (M2 Phase D).
+
+Covers:
+  - lambdagent.agentpack: manifest validation (kernel, pure)
+  - agentpaas.engine.agentpack_store: safe install / list / uninstall
+  - zip-slip defense
+  - SR-003 shell refusal for third-party packs
+
+Spec: docs/AGENTPACK_SPEC.md.
+"""
+from __future__ import annotations
+
+import os
+os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
+os.environ.setdefault("AGENTPAAS_TESTING", "1")
+
+import io
+import zipfile
+
+import pytest
+import yaml
+
+from lambdagent.agentpack import (
+    AgentPackManifest,
+    PackPermissions,
+    AgentPackError,
+    load_manifest,
+    validate_manifest_dict,
+)
+from agentpaas.engine.agentpack_store import (
+    install_from_zip,
+    list_installed,
+    get_installed,
+    uninstall,
+    AgentPackInstallError,
+)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Kernel: manifest validation
+# ─────────────────────────────────────────────────────────────────────────────
+
+_VALID = {
+    "id": "research.top-journal-reviewer",
+    "name": "Top Journal Reviewer",
+    "version": "0.1.0",
+    "domain": "research",
+    "entrypoint": "agents/reviewer.yml",
+    "permissions": {"network": False, "shell": False, "file_write": "workspace"},
+    "audience": ["professor", "phd_student"],
+    "model": {"recommended": ["claude-code/sonnet"]},
+}
+
+
+def test_manifest_valid():
+    m = validate_manifest_dict(_VALID)
+    assert m.id == "research.top-journal-reviewer"
+    assert m.version == "0.1.0"
+    assert m.permissions.file_write == "workspace"
+    assert m.permissions.shell is False
+    assert "professor" in m.audience
+    assert m.model_recommended == ["claude-code/sonnet"]
+
+
+@pytest.mark.parametrize("missing", ["id", "name", "version", "domain", "entrypoint", "permissions"])
+def test_manifest_missing_required_field(missing):
+    bad = dict(_VALID)
+    bad.pop(missing)
+    with pytest.raises(AgentPackError):
+        validate_manifest_dict(bad)
+
+
+def test_manifest_bad_id_rejected():
+    bad = dict(_VALID, id="Bad ID With Spaces!")
+    with pytest.raises(AgentPackError):
+        validate_manifest_dict(bad)
+
+
+def test_manifest_bad_version_rejected():
+    bad = dict(_VALID, version="v1")
+    with pytest.raises(AgentPackError):
+        validate_manifest_dict(bad)
+
+
+def test_manifest_entrypoint_escape_rejected():
+    bad = dict(_VALID, entrypoint="../../etc/passwd")
+    with pytest.raises(AgentPackError):
+        validate_manifest_dict(bad)
+
+
+def test_manifest_bad_file_write_scope_rejected():
+    bad = dict(_VALID, permissions={"file_write": "everywhere"})
+    with pytest.raises(AgentPackError):
+        validate_manifest_dict(bad)
+
+
+def test_permissions_deny_by_default():
+    # Empty permissions block → safe defaults.
+    m = validate_manifest_dict(dict(_VALID, permissions={}))
+    assert m.permissions.network is False
+    assert m.permissions.shell is False
+    assert m.permissions.file_write == "workspace"
+
+
+def test_permission_summary_zh():
+    m = validate_manifest_dict(_VALID)
+    s = m.permissions.summary_zh()
+    assert "读取本地知识库" in s
+    assert "不执行 shell" in s
+    assert "不访问网络" in s
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Helpers to build pack zips
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _make_pack_zip(tmp_path, manifest_dict, *, entrypoint_body="type: react\nname: x\nsystemPrompt: hi\n", nested=False, extra_members=None):
+    """Build a .zip containing manifest.yml + the entrypoint config.
+    If nested=True, wraps everything in a top-level dir."""
+    prefix = "mypack/" if nested else ""
+    zpath = tmp_path / "pack.zip"
+    with zipfile.ZipFile(zpath, "w") as zf:
+        zf.writestr(prefix + "manifest.yml", yaml.safe_dump(manifest_dict, allow_unicode=True))
+        zf.writestr(prefix + manifest_dict["entrypoint"], entrypoint_body)
+        for name, body in (extra_members or {}).items():
+            zf.writestr(prefix + name, body)
+    return str(zpath)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Store: install / list / get / uninstall
+# ─────────────────────────────────────────────────────────────────────────────
+
+def test_install_list_get_uninstall_roundtrip(tmp_path):
+    packs_dir = tmp_path / "agentpacks"
+    packs_dir.mkdir()
+    zp = _make_pack_zip(tmp_path, _VALID)
+
+    pack = install_from_zip(zp, str(packs_dir))
+    assert pack.id == "research.top-journal-reviewer"
+    assert pack.version == "0.1.0"
+    assert os.path.isfile(os.path.join(pack.path, "manifest.yml"))
+    assert os.path.isfile(os.path.join(pack.path, "agents", "reviewer.yml"))
+
+    listed = list_installed(str(packs_dir))
+    assert len(listed) == 1
+    assert listed[0].id == pack.id
+
+    got = get_installed(str(packs_dir), pack.id)
+    assert got is not None and got.version == "0.1.0"
+
+    n = uninstall(str(packs_dir), pack.id)
+    assert n == 1
+    assert list_installed(str(packs_dir)) == []
+
+
+def test_install_nested_pack_zip(tmp_path):
+    """A zip with everything under a single top-level dir still installs."""
+    packs_dir = tmp_path / "agentpacks"
+    packs_dir.mkdir()
+    zp = _make_pack_zip(tmp_path, _VALID, nested=True)
+    pack = install_from_zip(zp, str(packs_dir))
+    assert pack.id == "research.top-journal-reviewer"
+
+
+def test_install_rejects_missing_entrypoint(tmp_path):
+    """Manifest references agents/reviewer.yml but the zip doesn't contain it."""
+    packs_dir = tmp_path / "agentpacks"
+    packs_dir.mkdir()
+    zpath = tmp_path / "pack.zip"
+    with zipfile.ZipFile(zpath, "w") as zf:
+        zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
+        # entrypoint deliberately absent
+    with pytest.raises((AgentPackInstallError, AgentPackError)):
+        install_from_zip(str(zpath), str(packs_dir))
+
+
+def test_install_refuses_third_party_shell(tmp_path):
+    """SR-003: a pack declaring shell:true is refused unless allow_shell."""
+    packs_dir = tmp_path / "agentpacks"
+    packs_dir.mkdir()
+    shell_manifest = dict(_VALID, id="research.shelly",
+                          permissions={"shell": True, "file_write": "workspace"})
+    zp = _make_pack_zip(tmp_path, shell_manifest)
+
+    with pytest.raises(AgentPackInstallError) as exc:
+        install_from_zip(zp, str(packs_dir))
+    assert "shell" in str(exc.value).lower()
+
+    # With allow_shell it goes through.
+    pack = install_from_zip(zp, str(packs_dir), allow_shell=True)
+    assert pack.manifest.permissions.shell is True
+
+
+def test_install_rejects_zip_slip(tmp_path):
+    """A malicious member with ../ must be rejected (SPEC §6 zip-slip)."""
+    packs_dir = tmp_path / "agentpacks"
+    packs_dir.mkdir()
+    zpath = tmp_path / "evil.zip"
+    with zipfile.ZipFile(zpath, "w") as zf:
+        zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
+        zf.writestr("agents/reviewer.yml", "type: react\n")
+        zf.writestr("../../escape.txt", "pwned")
+    with pytest.raises(AgentPackInstallError) as exc:
+        install_from_zip(str(zpath), str(packs_dir))
+    assert "escape" in str(exc.value).lower() or "unsafe" in str(exc.value).lower()
+
+
+def test_uninstall_nonexistent_returns_zero(tmp_path):
+    packs_dir = tmp_path / "agentpacks"
+    packs_dir.mkdir()
+    assert uninstall(str(packs_dir), "does.not.exist") == 0