OpenHuman cwd_jail 深度解析:跨平台目录沙箱与 Landlock / Seatbelt / AppContainer 三后端实现
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
本文基于 OpenHuman 仓库中的 cwd_jail 模块文档 及其完整源码编写,围绕“如何把 Agent 派生的子进程安全地关进一个单一读写根目录”这一核心主题展开,覆盖
Jail声明式描述、后端自动探测、多工作区注册表持久化,以及三个操作系统级沙箱后端的底层原理与已知边界。
一、cwd_jail 是什么:目录隔离的面向用户门面
OpenHuman 是一个本地优先的个人 AI 应用。当 Agent 需要执行命令、运行脚本或调用工具时,系统必须回答两个截然不同的问题:这条命令能不能跑(决策),以及这条命令跑起来之后能看到什么文件系统(隔离)。cwd_jail 正是负责第二个问题的跨平台目录隔离门面(facade)。
它的定位在模块文档中写得很清楚:
- 它是
src/openhuman/security/的用户侧补充——安全子系统(autonomy gate)决定命令是否获准执行;cwd_jail 决定获准执行的子进程能访问哪个文件系统。 - 它只关押自己派生的子进程,永远不关押核心进程本身。核心进程是受信任的,被关进笼子的是它派生出去干活的东西。
- 它不替代
security::SecurityPolicy,也不加密文件。ACL、Landlock 规则、Seatbelt profile 就是那堵墙——root目录里的内容对子进程是完全可见的。
从模块文档(mod.rs 头部注释)可以看到它的设计动机:src/openhuman/security/里已有的Sandboxtrait 包装了 Landlock / Firejail / Bubblewrap / Docker,在 Linux 上表现良好,但 macOS 分支是 stub(bwrap在 macOS 上不存在),Windows 上则完全没有后端;而且调用方必须把SecurityConfig一路线程化地传进每个调用点。cwd_jail 把这一切收敛成一个统一门面:调用方描述“监狱长什么样”,模块自动挑选当前操作系统上可用的最强后端。
二、模块结构与关键文件
cwd_jail 是一个刻意自包含的模块。它的 14 个源文件位于 src/openhuman/sandbox/cwd_jail/,在 src/openhuman/mod.rs#L37 以pub mod cwd_jail;声明:
| 文件 | 角色 |
|---|---|
| mod.rs | 模块文档 + 薄门面:spawn/spawn_with/default_backend(OnceLock缓存),重导出公共表面 |
| jail.rs | 核心类型:Jail描述结构体(builder +canonicalize/canonicalize_or_log)与JailBackendtrait |
| detect.rs | pick_backend()——cfg 平台选择,返回第一个可用后端,否则NoopBackend |
| noop.rs | NoopBackend——无强制,普通Command::spawn,永远可用 |
| linux.rs | LandlockBackend——内核 5.13+ Landlock LSM,在pre_exec中(fork 之后、exec 之前)于子进程侧生效,由sandbox-landlockcargo feature 控制 |
| macos.rs | SeatbeltBackend——把命令包装进/usr/bin/sandbox-exec -p '<profile>',渲染“默认允许读 / 默认拒绝写”的 Seatbelt profile |
| windows.rs | AppContainerBackend——CreateAppContainerProfile+ DACL 授权 +STARTUPINFOEX/CreateProcessW(经windows-sysFFI) |
| registry.rs | JailRegistry+JailRecord——多监狱管理器,持久化到index.json,原子重命名写入 + 包含性检查 |
| registry_tests.rs | 注册表配套测试(经#[path]从registry.rs引入) |
模块在 Cargo.toml 中以sandbox-landlock = ["dep:landlock"]定义了 feature,并提供了landlock = ["sandbox-landlock"]别名。
三、公共 API:一个 Jail 描述,一个 Backend 强制执行
3.1Jail——声明式的监狱描述
Jail用一句话说清楚“Agent 被允许看到什么”:
// 结构体字段(见 src/openhuman/sandbox/cwd_jail/jail.rs#L18-L34) pub struct Jail { pub root: PathBuf, // 主要读写根目录,子进程写操作无法逃逸出此目录 pub read_only: Vec<PathBuf>, // 子进程可读的额外路径(如 /usr/lib),写仍被拒绝 pub allow_net: bool, // 是否允许出站网络 pub allow_subprocess: bool, // 是否允许子进程再派生孙进程 pub label: String, // 审计日志用自由文本标签(Windows 上还用作 AppContainer profile 名基础) }builder 提供四个便捷方法(jail.rs#L36-L62):
let mut jail = Jail::new("/Users/x/work/proj", "agent.delegate") // 默认 allow_net=true, allow_subprocess=true .add_read_only("/usr/lib") // 追加只读路径 .deny_net() // 关闭出站网络 .deny_subprocess(); // 禁止派生孙进程模块文档中的快速上手示例(mod.rs#L21-L35):
use openhuman::openhuman::sandbox::cwd_jail::{spawn, Jail}; use std::process::Command; let mut jail = Jail::new("/Users/x/work/proj", "agent.delegate") .add_read_only("/usr/lib") .deny_subprocess(); jail.canonicalize_or_log(); // 尽力规范化,失败只记日志 let mut cmd = Command::new("node"); cmd.arg("script.js"); let child = spawn(&jail, cmd)?;3.2JailBackend——操作系统侧的执行 trait
JailBackend是一个只有三个方法的 trait(jail.rs#L95-L109):
name() -> &'static str:稳定标识符,用于日志/审计("landlock"、"seatbelt"、"appcontainer"、"noop");is_available() -> bool:当前进程/内核上是否真能强制执行,自动探测会先调用它;spawn(&self, jail: &Jail, cmd: Command) -> io::Result<Child>:把 jail 物化成 Landlock 规则集、Seatbelt wrapper 或 AppContainer profile + 受限 token,再启动命令。
选择“建模 spawn 而不是修改Command”是因为 Windows AppContainer 需要自定义CreateProcess标志,std的Command::spawn暴露不了(jail.rs#L91-L94)。
3.3 三个自由函数
mod.rs 暴露三个自由函数:
default_backend() -> Arc<dyn JailBackend>:进程级缓存、懒加载自动探测的后端。底层是静态OnceLock<Arc<dyn JailBackend>>,get_or_init(detect::pick_backend)保证全进程只探测一次;spawn(jail: &Jail, cmd: Command) -> io::Result<Child>:先canonicalize()再在默认后端下 spawn。如果 root 不存在,canonicalize()会把NotFound错误冒泡上来——调用方应当先创建好工作区再封装;spawn_with(backend: &dyn JailBackend, jail: &Jail, cmd: Command) -> io::Result<Child>:显式指定后端,供测试或本地开发时强制使用较弱后端(如强制NoopBackend)。
四、后端自动探测:pick_backend 与降级链
pick_backend()(detect.rs)的探测逻辑非常直白,按平台返回第一个可用的后端,全部不可用则回退NoopBackend:
- Linux:
LandlockBackend::new(),若is_available()返回 true,选中landlock; - macOS:
SeatbeltBackend::new(),若可用,选中seatbelt; - Windows:
AppContainerBackend::new(),若可用,选中appcontainer; - 否则记
warn日志并返回NoopBackend。
每个分支都会打一条[cwd_jail] backend=<name>的信息日志,方便在运行时确认实际生效的后端。
关键点:调用方永远不按名字挑选后端,只跟Jail和顶层的spawn打交道。后端选择是自动的、进程级缓存的、可降级的。模块文档(README.md)明确指出,noop回退依然有用——NoopBackend的文档(noop.rs)说明,即使没有 OS 级沙箱,调用方仍可依赖应用层的validate_path_within_root路径检查。
配套测试(mod_tests.rs)验证了这些契约:
default_backend_is_cached:用Arc::ptr_eq断言OnceLock保证每次调用返回同一个Arc;default_backend_returns_something:后端名非空;missing_root_errors:root 不存在时 spawn 返回ErrorKind::NotFound;noop_backend_spawns_unrestricted:spawn_with(&NoopBackend, ...)直接 spawn 并wait成功。
五、三个平台后端详解
5.1 Linux:Landlock LSM(内核 5.13+)
Landlock 是 Linux 内核自 5.13 起提供的无特权 LSM。LandlockBackend(linux.rs)通过pre_exec应用规则集——pre_exec在fork()之后的子进程、exec()之前运行,因此父进程保留原有权限,子进程在任何用户代码执行前先戴上规则集。模块文档特别点明:这与 Chromium 的 Linux 沙箱采用同一模型。
is_available()在启用sandbox-landlockfeature 时实际尝试创建规则集(handle_access(ReadFile)+create()),失败或未启用 feature 则返回 false。
spawn()的核心流程(feature 开启时):
- 规则集 handle 一组访问类型:
Execute | ReadFile | WriteFile | ReadDir | RemoveDir | RemoveFile | MakeReg | MakeDir | MakeSym; - 对
root用PathBeneath添加规则,授予上述除MakeSym外的写/读/执行权限; - 对每个
read_only路径授予Execute | ReadFile | ReadDir——只读路径也授予Execute,这样子进程能运行在那里找到的二进制(例如/usr/bin/sh),否则 Landlock 会阻断对root之外一切可执行文件的execve; restrict_self()把规则集应用到子进程自身。
一个重要语义差异:Landlock 完全不门控网络。Jail.allow_net的字段文档明确写了这一点(jail.rs#L25-L30)。
5.2 macOS:Seatbelt via sandbox-exec
macOS 后端(macos.rs)把命令包装进系统自带的/usr/bin/sandbox-exec -p '<profile>'。sandbox-exec是 macOS 内置二进制,接收 Scheme 风格的 profile(Seatbelt / TrustedBSD 策略语言),并在其约束下 exec 目标命令。模块文档注明:Chromium、iOS 模拟器和 Apple 自己的工具底层都在用同一套 SPI;该 CLI 虽在技术上被标记为 deprecated,但已随系统发布了十年,也是不绑定私有框架的前提下应用 Seatbelt 的唯一受支持方式。
is_available()只检查/usr/bin/sandbox-exec是否存在。
profile 渲染(render_profile)遵循一个务实模型:读默认允许,写默认拒绝。原因在源码注释里说得很透:全拒绝的 profile 在 macOS 上根本不可用——Mach-O 二进制需要 dyld、libsystem、共享缓存、Foundation 的 mach lookup 以及十几种随系统版本变化的依赖,把这些锁死会让工具坏得比防攻击更快。实际渲染结果:
(version 1) (allow default) ; 读默认允许(网络默认也允许) (deny network*) ; 仅当 Jail 显式 deny_net() 时输出 (deny process-fork) ; 仅当 Jail 显式 deny_subprocess() 时输出 (deny process-exec) (deny file-write*) ; 目录监狱的核心:全盘拒绝写 (allow file-write* (subpath "<canonicalized root>") ; 然后在 root 内重新放行 (subpath "/private/tmp")) ; 以及 macOS 系统暂存区几个值得注意的细节:
read_only在 macOS 上是“信息性”的——(allow default)已经放行了所有读,保留该字段是为了让调用方在三平台间表达一致的意图,以及供 Landlock / AppContainer 使用;- profile 用
-p内联传入,避免临时文件的生命周期问题(子进程可能活得比父进程作用域更长); - stdio 是继承的——
sandbox-execwrapper 无法重新应用原命令的Stdio配置,只能用sandbox-exec默认值(继承); - 必须先 canonicalize:profile 只放行 root 与
/private/tmp下的写,而/tmp是/private/tmp的符号链接,如果调用方传入未规范化的/tmp路径,root 内的写可能被拒绝。spawn门面会自动完成 canonicalize,这正是它的价值之一。
5.3 Windows:AppContainer(UWP 同款隔离模型)
Windows 后端(windows.rs)使用 Microsoft 的 AppContainer——UWP 应用与 Edge 渲染器所用的严格进程隔离模型。每个容器有唯一 SID,进程只能触碰 DACL 显式授予该 SID 权限的文件系统对象。
spawn_in_container的执行步骤(源码注释即文档):
CreateAppContainerProfile创建/打开 profile 并取得 SID。若返回0x800700B7(ERROR_ALREADY_EXISTS),则用DeriveAppContainerSidFromAppContainerName从现有 profile 派生 SID。profile 名由jail.label经sanitize_profile_name清洗(≤60 字符、ASCII 字母数字与点号、其余映射为_)并加openhuman.前缀,确定性命名保证重跑复用同一 SID;grant_sid_access对root授予GENERIC_READ | GENERIC_WRITE | DELETE,对每个read_only路径授予GENERIC_READ。实现上先用GetNamedSecurityInfoW取回现有 DACL再经SetEntriesInAclW合并新 ACE——源码注释特别警告:如果直接传空旧 ACL,SetNamedSecurityInfoW会用只含 AppContainer ACE 的新 DACL 整个替换原 DACL,把 owner / SYSTEM / Administrators 全部锁在门外;- 构建
SECURITY_CAPABILITIES:AppContainer 默认没有任何网络能力,allow_net=true时经DeriveCapabilitySidsFromName派生internetClient能力 SID,以SE_GROUP_ENABLED挂进cap_attrs; - 构建
STARTUPINFOEXW,用UpdateProcThreadAttribute挂上PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES; CreateProcessW带EXTENDED_STARTUPINFO_PRESENT启动,命令行经自实现的build_command_line按CommandLineToArgvW兼容规则引号化;- 句柄桥接(见下节边界)。
关于网络能力的取舍,源码中有详细注释(windows.rs#L68-L92):allow_net的契约是“允许出站网络”,因此能力集严格限定为internetClient(仅出站 TCP/UDP),刻意不授予internetClientServer(会让子进程bind()并接受公网入站连接)和privateNetworkClientServer(LAN 入站)。若未来需要 LAN 入站或公网服务器角色,应新增专用Jail标志(如allow_private_lan_server)并条件性扩展该列表,绝不静默扩大allow_net的语义。
5.4 noop:降级兜底
NoopBackend(noop.rs)不做任何强制,直接cmd.spawn(),is_available()恒为 true。它是“无 OS 级沙箱可用”时的最后防线,同时让调用方依然可以依赖应用层的路径检查。
六、JailRegistry:多工作区并行的持久化注册表
当 Agent 需要同时管理多个被隔离的工作区时,JailRegistry(registry.rs)提供按 id 索引的持久化管理。它固定在一个基础目录(典型如~/.openhuman/jails/或<workspace>/jails/)下,每个监狱是<base>/<id>/目录,所有元数据集中在<base>/index.json。
6.1 JailRecord 字段
pub struct JailRecord { pub id: String, // 稳定 id,用于路径与索引 pub label: String, // 用户可见自由文本(UI 展示 / Windows AppContainer profile 派生) pub dir: PathBuf, // <base>/<id>/ 目录 pub backend_at_create: String,// 创建时的后端名 pub created_at_unix: u64, pub updated_at_unix: u64, pub notes: Option<String>, // 可选备注 }6.2 持久化与并发语义
- 磁盘索引是唯一事实来源:内存态(
Index,一个BTreeMap保证list()确定性排序)在每次open()时从磁盘重建; - 原子写入:
persist()先写index.json.tmp再fs::rename;Windows 上 rename-over-existing 失败时回退直接覆写并清理临时文件; - 失败回滚:
create/rename/set_notes在持久化失败时回滚内存变更(create还会删掉刚建的空目录),避免“内存有记录、磁盘没有”的幽灵状态; - id 生成:
j<ts_hex><counter_hex>格式(时间戳 + 进程内原子计数器),非密码学随机——因为它只用作目录名而非令牌;create带冲突重试循环,防止同一秒内进程重启导致 id 重复覆盖; - 并发:单
std::sync::Mutex守卫变更;多进程并发访问是显式非目标(不做 OS 文件锁)。
6.3 方法一览
JailRegistry的方法(README 列出完整清单):open、base、create、get、list、find_by_label(大小写不敏感子串搜索,供 UI 用)、rename(只改 label,目录 id 不动,已有路径引用保持有效)、set_notes、delete、clear、spawn_in、spawn_in_with。
spawn_in(id, cmd)等价于spawn(&Jail::new(record.dir, record.label), cmd),但会先经过jail_for的包含性检查并 canonicalize。
6.4 包含性守卫(防索引损坏逃逸)
delete与jail_for(被spawn_in/spawn_in_with使用)在操作前都会把记录的dir与base分别 canonicalize,然后校验resolved.starts_with(&resolved_base)。这是针对“损坏的索引指向/”这类场景的防御:若索引被篡改或损坏到把某个 jail 的 dir 指到 base 之外,delete返回PermissionDenied且不触碰磁盘,jail_for拒绝 spawn——否则被隔离的“沙箱”反而成为逃逸到任意路径的通道。delete中磁盘删除先于内存移除,且持久化失败时保留内存记录与磁盘现实一致,避免下次open()复活幽灵记录。
配套测试套件见 registry_tests.rs。
七、依赖边界:刻意自包含的设计
模块文档强调一个有趣的架构事实:cwd_jail 自己的文件里没有任何use crate::openhuman::或use crate::core::导入,依赖只有外部与 std:
std::process(Command/Child)、std::fs、std::sync(Mutex/OnceLock/Arc)、std::time;serde/serde_json——JailRecord与索引序列化;landlockcrate——Linux 后端,由sandbox-landlockfeature 门控(Cargo.toml#L1355);windows-sys——Windows AppContainer FFI(Security/Isolation、Threading、Memory API);- macOS 后端直接调用系统二进制
/usr/bin/sandbox-exec。
Linux 后端的文档字符串引用crate::openhuman::security::landlock作为概念先例,但实现完全自包含(不 import 该模块)。Windows 后端内联了GENERIC_READ/GENERIC_WRITE/DELETE等访问掩码常量并注明出处为 WinNT.h,原因是windows-sys在 0.59 与 0.60+ 之间搬移过这些常量的模块位置,内联规范值既稳定又避免猜测所装小版本暴露在哪个模块。
另外,模块文档明确列出“Used by”现状:除 src/openhuman/mod.rs#L37 的pub mod cwd_jail;声明外,当前src/下尚无其他 Rust 文件引用openhuman::sandbox::cwd_jail——它是一个尚未接入任何调用域的独立门面。
八、已知边界与陷阱(实践必读)
README 的 “Notes / gotchas” 一节值得完整转述,它们决定了你能在多大胆的场景下依赖这个模块:
8.1 不是 RPC、没有 agent 工具、不上事件总线
模块内没有schemas.rs、tools.rs、bus.rs;不暴露任何openhuman.*RPC 方法,不拥有 agent 工具,不发布/订阅任何DomainEvent。它是一个纯库式门面。
8.2 三个后端对allow_net/read_only的语义各不相同
| 后端 | allow_net语义 | read_only语义 |
|---|---|---|
| Landlock(Linux) | 完全不门控网络 | 真正生效:Execute | ReadFile | ReadDir |
| Seatbelt(macOS) | allow default即全量网络;deny_net()时输出(deny network*) | 信息性——读本来就允许 |
| AppContainer(Windows) | 严格出站internetClient能力;LAN/入站能力刻意排除 | 真正的读授权(GENERIC_READ) |
跨平台时不能假设三者的网络/只读行为一致。
8.3 Windows 当前无法把子进程句柄桥接成 std::process::Child
spawn_in_container能成功创建进程,但无法把原始HANDLE桥接成std::process::Child(所需的FromRawHandle for Child目前是 unstable 的),因此关闭句柄后返回io::ErrorKind::Unsupported(windows.rs#L287-L295 有 TODO)。同时is_available()当前恒返回false,让pick_backend()走NoopBackend返回真正可wait的Child——否则“已成功启动却报错”会让调用方wait不到孤儿进程(PR #4723 评审中的修复)。Windows 路径只做了编译检查,被标记为需要真机硬件测试。
8.4 macOS 的 stdio 与路径规范化
Seatbelt wrapper 无法重放原命令的Stdio配置,沿用sandbox-exec默认(继承);profile 只放行 canonicalizedroot+/private/tmp的写,所以调用方必须先行规范化(spawn门面自动完成),否则/tmp这类符号链接路径内的写会被拒绝。
8.5 Linux 只读路径需要 Execute
Landlock 下read_only路径同时授予Execute,子进程才能运行其中的二进制(如/usr/bin/sh);缺少它会阻断对root之外一切execve。
8.6 日志卫生:长度日志而非值日志
create与rename的 label、set_notes的 notes 都是自由文本输入,日志只记录label_len/new_label_len/has_notes之类的长度或布尔信息,绝不把任意文本写进日志,避免审计日志泄露用户输入。
九、快速上手与适用场景小结
对 OpenHuman 的实际使用者来说,cwd_jail 的典型用法是:
- 用一个
Jail描述 Agent 某项任务的专属工作区(root)与必要只读依赖(如运行时安装目录); - 对高风险的不可信代码执行任务,链式调用
.deny_net()/.deny_subprocess()收紧边界; - 需要多任务并行时,用
JailRegistry::open(base)创建多个带稳定 id 的隔离工作区,并通过spawn_in在注册表内直接执行命令; - 依赖
spawn门面的自动 canonicalize,确保三平台后端看到的都是规范路径; - 从启动日志中的
[cwd_jail] backend=<name>确认当前平台实际生效的后端,并留意 README 记录的 Windows/macOS 边界。
cwd_jail 的价值在于:它把“给子进程造一个目录监狱”这个跨平台难题收敛为一份声明式描述(Jail)+ 一个自动探测门面(default_backend)+ 一个持久化管理器(JailRegistry),并诚实地把每个平台后端的语义差异与未完成边界写进了源码注释与 模块 README——这正是把它接入 Agent 执行链前最值得细读的文档。
【免费下载链接】openhumanOpenHuman is an open source personal AI for Mac, Windows and Linux — local-first memory, agent orchestration, and deep research.项目地址: https://gitcode.com/GitHub_Trending/op/openhuman
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考