一个自己开发的 Agent Harness-沙箱篇

本文讲 区域 6 · 安全内核 的第三环------沙箱:权限门决定「该不该放行」,沙箱决定「放行之后,它在哪个牢房里跑」。

0. 先建立心智模型

权限放行了,然后呢?

06 篇讲的是「该不该放行」------模型要跑 exec,权限引擎说 Allow 或你点了 Approve。但「允许执行」和「随便执行」之间还差着一层:允许它跑 rm -rf /,不代表它真的能碰到 /

这就是沙箱要回答的问题。它的哲学和权限一脉相承,只是换了个对象:

  • 权限是门禁(谁可以进);
  • 沙箱是牢房(进去之后,手能伸到哪)。

Grodex 对「放行之后」的答案是 macOS Seatbelt 内核级沙箱 ------不是用户态 chroot 那种能被绕过的玩具,而是把 deny 规则交给内核,变成真正的 EPERM

一道底线贯穿始终

和 06 一样,沙箱的灵魂也是一句:不静默裸跑 。后端缺失、平台不支持、profile 表达不了、权限上限为 0------任何一个环节不满足,一律 Refused(拒绝执行),绝不让命令在没有牢房的情况下跑出去

1. 全景:五种后端,只有一种是内核强制

grodex-sandbox-types 里声明了五种沙箱后端:

后端 真实程度
Seatbelt(macOS) 有真实内核强制,本文的主角
Landlock(Linux) 只有 generate_landlock_rules 生成规则,没接线、没依赖
Bubblewrap / Docker 纯类型占位,没实现

这里要诚实:grodex 的沙箱目前只在 macOS 上真正生效。Linux 的 Landlock 是「设计到位、等待接线」,Bubblewrap/Docker 只是枚举占位。这不是缺陷,而是「先把最硬的一条路走通」------Seatbelt 是 macOS 原生提供、零额外依赖、内核强制的方案,选它做第一块拼图最合理。

2. Seatbelt:默认全 deny 的白名单

Seatbelt 的 profile 是一个 Lisp 风格的规则文件。grodex 生成 profile 的第一行永远是:

lisp 复制代码
(version 1)
(deny default)

默认全拒绝,然后逐条放行------这是沙箱和权限共用的一套思维:不是「列出危险的东西挡住」,而是「默认关死,只开白名单」。因为「危险」是列不完的,「安全」才是有限的。

白名单怎么放(platform.rs):

lisp 复制代码
read_only_paths → (allow file-read* (subpath "/workspace"))
read_write_paths → (allow file-read* file-write* (subpath "/workspace"))
deny_paths      → (deny file-read* file-write* (subpath "/etc/passwd"))  ; 显式 deny 覆盖父级 allow
allow_exec      → (allow process-exec)
allow_fork      → (allow process-fork)
network         → (allow network*) / (deny network*)

「编译」这个词用在这里其实是写临时文件 :把 profile 写到 {tmp}/grodex-sandbox-{pid}.sb,然后执行:

bash 复制代码
sandbox-exec -f <tmpfile.sb> -- <program> [argv...]

有个值得记一笔的坑:早期实现用 -p - 从 stdin 喂 profile,但当前 macOS 的 sandbox-exec 拒绝 - 作为 profile 来源,所以改成了 -f <file>。这种「和平台 API 的隐性约定搏斗」的细节,正是做沙箱最磨人的地方。

3. 注入防护:一个字符都不能含

Seatbelt 的 profile 语言没有转义机制 。这意味着如果路径里含 "\,你没法「安全地」把它塞进 profile 字符串------任何转义尝试都是语义不明的。

grodex 的处理是直接拒绝 (seatbelt_safe_path):

rust 复制代码
// 路径含 `"`、`\` 或控制字符 → 返回 None → ProfileUnrepresentable

宁可让这次操作失败(用户换条路),也绝不出带语义不明的规则------因为一旦 profile 被注入,攻击者就能在牢房里越狱。这是 fail-closed 原则在「字符串安全」这个最不起眼、却最致命的地方的体现。

4. fail-closed 的五道防线

「绝不裸跑」不是一句话,而是五处独立的兜底:

场景 位置 行为
平台不支持(非 macOS) platform.rs Err(Unsupported)
sandbox-exec 不存在 platform.rs Err(BackendMissing)
profile 含不安全路径 platform.rs Err(ProfileUnrepresentable)
上述任一错误 runtime.rs::run 统一映射 Refused
权限上限为 0 外部 supervisor 二进制 无条件 Refused

最后一条最关键:外部 supervisor 是一个独立进程 ,它读请求 JSON、跑 sandbox-exec、写回响应。它在入口处无条件拒绝 authority_ceiling == 0------意思是「子代理没继承权限上限,就不许跑」。这条防线跑在独立进程里,隔离了 Agent 的地址空间和它要执行的命令,就算 Agent 本身被攻破,也污染不到牢房。

5. 权限 vs 沙箱:两道门的协同

turn_coordinator 的工具执行点,两道门的顺序是固定的(execute_single_tool):

markdown 复制代码
1. Delegation envelope 检查   ------ 父代理的硬上限(见 08 篇)
2. 权限检查 + PermissionLease ------ 「该不该放行」
3. invariant #5 断言           ------ 权限没放行,绝不调用运行时
4. 沙箱路径校验                ------ 「放行后能碰哪些路径」
5. 真正执行

lease 决定调用是否允许;沙箱路径校验决定允许后能碰哪些路径。 两者都通过,才走到真正的 runtime.execute()

一个值得注意的细节:第 4 步的沙箱校验是用户态 的路径检查(PathValidator,防 ../ 和符号链接逃逸,对原始/词法归一/canonicalize 三种形态都做前缀匹配)。它挡的是「明显越界」;而真正硬的内核强制,发生在 exec 工具内部执行时------那是第二层(见 §7)。

6. 七层 profile:一次执行,七份规则的求交

沙箱最独特的设计,是 profile 不是一份写死的规则,而是七个来源求交的结果:

scss 复制代码
PolicyCeiling(最严) → UserBinding → Capability → Tool → Supervisor → OS → Default(最宽)

求交算法(intersect_profiles)是这套机制的灵魂:

  • 只读路径 / deny 路径:并集------任何一层说不许读,就不许读;
  • 读写路径:交集 ------所有层都允许写,才允许写(/ 视为全集);
  • 网络:交集 ------DenyAll 吸收一切,仅同名 Allow(host) 保留;
  • allow_exec / allow_fork:&&------任一层禁止,就禁止。

关键的一条 fail-closed:读写交集为空时,自动把 / 追加进 deny_paths------「没有任何地方被允许写」不能被理解成「哪里都能写」,而是「哪里都不能写」。

要诚实说明现状:这套七层求交的类型和算法已完备 ,但当前 CLI 接线只用了最浅的两层(default + user_binding,即 AccessLevel::Level2)。七层全量注入(policy_ceiling / capability / tool / supervisor)是设计预留,还没从配置全接进来。这是又一个「设计就绪、等待接线」的诚实现状。

7. exec 的两层沙箱

exec 是最危险的工具,所以它享受的是双层沙箱:

  1. coordinator 里的 validate_exec:用户态检查(§5 的第 4 步),挡明显越界;
  2. ExecTool 里的内核 sandbox-exec :真正执行时,把 effective profile 编译成 .sb 文件,套在命令外面。

第二层是怎么跑起来的(exec.rs):构造 PreparedOperation { program: "sh", argv: ["-c", command] }spawn_blocking 里调用 client.run_dispatched()(因为 sandbox-exec 是阻塞式 std::process::Command)→ 结果三态分派:

  • Completed → stdout/stderr 各截断到上限(默认 100KB),提取退出码;
  • Refused → 工具报错「sandbox refused exec」,绝不裸跑;
  • 超时 → 标记 timed_out

一个真实的测试 seatbelt_actually_denies_blocked_read 证明了这不是字符串层面的安慰:用「allow default + 单条 deny」的 profile 实测 cat 被拒路径退出码非 0------EPERM 是内核给的,不是 grodex 自己吓唬模型

写在最后

grodex 围绕它垒起的态度:

  • 默认全 deny,白名单式放行;
  • 一个字符不安全就整体拒绝;
  • 五道防线,每一道都指向同一个结局------Refused;
  • 七层求交,「没地方允许写」绝不变成「哪里都能写」;
  • 最后用一条内核级测试证明:不是装样子,是真的 EPERM

对任何一个「让 AI 触碰真实世界」的系统,权限决定它能不能 动手,沙箱决定它动手后碰得到什么

相关推荐
SimonKing34 分钟前
手机投屏到电脑,不用装任何 App,这个开源工具免费搞定:QtScrcpy
java·后端·程序员
学长毕业设计42 分钟前
基于SpringBoot的奶茶店服务管理系统的设计与实现(源码+文档+讲解视频)
java·spring boot·后端
IT_陈寒1 小时前
Vue的computed属性差点让我加班到凌晨
前端·人工智能·后端
SelectDB技术团队1 小时前
StarRocks 适合做日志分析吗?
大数据·数据结构·后端·python·doris·日志分析·starrock
谢亮_vipxieliang2 小时前
ValidX vs Apache Commons Validator:功能与性能对比
java·服务器·spring boot·后端·spring cloud·apache·hibernate
小裕哥略帅2 小时前
Spring AOP 实现通用 Token 自动刷新重试工具包
java·后端·spring
凤山老林2 小时前
Spring Boot 3 + Spring Authorization Server 构建企业级 SSO:多端互通、会话共享与
spring boot·后端·spring·单点登录·sso·多端互通
.Hypocritical.2 小时前
【SpringBoot】配置文件加载位置与优先级详解
java·spring boot·后端
码事漫谈11 小时前
勿删
后端