OpenShell supervisor 源码解剖:一个不信任任何人的进程

OpenShell supervisor 源码解剖:一个不信任任何人的进程

TL;DR

读 OpenShell v0.1.5 源码:supervisor 如何用一张“不信任清单”撑起沙箱的全部安全边界,每条不信任都有 文件:行号 为证。

引言:教程里那些“魔法”的出处

上一篇 OpenShell 教程 留了一堆“它就是能做到”的现象:沙箱里 DNS 永远“解析成功”,拿到却是 198.18 开头的假地址;越界的 POST 收到一个带规则名、带发起程序真身路径的 JSON 403;gateway 一重启,所有沙箱的 workload 整齐地死于同一条 SIGTERM 报错。教程解释了它们“是什么”,没解释“凭什么”。

这些现象背后是同一个进程:supervisor。它跑在沙箱边界之外的可信侧,Agent 的每一次 DNS 查询、每一条 TCP 连接、每一个 HTTP 请求、每一份凭证,都要经过它的手。官方架构文档给了它一句定位——“checks requests against policy, supplies credentials, resolves DNS, opens approved connections”——但读完文档你依然不知道它为什么敢说自己安全。

所以这篇文章直接读源码。OpenShell v0.1.5(与教程同版本),supervisor 相关七个 crate 约十七万行,其中网络执法一个 crate 占九万两千行——这个不对称本身就是第一个线索:整个系统的复杂度,都花在“不放心”上。

读完之后我发现,把 supervisor 的设计说成一份不信任清单最准确:它不信任对端的连接(哪怕握过手)、不信任进程自报的身份(哪怕看起来无害)、不信任加密的流量(哪怕是你自己配的证书)、不信任沙箱里的凭证(哪怕 key 是对的)、甚至不信任来恢复它的人(哪怕 token 有效)。每一条不信任,都对应一段可以指出行号的实现。

先从最外层开始:这个进程到底站在哪、和谁说话。

Supervisor 是什么:三明治的中间层

整个 OpenShell 是一个三明治:

supervisor 夹在中间,官方对它的分工描述只有一句,但那句是整个系统的宪法:sandbox 永远不做策略决策,只上报“Agent 想干什么”。落到实现是两次权力反转:网络层面,workload 容器干脆没有网卡(network=none),到 supervisor 的通道是它唯一的出路,DNS 和 TCP 都被透明捕获后转交出去;协议层面,supervisor 是客户端、sandbox 是服务端——与“监管者”守在门口的直觉相反,连接由 supervisor 主动发起,全部控制请求来自它,沙箱侧只应答、执行、记账。

源码结构同样在讲这个故事。supervisor 相关七个 crate 约十七万行,分工是:

crate行数职责
openshell-supervisor-network92k代理、L7 执法、OPA、fake-IP DNS、身份观测
openshell-sandbox27k沙箱内的对应组件:exec、进程管理、seccomp/Landlock
openshell-supervisor17k主进程、策略获取、advisor 草案起草
openshell-supervisor-middleware(+builtins)13k流量中间件链(如正则脱敏)
openshell-sandbox-backend10.5kSandbox Protocol 与隔离后端
openshell-supervisor-process8.8kgateway 会话、ssh/exec 中继、日志推送

一个 crate 吃掉一半以上,全部花在“检查每个包”上。而“可信侧”具体跑在哪,由 compute driver 决定:

runtimesupervisor 位置与沙箱的通道原始出路的封堵方式
Docker / Podman独立容器驱动卷上的 Unix socketworkload 容器 network=none
Kubernetes独立 podmTLS 私有 serviceNetworkPolicy 只放 supervisor
VM宿主进程vsockguest 无网卡

注意最后一列的共同点:封堵不靠 supervisor 仁慈,靠物理隔离。沙箱想上网只有一条路——走进 supervisor 的视线。那这条路本身怎么防伪造,就成了下一个问题:不信任清单第一项,不信任对端的连接。

不信任对端:Sandbox Protocol

这条通道要防两件事:有人伪造 supervisor 去接管沙箱,或者有人伪造沙箱去骗 supervisor。官方架构文档的答案是“Mutual TLS, plus a sandbox JWT”——但源码给出的答案更冷:它其实不信任证书能证明对端。

先纠正文档:两侧的 TLS 配置都是 with_no_client_auth()(runtime.rs:327、boundary_server.rs:3710),这是单向 TLS,不是 mTLS。双向认证是这么拼出来的:supervisor 用 driver 下发的 trust_anchor 验证沙箱服务端证书;沙箱则用网关公钥验证 supervisor 携带的 JWT,并核对 JWT 里的 sandbox_id、runtime_generation、auth_epoch 三个字段与自己的档案完全一致(sandbox_auth.rs:109-118)。证书只解决“这条连接没被第三方窃听”,“你有没有资格指挥我”由 JWT 说了算(boundary_protocol.rs:289-293 的注释原文如此)。

沙箱侧那张服务端证书,制造过程是一场偏执表演。每次启动,driver 现场生成一个一次性 Ed25519 CA,签出一张 server-only 叶子证书,签完立刻销毁 CA 私钥;证书的 server_name 绑定本次会话 ID(sandbox.{session_id}.openshell.internal);有效期从 1975 年到 4096 年——不是笔误,是刻意为之:整条信任链的校验不依赖任何一台机器的时钟正确。最后,沙箱加载证书后把证书文件从磁盘上删掉(boundary_server.rs:3714-3716)。

JWT 那一侧的防接管是三把锁:代次(generation)不符的 token 直接拒绝——沙箱重启即换代次,旧 token 全体作废;凭证序号(auth_epoch)只增不减,防止旧凭证回放;最狠的是实例钉死——沙箱第一次被 attach 时记下 supervisor 的实例 ID(一个每次进程启动都重新生成的 UUID),之后任何持有有效 token 的其他 supervisor 实例来接管,都会被 WrongSupervisorInstance 挡回(sandbox_auth.rs:181-187)。也就是说:token 正确也不行,必须还是原来那个进程。

连接本身的两阶段更换同样贯彻这个原则:新 supervisor 先 attach 进待定区,confirm 之后才切换为活动连接并关闭旧连接——中间不存在任何一刻“沙箱没人管”。至于协议里跑什么(进程流的五类通道、TCP 中继、DNS 询问这些带类型帧),一句话概括:所有通信复用这一条 HTTP/2 连接上的独立流,帧的种类、方向、顺序由状态机强制,走错一步即断。

通道建立了,但 supervisor 对通道里传来的第一类信息就抱有怀疑:沙箱说“这个连接是 curl 发起的”——凭什么信?

不信任自报身份:binary identity

“哪个程序在发请求”这个问题在沙箱里很难回答。连接是系统调用级的,Agent 完全可以起一个进程、在参数里自称 curl。OpenShell 的答案是不问进程,问内核——它从来不让沙箱自报身份,而是自己沿着 /proc 里的内核账本倒查。

归因链是这样的:沙箱侧先在 /proc/<入口进程>/net/tcp 里按连接四元组找到 socket 的 inode,再扫描所有进程的 /proc/*/fd,看谁持有这个 socket——找到 owner PID;最后 readlink /proc/<pid>/exe 拿到真身路径(procfs.rs:343-516)。这三个数据源全部由内核维护,用户态改不了。

拿到路径还不够,防伪造有三道细节。第一,exe 链接是内核对“正在运行的那个文件”的记账,换掉磁盘上的路径骗不过它;第二,计算哈希时直接 File::open("/proc/<pid>/exe") 读活 inode——就算攻击者把磁盘文件换成恶意版本,读到的仍是正在运行的那份(identity.rs:199-202);第三,cmdline 被永久排除在身份信号之外,源码注释写得毫不客气:argv[0] “trivially spoofable via execve”(用 execve 一行就能伪造,sandbox-policy.rego:166-168)——它只被用来识别“node 跑的是哪个脚本”这类场景,永远不参与授权。

哈希本身是 TOFU 模型(Trust On First Use):某二进制第一次参与连接时记下 SHA256 和文件指纹,之后每次比对;文件变了就重算,重算结果和初次记录不一致,报 Binary integrity violation: executable changed,之后一律拒绝(identity.rs:249-256)。教程里那个“允许 curl、拒绝 bash”的矩阵,执法依据就是这条链——日志里 DENIED /usr/bin/bash(0) 的路径不是谁自报的,是内核账本上抄的。

但严格的按程序授权有个现实问题:Agent 的一切行为几乎都不是它自己直连的——opencode 起的 shell、shell 起的 git、node 起的子进程。OpenShell 的折衷是祖先链继承:从 socket owner 沿 PPid 一路向上走到入口进程(深度上限 64),规则匹配 exe 本身或链上任何一个祖先。也就是“允许 opencode ⇒ 允许它启动的一切”。这个设计是安全与实用的明确交易——继承放行了子进程的全部能力,教程里实测的 ancestors: [] 则说明 owner 就是入口进程本身。

一句诚实的注脚:v0.1.5 的 policy DNS 资格判定用的是策略快照而不是每次查询的进程身份,源码注释承认“DNS alone cannot identify the requesting executable”(policy_dns/mod.rs:152-154)——身份观测还没有铺到每一个角落。

身份可信了,接下来是更大的对象:Agent 发出的流量本身。

不信任加密流量:TLS 终结与 L7 执法

对普通网络设备来说,HTTPS 是个黑盒——防火墙看得见 SNI,看不见方法和路径。supervisor 的选择简单粗暴:在中间把 TLS 拆开。教程里已经见过证据:沙箱内 curl 看到的证书签发者是 CN=OpenShell Sandbox CA。源码里的机制是,supervisor 启动时生成一个临时 CA,注入沙箱信任链(只读挂载 /run/openshell-supervisor-ca),对沙箱客户端按主机名现场签发叶子证书(每个主机名一张,缓存上限 256,l7/tls.rs:146-204);检查完明文,再用真实系统根证书向上游重新加密。Kubernetes 等场景可换用驱动预置的持久 CA,其余行为不变。

但终结只是手段,真正值得看的是它对明文的态度——supervisor 连 HTTP 解析器都不信任。请求逐字节读到头部结束(防流水线走私)、拒绝裸 LF 换行、强制严格 UTF-8、绝对形式 URI 的 authority 必须与 Host 头一致、多重 Host 头直接拒绝、Content-Length 与 Transfer-Encoding 的组合走私全部拦下(rest.rs:190-313)。理由很直接:L7 策略的语义建立在解析结果上,解析器被绕过一次,全部规则等于零。

判定引擎本身反而简单:嵌入式 Rego(regorus 库编进 supervisor 进程,规则文件编译期烧入二进制——用户永远不需要写 Rego),每次连接、每个请求求值一次。分工是“判定归 Rego,观测与执行归 Rust”:身份解析、协议解析、转发都是 Rust,Rego 只回答一个问题——允许吗。

这个“允许吗”的数学结构有个值得记住的细节:deny 永远赢。准入的定义是“存在任一规则允许”与“不存在任何规则拒绝”的合取(allow ∧ ¬deny)——多条规则取并集,但并集整体被一个全局的 not deny 门控(sandbox-policy.rego:239-250)。没有顺序、没有优先级,也就没有“排在前面的规则先放行”这类绕法。

执法分两档:enforce 真拦,audit 只记。教程实测过 audit 的诚实——被禁的 POST 原样穿到上游,只留一条违规日志。拒绝响应也有个教程没提的细节:403 的 JSON 之外还带一个 X-OpenShell-Policy 响应头,客户端不解析 body 也能拿到判定来源(rest.rs:2896)。

流量检查完了,还剩最后一类敏感物:凭证。而 supervisor 对凭证的不信任,比前四项加起来都细。

不信任沙箱内的凭证:占位符机制

教程里见过沙箱的 env:LLM_API_KEY=openshell:resolve:env:v10698058656670526006_LLM_API_KEY。一串句柄,不是 key。这串数字是什么?源码的回答很优雅:它不是版本号,也不是随机数,是内容指纹——gateway 对 provider 身份、凭证键、过期时间、策略绑定整体做 SHA-256,取前 8 字节(grpc/policy.rs:3326-3329 那行 u64::from_le_bytes(digest[..8]))。文档注释的原话:“opaque content identities, not ordered counters”。推论是:同一份凭证同一份绑定,句柄必然相同;你轮换了凭证,句柄必然变——教程实测轮换后句柄更换,正是这个性质的表现。

注入发生在 supervisor 拿到明文之后、转发给上游之前。凡是占位符可能出现的地方都处理:

位置处理
请求头直接值或 Bearer 前缀
Basic authbase64 解开、替换、重新编码
query 参数逐个值替换
URL path 段支持段内拼接(如 /bot{TOKEN}/sendMessage)
REST body需端点显式开启,含 form 表单与流式扫描
WebSocket 文本帧逐帧替换,失败即断连
AWS SigV4先剥掉旧签名,用真实密钥重签

“key 能去哪”是另一道独立的闸:由 provider 声明的端点绑定说了算,跟网络规则无关——教程实测过,网络层放行 GitHub、占位符带过去,换来的是 403 credential_endpoint_mismatch。而这条 403 的响应体是一段固定字符串(relay.rs:586-626),不含 key、不含占位符、不含请求内容——排查时什么都查不到,这是故意的。

fail-closed 在这一节有三层。最外层:未解析的占位符绝不转发——教程说“请求成功本身就是注入发生的证明”,逻辑反面就在这,supervisor 没换成真实 key 的话请求根本出不去。中间层:改写完成之后还有一道后置扫描,确认明文里再无占位符残留(secrets.rs:1303-1308)——改写代码自己也不被信任。最里层是日志:SecretResolver 的 Debug 实现只打印条目数量,日志里的敏感位置一律用 [CREDENTIAL] 替身顶替,错误信息只描述位置和原因(secrets.rs:131-145)——实测“错误体干净”不是巧合,是逐行实现的。

凭证管完了,最后一个问题是:如果 supervisor 自己出了事,沙箱会怎样?答案是不信任任何来收拾残局的人——包括另一个 supervisor。

不信任恢复者:冻结与 fail-closed

这个问题的正确提法是:supervisor 消失的那一刻,沙箱最安全的形态是什么?OpenShell 的答案:原地不动,等正确的人来——而“正确的人”还要再验一遍。

先说启动期的规矩,它直接解释了教程里的 bug。沙箱侧的 boundary 进程启动后处于“等待 attach、等待确认”状态,supervisor 要走完 Attach、Confirm 两步握手,它才进入 Connected。在确认到达之前收到 SIGTERM,进程直接以错误退出——就是教程里 gateway 重启后所有 workload 齐刷刷报的那条 sandbox boundary received SIGTERM before supervisor confirmation(boundary_server.rs:508-511)。当时我们只观察到现象,源码补全了因果:gateway 重启时新 supervisor 还停在拉取策略的阶段,尚未 Confirm,workload 的 SIGTERM 恰好落在这个窗口里。如果当时已经 Connected,行为会完全不同——不会死,会冻结。

冻结是这一节最值得看的机制。连接断开,boundary 对全部已注册进程组发 SIGSTOP,状态置为 FROZEN,并启动 30 秒恢复计时(boundary_server.rs:1954-2010)。最狠的细节是防自救:workload 自己发 SIGCONT 会被拒绝、返回 EPERM(process_signal.rs:137-140),连绕道 tgkill 系统调用的路径也被 seccomp 通知拦截(seccomp_notify.rs:731-740)——被冻者没有任何办法解冻自己。解冻权只属于一种人:持有有效 token、且实例 ID 与首次 attach 记录一致的原来那个 supervisor 进程,走完重新认证加 confirm,才换来 SIGCONT(boundary_io.rs:146-161)。第 3 节的“实例钉死”在这里收口——防接管设计同时就是恢复设计。

30 秒内没人来呢?先发 SIGCONT 把进程解冻、再发 SIGTERM 让它退出(boundary_server.rs:2013-2032)。顺序有讲究:先解冻再杀,进程有机会跑完清理逻辑——连死都给你体面,但不给你自由。

还有个朴素得近乎可爱的机制:supervisor 检测自己父进程(compute driver)是否存活的方式,是一个专职线程阻塞读 stdin——driver 持有管道写端,EOF 即父亡,立刻自杀(main.rs:171-190)。没有心跳、没有轮询、没有 PID 探测,一根管道,读完为止。

五项不信任到此收齐。把它们放回一张表上,你会看到同一个设计在五个层面反复出现。

结论:把信任最小化写进进程

不信任对象机制代表锚点
连接对端单向 TLS + JWT 三把锁(代次/凭证序号/实例钉死)sandbox_auth.rs
自报身份内核账本倒查;argv[0] 永久排除procfs.rs
加密流量TLS 终结 + 解析器级防走私 + deny 永远赢l7/tls.rs、rest.rs
沙箱内凭证占位符内容指纹 + 三层 fail-closedsecrets.rs
恢复者SIGSTOP 冻结 + SIGCONT 拦截 + 同实例恢复boundary_server.rs

五个层面做法各异,但语法是同一套。第一,能验证的地方绝不相信:对端是谁看 JWT 不看证书,进程是谁看内核账本不看自报,key 对不对看绑定声明不看网络放行——每一处“本可以直接相信”的位置都换成了一个可验证的凭证或一个内核事实。第二,能用结构解决的不用逻辑解决:沙箱干脆没有网卡、信任链只读挂载、CA 私钥签完即毁——物理结构一次到位,比任何运行时检查都难绕。第三,fail-closed 不是异常分支,是默认路径:确认前收到信号就死、占位符没解析就不转发、策略建模不了就返回 unsupported——“失败时关上”在代码里和“成功时放行”是同等地位的主干逻辑。

回到教程结尾的那句话:边界能不能划住,不取决于墙多厚,取决于有没有人站在门口看清每一个请求。这篇文章就是那句承诺的兑现——站在门口的 supervisor,五个层面都不肯相信任何一句“相信我”,连自己的改写代码都要事后扫描一遍。九万两千行的网络执法代码,每一行都在回答同一个问题:如果这一步是恶意的,会发生什么?

对 AI Agent 的基础设施来说,这可能是最值得抄走的部分。Agent 的能力增长远快于它的可信任性增长,泼冷水式的限制只会废掉它的用处;OpenShell 给的示范是另一条路——能力照单全收,信任逐项审查。沙箱里的 Agent 可以读文件、装包、调任何 API,但它的每一次连接、每一个请求、每一份凭证,都被一个不轻信任何人的进程看着。

这大概就是“安全”在 Agent 时代的具体形状:不是一句承诺,是一份逐行可查的不信任清单。

乱世浮生微信公众号二维码

(转载本站文章请注明作者和出处乱世浮生,请勿用于任何商业用途)