Skip to content

第 4 章 Shell 执行与沙箱 —— 最危险也最强大的工具

Agent 需要 shell 的全部能力来完成真实任务,但不受控的 shell 等价于把 root 权限交给一个随机性模型——这是 coding agent 架构中最核心的安全矛盾。

4.1 Bash 工具的实现

最简形态:Python subprocess 方案

最基础的 Bash 工具实现是所有 coding agent 中最直白的版本——直接调用 subprocess.run

python
# Python Bash 工具(教学级实现)
def run_bash(command: str) -> str:
    dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]
    if any(d in command for d in dangerous):
        return "Error: Dangerous command blocked"
    try:
        r = subprocess.run(command, shell=True, cwd=WORKDIR,
                           capture_output=True, text=True, timeout=120)
        out = (r.stdout + r.stderr).strip()
        return out[:50000] if out else "(no output)"
    except subprocess.TimeoutExpired:
        return "Error: Timeout (120s)"

几个关键设计点:

  1. shell=True:将命令字符串交给 /bin/sh -c 解释,支持管道、重定向、环境变量展开等全部 shell 语法。这是功能上的必要选择——Agent 生成的命令天然是 shell 命令字符串,不是 argv 数组。
  2. 黑名单过滤:用子串匹配拦截 rm -rf /sudo 等危险命令。这种方式极易绕过(如 r"m" -rf /su\do),仅作为教学示例的最低保护。
  3. 输出截断out[:50000] 防止巨量输出撑爆上下文窗口。
  4. 超时控制:120 秒硬限制,防止死循环或长时间阻塞。

路径安全通过独立的 safe_path 函数实现:

python
# 路径安全校验
def safe_path(p: str) -> Path:
    path = (WORKDIR / p).resolve()
    if not path.is_relative_to(WORKDIR):
        raise ValueError(f"Path escapes workspace: {p}")
    return path

resolve() 消除符号链接和 ..,然后检查解析后的路径是否仍在 workspace 内。这防止了 ../../etc/passwd 类的路径穿越,但仅对 read_file/write_file 工具生效——run_bash 本身不受此约束,Agent 完全可以 cat /etc/passwd

Rust 实现的 workspace 校验

更成熟的实现使用 std::process::Command 替代 Python 的 subprocess:

rust
// Rust Bash 工具实现
let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let output = Command::new(&shell)
    .arg("-lc")
    .arg(&request.command)
    .current_dir(&workdir)
    .stdin(Stdio::null())
    .stdout(Stdio::piped())
    .stderr(Stdio::piped())
    .output()
    .with_context(|| format!("failed to run shell command via {shell}"))?;

与 Python 方案的区别:

  • 使用 Command::new(shell).arg("-lc") 而非直接 shell=True——效果等价(都通过 shell 解释命令),但显式选择用户的 login shell(-l 加载 profile),使 Agent 能使用用户安装的工具和 PATH。
  • stdin(Stdio::null()):关闭标准输入,防止命令阻塞等待交互输入。
  • workspace 范围校验在工作目录解析函数中实现:
rust
// Rust workspace 路径校验
fn resolve_workdir(workspace_root: &Path, requested: Option<&str>) -> Result<PathBuf> {
    let root = workspace_root.canonicalize()?;
    let candidate = match requested {
        None | Some("") | Some(".") => root.clone(),
        Some(path) => root.join(path),
    };
    let candidate = candidate.canonicalize()?;
    if !candidate.starts_with(&root) {
        bail!("workdir escapes workspace: {}", candidate.display());
    }
    Ok(candidate)
}

canonicalize()resolve() 的 Rust 等价物——解析符号链接并规范化路径。但同样,这只约束了 workdir 参数,命令本身可以访问任意路径。

权衡:shell=True 的便利性 vs 注入风险

shell=True(或等价的 sh -c)是 coding agent 的事实标准选择。原因很简单:LLM 生成的是给人类阅读的 shell 命令(grep -r "TODO" src/ | wc -l),而非结构化的 ["grep", "-r", "TODO", "src/"] 数组。

注入风险在传统 web 应用中是核心威胁——用户输入被拼接进 shell 命令导致任意代码执行。但在 Agent 场景中,LLM 本身就是命令的生成者,"注入"的概念发生了变化:攻击面从"用户输入注入"转移到"prompt injection 导致 LLM 生成恶意命令"。这就是为什么沙箱比输入过滤更重要——你无法在 shell 层面可靠地区分"有用的命令"和"有害的命令"。

4.2 沙箱架构(生产级深度分析)

生产级沙箱(以 Codex 为代表)为三大平台提供了完全不同的隔离机制,但统一在相同的策略模型下。

mermaid
graph TD
    subgraph "沙箱决策流"
        A[SandboxPolicy] --> B{平台检测}
        B -->|macOS| C[Seatbelt<br>/usr/bin/sandbox-exec]
        B -->|Linux| D{内核版本检查}
        B -->|Windows| G[Restricted Token<br>CreateProcessAsUserW]
        D -->|默认路径| E[Bubblewrap bwrap<br>+ seccomp]
        D -->|--use-legacy-landlock| F[Landlock LSM<br>+ seccomp]
    end

macOS:Seatbelt

macOS 使用 Apple 的 sandbox-exec 机制(内部称为 Seatbelt)。沙箱生成一个 sandbox profile 文件,指定允许的文件系统操作和网络访问,然后通过 sandbox-exec -f <profile> 启动子进程。Profile 使用 S-expression 语法声明 (allow ...)(deny ...) 规则。

Linux:Bubblewrap + seccomp 双层隔离

Linux 沙箱是最复杂的,因为 Linux 内核提供了多种隔离原语,但没有一个像 Seatbelt 那样开箱即用。生产级方案采用了两阶段设计:

阶段 1:Bubblewrap 构建文件系统视图。 Bubblewrap(bwrap)利用 user namespace 和 mount namespace 构建一个受限的文件系统挂载树:

rust
// Bubblewrap 参数构建(Rust 沙箱实现)
fn create_bwrap_flags(
    command: Vec<String>,
    file_system_sandbox_policy: &FileSystemSandboxPolicy,
    sandbox_policy_cwd: &Path,
    command_cwd: &Path,
    options: BwrapOptions,
) -> Result<BwrapArgs> {
    // ...
    args.push("--new-session".to_string());
    args.push("--die-with-parent".to_string());
    // 文件系统参数(只读根 + 可写绑定挂载)
    args.extend(filesystem_args);
    args.push("--unshare-user".to_string());
    args.push("--unshare-pid".to_string());
    if options.network_mode.should_unshare_network() {
        args.push("--unshare-net".to_string());
    }
    // ...
}

挂载顺序严格定义:

  1. --ro-bind / / 使整个根文件系统只读
  2. --dev /dev 挂载最小化的 /dev(null, zero, urandom, tty)
  3. 为可写根的不可读祖先路径打掩码
  4. --bind <root> <root> 恢复特定目录的写权限
  5. --ro-bind <subpath> <subpath> 在可写目录内重新施加只读保护(如 .git
  6. 不可读路径掩码

阶段 2:seccomp 过滤网络系统调用。 Bubblewrap 完成文件系统布局后,沙箱重新进入自身二进制文件(--apply-seccomp-then-exec),在新环境中安装 seccomp BPF 过滤器:

rust
// seccomp 网络过滤器安装(Rust 沙箱实现)
fn install_network_seccomp_filter_on_current_thread(
    mode: NetworkSeccompMode,
) -> std::result::Result<(), SandboxErr> {
    let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();

    // 无条件拦截 ptrace 和 io_uring
    deny_syscall(&mut rules, libc::SYS_ptrace);
    deny_syscall(&mut rules, libc::SYS_io_uring_setup);
    deny_syscall(&mut rules, libc::SYS_io_uring_enter);

    match mode {
        NetworkSeccompMode::Restricted => {
            // 拦截所有网络系统调用
            deny_syscall(&mut rules, libc::SYS_connect);
            deny_syscall(&mut rules, libc::SYS_bind);
            deny_syscall(&mut rules, libc::SYS_listen);
            // ... 更多网络 syscall
            // 只允许 AF_UNIX socket(本地进程间通信仍需工作)
            let unix_only_rule = SeccompRule::new(vec![
                SeccompCondition::new(0, SeccompCmpArgLen::Dword,
                    SeccompCmpOp::Ne, libc::AF_UNIX as u64)?
            ])?;
            rules.insert(libc::SYS_socket, vec![unix_only_rule]);
        }
        NetworkSeccompMode::ProxyRouted => {
            // 允许 AF_INET/AF_INET6(用于连接本地代理桥),拦截 AF_UNIX
            // ...
        }
    }

    let filter = SeccompFilter::new(
        rules,
        SeccompAction::Allow,                     // 默认放行
        SeccompAction::Errno(libc::EPERM as u32), // 命中规则时返回 EPERM
        // ...
    )?;
    apply_filter(&prog)?;
    Ok(())
}

两阶段分离的原因很精妙:许多系统上 bwrap 依赖 setuid 权限来创建 namespace,而 PR_SET_NO_NEW_PRIVS(seccomp 的前置要求)会阻止 setuid 提权。因此必须先让 bwrap 在没有 no_new_privs 的情况下创建 namespace,再在 namespace 内部施加 seccomp。

Bubblewrap 的查找策略:

rust
// Bubblewrap 查找策略
fn preferred_bwrap_launcher() -> BubblewrapLauncher {
    if !Path::new(SYSTEM_BWRAP_PATH).is_file() {
        return BubblewrapLauncher::Vendored; // 使用内置的 bwrap
    }
    BubblewrapLauncher::System(system_bwrap_path) // 优先用系统安装的
}

沙箱优先使用系统 /usr/bin/bwrap,不存在则回退到自带的 vendored 版本。系统版本通过 execv 执行(跨进程边界),vendored 版本直接内联执行。

Legacy Landlock 回退路径:

沙箱保留了 Landlock LSM 作为向后兼容路径(--use-legacy-landlock)。Landlock 是 Linux 5.13 引入的轻量级安全模块,可以在用户态为当前线程添加文件系统访问规则,无需特权。但 Landlock 的限制是无法实现细粒度的挂载隔离(无法让 .git 在可写父目录下保持只读),所以 Bubblewrap 成为了默认路径。

/proc 挂载兼容性探测:

某些容器环境(如 Docker with restricted seccomp profile)不允许在 namespace 内挂载 /proc。沙箱通过一个 preflight 检测来处理:

rust
// /proc 挂载兼容性探测
fn preflight_proc_mount_support(/* ... */) -> bool {
    let preflight_argv = build_preflight_bwrap_argv(/* ... */);
    let stderr = run_bwrap_in_child_capture_stderr(preflight_argv);
    !is_proc_mount_failure(stderr.as_str())
}

先用 bwrap --proc /proc -- /bin/true 做一次探测,如果失败(stderr 含 "Can't mount proc"),就降级到 --no-proc 模式重试。

Windows:Restricted Token

Windows 使用 CreateProcessAsUserW 创建受限进程,剥离不必要的 token 权限。这是 Windows 独有的安全模型——通过调整进程 token 中的 SID 和 privilege 来限制其能力。

rust
// 沙箱类型枚举
pub enum SandboxType {
    None,
    MacosSeatbelt,       // macOS 专用
    LinuxSeccomp,        // Linux 专用
    WindowsRestrictedToken, // Windows 专用
}

三级策略

所有平台共享同一套策略枚举:

rust
// 沙箱策略定义
pub enum SandboxPolicy {
    DangerFullAccess,          // 无任何限制
    ReadOnly { access, network_access },  // 只读文件系统
    ExternalSandbox { network_access },   // 已在外部沙箱中
    WorkspaceWrite {           // 可写 workspace
        writable_roots,
        read_only_access,
        network_access,
        exclude_tmpdir_env_var,
        exclude_slash_tmp,
    },
}

对应用户可见的三级:

  • read-only:Agent 只能读取文件系统,不能写入。适合纯分析任务。
  • workspace-write:Agent 可以写入当前工作目录和临时目录,其余只读。这是默认模式,兼顾了功能和安全。
  • full-accessDangerFullAccess):无限制。命名中的 "Danger" 是刻意的——提醒用户这是危险选项。

4.3 文件系统权限的精细控制

FileSystemSandboxPolicy

生产级沙箱引入了细粒度的 FileSystemSandboxPolicy,从 SandboxPolicy 中拆分出来:

rust
// 文件系统沙箱策略
pub struct FileSystemSandboxPolicy {
    pub kind: FileSystemSandboxKind,  // Restricted | Unrestricted | ExternalSandbox
    pub entries: Vec<FileSystemSandboxEntry>,
}

每个 FileSystemSandboxEntry 是一个 (path, access) 对,access 可以是 ReadWriteNone

rust
// 文件系统访问模式
pub enum FileSystemAccessMode {
    Read,   // 只读
    Write,  // 读写
    None,   // 不可见
}

冲突解决规则:当两个同等具体的条目指向同一路径时,None 优先于 WriteWrite 优先于 Read——这是一个 deny-wins 策略。

.git 和配置目录始终只读

这是一个关键的安全设计决策。即使 workspace 被标记为可写,其中的 .git.codex.agents 目录仍然被强制设为只读:

rust
// 可写根目录下的默认只读子路径
fn default_read_only_subpaths_for_writable_root(
    writable_root: &AbsolutePathBuf,
) -> Vec<AbsolutePathBuf> {
    let mut subpaths: Vec<AbsolutePathBuf> = Vec::new();
    let top_level_git = writable_root.join(".git").expect("...");
    let top_level_git_is_file = top_level_git.as_path().is_file();
    let top_level_git_is_dir = top_level_git.as_path().is_dir();
    if top_level_git_is_dir || top_level_git_is_file {
        // 如果 .git 是文件(worktree/submodule),还要保护其指向的 gitdir
        if top_level_git_is_file {
            if let Some(gitdir) = resolve_gitdir_from_file(&top_level_git) {
                subpaths.push(gitdir);
            }
        }
        subpaths.push(top_level_git);
    }
    // .agents 和 .codex 也是只读
    for subdir in &[".agents", ".codex"] {
        let top_level_codex = writable_root.join(subdir).expect("...");
        if top_level_codex.as_path().is_dir() {
            subpaths.push(top_level_codex);
        }
    }
    dedup_absolute_paths(subpaths, false)
}

为什么这些路径必须只读?因为它们都是 权限提升向量

  • .git/hooks/:Git hooks 会在 git commitgit push 等操作时自动执行。如果 Agent 能写入 pre-commit hook,它就能在用户下次提交时以用户权限执行任意代码。
  • .codex/:Agent 的配置文件。如果 Agent 能修改它,就能在下次启动时改变自己的沙箱策略——典型的权限自提升。
  • .agents/:类似地,包含 Agent 的 skill 和规则定义。

WritableRoot 与嵌套权限处理

WritableRoot 结构体将可写路径与其受保护子路径绑定在一起:

rust
// WritableRoot 嵌套权限判定
pub struct WritableRoot {
    pub root: AbsolutePathBuf,
    pub read_only_subpaths: Vec<AbsolutePathBuf>,
}

impl WritableRoot {
    pub fn is_path_writable(&self, path: &Path) -> bool {
        if !path.starts_with(&self.root) {
            return false;
        }
        for subpath in &self.read_only_subpaths {
            if path.starts_with(subpath) {
                return false;
            }
        }
        true
    }
}

这在 Bubblewrap 的挂载层面通过 mount 叠加实现(第 4.2 节描述的挂载顺序):先 --bind 使目录可写,再 --ro-bind 使子路径只读。mount namespace 的后挂载覆盖前挂载的语义保证了这种嵌套的正确性。

失败模式

沙箱拒绝操作时的检测并非完全确定性的:

rust
// 沙箱拒绝检测(启发式)
pub(crate) fn is_likely_sandbox_denied(
    sandbox_type: SandboxType,
    exec_output: &ExecToolCallOutput,
) -> bool {
    if sandbox_type == SandboxType::None || exec_output.exit_code == 0 {
        return false;
    }
    const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [
        "operation not permitted",
        "permission denied",
        "read-only file system",
        "seccomp",
        "sandbox",
        "landlock",
        "failed to write file",
    ];
    // 在 stdout/stderr 中搜索这些关键词
    // ...
}

函数名中的 "likely" 暴露了本质问题:沙箱拒绝不像进程退出码那样有标准化表示。一个命令返回 "permission denied" 可能是因为沙箱拒绝,也可能是文件本身权限不足。沙箱通过关键词启发式 + 退出码分析做最佳猜测,但这不是一个可靠的信号。对于 Linux seccomp,SIGSYS 信号(128 + 31 = 159)是更可靠的指示器。

4.4 网络隔离

网络策略

网络隔离通过两个维度控制:

rust
// 网络沙箱策略
pub enum NetworkSandboxPolicy {
    Restricted,  // 默认:禁止出站网络
    Enabled,     // 允许出站网络
}

在 Linux 上,网络隔离有三种具体实现模式:

rust
// Bubblewrap 网络模式
pub(crate) enum BwrapNetworkMode {
    FullAccess,  // 保留主机网络命名空间
    Isolated,    // 完全隔离(unshare network namespace)
    ProxyOnly,   // 网络命名空间隔离 + 本地代理桥
}

Isolated 模式通过 --unshare-net 创建独立的网络命名空间,进程看不到任何网络接口(甚至 loopback 都是新的),彻底断网。

ProxyOnly 模式是最精巧的设计。当 Agent 需要受控的网络访问时(如 pip install),沙箱的 network-proxy 组件充当中间人:

mermaid
graph LR
    subgraph "沙箱内(隔离 netns)"
        A[Agent 进程] -->|HTTP_PROXY=127.0.0.1:port| B[本地桥<br>TCP Listener]
    end
    B -->|Unix Socket| C[主机桥<br>UDS→TCP]
    C -->|TCP| D[network-proxy<br>域名白名单/黑名单]
    D -->|允许的请求| E[互联网]

代理路由的建立过程:

  1. Host 端:扫描环境变量中的 HTTP_PROXY/HTTPS_PROXY 等,解析出 loopback 端点,为每个端点创建 Unix Domain Socket,fork() 出桥进程做 UDS<->TCP 双向转发。
  2. 沙箱内:启动 loopback 接口(在新 netns 里 lo 默认是 down 的),绑定本地 TCP 监听端口,将 HTTP_PROXY 等环境变量重写为指向本地端口,再 fork() 出桥进程做 TCP<->UDS 转发。
  3. seccomp 配合ProxyRouted 模式允许 AF_INET/AF_INET6 socket(用于连接本地桥),但 拦截 AF_UNIX(防止绕过代理直接连接主机 socket)。

network-proxy 的域名策略

network-proxy 实现了完整的 HTTP/HTTPS/SOCKS5 代理,支持细粒度的域名控制:

rust
// 网络代理配置
pub struct NetworkProxySettings {
    pub enabled: bool,
    pub mode: NetworkMode,           // Full | Limited
    pub allowed_domains: Vec<String>, // 白名单
    pub denied_domains: Vec<String>,  // 黑名单
    pub allow_unix_sockets: Vec<String>,
    pub mitm: bool,                  // MITM 解密 HTTPS
    // ...
}

NetworkMode 提供方法级控制:

rust
// 网络访问模式
pub enum NetworkMode {
    Limited, // 只允许 GET/HEAD/OPTIONS(只读网络访问)
    Full,    // 允许所有 HTTP 方法
}

Limited 模式是一个有趣的中间态——允许读取网络资源但禁止写入(POST/PUT/DELETE)。这对于需要查阅文档但不应发送数据的场景很有用。

域名匹配支持三种模式:

  • example.com:精确匹配
  • *.example.com:仅子域名(不含 apex)
  • **.example.com:apex 和所有子域名

SSRF 防护通过 is_non_public_ip 函数实现,拦截对私有地址、链路本地地址、loopback 和保留地址段的请求。

"trust the LLM" vs "enforce at sandbox level"

这是 Agent 安全架构中的根本分歧。

以 DeepAgents 为代表的"trust the LLM"方案:通过 system prompt 告诉模型"不要执行危险操作",依赖模型的 instruction following 能力。优点是零工程开销,缺点是没有真正的安全边界——prompt injection 或模型幻觉就能突破。

Codex 的"enforce at sandbox level"方案:在 OS 内核层面施加限制,即使模型"想"执行危险操作也会被 seccomp/Seatbelt/namespace 拦截。代价是巨大的工程复杂度(三平台沙箱代码 + 代理桥 + 策略系统),但提供了真正的安全隔离。

介于两者之间的方案:有用户确认机制(prompt_for_approval)但没有 OS 级沙箱。这是"human-in-the-loop"模式——安全性完全取决于用户是否认真审查每条命令。

边界情况:Agent 需要 pip install 时怎么办?

这是网络隔离的经典困境。Agent 要修复一个 Python 项目的 bug,发现缺少依赖,需要 pip install requests——这需要网络访问。

三种处理方式:

  1. 完全放开网络full-access):最简单但最危险。Agent 可以 curl 任意 URL,包括将代码库内容外传到恶意服务器。
  2. 代理白名单(ProxyOnly 模式):配置 allowed_domains: ["pypi.org", "files.pythonhosted.org"],只放行 pip 需要的域名。这是最佳平衡点,但需要用户预知 Agent 可能需要哪些域名。
  3. 预安装依赖(完全隔离模式下):在启动 Agent 前确保环境就绪。适合 CI/CD 场景,不适合交互式开发。

Limited 网络模式为第二种方式提供了额外保险——即使域名白名单配置过宽,Limited 模式也阻止了 POST 请求,防止数据外泄。这种纵深防御是生产级安全设计的标志。