diff --git a/PROGRESS.md b/PROGRESS.md index 44ce02e..98394bd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-05-26(Stage C Step 1:Executor 接口骨架 + HostExecutor in-process backend,行为零变化) +最后更新:2026-05-26(Stage C Step 2:Docker per-user 容器池 + Dockerfile / init.sh / network ensure,代码就绪未集成 AgentLoop) --- @@ -23,6 +23,7 @@ ### 2026-05-26 +- **Stage C Step 2:Docker per-user 容器 + iptables blocklist(§7.5 #1 + #3 落地基底,未接入 AgentLoop)**:`deploy/sandbox/Dockerfile`(python:3.11-slim + tini PID 1 + iptables/iproute2/netbase + non-root user uid `HOST_UID` build-arg + 全套 requirements.txt 装到容器内)+ `deploy/sandbox/init.sh`(`set -euo pipefail`,任一 iptables 规则失败 fail-fast → 容器终止,符合 §7.5 #1"任一缺失视为 Stage C 未完成"硬协议;6 段 IPv4 红线 + ::1 IPv6 loopback 降级容忍 + `ZCBOT_PG_IPS` env 逐 IP DROP;`exec sleep infinity` 等 `docker exec` 进来)。`core/sandbox/network.py` 单函数 `ensure_network()`,`docker network create --internal zcbot-sandbox-net`(默认无 outbound + 跨容器隔离,Step 4 加 proxy 时 proxy 同接此网络);`core/sandbox/pool.py` `SandboxPool` 类持 per-user `asyncio.Lock` + in-memory `_last_active` dict —— ensure 路径 inspect 探测 → running 直接返 / exists-but-stopped `rm -f` 重起(保 iptables 重新 apply)/ 不存在 `docker run` 装齐 hardening flags(`--read-only --tmpfs /tmp:exec --cap-drop=ALL --cap-add=NET_ADMIN --security-opt=no-new-privileges --pids-limit=256 --memory=2g --cpus=1.0` + bind mount user_root → `/workspace` + label `zcbot.product=sandbox` 给批量清扫用 + `--restart=no`);`mark_active` 更新 dict / `reap_idle` 按 ttl 杀 / `shutdown_all` 杀 label 全集(app 启动清前驱孤儿用)。容器命名 `zcbot-sandbox-`(UUID 标准串带 dash,与 mount 路径 `/users//` 视觉对齐 ── `docker ps | grep zcbot-sandbox-` 直接看活跃 user)。**关键决策**:(a) **docker CLI via subprocess 而非 docker-py SDK** ── §7.5 #5 "接口形状不泄漏 Docker 假设"对应到实现层,subprocess 行为透明、零新依赖、`docker ps` 实地对账;(b) **`docker update --label-add` 不可用 → 用 in-memory dict** ── Docker 23+ 移除 runtime label 修改,所以 last_active 落 Python dict;app 重启 dict 空 → 历史孤儿由 `shutdown_all` 兜底清(lifespan 启动钩子里调);(c) **`--internal` 网络从 Step 2 即生效** ── iptables OUTPUT 规则作为 defense-in-depth(网络层已堵死 outbound,iptables 仍按协议加规则);Step 4 加 proxy 时 proxy 容器同接 `zcbot-sandbox-net`,加 iptables ACCEPT 例外 + 改默认 DROP 实现"默认 deny + 仅经 proxy";(d) **NET_ADMIN cap 留给 PID 1 root 跑 iptables** ── 容器整生命周期持 NET_ADMIN,但 PID 1 `sleep infinity` 不接外部输入,`docker exec` 进来由 `--user 1000:1000` 锁 non-root + 空 cap_effective,等同于无 NET_ADMIN。Step 3 DockerExecutor 必须硬编 --user 1000 不让 root 路径打开(代码 review 守住)。**Step 2 范围明确不包含**:① AgentLoop 集成(`agent_builder.py` 不动 ── pool 是孤立模块,Step 3 才插)② shell/run_python 切到容器 ③ egress proxy(Step 4)④ reaper 后台 task(Step 3 接入 web lifespan 时一起加)。**验证**:`from core.sandbox import ...` 全套导入 + ctor 通过;`SandboxPool(user_root_base=Path(...), pg_ips='10.x,172.x')` 字段正确;`unittest discover` 1/1 PASS。docker 真起容器验证在 Ubuntu 上跑(RUN.md "Sandbox(Stage C,Ubuntu)" 段写了 5 条 smoke 命令:build / iptables 段 / non-root uid / read-only / 销毁)。`DESIGN.md` 不动(纯按 §7.5 #1 #3 既有协议实施);`RUN.md` 加 "Sandbox(Stage C,Ubuntu)" 部署段(镜像构建 / sandbox env / 5 条验证命令 / xfs project quota 升级时点)+ 故障兜底加 2 条(uid 错配 EACCES / NET_ADMIN 缺失)。否决:(a) 容器名用 sha256(uid)[:12] + label 反查 —— 每次 exec 多一次 `docker ps --filter` round-trip,可读性损失,隐私收益 0;(b) per-task 容器 —— DESIGN §7.5 已锁 per-user 共享心智模型(同 user 多 task 共享素材),不重开;(c) 用 docker `init container` 范式做 iptables —— Docker 没原生支持(那是 k8s),compose v2 同步又增复杂度,NET_ADMIN + 非 root exec 范式更直接;(d) Step 2 立即接入 AgentLoop —— 接了不能 dogfood(本地 Windows 无 docker),反而污染 host 路径;pool 孤立 commit 留 Step 3 一起接。 - **Stage C Step 1:Executor 接口骨架 + HostExecutor in-process backend(§7.5 #5 落地)**:`core/executor.py` 加 `Executor` ABC + `ExecCtx`(user_id/task_id/working_dir/cancel_check)+ `ToolResult`(content/exit_code);`core/executor_host.py` 加 `HostExecutor` 包原 tools dict,`call_tool` 内部分流到对应 `Tool.execute` 并把三种错误(unknown / TypeError / 抛异常)统一收成 `[Error] ...` content + exit_code 区分。`AgentLoop.__init__` 改接 `executor` 而非 `tools` dict、加 `working_dir` 形参;`_stream_llm` 用 `executor.schemas()` 拼 LLM tools 字段;`_execute_tool_call` 改单条 `executor.call_tool(name, args, ctx)`,删原三段错误 emit(unknown/TypeError/Exception 已被 executor 收编为 ToolResult,只剩一处 emit)。`agent_builder.py` 装完 tools dict 后 `HostExecutor(tools)` 包一层,传给 `AgentLoop`。**接口形状刻意 backend 无关**——不暴露 `docker exec` / `docker cp` 等 Docker 假设,Step 3 切 docker backend 时 `AgentLoop` 零改动,只换 `agent_builder.py` 里 `HostExecutor` → `DockerExecutor(host_tools=..., docker_tools={shell, run_python})`。**行为零变化** —— sanity import 通过,`unittest discover -s tests` 1/1 PASS。`DESIGN.md` 不动(纯按 §7.5 #5 既有协议实施,无架构漂移);`RUN.md` 不动(无新 env / CLI 变化,`ZCBOT_SANDBOX_BACKEND` env 留到 Step 3 docker backend 引入时一起加)。否决:(a) 不抽 Executor 直接在 `shell.py/run_python.py` 里 `if backend=='docker'` —— 违反 §7.5 #5,未来切 gVisor/Firecracker 时改动散到工具层;(b) Executor 用 `exec(cmd, ctx)` primitive 而非 `call_tool(name, args, ctx)` dispatcher —— 不匹配 DESIGN 签名,且 host 工具(read/web_*/seedream)不是 "命令" 语义;(c) 用 `cancel_check` callable 替代 ExecCtx 重建 —— 当前 cancel_check 是 build 后 setter 赋值,ctx 缓存会指向 stale,per-call 构 ExecCtx 是 dataclass 廉价。 - **REVISIONS.md 修订日志机制(覆盖 proposal/patent/ppt 三个产物型 skill)**:`/REVISIONS.md` 作为产物迭代过程的紧凑 changelog —— task 对话历史是粗流水(50 条消息找上周改动靠翻),REVISIONS 是用户与 LLM 共同沉淀的实质决策列表(5 行就能复盘"上周这章为啥这么写"),与 spec 定位互补:**spec = 宪法(定调一次),REVISIONS = 实施日志(每次卡点累加)**。三个 SKILL.md 各加 (a) 起草步骤里加一步"用户确认实质改动后追加一行" + (b) "## 修订日志" 独立小节(何时记/何时不记表 + 格式约定 + 实例 + 操作)。三类 skill 的"实质改动"判据按各自领域定制:proposal = 技术路线/考核指标/创新点/课题分解/关键引文/预算结构;patent = 区别技术特征/关键参数/公式/实施例/章节;ppt = 版式/主色/页/图标/文案要点。统一原则:首次起草不记 / 错别字微调不记 / 模型自己改改撤撤不记 — 拿不准倾向不记,避免变流水账。格式选**单行 bullet 倒序追加**(时间在前、文件:章节定位、改了什么 — 为什么),用 edit 在头注释后插入新一行(不 append 到末尾,倒序读秒看最新)。否决:(a) 走 system prompt 软约束 — 对 coding/research/documents/imagegen/videogen 等非产物型 skill 强加无关约束;(b) 新建 `record_revision` tool — 开发期内 LLM 直接 edit 追加足够,加 tool 增加每次小改的调用开销,后期发现 LLM 漏记多再升 tool 化;(c) 按产物拆多文件(`.revisions.md`)— 单文件好读、跨产物时间线统一。`DESIGN.md` 不动(无架构变化);`RUN.md` 不动(无 CLI/env 变化)。 - **新增 patent skill(中国发明专利技术交底书)**:`skills/patent/` 完整 6 文件 — `SKILL.md` 主入口(五阶段 workflow:摄取 → 挖点 → 检索 → spec → 逐章起草 → 自查渲染,跟 proposal 同款 BLOCKING 节奏)+ `references/{disclosure_structure,patent_point_taxonomy,prior_art_search,self_check}.md` 4 份指南 + `templates/{spec,disclosure}.md` 2 份模板。**关键复用避免重复造**:① 素材摄取用 `markitdown` CLI(不内置 docx/pptx→md);② mermaid + docx 渲染直接复用 `skills/proposal/scripts/{render_diagrams,render_docx}.py`(参数兼容,patent 不另写);③ 现有技术检索走现成的 `web_search`/`web_fetch`(Bocha)+ `documents` + `research`,不实现 CNIPA Playwright 爬虫(反爬重、维护成本高,正式可作 IDS 提交的检索建议走线下专业渠道);④ 不实现修订日志(zcbot task 对话历史已有)。源 repo `github.com/handsomestWei/patent-disclosure-skill` 的 11 prompts 文件折叠进单份 SKILL.md(跟 proposal/ppt 风格一致)+ 8 Python tools 减到 0(全靠复用)。skill 内特有内容:7 章交底书骨架(技术领域 / 背景 / 发明内容 / 附图 / 实施方式 / 有益效果 / 权利要求建议)+ 三性自检(新颖/创造/实用)+ 9 类客体排除清单 + 6 类自查清单 + 脱敏边界(商业敏感词中性化、技术参数不脱敏)。`SkillRegistry` 自动发现验证通过。`DESIGN.md` 不动(无架构变化,纯新 skill);`RUN.md` 不动(无 CLI/env 变化)。 diff --git a/RUN.md b/RUN.md index 1bc14d9..d9e1da0 100644 --- a/RUN.md +++ b/RUN.md @@ -253,6 +253,100 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干 --- +## Sandbox(Stage C,Ubuntu) + +> 为外部用户开放前必须完成。当前 dogfood + 信任同事白名单阶段可跳过 ── 默 backend = host, +> `shell` / `run_python` 仍走 subprocess(未隔离)。Step 3 接入 DockerExecutor 后切 +> `ZCBOT_SANDBOX_BACKEND=docker` 启用。 + +### 镜像构建 + +容器内 uid/gid 与 host `zcbot` 用户必须对齐(bind mount 保 host owner;错配导致 EACCES): + +```bash +# 1) 确保 zcbot 用户存在,uid 拿出来 +id -u zcbot # 期望:整数,后面用作 HOST_UID +id -g zcbot # 期望:整数 + +# 2) 构建镜像(build context = repo 根) +cd /opt/zcbot +sudo -u zcbot docker build \ + -f deploy/sandbox/Dockerfile \ + --build-arg HOST_UID=$(id -u zcbot) \ + --build-arg HOST_GID=$(id -g zcbot) \ + -t zcbot-sandbox:latest . + +# 3) 创建 sandbox 网络(--internal,默认无 outbound) +sudo -u zcbot docker network create --internal zcbot-sandbox-net +# 或 SandboxPool.setup_pool() 自动 ensure +``` + +### Sandbox 相关 env(.env 加) + +``` +# 容器镜像 tag(默 zcbot-sandbox:latest) +# ZCBOT_SANDBOX_IMAGE=zcbot-sandbox:latest +# 容器 runtime(切 gVisor 用 runsc,Firecracker 用 kata;默 runc) +# ZCBOT_SANDBOX_RUNTIME= +# 空闲多少秒回收(默 300) +# ZCBOT_SANDBOX_IDLE_TTL=300 +# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1), +# init.sh 再加一遍 DROP 规则。生产部署必填。 +ZCBOT_PG_IPS=10.1.2.3,10.1.2.4 +``` + +### 验证(Step 2 部分能验) + +```bash +# 起一个测试容器(直接 docker run,不走 pool ── pool 在 Step 3 接入后才用) +USER_ID=00000000-0000-0000-0000-000000000001 +sudo -u zcbot docker run -d \ + --name zcbot-sandbox-$USER_ID \ + --label zcbot.product=sandbox \ + --label zcbot.user_id=$USER_ID \ + --network zcbot-sandbox-net \ + --read-only --tmpfs /tmp:exec,size=512m,mode=1777 \ + --cap-drop=ALL --cap-add=NET_ADMIN \ + --security-opt=no-new-privileges \ + --pids-limit=256 --memory=2g --cpus=1.0 \ + -v /opt/zcbot/workspace/users/$USER_ID:/workspace \ + -e ZCBOT_PG_IPS=10.1.2.3 \ + zcbot-sandbox:latest + +# 看 iptables 规则确实 apply 了(应 6+1 条 DROP) +sudo -u zcbot docker exec zcbot-sandbox-$USER_ID iptables -L OUTPUT -n --line-numbers + +# 看 non-root 用户(uid 应 = host zcbot uid) +sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \ + zcbot-sandbox-$USER_ID id + +# 看 rootfs read-only(应报 Read-only file system) +sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \ + zcbot-sandbox-$USER_ID touch /badtest + +# 销毁 +sudo -u zcbot docker rm -f zcbot-sandbox-$USER_ID +``` + +Step 4 引入 egress proxy 后,完整 5 条红队用例(metadata / loopback / 跨 user / nohup +残留 / allowlist 外 403)进 `tests/test_sandbox_redteam.py` 自动化跑。 + +### 配额硬化(§7.5 #4,外部开放前必做) + +应用层磁盘配额(Step 5 引入)能挡常规超额,**但扫描间隙打满共享 fs 拖死同节点**这条 +硬要 **xfs / ext4 project quota 或 zfs dataset quota**。部署到独立服务器 + 多租户开放前: + +```bash +# 示例(xfs project quota): +sudo mount -o remount,prjquota /opt +sudo xfs_quota -x -c "project -s -p /opt/zcbot/workspace/users/ " /opt +sudo xfs_quota -x -c "limit -p bhard=10g " /opt +``` + +具体方案视部署 fs 选择(xfs 推荐)── 不做这步等于"软配额 + 信任用户不写满"。 + +--- + ## 故障兜底 | 现象 | 原因 / 处理 | @@ -263,6 +357,8 @@ sudo journalctl -u zcbot -n 50 # 看新进程起没起干 | `db upgrade` 报 `column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER 或 `db downgrade base` 重来 | | Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在;或 `curl /v1/tasks` 拉 | | `--working-dir` 指定后 task 删了目录还在 | 两种情况:① 目录非空(有用户文件) — 设计如此,绝不 rmtree,手动 `rm -rf ` 清;② 外部 `--working-dir`(DB 存绝对路径)— 不自动清,避免误删用户外部项目。ROOT 内 + 同 working_dir 无其他 task 引用 + FS 空 → DELETE task 时已自动 rmdir | +| Sandbox 容器内 `touch /workspace/x` 报 `Permission denied` | 容器 uid 1000 与 host `zcbot` 用户 uid 不一致(bind mount 保 host owner)。`docker build --build-arg HOST_UID=$(id -u zcbot)` 重建镜像 | +| Sandbox 容器 build 完起不来,`docker logs` 显示 iptables 报错 | 缺 NET_ADMIN cap(`--cap-add=NET_ADMIN` 漏了)或 kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常。验:`docker exec ... iptables -V` | | Export 报 "无可导出内容" | task 没 messages(只 system 不算);先发条消息再 export | | `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) | | `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY` 或 `curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` | diff --git a/core/sandbox/__init__.py b/core/sandbox/__init__.py new file mode 100644 index 0000000..c507c60 --- /dev/null +++ b/core/sandbox/__init__.py @@ -0,0 +1,19 @@ +"""Sandbox 容器管理(DESIGN §7.5)。 + +模块边界: +- `network.py`:Docker network ensure(`zcbot-sandbox-net`,`--internal` 隔离 outbound + cross-container) +- `pool.py`:per-user 容器生命周期(ensure / mark_active / reap_idle / shutdown_all) + +不在本目录:`shell` / `run_python` 工具的 docker exec 调用 ── 那是 Step 3 的 +`core/executor_docker.py`,调用本模块的 `pool.ensure(user_id)` 拿到容器名后再 exec。 +""" +from .pool import SandboxPool, container_name, setup_pool +from .network import NETWORK_NAME, ensure_network + +__all__ = [ + "SandboxPool", + "container_name", + "setup_pool", + "NETWORK_NAME", + "ensure_network", +] diff --git a/core/sandbox/network.py b/core/sandbox/network.py new file mode 100644 index 0000000..86a38d9 --- /dev/null +++ b/core/sandbox/network.py @@ -0,0 +1,38 @@ +"""Sandbox Docker network 管理。 + +`zcbot-sandbox-net` 是 `--internal` bridge: +- 默认无 outbound(Docker bridge 移除 host NAT 路由) +- 同网络下容器之间默认隔离(Docker bridge 默认行为,internal 也成立) + +Step 2 起即用 `--internal`,iptables OUTPUT blocklist(init.sh 里的)作为 defense-in-depth +(网络层已堵死,iptables 仍按 §7.5 #1 协议加规则,任一缺失视为部署未完成)。 + +Step 4 引入 egress proxy 时:proxy 容器同接 `zcbot-sandbox-net`(从内部网到 proxy 容器 +保持联通),proxy 容器再走 host 默认网出网。sandbox 容器 env `HTTP_PROXY` 指向 +proxy 容器名 + iptables 加 ACCEPT 例外,实现"默认 deny + 仅经 proxy"。 + +操作幂等:create 前 inspect 探测,已存在直接返。 +""" +from __future__ import annotations + +import subprocess + +NETWORK_NAME = "zcbot-sandbox-net" + + +def ensure_network() -> None: + """创建 `zcbot-sandbox-net`(若不存在)。失败 raise。""" + inspect = subprocess.run( + ["docker", "network", "inspect", NETWORK_NAME], + capture_output=True, text=True, + ) + if inspect.returncode == 0: + return + r = subprocess.run( + ["docker", "network", "create", "--internal", NETWORK_NAME], + capture_output=True, text=True, + ) + if r.returncode != 0: + raise RuntimeError( + f"docker network create {NETWORK_NAME} failed: {r.stderr.strip()}" + ) diff --git a/core/sandbox/pool.py b/core/sandbox/pool.py new file mode 100644 index 0000000..7a15f19 --- /dev/null +++ b/core/sandbox/pool.py @@ -0,0 +1,211 @@ +"""Per-user sandbox 容器池(DESIGN §7.5)。 + +命名:`zcbot-sandbox-`(user_id = UUID 标准串带 dash,与 bind mount +源路径 `/users//` 对齐 ── `docker ps` 看到容器名能直接 grep +出 workspace 目录)。 + +生命周期: +- `ensure(user_id)`:per-user `asyncio.Lock` 串行化 → `docker inspect` 探测 → 已 running + 直接返;exists-but-stopped 先 `rm -f` 重起(保证 iptables 重新 apply);不存在 `docker run` +- `mark_active(user_id)`:exec 完更新 in-memory `_last_active[uid]=now`(docker labels + 不可运行时修改 ── Docker 23+ 移除 `docker update --label-add` 支持) +- `reap_idle()`:周期任务,扫 `_last_active` dict,>`idle_ttl` 的 `docker rm -f` +- `shutdown_all()`:app 启动时清前驱孤儿(`docker ps --filter label=zcbot.product=sandbox`) + +幂等性: +- ensure 在重复调用时跨 daemon round-trip < 100ms(纯 `docker inspect`);per-user lock + 防同 user 两并发 `docker run --name` 撞 "Conflict"(虽然 docker 本身会 reject,提前 + 锁更干净) +- reaper 只杀 dict 里有记录的容器 ── 重启后 dict 空 → 不杀历史孤儿(这条由 startup + `shutdown_all` 兜底) + +Step 2 范围:仅 pool / lifecycle。Tools(shell / run_python)在 Step 3 接入。 +""" +from __future__ import annotations + +import asyncio +import os +import subprocess +import time +from pathlib import Path +from typing import Dict, List, Optional +from uuid import UUID + +from .network import NETWORK_NAME, ensure_network + + +CONTAINER_NAME_PREFIX = "zcbot-sandbox-" +LABEL_PRODUCT_KEY = "zcbot.product" +LABEL_PRODUCT_VALUE = "sandbox" +LABEL_USER_ID_KEY = "zcbot.user_id" + +DEFAULT_IMAGE = "zcbot-sandbox:latest" +DEFAULT_IDLE_TTL_SECONDS = 300 + + +def container_name(user_id: UUID) -> str: + return f"{CONTAINER_NAME_PREFIX}{user_id}" + + +def _now() -> int: + return int(time.time()) + + +def _container_exists(name: str) -> bool: + """任何 state(running / exited / created)都算存在。""" + r = subprocess.run( + ["docker", "inspect", "--type=container", name], + capture_output=True, text=True, + ) + return r.returncode == 0 + + +def _container_running(name: str) -> bool: + r = subprocess.run( + ["docker", "inspect", "--type=container", + "--format={{.State.Running}}", name], + capture_output=True, text=True, + ) + return r.returncode == 0 and r.stdout.strip() == "true" + + +class SandboxPool: + def __init__( + self, + user_root_base: Path, + image: Optional[str] = None, + runtime: Optional[str] = None, + idle_ttl: Optional[int] = None, + pg_ips: Optional[str] = None, + ) -> None: + """ + user_root_base: per-user 子树父目录,典型 `/users`。bind mount 源 + = `user_root_base / `,目标 `/workspace`。 + image: sandbox 镜像 tag(默 env `ZCBOT_SANDBOX_IMAGE`) + runtime: `docker run --runtime` 值(runc / runsc / kata 等);空 = 默认 + (env `ZCBOT_SANDBOX_RUNTIME`)。§7.5 #5 / §7.9 升级表 ── 切 + gVisor / Firecracker 时改这一项即可,应用层零改动。 + idle_ttl: 秒;`mark_active` 时间戳 < now - ttl 的容器被 reap_idle 杀 + (env `ZCBOT_SANDBOX_IDLE_TTL`,默 300) + pg_ips: 逗号分隔的 PG IP 串,塞容器 `ZCBOT_PG_IPS` env,init.sh 加 DROP 规则 + (env `ZCBOT_PG_IPS`)。defense-in-depth ── 即便落内网三段。 + """ + self.user_root_base = user_root_base + self.image = image or os.getenv("ZCBOT_SANDBOX_IMAGE", DEFAULT_IMAGE) + self.runtime = runtime or os.getenv("ZCBOT_SANDBOX_RUNTIME") or "" + self.idle_ttl = idle_ttl if idle_ttl is not None else int( + os.getenv("ZCBOT_SANDBOX_IDLE_TTL", str(DEFAULT_IDLE_TTL_SECONDS)) + ) + self.pg_ips = pg_ips if pg_ips is not None else os.getenv("ZCBOT_PG_IPS", "") + self._locks: Dict[UUID, asyncio.Lock] = {} + self._last_active: Dict[UUID, int] = {} + + def _lock_for(self, user_id: UUID) -> asyncio.Lock: + if user_id not in self._locks: + self._locks[user_id] = asyncio.Lock() + return self._locks[user_id] + + async def ensure(self, user_id: UUID) -> str: + """返回容器名;create-or-reuse 原子。""" + async with self._lock_for(user_id): + name = container_name(user_id) + if _container_running(name): + self._last_active[user_id] = _now() + return name + if _container_exists(name): + # stopped / crashed ── rm 重起。iptables 规则随容器生命周期重新 apply。 + subprocess.run( + ["docker", "rm", "-f", name], + capture_output=True, check=False, + ) + await asyncio.to_thread(self._docker_run, user_id, name) + self._last_active[user_id] = _now() + return name + + def _docker_run(self, user_id: UUID, name: str) -> None: + """同步阻塞;由 ensure 在 to_thread 里调。""" + user_root = self.user_root_base / str(user_id) + user_root.mkdir(parents=True, exist_ok=True) + + cmd: List[str] = [ + "docker", "run", "-d", + "--name", name, + "--label", f"{LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}", + "--label", f"{LABEL_USER_ID_KEY}={user_id}", + "--network", NETWORK_NAME, + # §7.5 硬限制(任一缺失视为 hardening 未完成) + "--read-only", # rootfs read-only + "--tmpfs", "/tmp:exec,size=512m,mode=1777", # 可写临时区,exec 允许 (run_python 写脚本) + "--cap-drop=ALL", # 默全丢 + "--cap-add=NET_ADMIN", # init.sh 配 iptables 需要;exec 进来的 uid 1000 拿不到 + "--security-opt=no-new-privileges", + "--pids-limit=256", + "--memory=2g", + "--cpus=1.0", + "-v", f"{user_root}:/workspace", + "-e", f"ZCBOT_PG_IPS={self.pg_ips}", + "--restart=no", + ] + if self.runtime: + cmd += ["--runtime", self.runtime] + cmd.append(self.image) + + r = subprocess.run(cmd, capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError( + f"docker run {name} failed (rc={r.returncode}): {r.stderr.strip()}" + ) + + def mark_active(self, user_id: UUID) -> None: + """每次 `docker exec` 完调一次,刷新 idle 计时。""" + self._last_active[user_id] = _now() + + def reap_idle(self) -> List[str]: + """杀超过 idle_ttl 没活跃的容器。返回已杀容器名列表(供日志 / 审计)。""" + removed: List[str] = [] + cutoff = _now() - self.idle_ttl + for uid, ts in list(self._last_active.items()): + if ts < cutoff: + name = container_name(uid) + r = subprocess.run( + ["docker", "rm", "-f", name], + capture_output=True, text=True, + ) + if r.returncode == 0: + removed.append(name) + # 无论 rm 成功与否,从 dict 移除 ── 失败则下次启动靠 shutdown_all 兜底 + del self._last_active[uid] + return removed + + def shutdown_all(self) -> List[str]: + """杀所有 label=zcbot.product=sandbox 的容器。 + + 典型用途:① app 启动时清前驱进程留下的孤儿 ② 测试 / 维护手动调。 + """ + list_r = subprocess.run( + ["docker", "ps", "-aq", "--filter", + f"label={LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}"], + capture_output=True, text=True, + ) + if list_r.returncode != 0 or not list_r.stdout.strip(): + return [] + ids = list_r.stdout.strip().splitlines() + subprocess.run( + ["docker", "rm", "-f", *ids], + capture_output=True, text=True, + ) + # 反查容器名给调用方记日志(rm 前先 inspect)── 这里简化只返 id + self._last_active.clear() + return ids + + +def setup_pool(user_root_base: Path) -> SandboxPool: + """app 启动便捷入口:ensure 网络存在 + 返回 pool 实例。 + + 典型用法(lifespan 启动钩子): + pool = setup_pool(workspace / "users") + pool.shutdown_all() # 清前驱孤儿 + # 后台 reaper task 周期跑 pool.reap_idle() + """ + ensure_network() + return SandboxPool(user_root_base=user_root_base) diff --git a/deploy/sandbox/Dockerfile b/deploy/sandbox/Dockerfile new file mode 100644 index 0000000..34d95b3 --- /dev/null +++ b/deploy/sandbox/Dockerfile @@ -0,0 +1,41 @@ +# Per-user sandbox base image (DESIGN §7.5). +# 长驻 per-user 容器,工具调用走 `docker exec --user 1000:1000 --workdir /workspace/ setsid `(Step 3 接入)。 +# +# 启动协议:tini(PID 1,reap 僵尸) → init.sh(root,跑 iptables 配 blocklist) → sleep infinity。 +# `docker exec` 进来的进程由 --user flag 决定身份 ── 应用层(DockerExecutor)硬编 --user 1000, +# 不继承 PID 1 的 root 上下文。 +# +# 构建上下文 = repo 根(用 -f 指定 Dockerfile 路径): +# docker build -f deploy/sandbox/Dockerfile -t zcbot-sandbox \ +# --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) . +FROM python:3.11-slim + +# - iptables / ip6tables: init.sh 配 blocklist 需要(NET_ADMIN cap 在 docker run 处加) +# - iproute2: ip 命令(调试 / 排查) +# - netbase: /etc/protocols /etc/services(curl / 多数网络库依赖) +# - ca-certificates: HTTPS 出网(经 proxy 也要,Step 4) +# - tini: PID 1 信号转发 + 僵尸 reaper(setsid 包出的进程组结束时 PID 1 兜底回收) +RUN apt-get update && apt-get install -y --no-install-recommends \ + iptables iproute2 netbase ca-certificates tini \ + && rm -rf /var/lib/apt/lists/* + +# 容器内非 root 用户的 uid/gid 必须与 host 上 `zcbot` 用户对齐(bind mount 保 host owner; +# 错配会导致 exec 进来后 write /workspace 时 EACCES)。build-arg 默认 1000,部署时按 +# `id -u zcbot` 实际值显式传(详 RUN.md "sandbox 部署"段)。 +ARG HOST_UID=1000 +ARG HOST_GID=1000 +RUN groupadd -g ${HOST_GID} zcbot && useradd -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash zcbot + +# 装全套 requirements ── 模型在 run_python 里写的脚本可能用 fastapi / sqlalchemy / litellm +# 等,装齐免 "ModuleNotFoundError" 摩擦。镜像偏大(~1G)是接受成本。 +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt + +COPY deploy/sandbox/init.sh /init.sh +RUN chmod +x /init.sh + +# 默认 cwd /workspace ── 但每次 `docker exec --workdir /workspace/` 会覆盖 +WORKDIR /workspace + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["/init.sh"] diff --git a/deploy/sandbox/init.sh b/deploy/sandbox/init.sh new file mode 100644 index 0000000..748531b --- /dev/null +++ b/deploy/sandbox/init.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Sandbox container init (DESIGN §7.5 #1 网络 blocklist 协议)。 +# +# 启动流程:配 iptables OUTPUT blocklist → sleep infinity 等 `docker exec` 进来。 +# 以 root 跑(需要 NET_ADMIN),docker exec 进来的 --user 1000 进程拿不到 cap。 +# +# Stage C 协议:任一规则 apply 失败 → `set -e` 退出,容器立即终止;视为部署未完成, +# 上层 ensure() 会 raise。 +set -euo pipefail + +apply_blocklist() { + # §7.5 #1 红线段(任一缺失视为 Stage C 未完成): + iptables -A OUTPUT -d 169.254.0.0/16 -j DROP # cloud metadata (Capital One 2019 SSRF) + iptables -A OUTPUT -d 127.0.0.0/8 -j DROP # IPv4 loopback (容器回头打宿主端口) + iptables -A OUTPUT -d 10.0.0.0/8 -j DROP # 内网段 A + iptables -A OUTPUT -d 172.16.0.0/12 -j DROP # 内网段 B(Docker 默认 bridge 网段在此) + iptables -A OUTPUT -d 192.168.0.0/16 -j DROP # 内网段 C + iptables -A OUTPUT -d 100.64.0.0/10 -j DROP # CGNAT(云平台常用) + + # IPv6 loopback ── ip6tables 在某些精简镜像 / sysctl ipv6.disable_ipv6=1 下不可用, + # 失败降级为 warn(IPv6 没起的话本来也打不通,容忍) + if ! ip6tables -A OUTPUT -d ::1 -j DROP 2>/dev/null; then + echo "[init] ip6tables ::1 rule skipped (ip6tables unavailable or v6 disabled)" >&2 + fi + + # ZCBOT_PG_IPS=10.x.x.x,172.x.x.x[/prefix],... + # 部署侧把 PG 实际 IP 显式 block ── 即使已落在内网三段,defense-in-depth(§7.5 #1)。 + if [ -n "${ZCBOT_PG_IPS:-}" ]; then + IFS=',' read -ra _pg_ips <<< "$ZCBOT_PG_IPS" + for ip in "${_pg_ips[@]}"; do + ip_trim="$(echo "$ip" | tr -d '[:space:]')" + [ -z "$ip_trim" ] && continue + iptables -A OUTPUT -d "$ip_trim" -j DROP + echo "[init] blocked PG IP: $ip_trim" + done + fi +} + +apply_blocklist +echo "[init] iptables OUTPUT blocklist applied:" +iptables -L OUTPUT -n --line-numbers +echo "[init] sleeping forever, ready for docker exec." + +# tini 是 PID 1;这里把 sleep 作为 tini 的子进程,信号能正常转发(docker stop → SIGTERM +# → tini 转给 sleep → 容器干净退出)。exec 避免再 fork 一层。 +exec sleep infinity