Files
beaver_project/app-instance/backend-old/nanobot/agent/skill_reviews.py

239 lines
8.7 KiB
Python

"""Review-first skill installation helpers."""
from __future__ import annotations
import json
import secrets
import shutil
import zipfile
from pathlib import Path, PurePosixPath
from typing import Any
from nanobot.utils.helpers import ensure_dir, get_workspace_state_path, safe_filename, timestamp
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
return True
except ValueError:
return False
def _parse_frontmatter(content: str) -> dict[str, str]:
if not content.startswith("---"):
return {}
end = content.find("\n---", 3)
if end == -1:
return {}
metadata: dict[str, str] = {}
for line in content[3:end].splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip().strip("\"'")
return metadata
def _parse_skill_metadata(raw: str) -> dict[str, Any]:
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
nested = data.get("nanobot", data.get("openclaw", {}))
return nested if isinstance(nested, dict) else {}
class SkillReviewManager:
"""Stage workspace skill installs until the user explicitly approves them."""
REVIEW_META_FILE = "review.json"
ARCHIVE_FILE = "upload.zip"
STAGED_DIR = "staged"
def __init__(self, workspace: Path):
self.workspace = workspace.expanduser().resolve()
self.workspace_skills = ensure_dir(self.workspace / "skills")
self.reviews_dir = ensure_dir(get_workspace_state_path(self.workspace) / "skill-reviews")
def list_reviews(self) -> list[dict[str, Any]]:
reviews: list[dict[str, Any]] = []
for review_dir in sorted(self.reviews_dir.iterdir(), reverse=True):
if not review_dir.is_dir():
continue
try:
reviews.append(self._read_review(review_dir))
except FileNotFoundError:
continue
return reviews
def get_review(self, review_id: str) -> dict[str, Any]:
return self._read_review(self._review_dir(review_id))
def create_review_from_zip(self, filename: str, content: bytes) -> dict[str, Any]:
review_id = secrets.token_hex(8)
review_dir = ensure_dir(self._review_dir(review_id))
archive_path = review_dir / self.ARCHIVE_FILE
archive_path.write_bytes(content)
staged_root = ensure_dir(review_dir / self.STAGED_DIR)
preview = self._extract_archive(archive_path, staged_root, filename)
review = {
"id": review_id,
"status": "pending_review",
"created_at": timestamp(),
"archive_name": filename,
**preview,
}
self._write_review(review_dir, review)
return review
def approve_review(self, review_id: str, overwrite: bool = False) -> dict[str, Any]:
review_dir = self._review_dir(review_id)
review = self._read_review(review_dir)
if review.get("status") == "approved":
return review
skill_name = str(review.get("skill_name") or "").strip()
if not skill_name:
raise ValueError("Review is missing a skill_name")
source_dir = review_dir / self.STAGED_DIR / skill_name
if not source_dir.is_dir():
raise FileNotFoundError(f"Staged skill not found for review {review_id}")
target_dir = self.workspace_skills / skill_name
if target_dir.exists():
if not overwrite:
raise FileExistsError(
f"Skill '{skill_name}' already exists. Re-submit approval with overwrite=true."
)
shutil.rmtree(target_dir)
shutil.copytree(source_dir, target_dir)
review["status"] = "approved"
review["approved_at"] = timestamp()
review["overwrite"] = overwrite
review["installed_path"] = str(target_dir / "SKILL.md")
self._write_review(review_dir, review)
return review
def discard_review(self, review_id: str) -> None:
review_dir = self._review_dir(review_id)
if not review_dir.exists():
raise FileNotFoundError(f"Skill review '{review_id}' not found")
shutil.rmtree(review_dir)
def _review_dir(self, review_id: str) -> Path:
return self.reviews_dir / review_id
def _read_review(self, review_dir: Path) -> dict[str, Any]:
review_file = review_dir / self.REVIEW_META_FILE
if not review_file.exists():
raise FileNotFoundError(f"Skill review metadata not found: {review_dir.name}")
return json.loads(review_file.read_text(encoding="utf-8"))
def _write_review(self, review_dir: Path, review: dict[str, Any]) -> None:
review_file = review_dir / self.REVIEW_META_FILE
review_file.write_text(
json.dumps(review, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def _extract_archive(
self,
archive_path: Path,
staged_root: Path,
upload_name: str,
) -> dict[str, Any]:
with zipfile.ZipFile(archive_path, "r") as zf:
file_infos = [info for info in zf.infolist() if not info.is_dir()]
if not file_infos:
raise ValueError("Zip archive is empty")
skill_md_entries: list[str] = []
for info in file_infos:
rel = PurePosixPath(info.filename)
if rel.name != "SKILL.md":
continue
if len(rel.parts) not in (1, 2):
raise ValueError(
"SKILL.md must be at the archive root or inside a single top-level directory"
)
skill_md_entries.append(info.filename)
if not skill_md_entries:
raise ValueError("Zip must contain a top-level SKILL.md file")
skill_md_entry = skill_md_entries[0]
skill_md_parts = PurePosixPath(skill_md_entry).parts
top_level_dir = skill_md_parts[0] if len(skill_md_parts) == 2 else ""
frontmatter = _parse_frontmatter(
zf.read(skill_md_entry).decode("utf-8", errors="replace")
)
if top_level_dir:
skill_name = top_level_dir
else:
skill_name = frontmatter.get("name") or Path(upload_name).stem
skill_name = safe_filename(skill_name).replace(" ", "-")
if not skill_name:
raise ValueError("Could not determine a safe skill name")
staged_skill_dir = staged_root / skill_name
staged_skill_dir.mkdir(parents=True, exist_ok=False)
extracted_files: list[str] = []
for info in file_infos:
raw_rel = PurePosixPath(info.filename)
if "__MACOSX" in raw_rel.parts or raw_rel.name == ".DS_Store":
continue
if top_level_dir:
if not raw_rel.parts or raw_rel.parts[0] != top_level_dir:
continue
rel_parts = raw_rel.parts[1:]
else:
rel_parts = raw_rel.parts
if not rel_parts:
continue
if any(part in {"", ".", ".."} for part in rel_parts):
raise ValueError(f"Unsafe archive entry: {info.filename}")
dest = staged_skill_dir.joinpath(*rel_parts)
dest.parent.mkdir(parents=True, exist_ok=True)
resolved_dest = dest.resolve()
if not _is_relative_to(resolved_dest, staged_skill_dir.resolve()):
raise ValueError(f"Unsafe archive entry: {info.filename}")
with zf.open(info) as src, open(dest, "wb") as dst:
shutil.copyfileobj(src, dst)
extracted_files.append(PurePosixPath(*rel_parts).as_posix())
if not (staged_skill_dir / "SKILL.md").exists():
raise ValueError("Staged skill is missing SKILL.md after extraction")
skill_meta = _parse_skill_metadata(frontmatter.get("metadata", ""))
target_dir = self.workspace_skills / skill_name
return {
"skill_name": skill_name,
"declared_name": frontmatter.get("name", skill_name),
"description": frontmatter.get("description", ""),
"metadata": frontmatter,
"requires": skill_meta.get("requires", {}),
"file_count": len(extracted_files),
"files": sorted(extracted_files),
"target_exists": target_dir.exists(),
"target_path": str(target_dir / "SKILL.md"),
"staged_path": str(staged_skill_dir / "SKILL.md"),
}