aps-agent/poc/pi-fallback/sandbox.py

188 lines
8.6 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""
sandbox.py — Pi Agent 兜底能力 P0 PoC:四层围墙骨架
====================================================
对应 `docs/architecture/fallback.md` 的沙箱围墙设计:
- L1 工具层:由 tool_bridge.py 的工具白名单 + orchestrator 启动参数 `--tools read,grep,find,ls`
实现(pi 只看得见白名单工具,且本 PoC **不装 bash/edit 给真实路径**,见下);
- L2 文件层:本模块。每次运行创建 runs/<runId>/{inbox,work,outbox} 三区
(inbox=可读注入区 / work=可写工作区 / outbox=产物出口),并提供 validate_path()
路径校验(越界即拒绝);真实模式下 pi 子进程 cwd 圈禁在 work 区;
- L3 网络层:本 PoC 以「不装 bash 工具(无任意命令执行面)+ 模型出口仅经
$PI_CODING_AGENT_DIR/models.json 里配置的 provider baseUrl」实现——即网络面收敛为
唯一可配的 LLM 网关地址(对应方案「模型 API 出口走 gateway 代理(可关)」)。
pi 自身无沙箱(Agent-A 侦察结论),更严格的端口级限制(仅 127.0.0.1:gateway)
留待产品化阶段用防火墙规则/网关代理实现,此处以注释与配置约束说明对应关系;
- L4 进程层:由 orchestrator.py 实现(build_child_env 环境清洗 + taskkill /T /F 进程树回收)。
同时本模块负责生成 Pi 扩展守卫(TypeScript,参考 probes/guard-ext.ts 模式):
tool_call 钩子里 bash/edit 默认 block、文件类工具限制在 run 目录内。
"""
from __future__ import annotations
import time
import uuid
from pathlib import Path
from typing import Optional
# ---------------------------------------------------------------------------
# 集中配置:poc 目录布局(一切产出只在 poc/pi-fallback/ 内,GOAL.md 硬约束)
# ---------------------------------------------------------------------------
POC_ROOT = Path(__file__).resolve().parent # poc/pi-fallback/
RUNS_ROOT = POC_ROOT / "runs" # 每次运行一个子目录
RUNTIME_ROOT = POC_ROOT / "runtime" # Agent-A 装好的 pi 运行时
PI_HOME = RUNTIME_ROOT / "pi-home" # PI_CODING_AGENT_DIR 指向这里
PI_CLI = (RUNTIME_ROOT / "node_modules" / "@mariozechner"
/ "pi-coding-agent" / "dist" / "cli.js") # headless 入口(Agent-A 实测)
class SandboxViolation(Exception):
"""路径越界 / 围墙违规时抛出。调用方必须把它当失败处理,不许静默放行。"""
def new_run_id(prefix: str = "run") -> str:
"""生成 runId:时间戳 + 短随机串,贯穿 propose→execute→verify 全链(方案 §4.5)。"""
return time.strftime(f"{prefix}-%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
def create_run_dirs(run_id: Optional[str] = None) -> dict:
"""
为一次运行创建 L2 三区目录。
返回 {"root", "inbox", "work", "outbox"} 四个 Path:
- inbox/ 可读注入区:用户上传文件/演示数据的副本放这里,语义上只读;
- work/ 可写工作区:pi 子进程 cwd 圈禁于此,中间产物写这里;
- outbox/ 产物出口:最终报告唯一落点(对应方案 §4.3 report_emit 唯一出口)。
"""
run_id = run_id or new_run_id()
root = RUNS_ROOT / run_id
dirs = {
"root": root,
"inbox": root / "inbox",
"work": root / "work",
"outbox": root / "outbox",
}
for p in dirs.values():
p.mkdir(parents=True, exist_ok=True)
return dirs
def validate_path(path, run_root: Path, zone: Optional[str] = None,
must_exist: bool = False) -> Path:
"""
L2 文件层核心校验:确认 path 解析后落在 runs/<runId>/ 内,越界即拒绝。
参数:
path: 待校验路径(相对或绝对,可含 ../ 等逃逸企图)。
run_root: 本次运行的根目录(runs/<runId>)。
zone: 可选,进一步限定必须落在 "inbox"/"work"/"outbox" 某一区。
must_exist: 为 True 时目标必须已存在(用于读场景)。
返回解析后的绝对 Path;违规抛 SandboxViolation。
实现要点:resolve() 归一化 + is_relative_to 前缀判断,挡住 `..`、
绝对路径跳转、大小写差异(Windows resolve 会规范化盘符大小写)。
"""
run_root = Path(run_root).resolve()
candidate = Path(path)
if not candidate.is_absolute():
candidate = run_root / candidate
candidate = candidate.resolve()
if must_exist and not candidate.exists():
raise SandboxViolation(f"路径不存在: {candidate}")
if not candidate.is_relative_to(run_root):
raise SandboxViolation(f"路径越界(逃出 run 目录): {candidate} 不在 {run_root} 内")
if zone:
zone_root = (run_root / zone).resolve()
if not candidate.is_relative_to(zone_root):
raise SandboxViolation(f"路径越区(须在 {zone}/ 内): {candidate}")
return candidate
# ---------------------------------------------------------------------------
# Pi 扩展守卫生成(L1 工具层的可编程部分,参考 probes/guard-ext.ts 模式)
# ---------------------------------------------------------------------------
# TypeScript 模板。占位符:{RUN_ROOT_POSIX} {BASH_WHITELIST_JS} {BLOCKLOG_POSIX}
_GUARD_TS_TEMPLATE = """// AUTO-GENERATED by sandbox.py — PoC 守卫扩展(L1/L2 围墙的 pi 侧执行点)。
// 机制依据:NOTES-pi-runtime.md §4 第二道墙 —— pi.on("tool_call") 返回
// {{ block: true, reason }} 即可在工具执行前拦截(Agent-A 已实测钩子加载)。
import fs from "node:fs";
import path from "node:path";
const RUN_ROOT = path.normalize("{RUN_ROOT_POSIX}");
const BLOCKLOG = path.join(RUN_ROOT, "guard-blocked-calls.jsonl");
// bash 白名单命令前缀(正则字符串数组);空数组 = bash 全禁(本 PoC 默认)。
const BASH_WHITELIST: RegExp[] = [{BASH_WHITELIST_JS}].map((s) => new RegExp(s));
function inRunRoot(p: string): boolean {{
const abs = path.resolve(process.cwd(), p);
const norm = path.normalize(abs);
return norm === RUN_ROOT || norm.startsWith(RUN_ROOT + path.sep);
}}
function deny(toolName: string, toolCallId: string, reason: string, input: any) {{
fs.appendFileSync(
BLOCKLOG,
JSON.stringify({{ ts: new Date().toISOString(), toolName, toolCallId, reason, input }}) + "\\n",
);
return {{ block: true, reason }};
}}
export default function (pi: any) {{
pi.on("tool_call", async (event: any, _ctx: any) => {{
const name: string = event.toolName;
const input: any = event.input || {{}};
// 1) bash / edit:默认 block;仅放行命中白名单前缀的 bash 命令。
if (name === "edit") {{
return deny(name, event.toolCallId, "edit disabled by PoC guard (use write into work/ instead)", input);
}}
if (name === "bash") {{
const cmd: string = String(input.command || "");
const ok = BASH_WHITELIST.some((re) => re.test(cmd));
if (!ok) return deny(name, event.toolCallId, "bash command not in whitelist", input);
}}
// 2) 文件类工具:路径必须落在 run 目录内(L2 圈禁的 pi 侧执行点)。
const fileTools = ["read", "write", "edit", "grep", "find", "ls"];
if (fileTools.includes(name)) {{
const p: string = String(input.path || input.pattern || ".");
if (path.isAbsolute(p) || p.includes("..")) {{
if (!inRunRoot(p)) return deny(name, event.toolCallId, "path escapes run root", input);
}} else if (!inRunRoot(p)) {{
return deny(name, event.toolCallId, "path escapes run root", input);
}}
}}
// 放行
}});
}}
"""
# 本 PoC 默认 bash 全禁(网络限制 L3 的前提:没有任意命令执行面)。
# 需要放行时按命令前缀正则登记,例如:r"^python3? \"?[^\x22']*analyze\.py"
DEFAULT_BASH_WHITELIST: tuple = ()
def write_guard_extension(run_root: Path,
bash_whitelist=DEFAULT_BASH_WHITELIST) -> Path:
"""
为本次运行生成守卫扩展 guard-<runId>.ts(落在 run 目录内,随运行归档)。
真实模式启动 pi 时以 `-e <该文件>` 加载。返回生成文件路径。
bash_whitelist: bash 命令前缀正则元组;默认空 = bash 全禁。
"""
run_root = Path(run_root).resolve()
bash_js = ", ".join(f'{r!r}'.replace("'", '"') for r in bash_whitelist)
content = _GUARD_TS_TEMPLATE.format(
RUN_ROOT_POSIX=run_root.as_posix(),
BASH_WHITELIST_JS=bash_js,
)
out = run_root / f"guard-{run_root.name}.ts"
out.write_text(content, encoding="utf-8")
return out