沙箱拦掉了用户已经批准的操作
编码 Agent 在沙箱下执行工具:一层 OS 级隔离(macOS 用 Seatbelt,Linux 用 bubblewrap),再加一层应用级 path checker。最初的模型把任何位于工作目录之外的路径都当成硬错误。写 /etc/nginx/nginx.conf 会返回 PermissionDenied,操作就此终止——哪怕用户早已授权这个工具运行。
对一个本该承担运维任务的 Agent 来说,这是错误的失败模式。用户批准把 Write 落到 cwd 之外的配置文件,不是一条需要驳回的策略违规,而是人有权做的决定。沙箱在这里覆盖了人,而不是协助人。
两次改动修了这个问题:先把路径决策收敛成三态、显式拆出一条"需要批准"的分支(#80 / #83),再把 OS 级写限制整体移除、把细粒度保护下沉到应用层(#101)。
三态取代两态
旧的 path checker 返回两个独立的硬禁止变体——DenyWrite 和 DenyRead,两者都映射到 PermissionDenied 并终止操作。新的决策类型多了第三条分支:
pub enum PathDecision {
Allow,
/// 硬禁止——不可覆盖(ReadOnly 模式、路径解析失败)。
Deny(String),
/// 软禁止——越出常规沙箱边界,但可经权限系统批准:
/// Bypass 自动放行;AskAnyWrite / AskDangerous 下由
/// 处理链(Manual 或 Auto)决定。
RequiresApproval(String),
}
区别在于哪些越界是可协商的。写到可写集合之外、命中 deny_write_globs、命中 deny_read_globs,现在都是软禁止——RequiresApproval。仍然是硬 Deny 的:read-only 模式,以及路径解析失败(.. 穿越、符号链接逃逸)。这些不是用户能决定的事;一个逃出沙箱根的符号链接,无论意图如何都是一次围栏失效。
I/O 错误类型相应加了 RequiresApproval 变体,让运行时能把"可批准"和"已封死"区分开:
/// 与 PermissionDenied(硬封)区分开,
/// 让运行时能把它路由进权限系统。
#[error("requires approval: {0}")]
RequiresApproval(String),
路由进已有的权限系统
Agent 本来就有一套独立于沙箱的工具权限模型:
PermissionLevel:ReadOnly/Write/DangerousPermissionMode:Bypass/AskDangerous/AskAnyWritePermissionMode::check(level) -> Allow | Ask | Deny
目标是复用这条管线,而不是再造一套审批路径。工具执行前的 precheck 阶段,按工具名从入参里提取它会触碰的路径:
pub fn extract_paths(tool_name: &str, input: &Value) -> Vec<(String, bool)> {
match tool_name {
"Write" | "Edit" | "MultiEdit" => single(input, "file_path", true),
"Read" => single(input, "file_path", false),
"Delete" => single(input, "path", true),
"MoveFile" => /* src + dst,皆为写 */,
"CopyFile" => /* src 读,dst 写 */,
"ApplyPatch" => patch_paths(input),
_ => Vec::new(), // 不透明工具留给执行期处理
}
}
只要有一个提取出的路径返回 RequiresApproval,precheck 就把这次调用的工具有效权限提升到 Dangerous:
let effective_perm = if sandbox_needs.is_empty() {
tool_perm
} else {
Some(PermissionLevel::Dangerous)
};
let decision = effective_perm
.map(|p| self.params.config.permission_mode.check(p))
.unwrap_or(PermissionDecision::Allow);
接下来交给已有的模式裁决。Bypass 下无提示直接批准这些路径。AskAnyWrite / AskDangerous 下,决策流向人或自动分类器,并把沙箱给出的原因挂到提示上(sandbox_approval_reason),让人看到的是"为什么"要批准,而不只是"要批准"这件事本身。
在会话内缓存批准
每个文件问一次可以接受;对同一文件每次操作都问一次不行。批准过的路径在后端用一个读写集合缓存,作用域是整个会话:
pub struct ApprovedPaths {
inner: RwLock<HashSet<PathBuf>>,
}
RwLock 契合这里的访问特征——写很少(某路径首次批准),读很频繁(之后每次检查)——并且能让这个集合活在 Arc<LocalBackend> 里而不需要 &mut self。一旦路径进了集合,check_sandbox_path 就放行。
路径提取覆盖不了全部。Bash、Glob、Grep 和 MCP 工具的路径语义是不透明的——你没法静态读出一条 shell 命令会碰哪些文件。对这些,extract_paths 返回空,改由后端的执行期兜底处理:当某次 I/O 调用返回 RequiresApproval,先回查批准集合,再决定是否把错误抛出去。
match path::resolve(self.cwd.as_path(), raw, is_write, self.policy.as_ref()) {
Ok(p) => Ok(p),
Err(ToolIoError::RequiresApproval(reason)) => {
let abs = path::to_absolute(self.cwd.as_path(), raw);
if self.approved.contains(&abs) {
Ok(abs.canonicalize().unwrap_or(abs))
} else {
Err(ToolIoError::RequiresApproval(reason))
}
}
// ...
}
移除 OS 级写限制
第一次改动让路径越界变得可批准,但 OS 沙箱仍在对写操作做工作区围栏。这一层弊大于利。#101 里的判断:
seatbelt/bwrap 的文件写限制对 Bash 命令没带来任何真实安全收益(process-exec 本就不受限),却弄坏了每一个会往
$HOME配置目录写东西的 CLI 工具(lark-cli、npm、cargo 等)。
安全论证是承重的那一环。如果 process-exec 和 process-fork 不受限——它们确实不受限,因为一限就会弄坏 Bazel、JVM、Nix 工具链——那么写限制是可以轻易绕过的:在沙箱外拉起一个静态 helper 二进制,让它去写就行。这条限制为一个其实并不成立的边界,付着实打实的兼容代价(凡是碰 ~/.npm、~/.cargo、~/.config 的工具都失败)。
于是 WorkspaceWrite 改名为 DefaultWrite(用 serde alias 兼容旧配置)。该模式下 OS 沙箱追加 (allow file-write*)——所有写在 OS 层放行。ReadOnly 模式仍然不带写规则、封死一切。File 工具的保护下沉到应用级 path checker,现在默认把 $HOME 视为可写,但用 deny_write_globs 守住具体文件:
// 加进默认 deny-write 列表
"**/.bashrc", "**/.bash_profile", "**/.zshrc", "**/.zprofile", "**/.profile",
"**/authorized_keys", "**/LaunchAgents/**",
写 ~/.npm/_cacheinfo 静默通过;写 ~/.zshrc 或 ~/.ssh/authorized_keys 变成 RequiresApproval 并浮到用户面前。保护现在落在真正能强制执行、也能把原因讲清楚的地方,而不是一个任何子进程都能绕开的内核层。
可迁移的几点
有几条约束能推广到这套代码之外。
沙箱决策不是二元的。"在默认边界之外"和"绝不允许发生"是两种不同的断言,把它们揉进同一个硬错误,等于让"安全但少见"的情形和"真正危险"的情形付一样的代价。把 Deny 与 RequiresApproval 拆开,正是让少见情形得以在人的控制下继续。
边界要设在它真能守住的那一层。一个 OS 写限制压在不受限的 exec 之上是做样子——它拦住老实的路径,放着不老实的路径不管,还顺手弄坏真实工具。把检查上提到应用层,没丢掉任何本就成立的安全,反而换来一个用户能据此行动的解释。
复用你已经有的审批管线。沙箱没有自建提示 UX、分类器或缓存。它把唯一的新状态映射到 Dangerous,交给已有的权限机制裁决。这次改动在 #80/#83 落地,横跨后端、沙箱、运行时三层新增 35 个测试——之所以小,是因为大部分行为本就在那里。