数据截至 (上游 commit 53ea1e8ba6fd)
容器隔离与凭据边界
本章讲什么: NanoClaw 敢把
Bash工具直接开给模型,靠的是四层边界:文件系统只挂该挂的、容器参数硬化、出网只能走网关、凭据从不进容器。本章把这四层逐个拆开。
1. 它要解决的小问题
Claude Code 这类 agent 最有用的能力就是「跑命令」。可它跑在你的笔记本上,rm -rf ~ 只是一个模型幻觉的距离。
常见做法是在应用层做权限检查:维护一个命令白名单、路径白名单,拦截危险调用。NanoClaw 的 README 明确说这不够——它选择OS 级隔离:
agent 跑在 Linux 容器里,只能看见明确挂进去的东西。Bash 是 安全的,因为命令跑在容器里而不是你的宿主机上。
代价:每个会话要起一个容器(有启动成本),而且宿主机上的东西 agent 默认全看不见——想给它看就得显式挂载,而挂载本身又需要一道白名单。
2. 一次 spawn 都干了什么
spawnContainer(src/container-runner.ts:157)的顺序:
① 刷新 destinations 表 + session_routing ← 让管理员的改动在唤醒时生效
② materializeContainerJson ← 从中央库落一份 container.json
③ initGroupFilesystem(幂等) ← 建 groups/<folder>/ 骨架
④ 解析 provider + 它声明的额外挂载/环境变量
⑤ buildMounts ← 算出完整挂载表
⑥ buildContainerArgs ← 拼 docker run 参数(含 OneCLI 网关)
⑦ 删掉旧心跳文件 ← 否则新容器刚起来就被判卡死
⑧ spawn(docker, args)
为什么每次 spawn 都重算配置而不是缓存: 管理员随时可能改接线、改容器配置、给 agent 加一个 MCP server。每次唤醒重算,改动就在下一次对话生效,不需要重启主机。
3. 第一层:挂载表(agent 能看见什么)
buildMounts(src/container-runner.ts:457)算出来的表:
| 宿主机路径 | 容器路径 | 读写 | 干什么 |
|---|---|---|---|
data/v2-sessions/<group>/<session>/ | /workspace | RW | 会话目录:两个 DB、inbox、outbox |
groups/<folder>/ | /workspace/agent | RW | agent 的工作区、记忆、笔记 |
groups/<folder>/container.json | /workspace/agent/container.json | RO | 自己的配置:能读不能改 |
groups/<folder>/plugins | /workspace/agent/plugins | RO | 模板 stamp 进来的插件基线,运行时不可变 |
groups/<folder>/CLAUDE.md | 同名 | RO | 每次 spawn 重 新组装,agent 写了也会被覆盖 |
groups/<folder>/.claude-fragments | 同名 | RO | 组装 CLAUDE.md 用的片段 |
container/CLAUDE.md | /app/CLAUDE.md | RO | 所有 agent 共享的基底指令 |
data/v2-sessions/<group>/.claude-shared | /home/node/.claude | RW | Claude SDK 状态 + skill 符号链接 |
container/agent-runner/src | /app/src | RO | agent-runner 源码,所有组共享一份 |
container/skills | /app/skills | RO | 共享技能 |
| 白名单允许的额外目录 | /workspace/extra/* 等 | 按配置 | 用户显式开的口子 |
「stamp」是这个项目自己的词: 建组时把模板目录整份拷进 plugins/<name>(copyPluginDir,src/templates/create-agent.ts:137),升级时整份替换(restampAgentFromTemplate,src/templates/restamp.ts:87)。
它为什么必须只读: 这份拷贝就是「用户有没有改过」的比对基准,注释直接叫它 “pristine baseline”(src/templates/restamp.ts:12)。agent 要是能改它,漂移检测就算不出来了。
值得单独说的两点
① 「嵌套只读挂载」这个手法。 组目录整体是 RW 的,但里面几个文件要禁止 agent 改。做法是在 RW 挂载之上再叠一个针对单文件/子目录的只读挂载——buildMounts(src/container-runner.ts:457)里直接给这些条目打 readonly: true,注释原文就叫 “nested RO mount on top of RW group dir”(:503)。container.json(:509)和 plugins/(:527)就是这么保护的。(旧版独立的 readonlyMountArgs 辅助函数已随容器运行时逻辑迁入 src/drivers/ 缝而移除。)
② agent-runner 源码是共享 RO 挂载,不是每组一份拷贝。 这意味着改容器侧代码不需要重建镜像——Dockerfile 里明确写了 Source is never baked in。开发循环因此很快。
skill 符号链接的小把戏
// src/container-runner.ts:433
fs.symlinkSync(`/app/skills/${skill}`, linkPath);
符号链接的目标是容器内路径,所以它在宿主机上是断的(dangling),进容器后才有效。这样 .claude-shared/skills/ 里放哪些链接,就等于「这个组启用了哪些技能」,而技能内容本身只有一份。
4. 第二层:容器硬化参数
容器 spec 用声明式的 hardening: 'standard'(src/container-runner.ts:743)起掉,docker-driver 据此对每个容器无条件加:
| 参数 | 作用 | 注释里的诚实说明 |
|---|---|---|
--cap-drop=ALL | 丢掉所有 Linux capability | 在 --user 映射下本来就是空的,这是纵深防御 |
--security-opt no-new-privileges | 禁止提权 | 同上,防的是「容器内是 root」的路径 |
--init | 用 docker-init 当 PID 1 | 不是可选的,见下 |
--pids-limit( 默认 2048) | fork 炸弹兜底 | cgroups v2 数的是线程,Chromium 很吃线程,所以不能设太低 |
--init 为什么是必需的
注释讲得很清楚:后面的 --entrypoint bash 覆盖打败了镜像自带的 tini,于是 bun 成了 PID 1 而它没有信号处理器;Linux 会丢弃发给 PID 1 的默认动作信号。没有 docker-init 的话,SIGTERM 被忽略,每次停止都要等满宽限期再 SIGKILL。
其他参数
| 参数 | 值 | 为什么 |
|---|---|---|
--rm | 总是加 | 容器退出即删——代价是日志也没了,见第 7 章局限 |
--shm-size=1g | 固定 | Docker 默认 64m,超过会静默短写;第三方 puppeteer 未必传 --disable-dev-shm-usage |
--cpus / --memory | 默认不加 | opt-in,避免 OOM 掉现有用户的工作负载 |
--user <hostUid>:<hostGid> | 宿主机 uid 不是 0 或 1000 时加 | 让挂载出来的文件属主正确 |
--label nanoclaw-install=<slug> | 总是加 | 启动时的收养/清理(adoptRunningSessions)只认本安装的容器,不误杀同机的另一份(旧版叫 cleanupOrphans,已移除) |
5. 第三层:出网封锁(可选但很硬)
默认情况下容器用宿主机网关正常出网。打开 NANOCLAW_EGRESS_LOCKDOWN=true 后(src/egress-lockdown.ts):
┌──────────────────────────────────┐
│ Docker 网络 nanoclaw-egress │
│ (--internal,无外网路由) │
│ │
│ [agent 容器] │
│ │ │
│ ▼ │
│ host.docker.internal ──────────┼ ──▶ [OneCLI 网关容器] ──▶ 互联网
│ (别名指向网关容器) │
└──────────────────────────────────┘
关键点:
- 网络是
--internal,没有外网路由。 - OneCLI 网关容器被
connect进这个网络,并起了别名host.docker.internal——于是注入给容器的HTTPS_PROXY指向的那个地址,是这个网络里唯一能到的下一跳。 - agent 是非 root、没有
NET_ADMIN,改不了路由表。
Fail-fast 而不是 fail-open(ensureEgressNetwork,src/egress-lockdown.ts:62):开关打开但网络建不起来 / 网关连不上时抛异常中止 spawn,而不是「退回开放出网」。
sweep 每 60 秒还会调一次 ensureEgressNetwork 自愈(src/host-sweep.ts:131),这次是 best-effort:治不好也不算泄漏(agent 还在内网里),记日志继续。
6. 第四层:凭据从不进容器
模型
[agent 容器] 想调某个 API
│ 普通 HTTPS 请求(不带任何 key)
▼
[OneCLI 网关] ← HTTPS_PROXY + 证书由 onecli.applyContainerConfig 注入
│ 在请求发出的那一刻注入凭据
│ 按 agent 做策略与限流
▼
[真正的 API]
主机侧的接线在 spec 组装(src/container-runner.ts:539):
if (agentIdentifier) {
await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier });
}
const onecliApplied = await onecli.applyContainerConfig(args, { addHostMapping: false, agent: agentIdentifier });
if (!onecliApplied) {
throw new Error('OneCLI gateway not applied — refusing to spawn container without credentials');
}
注意最后那个 throw: 网关接不上就不 spawn。注释说明这被当成「短暂的硬失败」——调用方(router 或 sweep)接住,入站消息保持 pending,下个 tick 重试。
两个细节
- OneCLI 的 agent 标识符固定用 agent 组 id(
src/container-runner.ts:158),跨会话稳定,审批路由时能反查回 agent 组。 - 网关的挂载放在自己的挂载之后(注释在
:530-538):这样如果网关要塞一个凭据桩文件进某个被我们 RW 挂载的父目录里,它在参数里排在后面就不会被我们的挂载遮住。
7. 额外挂载的白名单:防的是「操作员被 agent 忽悠」
用户可以给 agent 组配额外挂载(比如「让它读我的 Obsidian 目录」)。这些挂载要过 validateAdditionalMounts(src/modules/mount-security/index.ts:396)。
白名单文件放在 ~/.config/nanoclaw/mount-allowlist.json——故意在项目根目录之外(src/config.ts:55),这样容器里的 agent 改不到管着它自己的规则。
默认黑名单(DEFAULT_BLOCKED_PATTERNS,src/modules/mount-security/index.ts:55)包括 .ssh .gnupg .aws .kube .docker credentials .env .netrc id_rsa 等,还有两条项目自己的:
.config/nanoclaw—— 挂了它就等于把管自己的规则交出去了。.local/bin—— setup 把onecli和claude装在这里,宿主机会以操作员身份按名字调用它们。RW 挂载这个目录 = 容器到宿主机的代码执行原语(扔个文件进去,等宿主机跑)。
一段罕见的诚实注释
模块头部(src/modules/mount-security/index.ts:46-53)明确写了这套检查的边界:
检查只跑一次,针对挂载根的 realPath,不向下递归。它阻止操作员把这些路径命名为挂载点,但对一个已允许根目录下面有什么毫无办法。白名单里放
~(或~/.config),下面的每个凭据都会被挂进去,不管这个列表怎么写。不要把这里的条目读成「这个文件是安全的」。
这段值得单独学: 安全模块最该写的不是「我防住了什么」,而是「我防不住什么」。
8. 入站/出站附件的路径穿越防护
附件是外部输入(WhatsApp 直接透传客户端生成的 msg.key.id),而会话目录是 RW 挂给容器的——所以一个被攻破的 agent 可以预先在 inbox/<未来的 msgId>/ 放一个符号链接,等一条 id 匹配的聊天消息到来,把主机的写重定向出去。
extractAttachmentFiles(src/session-manager.ts:333)的四层防护:
① isSafeAttachmentName(messageId) 和文件名 ← basename 检查
② ensureContainedInboxDir 对 inbox 目录 lstat ← 拒绝预置的符号链接
③ realpath 包含性检查 ← 必须在 inbox 根之下
④ writeFileSync 用 flag: 'wx' ← 独占创建,拒绝跟随已存在的符号链接、拒绝覆盖
还有一个懒解析的小优化:inbox 目录只在第一个真的带字节的附件上才创建,声明了附件但没有内联数据的消息不会留下空目录。
出站侧 readOutboxFiles(src/session-manager.ts:492)是对称的:lstat 判目录、拒符号链接、realpath 包含检查、逐文件再查一遍。
clearOutbox(:515)的注释还点了一个容易忽略的排序问题:清理失败绝不 能往上抛——消息已经在用户屏幕上了,抛出去会触发投递重试,变成发两遍。
9. 按组定制镜像
agent 可以申请装 apt/npm 包。审批通过后 buildAgentGroupImage(src/container-runner.ts:878)现场生成一个 Dockerfile:
FROM <基础镜像>
USER root
RUN apt-get update && apt-get install -y <包>...
RUN echo 'only-built-dependencies[]=<pkg>' >> /root/.npmrc && pnpm install -g <包>...
USER node
LABEL dev.nanoclaw.image-source="derived"
LABEL dev.nanoclaw.derived-from="<基础镜像 id>"
两个讲究
① 那两行 LABEL 是防「冒领出身」。 dev.nanoclaw.image-source 被文档定义为「retag 伪造不了的那一个声明」。但派生构建会继承基础镜像的 label——于是一个刚装了任意 apt/npm 包的组,会继续宣称自己是 hardened(厂商对它从没见过的字节做的背书)。所以这里主动覆写成 derived,并记下派生自哪个 image id。
② only-built-dependencies 那行是 pnpm 的坑。 pnpm 默认跳过 build script,不加这行的话 playwright、puppeteer、原生插件会装成静默损坏的状态。
构建用 await execAsync(不是 execSync),注释说明理由:构建可能几分钟,单线程的主机进程不能被阻塞。
10. 边界:这套隔离防不住什么
| 防得住 | 防不住 |
|---|---|
| agent 删你 home 目录 | 你自己把 ~ 加进挂载白名单 |
agent 读你的 ~/.ssh | 已允许根目录下面的任何凭据(检查不递归) |
| agent 拿到明文 API key | agent 通过网关使用它有权使用的凭据(这本来就是设计) |
| agent 绕过代理直连外网(开了 lockdown 时) | 不开 lockdown 时的任意出网 |
| 同机另一份 NanoClaw 的容器被误杀 | 无 |
| 容器内提权到宿主机 root | 容器逃逸类的 Docker 自身漏洞 |
11. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| spawn 全流程 | src/container-runner.ts | spawnContainer |
| 挂载表计算 | src/container-runner.ts | buildMounts |
| docker run 参数拼装 | src/container-runner.ts | buildContainerArgs |
| 硬化参数 | src/container-runner.ts | hardening: standard(spec) |
| 按组构建镜像 | src/container-runner.ts | buildAgentGroupImage |
| skill 符号链接同步 | src/container-runner.ts | syncSkillSymlinks |
| 嵌套只读挂载 | src/container-runner.ts | buildMounts |
| 启动收养存活容器(替代旧的孤儿回收) | src/container-runner.ts | adoptRunningSessions |
| 出网封锁 | src/egress-lockdown.ts | ensureEgressNetwork / EgressLockdownError |
| 挂载白名单校验 | src/modules/mount-security/index.ts | validateAdditionalMounts |
| 默认黑名单及其边界说明 | src/modules/mount-security/index.ts | DEFAULT_BLOCKED_PATTERNS |
| 入站附件落盘防护 | src/session-manager.ts | extractAttachmentFiles |
| 出站附件读取防护 | src/session-manager.ts | readOutboxFiles / clearOutbox |
| CLAUDE.md 每次 spawn 重组 | src/claude-md-compose.ts | composeGroupClaudeMd |
| 组文件系统骨架(幂等) | src/group-init.ts | initGroupFilesystem |
| 插件模板基线与重新 stamp | src/templates/restamp.ts | restampAgentFromTemplate / groupsCarryingPlugin |
| 容器配置落盘 | src/container-config.ts | materializeContainerJson |