Bromure 支持 OpenShell 策略,每条连接背后的推理都可追溯
NVIDIA 的 OpenShell 用一份策略文件描述编码代理可以碰哪些东西。Bromure Agentic Coding 5.4 在代理的 VM 中执行这些策略:Landlock 和 seccomp 在 VM 内部,网络规则在你的 Mac 上;同时用一个哨兵模块监视客户机内核。随后,它把每一条网络连接关联到打开它的进程、启动这些进程的工具调用,以及代理在发出该调用时给出的推理。
编码代理要真正有用,就需要真正的访问权限,而策略规定给多少权限。策略最好是一份你能读、能检查、能执行的单一文档,而不是分散在防火墙、挂载选项和代理自身配置里的各种设置。
NVIDIA 的 OpenShell
OpenShell 是 NVIDIA 面向代理的开源运行时,以 Apache 2.0 许可证发布。我们在它的策略格式之上进行构建:一份 YAML 文档涵盖四个方面:
filesystem_policy和landlock:代理可以读写哪些路径,由 Linux 内核中的 Landlock 安全模块执行。process:代理以哪个用户和组的身份运行。network_policies:每个程序可以访问哪些目的地,以及可以向那里发送哪些请求,细到 HTTP 方法和路径。凡是未被允许的都会被拒绝:“除非 network_policies 中有规则允许,否则 OpenShell 会拒绝沙盒发出的每一条出站连接。”
OpenShell 还附带一个证明器,它用 SMT 求解器检查一份策略是否允许了另一份策略之外的任何东西。组织可以设定一条边界,对每一次变更都得到“是”或“否”的答案,而不必去读 diff。
这是一个用心的设计,出自显然深入研究过代理会如何出错的人之手,而且是开放的,所以我们以它为起点。
Bromure 中的 OpenShell
在 Bromure Agentic Coding 5.4 中,工作区可以用 OpenShell 策略取代 Bromure 自己的防火墙规则。我们采用的是策略格式及其语义,而不是代码:引擎是我们自己的,运行在 Mac 上,再加上我们自己在 Linux 客户机中的组件。为了确保两者一致,我们让自己的引擎和 OpenShell 的参考引擎跑了 125,666 个生成的用例,覆盖网络规则、HTTP 规则、MCP、GraphQL、WebSocket 和目标地址检查。两者没有一处不一致。这轮测试还发现了 ..%3b 和双重编码斜杠(%252F)之类的路径花招,我们现在的处理方式与 OpenShell 相同。
Bromure 与 OpenShell 的区别在于执行发生在哪里。OpenShell 通过一个网关和一个容器运行时启动沙盒。Bromure 则在你的 Mac 上把每个工作区放进各自独立、用完即弃的 Linux VM,因此策略被分到两侧执行:
- 在 VM 内: 用于文件系统的 Landlock、seccomp、
no_new_privs,以及切换到策略指定的用户。下一节会介绍具体做法。 - 在 Mac 上: 网络规则。每一条连接都经由 Bromure 的虚拟交换机离开 VM,由它检查目的地和端口;再经由它的 TLS 代理服务器,由后者检查 HTTP 方法和路径,以及 MCP、GraphQL 和 WebSocket 消息。地址由 Mac 解析并固定,因此无法利用 DNS 绕过
allowed_ips规则。VM 里的 root 改不了一条位于 VM 之外的规则。
策略还可以指定允许发起某条连接的程序。开启严格模式后,Bromure 会通过哈希检查是哪个二进制文件打开了连接:curl 可以访问 api.github.com,而 Python 脚本不行。再配合一条 HTTP 规则,比如对 api.github.com/repos/** 只允许 GET,一个 DELETE 就会在代理服务器处被拒绝。以上这些 Landlock 一样也做不到:它只能限制文件。
当组织在 bromure.io 上设定了边界,Bromure 会用 NVIDIA 的 openshell-prover(若已安装)对照边界检查每一份工作区策略;若未安装,则使用一项更严格的内置检查。越出边界的策略无法保存。
我们做了一处有意的改动:每个部分只有在存在时才生效。 没有 filesystem_policy 的策略不会启用 Landlock,没有 process 的策略不会切换用户。OpenShell 会填入默认值,因为它的驱动总是发送一份完整的文档。而在 Bromure 中,采用 OpenShell 绝不会悄无声息地弄坏一个工作区。由此形成三个级别:
| 策略包含 | 在 VM 内应用 | 提供的保证 |
|---|---|---|
| 仅网络规则 | 无;代理保留 sudo | 网络策略,以及内核哨兵的观测 |
filesystem_policy | Landlock,在仍为 root 时应用 | 即使面对 root 也有效的文件系统限制 |
process 部分,或严格模式 | Landlock、seccomp、no_new_privs、非 root 用户;收回 sudo 和 docker | 以上全部,没有通往 root 的路径,也无法停止 Bromure 自己的进程 |
有少数字段会被接受但不执行,主要是凭据改写和签名字段:Bromure 本来就把真实凭据留在 Mac 上,在流量出去时再替换进去,所以代理手里始终只有替身。
VM 内部:Landlock、seccomp 和权限降级
客户机这一半是对 OpenShell sandbox-linux crate 中执行代码的重新实现。它用 Python 和 ctypes 编写,因为它必须在启动时于 VM 内运行,而那里没有 Rust 工具链。它发出的系统调用与 Rust 的 landlock 和 seccompiler crate 完全相同;一套差分测试工具会构建 OpenShell 的真实 crate,逐个路径、逐个系统调用地比较两者。
一个 root 监管进程 bromure-sandboxd 负责启动每一个代理会话。它把会话移入专属的 cgroup,即沙盒 cgroup,然后分三步对其加以限制。
Landlock。 Landlock 让一个无特权进程可以把自己以及它启动的每一个进程限制在一份路径列表之内。监管进程会:
- 调用
landlock_create_ruleset,传入该规则集所管辖的权限。在客户机的 6.8 内核(Landlock ABI 4)上,这些权限包括执行、读取和写入文件,列出目录,创建和删除各类文件,跨目录重命名(REFER)以及截断; - 以
O_PATH打开read_only和read_write中的每个路径,并用landlock_add_rule把它作为一条PATH_BENEATH规则加入,附带读取权限或全部权限; - 调用
landlock_restrict_self。
此后,凡是规则集所管辖、却没有任何规则授予的访问,都会以 EACCES 失败,无论由谁发起。Landlock 检查的是任务而不是用户,因此 sudo 无法放宽它。进程可以叠加更多规则集,但永远无法移除已有的规则集,这就是为什么文件系统的变更需要重启 VM,而网络规则可以实时生效。landlock.compatibility 决定在不支持该 ABI 的内核上如何处理:best_effort 会以内核所能提供的功能继续运行,hard_requirement 则拒绝启动会话。
seccomp。 在严格模式下,或策略中有 process 部分时,监管进程会设置 no_new_privs,并安装 OpenShell 的三个 seccomp 过滤器:
- 主过滤器默认放行,只拦截可以绕过沙盒的操作。其中包括
ptrace、bpf、io_uring_setup、userfaultfd、perf_event_open、process_vm_readv和process_vm_writev、整个 mount 系列,以及setns。它拦截memfd_create和带AT_EMPTY_PATH的execveat,因为从匿名文件运行代码可以绕过 Landlock。它拦截带CLONE_NEWUSER的unshare和clone,因为新的用户命名空间会授予 capabilities。它还拦截 packet、蓝牙和 vsock 协议族的套接字,以及除路由之外的 netlink。 - 一个兼容性过滤器对
clone3返回ENOSYS。seccomp 过滤器能读取寄存器的值,却无法跟随指针,而clone3是通过指针传递标志的。ENOSYS会让 glibc 退回使用clone,主过滤器可以检查后者的标志。 - 一个加固过滤器阻止会话触及它的监管进程,后者以同一用户身份运行:不允许
ptrace、process_vm_*、pidfd_getfd或kcmp,也不允许向监管进程或其进程组发送信号。
我们与 OpenShell 只有两处不同,而且只涉及上报什么内容和信号的指向。我们的过滤器带有 SECCOMP_FILTER_FLAG_LOG,因此被拒绝的系统调用会到达内核的审计钩子,哨兵就在那里看到它。另外,信号规则被收窄,使 shell 的作业控制(kill %1)在交互式终端中仍然可用。
权限降级。 run_as_user 把会话切换到策略指定的用户,不带默认用户的任何组,因此没有 sudo,也没有 docker。用户的主目录通过 idmapped 挂载接入会话,因此会话创建的文件在磁盘上仍归默认用户所有,代理的配置也依然有效。
客户机内核中的哨兵
为了记录代理实际做了什么,Bromure 会把自己的内核模块——哨兵——加载到 VM 中。它的设计假定代理能在 VM 内成为 root,因此任何 root 能够阻止的东西都不算作证据:捕获、缓冲和传输全部在内核中运行。
捕获。 哨兵从内核空间注册 kprobes:
- 程序启动,挂在
security_bprm_committed_creds上,附带命令行和进程的启动时间; - 凭据变更:
setuid和setgid系列、capset和commit_creds; ptrace、模块加载和卸载、bpf、kexec_load、mount、unshare和setns;- 新的网络流:TCP 在第一个 SYN 发出时(
tcp_connect),UDP 在首次向某个目的地发送时,以及 ICMP 和原始套接字,每条都附带其背后最多八个进程组成的进程链; - 被拒绝的系统调用,挂在
audit_seccomp上; - 被拒绝的文件操作,通过内核文件安全钩子上的 kretprobes 捕获:
security_file_open,以及对应 mkdir、rmdir、unlink、symlink、link、rename 和 truncate 的security_path_*钩子。当某个钩子对沙盒 cgroup 中的进程返回EACCES或EPERM时,就是一次拒绝。执行 Landlock 不允许的文件,会表现为一次带FMODE_EXEC的被拒绝的打开操作。
监管进程通过一个只能设置、永远不能更改的模块参数,把沙盒 cgroup 的 ID 一次性交给哨兵。root 无法把它指向别处,让代理的活动被标记为 Bromure 自己的活动。
传输。 一个内核线程用 sock_create_kern 打开一个连向 Mac 的 AF_VSOCK 套接字。这个套接字没有文件描述符,因此不会出现在 /proc 中任何进程之下,用户空间里也没有任何东西能关闭它。事件经过一个 256 槽的环形缓冲区,每种事件限速为每秒 64 个,突发最多 256 个。每一帧是一个 4 字节的长度,后面跟着 JSON。
保持加载。 该模块没有退出函数,因此内核拒绝卸载它;此外它还持有一个对自身的引用。加载后,加载程序会立即把内核的 lockdown 提升到 integrity,若工作区要求启用哨兵则提升到 confidentiality。此后,root 无法加载未签名的模块,无法通过 /dev/mem 写入内核内存,也无法 kexec 进入一个没有哨兵的内核。
证明自己还活着。 哨兵每秒发送一次心跳,附带序号、被丢弃和被限速的事件数,以及已启用的探针数量。root 若通过 debugfs 一次性关闭所有 kprobes,已启用数量并不能暴露这一点:这样做不会改动每个探针自身的标志,所以数量看起来依然正常。为了发现这种情况,哨兵在自己的一个空函数上挂了一个 kprobe,每次心跳时调用一次该函数。如果命中计数没有变化,就说明探针没有触发,无论原因为何,心跳都会如实报告。
证明自己就是哨兵。 每次加载都会生成一个 32 字节的密钥。第一次 hello 携带该密钥,Mac 在本次启动期间将其固定。重连时绝不会再次发送该密钥,而是发送 sha256(secret, boot_id, connection number),因此即使某个进程在第一次 hello 之后从内存中读出了密钥,也仍然无法冒充该模块。出现第二个哨兵、密钥不对、连接编号被重复使用、序号出现空缺或 15 秒的沉默,都算作篡改。root 剩下的手段只有让 VM 崩溃或关机,而 Mac 会将其视为沉默。
分发。 模块不在 VM 中构建。CI 为 20 个 Ubuntu 24.04 内核版本各构建一个模块,每个都构建两次,两次结果必须逐字节一致。每个模块以及列出它们的目录都用一个 ed25519 密钥签名,应用会拒绝签名或哈希校验不通过的模块。
开发过程中的一个教训:有一段时间,文件拒绝探针把一个无符号返回值与 -EACCES 比较,结果一次都没有触发过。测试只检查了探针是否已注册,而它始终是已注册的。现在测试会检查一次拒绝确实会产生事件。
评判事件。 在 Mac 上,事件按其含义来判断,而不是看触发的是哪个系统调用。每个事件都会给分数加上一定权重,60 秒内累计达到 20 就会触发警报:
| 事件 | 权重 |
|---|---|
| 篡改哨兵,或在严格沙盒内部获得 root | 20 |
未签名的模块、BPF 加载、kexec、试图降低 lockdown;同一个程序一分钟内被拒绝 20 次 | 10 |
ptrace,或被 seccomp 拒绝的系统调用 | 3 |
| 挂载或命名空间变更;被丢弃的事件 | 2 |
| 被沙盒拒绝的文件操作 | 1 |
在允许 sudo 的工作区里执行 sudo | 0,显示在时间线上 |
在第一分钟就响起的警报会让人学会忽略真正的警报。所以 Bromure 自己的日常维护任务在沙盒之外运行,永远不会显示为一次拒绝;在一个固定的启动窗口内,Bromure 在启动时组装 VM 所产生的事件只计数,不触发警报。警报触发时,Bromure 可以隔离该工作区:切断 VM 的网络并将其暂停。
溯源链:每条连接背后的推理
网络日志能显示 VM 里有东西连接了某台主机,却显示不了原因。当干活的是代理时,“为什么”正是你需要知道的。
Bromure 能告诉你,因为它两端都看得到。它的 AI 代理服务器位于代理和模型提供商之间,在代理行动之前读取每一条响应,因此它知道每一次工具调用、该调用将运行的命令,以及模型在此之前刚写下的内容。哨兵则报告 VM 启动的每一个程序及其命令行,以及每一条新连接及其背后的进程链。Bromure 把两者连接起来。
假设你让代理添加一个健康检查,对你的支付 API 进行重试。它判断自己需要一个 HTTP 客户端,于是运行 npm install axios。npm 从 registry.npmjs.org 获取这个包,这是策略允许的。接着,下载内容中的一个 postinstall 脚本启动,并试图访问 telemetry.example.net,而没有任何规则允许这样做。这条连接在 Mac 上的代理服务器处被拒绝,溯源链则显示了它是怎么发生的:


代理从未输入过任何访问 telemetry.example.net 的命令。普通的网络日志只会显示一个 Node 进程在和一台未知主机通信;溯源链则显示,那是某个依赖的安装脚本,发生在代理出于明确理由运行的 npm install 之下。
关联分三步完成:
- 工具调用。 AI 代理服务器会把模型响应中的每一次工具调用保留十分钟:它的 ID(
tool_use_id,或 OpenAI 的call_id)、命令,以及模型在同一轮中、在该调用之前写下的最多 2,000 个字符。 - Shell。 Mac 根据哨兵报告的程序启动,为每个 VM 维护一张进程表,以 pid 和启动时间为键,因此被复用的 pid 无法继承别人的历史。在响应后 30 秒内于代理之下启动的 shell,如果在还原代理自己的引号转义之后,其命令行中出现了该命令,就会关联到该工具调用。例如,Claude Code 运行命令的形式是
bash -c '… eval '"'"'<command>'"'"' …'。这就是精确匹配。如果代理改写了命令,Bromure 会退而依据第一个词、时间和进程祖先关系来判断,并把匹配标记为模糊匹配。 - 决定。 一条流会依据协议、目的地、端口和源端口,在 5 秒的时间窗口内与交换机的允许或拒绝决定对应起来;如果代理服务器检查过该请求,也会与代理服务器的决定对应起来。
该 shell 之下的每一条连接都会继承这一关联。这也包括经由 Bromure 代理服务器桥接的流量,否则它们看起来都只是发往 localhost 的连接。桥接在发往 Mac 的每条流开头加上一行,写明客户端的源端口,这样 Mac 就能把本地连接映射到真正的目的地。
在 Security 窗口中,对某个网络行点击 Show the Whole Chain,即可打开该视图。
有些模型不先写任何东西,直接发出工具调用。这时视图会改为显示提示词和该调用声明的意图。后台服务以及任何在工具调用之外启动的东西,都会被标记为未追溯到代理。
溯源链也改变了拦截之后发生的事。一个只看到连接被重置或权限错误的代理,会把它当成故障,转而尝试别的主机、端口或工具,而从外面看,这就像一个失控的代理。Bromure 会在代理自己的会话中告诉它:请求被策略拒绝了,原因是什么,并且应该来问你,而不是设法绕过。正在运行的工作区的沙盒抽屉会列出被拦截的内容,每一项都有一个 允许 按钮;策略顾问还会根据这些拒绝记录起草规则,由你决定是否批准。


代理的推理始终会显示在你自己的安全时间线上。只有当组织开启这一功能时(默认不开启),它才会被发送到该组织在 bromure.io 上的控制台,而私人配置文件永远不会发送。命令行中的令牌在任何内容离开 Mac 之前都会被脱敏。
开启方法
OpenShell 策略、内核哨兵和溯源链随 Bromure Agentic Coding 5.4 推出,默认关闭。要为某个工作区开启,请打开 Settings,进入该工作区的 沙盒策略,把 Sandbox engine 设为 OpenShell + Sentry。你可以在那里编写策略,也可以导入现有的 OpenShell YAML 文件。


- 网络规则实时生效。文件系统的变更在下次 VM 启动时生效,因为 Landlock 只能收窄运行中进程的权限。
- 哨兵默认以尽力而为模式运行。把它设为 Required,Bromure 就会把内核的 lockdown 提升到最严格的级别,并把始终未连接上的哨兵视为遭到篡改。
- 在要求启用哨兵的情况下,如果内核对应的模块已缓存,工作区的 shell 在 4 秒内就绪;如果 Bromure 需要下载模块,则为 6 秒。
想看它的实际运作,请观看 Claudia 的三集视频:她编写一份策略,在代理工作时调整它,并把一次凌晨 2 点的供应链攻击一路追溯到代理的推理。
感谢 NVIDIA 的 OpenShell 团队公开发布策略格式、参考引擎和证明器。这三者都是我们构建的基础。