#!/usr/bin/env python3 """Local-only allowlisted Bubblewrap validation broker for the Zhulan runtime.""" from __future__ import annotations import json import os import signal import socketserver import sqlite3 import subprocess import time from pathlib import Path from typing import Any MAX_REQUEST_BYTES = 64 * 1024 def safe_relative(value: Any) -> str: if not isinstance(value, str): raise PermissionError("validation_path_invalid") raw = value.replace("\\", "/").strip("/") if raw in {"", "."}: return "." parts = raw.split("/") if any(part in {"", ".", ".."} for part in parts): raise PermissionError("validation_path_invalid") return "/".join(parts) def path_within(path: str, prefix: str) -> bool: return prefix == "." or path == prefix or path.startswith(prefix + "/") class ValidationExecutor: def __init__(self) -> None: self.workspace_root = Path( os.getenv("ZHULAN_WORKSPACE_ROOT", "/srv/guanghu/zhulan-cell/workspaces") ).resolve() self.policy_path = Path( os.getenv("ZHULAN_POLICY", "/opt/guanghu/zhulan-remote-cell/policy.json") ) self.state_db = Path( os.getenv("ZHULAN_DB", "/var/lib/guanghu/zhulan-remote-cell/state.sqlite3") ) self.bwrap = Path( os.getenv( "ZHULAN_BWRAP", "/opt/guanghu/zhulan-remote-cell/runtime/zhulan-bwrap" ) ) def policy(self) -> dict[str, Any]: return json.loads(self.policy_path.read_text(encoding="utf-8")) def validate(self, request: dict[str, Any]) -> tuple[Path, list[str], list[str]]: if set(request) != { "operation", "request_id", "repository", "development_id", "workspace", "paths", "command", }: raise PermissionError("validation_request_fields_invalid") if request.get("operation") != "run": raise PermissionError("validation_operation_invalid") repository = str(request.get("repository", "")) request_id = str(request.get("request_id", "")) development_id = str(request.get("development_id", "")) command = request.get("command") paths = request.get("paths") if not isinstance(command, list) or not command or not all( isinstance(item, str) for item in command ): raise PermissionError("validation_command_invalid") if not isinstance(paths, list) or not paths: raise PermissionError("validation_paths_invalid") clean_paths = sorted(set(safe_relative(item) for item in paths)) policy = self.policy() repository_policy = policy.get("repositories", {}).get(repository) if not isinstance(repository_policy, dict): raise PermissionError("validation_repository_not_registered") allowed = repository_policy.get("validation_commands", []) if command not in allowed or command == ["git", "diff", "--check"]: raise PermissionError("validation_command_not_registered_for_broker") allowed_prefixes = [safe_relative(item) for item in repository_policy.get("allowed_path_prefixes", ["."])] if any(not any(path_within(path, prefix) for prefix in allowed_prefixes) for path in clean_paths): raise PermissionError("validation_path_outside_repository_policy") uri = f"file:{self.state_db}?mode=ro" with sqlite3.connect(uri, uri=True, timeout=5) as db: db.row_factory = sqlite3.Row row = db.execute( """SELECT id, state, picked_up_at, capability_expires_at, development_id, repository, paths_json, actions_json FROM requests WHERE id=?""", (request_id,), ).fetchone() if ( not row or row["state"] != "APPROVED" or not row["picked_up_at"] or int(row["capability_expires_at"] or 0) <= int(time.time()) ): raise PermissionError("validation_approval_not_active") if ( row["development_id"] != development_id or row["repository"] != repository or json.loads(row["paths_json"]) != clean_paths or "test" not in json.loads(row["actions_json"]) ): raise PermissionError("validation_approval_binding_mismatch") workspace = Path(str(request.get("workspace", ""))).resolve() workspace.relative_to(self.workspace_root) if ( not workspace.is_dir() or not (workspace / ".git").is_dir() or (workspace / ".git").is_symlink() ): raise PermissionError("validation_workspace_invalid") owner, name = repository.split("/", 1) if ( workspace.name != f"{owner}__{name}" or workspace.parent.name != development_id ): raise PermissionError("validation_workspace_repository_mismatch") if not self.bwrap.is_file(): raise RuntimeError("validation_bwrap_missing") return workspace, clean_paths, command @staticmethod def projection(workspace: Path, paths: list[str]) -> list[str]: if paths == ["."]: return ["--ro-bind", str(workspace), "/workspace"] result: list[str] = [] created = {"/workspace"} for relative in paths: target = workspace / relative destination = Path("/workspace") / relative parents = [item for item in reversed(destination.parents) if str(item).startswith("/workspace")] for parent in parents: text = str(parent) if text not in created: result.extend(["--dir", text]) created.add(text) if target.exists(): target.resolve().relative_to(workspace) result.extend(["--ro-bind", str(target), str(destination)]) elif str(destination) not in created: result.extend(["--dir", str(destination)]) created.add(str(destination)) return result def execute(self, request: dict[str, Any]) -> dict[str, Any]: if request.get("operation") == "ping": if set(request) != {"operation"}: raise PermissionError("validation_ping_fields_invalid") return {"ok": True, "executor": "zhulan-validation"} workspace, paths, command = self.validate(request) argv = [ str(self.bwrap), "--die-with-parent", "--new-session", "--unshare-user", "--unshare-pid", "--unshare-net", "--unshare-ipc", "--unshare-uts", "--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind-try", "/lib", "/lib", "--ro-bind-try", "/lib64", "/lib64", "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/home", "--dir", "/home/zhulan", *self.projection(workspace, paths), "--chdir", "/workspace", "--clearenv", "--setenv", "HOME", "/home/zhulan", "--setenv", "PATH", "/usr/local/bin:/usr/bin:/bin", "--", *command, ] result = subprocess.run( argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, env={"PATH": "/usr/local/bin:/usr/bin:/bin", "LANG": "C.UTF-8"}, ) return {"ok": True, "exit_code": result.returncode, "output": result.stdout[-12000:]} class Handler(socketserver.StreamRequestHandler): def handle(self) -> None: raw = self.rfile.readline(MAX_REQUEST_BYTES + 1) if not raw or len(raw) > MAX_REQUEST_BYTES: return try: request = json.loads(raw) if not isinstance(request, dict): raise ValueError("request_not_object") response = self.server.executor.execute(request) # type: ignore[attr-defined] except Exception as exc: response = {"ok": False, "error": str(exc)[:300]} self.wfile.write((json.dumps(response, sort_keys=True) + "\n").encode("utf-8")) class Server(socketserver.UnixStreamServer): allow_reuse_address = True def main() -> None: socket_path = Path( os.getenv( "ZHULAN_VALIDATION_SOCKET", "/run/guanghu/zhulan-validation/executor.sock" ) ) socket_path.parent.mkdir(parents=True, exist_ok=True) if socket_path.exists(): socket_path.unlink() server = Server(str(socket_path), Handler) server.executor = ValidationExecutor() # type: ignore[attr-defined] os.chmod(socket_path, 0o600) def stop(_signum: int, _frame: object) -> None: server.server_close() raise SystemExit(0) signal.signal(signal.SIGTERM, stop) signal.signal(signal.SIGINT, stop) try: server.serve_forever() finally: server.server_close() socket_path.unlink(missing_ok=True) if __name__ == "__main__": main()