第 4 章 Shell 执行与沙箱 —— 最危险也最强大的工具
Agent 需要 shell 的全部能力来完成真实任务,但不受控的 shell 等价于把 root 权限交给一个随机性模型——这是 coding agent 架构中最核心的安全矛盾。
4.1 Bash 工具的实现
最简形态:Python subprocess 方案
最基础的 Bash 工具实现是所有 coding agent 中最直白的版本——直接调用 subprocess.run:
# 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)"几个关键设计点:
shell=True:将命令字符串交给/bin/sh -c解释,支持管道、重定向、环境变量展开等全部 shell 语法。这是功能上的必要选择——Agent 生成的命令天然是 shell 命令字符串,不是argv数组。- 黑名单过滤:用子串匹配拦截
rm -rf /、sudo等危险命令。这种方式极易绕过(如r"m" -rf /、su\do),仅作为教学示例的最低保护。 - 输出截断:
out[:50000]防止巨量输出撑爆上下文窗口。 - 超时控制:120 秒硬限制,防止死循环或长时间阻塞。
路径安全通过独立的 safe_path 函数实现:
# 路径安全校验
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 pathresolve() 消除符号链接和 ..,然后检查解析后的路径是否仍在 workspace 内。这防止了 ../../etc/passwd 类的路径穿越,但仅对 read_file/write_file 工具生效——run_bash 本身不受此约束,Agent 完全可以 cat /etc/passwd。
Rust 实现的 workspace 校验
更成熟的实现使用 std::process::Command 替代 Python 的 subprocess:
// 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 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 为代表)为三大平台提供了完全不同的隔离机制,但统一在相同的策略模型下。
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]
endmacOS: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 构建一个受限的文件系统挂载树:
// 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());
}
// ...
}挂载顺序严格定义:
--ro-bind / /使整个根文件系统只读--dev /dev挂载最小化的/dev(null, zero, urandom, tty)- 为可写根的不可读祖先路径打掩码
--bind <root> <root>恢复特定目录的写权限--ro-bind <subpath> <subpath>在可写目录内重新施加只读保护(如.git)- 不可读路径掩码
阶段 2:seccomp 过滤网络系统调用。 Bubblewrap 完成文件系统布局后,沙箱重新进入自身二进制文件(--apply-seccomp-then-exec),在新环境中安装 seccomp BPF 过滤器:
// 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 的查找策略:
// 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 检测来处理:
// /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 来限制其能力。
// 沙箱类型枚举
pub enum SandboxType {
None,
MacosSeatbelt, // macOS 专用
LinuxSeccomp, // Linux 专用
WindowsRestrictedToken, // Windows 专用
}三级策略
所有平台共享同一套策略枚举:
// 沙箱策略定义
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-access(DangerFullAccess):无限制。命名中的 "Danger" 是刻意的——提醒用户这是危险选项。
4.3 文件系统权限的精细控制
FileSystemSandboxPolicy
生产级沙箱引入了细粒度的 FileSystemSandboxPolicy,从 SandboxPolicy 中拆分出来:
// 文件系统沙箱策略
pub struct FileSystemSandboxPolicy {
pub kind: FileSystemSandboxKind, // Restricted | Unrestricted | ExternalSandbox
pub entries: Vec<FileSystemSandboxEntry>,
}每个 FileSystemSandboxEntry 是一个 (path, access) 对,access 可以是 Read、Write 或 None:
// 文件系统访问模式
pub enum FileSystemAccessMode {
Read, // 只读
Write, // 读写
None, // 不可见
}冲突解决规则:当两个同等具体的条目指向同一路径时,None 优先于 Write,Write 优先于 Read——这是一个 deny-wins 策略。
.git 和配置目录始终只读
这是一个关键的安全设计决策。即使 workspace 被标记为可写,其中的 .git、.codex、.agents 目录仍然被强制设为只读:
// 可写根目录下的默认只读子路径
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 commit、git push等操作时自动执行。如果 Agent 能写入pre-commithook,它就能在用户下次提交时以用户权限执行任意代码。.codex/:Agent 的配置文件。如果 Agent 能修改它,就能在下次启动时改变自己的沙箱策略——典型的权限自提升。.agents/:类似地,包含 Agent 的 skill 和规则定义。
WritableRoot 与嵌套权限处理
WritableRoot 结构体将可写路径与其受保护子路径绑定在一起:
// 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 的后挂载覆盖前挂载的语义保证了这种嵌套的正确性。
失败模式
沙箱拒绝操作时的检测并非完全确定性的:
// 沙箱拒绝检测(启发式)
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 网络隔离
网络策略
网络隔离通过两个维度控制:
// 网络沙箱策略
pub enum NetworkSandboxPolicy {
Restricted, // 默认:禁止出站网络
Enabled, // 允许出站网络
}在 Linux 上,网络隔离有三种具体实现模式:
// Bubblewrap 网络模式
pub(crate) enum BwrapNetworkMode {
FullAccess, // 保留主机网络命名空间
Isolated, // 完全隔离(unshare network namespace)
ProxyOnly, // 网络命名空间隔离 + 本地代理桥
}Isolated 模式通过 --unshare-net 创建独立的网络命名空间,进程看不到任何网络接口(甚至 loopback 都是新的),彻底断网。
ProxyOnly 模式是最精巧的设计。当 Agent 需要受控的网络访问时(如 pip install),沙箱的 network-proxy 组件充当中间人:
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[互联网]代理路由的建立过程:
- Host 端:扫描环境变量中的
HTTP_PROXY/HTTPS_PROXY等,解析出 loopback 端点,为每个端点创建 Unix Domain Socket,fork()出桥进程做 UDS<->TCP 双向转发。 - 沙箱内:启动 loopback 接口(在新 netns 里 lo 默认是 down 的),绑定本地 TCP 监听端口,将
HTTP_PROXY等环境变量重写为指向本地端口,再fork()出桥进程做 TCP<->UDS 转发。 - seccomp 配合:
ProxyRouted模式允许AF_INET/AF_INET6socket(用于连接本地桥),但 拦截AF_UNIX(防止绕过代理直接连接主机 socket)。
network-proxy 的域名策略
network-proxy 实现了完整的 HTTP/HTTPS/SOCKS5 代理,支持细粒度的域名控制:
// 网络代理配置
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 提供方法级控制:
// 网络访问模式
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——这需要网络访问。
三种处理方式:
- 完全放开网络(
full-access):最简单但最危险。Agent 可以curl任意 URL,包括将代码库内容外传到恶意服务器。 - 代理白名单(ProxyOnly 模式):配置
allowed_domains: ["pypi.org", "files.pythonhosted.org"],只放行 pip 需要的域名。这是最佳平衡点,但需要用户预知 Agent 可能需要哪些域名。 - 预安装依赖(完全隔离模式下):在启动 Agent 前确保环境就绪。适合 CI/CD 场景,不适合交互式开发。
Limited 网络模式为第二种方式提供了额外保险——即使域名白名单配置过宽,Limited 模式也阻止了 POST 请求,防止数据外泄。这种纵深防御是生产级安全设计的标志。