技术4/15/2026·8 min

从硬禁止到权限门控:重做 Agent 沙箱的路径决策

Agent 沙箱用硬错误拦掉了用户本可以批准的运维操作,且用户无从覆盖。修法是不再把路径越界当致命错误,而是让它走已有的权限系统。

沙箱拦掉了用户已经批准的操作

编码 Agent 在沙箱下执行工具:一层 OS 级隔离(macOS 用 Seatbelt,Linux 用 bubblewrap),再加一层应用级 path checker。最初的模型把任何位于工作目录之外的路径都当成硬错误。写 /etc/nginx/nginx.conf 会返回 PermissionDenied,操作就此终止——哪怕用户早已授权这个工具运行。

对一个本该承担运维任务的 Agent 来说,这是错误的失败模式。用户批准把 Write 落到 cwd 之外的配置文件,不是一条需要驳回的策略违规,而是人有权做的决定。沙箱在这里覆盖了人,而不是协助人。

两次改动修了这个问题:先把路径决策收敛成三态、显式拆出一条"需要批准"的分支(#80 / #83),再把 OS 级写限制整体移除、把细粒度保护下沉到应用层(#101)。

三态取代两态

旧的 path checker 返回两个独立的硬禁止变体——DenyWriteDenyRead,两者都映射到 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 / Dangerous
  • PermissionMode:Bypass / AskDangerous / AskAnyWrite
  • PermissionMode::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 就放行。

路径提取覆盖不了全部。BashGlobGrep 和 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-execprocess-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 并浮到用户面前。保护现在落在真正能强制执行、也能把原因讲清楚的地方,而不是一个任何子进程都能绕开的内核层。

可迁移的几点

有几条约束能推广到这套代码之外。

沙箱决策不是二元的。"在默认边界之外"和"绝不允许发生"是两种不同的断言,把它们揉进同一个硬错误,等于让"安全但少见"的情形和"真正危险"的情形付一样的代价。把 DenyRequiresApproval 拆开,正是让少见情形得以在人的控制下继续。

边界要设在它真能守住的那一层。一个 OS 写限制压在不受限的 exec 之上是做样子——它拦住老实的路径,放着不老实的路径不管,还顺手弄坏真实工具。把检查上提到应用层,没丢掉任何本就成立的安全,反而换来一个用户能据此行动的解释。

复用你已经有的审批管线。沙箱没有自建提示 UX、分类器或缓存。它把唯一的新状态映射到 Dangerous,交给已有的权限机制裁决。这次改动在 #80/#83 落地,横跨后端、沙箱、运行时三层新增 35 个测试——之所以小,是因为大部分行为本就在那里。