openai-agents-python 沙箱工作区安全 Tar 归档工具 tar_utils 深度解析
tar_utils 是 openai-agents-python 沙箱子系统中负责工作区(workspace)tar 归档安全打包、校验与解包的底层工具模块,对应参考文档 docs/ref/sandbox/util/tar_utils.md,实际实现位于 src/agents/sandbox/util/tar_utils.py。无论是本地 unix_local 沙箱的持久化与恢复,还是 Docker 沙箱把容器内工作区快照传回宿主机,都会经过这组函数。读完本文,你将掌握:tar 成员路径的校验规则、符号链接与硬链接的处理策略、归档校验的全部参数及其语义,以及安全解包时文件权限恢复与写入顺序的底层设计。
为什么沙箱需要一套“安全 tar 工具”
沙箱 Agent 的运行离不开工作区(workspace)目录:Agent 在其中读写文件、执行命令,会话结束后需要把整个目录打包成 tar 流持久化保存;恢复会话时再把 tar 流解包回工作区。问题在于,tar 归档是一种“可被恶意构造”的容器——一个来历不明的归档可能包含:
- 绝对路径成员(
/etc/passwd),解包时直接写到目标根目录之外; - 父目录穿越路径(
../../etc/passwd); - Windows 驱动器路径(
C:\...)或反斜杠分隔符(\evil.txt); - 符号链接、硬链接,或“归档内的符号链接之下再嵌套其他成员”,从而借链接逃逸目标目录;
- 设备文件、FIFO 等非普通文件类型。
如果沙箱后端不加甄别地把这类归档解包到工作区根目录,就可能造成宿主机文件被覆盖或泄露。tar_utils 正是这一安全边界的实现者:它以一套统一的“安全策略”校验归档,再执行受控解包。
从源码结构看,模块的公共 API 非常收敛,只有 1 个异常类 + 4 个函数:
| 符号 | 职责 |
|---|---|
UnsafeTarMemberError |
安全校验失败的专用异常,携带 member(成员名)与 reason(失败原因) |
safe_tar_member_rel_path |
校验单个 tar 成员,返回相对路径(根目录成员返回 None) |
validate_tarfile / validate_tar_bytes |
对已打开或原始字节的 tar 归档执行完整安全校验 |
safe_extract_tarfile |
在安全策略约束下把归档解包到指定根目录 |
strip_tar_member_prefix |
重写 tar 流,把成员的前缀目录替换为 .(工作区快照归一化) |
should_skip_tar_member |
依据工作区相对路径前缀判断成员是否应被跳过 |
其中 UnsafeTarMemberError 继承自 ValueError,构造时统一生成形如 unsafe tar member '...': <reason> 的报错信息,并分别把 member、reason 存为实例属性,便于上层(如 archive_extraction.py)将其转换为带上下文的 WorkspaceArchiveWriteError。
成员路径校验:safe_tar_member_rel_path
这是整个模块的“地基”函数。它接收一个 tarfile.TarInfo 成员,返回该成员相对工作区根的 Path;若成员本身是归档根(名称为空、. 或 ./),则返回 None。其校验顺序与规则如下:
- 根成员校验:若成员名称是
""、"."或"./",则调用_validate_archive_root_member——只有目录型成员可以通过;根位置的符号链接、硬链接或普通文件一律抛出UnsafeTarMemberError(分别对应archive root symlink、archive root hardlink、archive root member must be directory)。 - Windows 路径拦截:用
PureWindowsPath解析成员名,若存在驱动器号(如C:)或成员名包含\,直接拒绝(windows drive path/windows path separator)。这一步防止在 Linux 上看似合法、但换到 Windows 语义下可逃逸的路径混入。 - 绝对路径拒绝:
PurePosixPath(member.name).is_absolute()为真时抛出absolute path。 - 父目录穿越拒绝:成员路径任一分量为
..时抛出parent traversal。 - 链接类型策略:默认(
allow_symlinks=False)拒绝符号链接成员(symlink member not allowed);任何硬链接成员都被拒绝(hardlink member not allowed)。 - 成员类型白名单:只有目录、普通文件(以及显式放行时的符号链接)是合法类型,设备、FIFO 等一律
unsupported member type。
函数签名中的 allow_symlinks: bool = False 是一个重要的“opt-in”开关:常规场景(如归档解包到沙箱工作区)要求符号链接显式放行,因为 Python 虚拟环境等正常开发工作区中天然存在符号链接。测试 test_tar_utils.py 中的 test_safe_tar_member_rel_path_requires_symlink_opt_in 精确验证了这一行为:默认调用抛 symlink member not allowed,传 allow_symlinks=True 后才返回 Path("link.txt")。
归档级校验:validate_tarfile 与 validate_tar_bytes
单成员校验之外,还需要对整个归档的成员间关系做一致性检查。validate_tarfile 接收一个已打开的 tarfile.TarFile,validate_tar_bytes 则直接接收原始字节(内部用 io.BytesIO 打开后委托给前者),两者共享同一套策略,参数完全一致:
| 参数 | 默认值 | 语义 |
|---|---|---|
reject_rel_paths |
() |
受保护路径集合;归档中任何成员与该集合中任一前缀“重叠”即拒绝 |
reject_symlink_rel_paths |
() |
指定的符号链接相对路径集合,匹配即拒绝该符号链接成员 |
skip_rel_paths |
() |
需跳过的路径前缀集合,命中成员直接跳过(不参与后续校验) |
root_name |
None |
tar 打包时可能带有的工作区根目录名,用于识别成员名的两种变体 |
allow_symlinks |
True |
是否放行符号链接成员(validate_tar_bytes 无此参数,固定放行) |
allow_external_symlink_targets |
True |
是否允许符号链接目标指向归档外部(绝对路径或 .. 逃逸) |
核心校验逻辑分两阶段:
阶段一:逐成员扫描。 每个成员先经过 should_skip_tar_member 判断是否应跳过;未跳过的成员调用 safe_tar_member_rel_path 取得相对路径(allow_symlinks 传参透传)。随后执行:
- 受保护路径重叠检查:对
reject_rel_paths中每个前缀,若成员路径“落在该前缀内”(_is_within),或成员非目录而该前缀落在成员路径内,则抛出archive member overlaps protected path: ...。这能同时拦截“写入受保护目录”和“把受保护路径变成普通文件”两种攻击。测试中的test_validate_tar_bytes_rejects_members_overlapping_protected_path与test_validate_tar_bytes_allows_directory_ancestor_of_protected_path分别验证了拒绝与放行边界。 - 重复路径检查:同一相对路径出现两次(且非“两个目录”这种安全叠加)即报
duplicate archive path。 - 符号链接目标检查:当
allow_external_symlink_targets=False时,符号链接目标必须是相对路径,且从成员自身所在目录解析后仍位于归档根内——绝对目标报absolute symlink target not allowed,越界相对目标(如../../etc/passwd)报symlink target escapes archive root。test_validate_tar_bytes_allows_internal_symlink_target_in_strict_mode证明../bin/python3这类“归档内部”的链接在严格模式下依然合法。 - 符号链接路径黑名单:命中
reject_symlink_rel_paths时抛出symlink member not allowed: <path>;路径会先经_normalize_rel归一化(去掉.、./前缀),所以"./workspace"与"workspace"等价(见test_validate_tar_bytes_specific_symlink_rejection_normalizes_dot_prefix)。
阶段二:成员间关系检查。 归档解包时成员可能有先后顺序问题:如果归档先写一个符号链接 escape -> /tmp/outside,再写 escape/pwned.txt,后者就会穿透链接写到根目录外。因此第二阶段对每个成员遍历其所有父路径:
- 若某个父路径是归档中的符号链接成员,抛出
archive path descends through symlink: ...(对应测试test_validate_tar_bytes_rejects_members_under_archive_symlink); - 若某个父路径在归档中是普通文件(非目录),抛出
archive path descends through non-directory: ...(对应测试test_validate_tar_bytes_rejects_member_under_non_directory_member)。
validate_tar_bytes 还有一层兜底:任何 tarfile.TarError 或 OSError 都会被包装为 UnsafeTarMemberError(member="<tar>", reason="invalid tar stream"),保证调用方只需捕获单一异常类型。
安全解包:safe_extract_tarfile
校验通过后,解包由 safe_extract_tarfile(tar, *, root, allow_external_symlink_targets=True) 执行。它在 validate_tarfile(此处 allow_symlinks 固定为 True,因为工作区快照需要保留符号链接)的基础上,进一步处理了既有文件系统状态带来的风险:
- 预先存在的符号链接父路径检查(
_ensure_no_symlink_parents):解包前把目标路径resolve()后与根目录比对,若解析结果逃出根目录(说明路径中已存在指向外部的符号链接组件),立即拒绝,reason 为path escapes root after resolution或symlink in parent path。测试test_safe_extract_tarfile_rejects_preexisting_symlink_parent用“根目录下预先放置指向外部的符号链接escape,再解包escape/pwned.txt”验证了这一点,并断言外部目录未被写入。 - 叶子节点替换规则:解包目标若已存在目录,且新成员是符号链接,则拒绝(
destination directory already exists,见test_safe_extract_tarfile_rejects_existing_leaf_directory_for_symlink);其余情况(文件↔符号链接、文件↔目录)允许替换——先unlink再创建,保证不会“穿透”既有符号链接写入。 - 文件写入使用
O_NOFOLLOW:打开目标文件时使用os.O_WRONLY | os.O_CREAT | os.O_EXCL,并尽量加上O_NOFOLLOW,从系统调用层面杜绝跟随符号链接写入。 - 权限恢复策略(
_restored_regular_file_mode):镜像 Python 标准库tarfile的datafilter 模式策略——丢弃 setuid/setgid/sticky 位以及 group/other 的写权限;只有属主具有执行位时才保留执行位;属主始终可读写。即mode & 0o755之后再保证0o600底线。这样既能让快照中的可执行脚本(如.venv/bin/python)保持可执行,又不会把危险的权限位带入恢复环境。测试 test_tar_utils.py 的test_safe_extract_tarfile_restores_regular_file_modes用 11 组参数化用例逐一验证:0o777 → 0o755(去掉 group/other 写位)、0o4755 → 0o755(去 setuid)、0o444 → 0o644(保持属主可写)、0o000 → 0o600(保持属主可读)等。 - 部分写入文件保持私有:写入时先以
0o600创建,fchmod恢复权限放在 payload 写完并flush之后——若拷贝中途失败,残留文件保持0o600私有模式而非最终可读/可执行模式。test_safe_extract_tarfile_keeps_a_partially_written_file_private用“读出一个 chunk 后抛错”的模拟 payload 验证了失败路径下文件既非可执行、权限仍为0o600。 - 两阶段写入顺序:第一遍先处理目录与普通文件(符号链接成员跳过),第二遍才创建符号链接——确保任何符号链接都建立在“目录和普通文件已就位”的文件系统之上,避免符号链接被后续成员当作父路径利用。
该函数返回 None,失败时统一抛出 UnsafeTarMemberError。
前缀归一化:strip_tar_member_prefix
不同打包后端产生的工作区 tar,成员前缀可能各不相同。例如 Docker 把工作区先拷贝到 /tmp/stage/workspace 再打包,得到的成员名是 workspace/...;而可移植的工作区快照应当与“源后端根目录名”无关,统一存成 . 与 ... 的相对形式。
strip_tar_member_prefix(data, *, prefix) 完成这一转换:它流式读取输入(mode="r|*" 自动识别压缩格式),逐成员重写后写出(mode="w|"),把 prefix 前缀替换为 .:
- 根成员(返回
None)或恰好等于prefix的成员 → 重命名为"."; - 以
prefix为前缀的成员 → 截掉前缀部分; - 其余成员 → 抛出
member does not start with prefix。
重写时通过 copy.copy(member) 保留元数据,并清理 PAX 扩展头中的 path 字段(避免长文件名场景下旧路径残留)。测试 test_strip_tar_member_prefix_returns_workspace_relative_archive 验证输入 workspace/pkg/main.py 等成员后输出为 [".", "pkg", "pkg/main.py", "pkg/python"];test_strip_tar_member_prefix_rewrites_pax_path_headers 则验证超长文件名(>100 字符,PAX 格式)下 pax_headers["path"] 被同步改写。函数返回一个 seekable 的新流,且在返回前会再次调用 validate_tarfile 对重写结果做整体校验。
成员跳过:should_skip_tar_member
打包时某些路径(如运行时缓存 .runtime、敏感挂载点)不应进入快照。should_skip_tar_member(member_name, *, skip_rel_paths, root_name) 依据“工作区相对前缀”判断成员是否应排除:由于 member_name 可能带 . 前缀或工作区根目录名,函数先通过 _tar_member_rel_variants 生成路径的多个变体(原始形式与去掉 root_name 后的形式),只要任一变体落在任一 skip_rel_paths 前缀内(_is_within)即返回 True。测试 test_validate_tar_bytes_ignores_skipped_unsafe_member 展示了跳过语义:即使被跳过的成员本身是危险符号链接(.runtime/escape -> /tmp/outside),只要声明了 skip_rel_paths=[Path(".runtime")],校验即通过。
在仓库中的真实调用链
tar_utils 不是孤立工具,它是沙箱“工作区持久化(persist)→ 传输 → 恢复(hydrate)”闭环的安全核心,各后端调用方式如下:
本地沙箱(unix_local),见 src/agents/sandbox/sandboxes/unix_local.py:
persist_workspace:用tar.add(root, arcname=".", filter=...)打包,filter 回调中调用should_skip_tar_member(root_name=None)跳过应排除的路径;hydrate_workspace:调用safe_extract_tarfile(tar, root=root, allow_external_symlink_targets=False)——本地恢复采用严格模式,禁止符号链接指向归档外部。
Docker 沙箱,见 src/agents/sandbox/sandboxes/docker.py:
persist_workspace:先在容器内把工作区拷到 staging 目录,打包后调用strip_tar_member_prefix(root_prefixed_archive, prefix=staging_workspace.name),把workspace/...归一化为./...,再交给上层存储;hydrate_workspace:先把输入流完整读入临时文件,再以validate_tarfile(tar, allow_external_symlink_targets=False)做严格校验,之后才通过容器 exec 落盘。
远程/容器内提取器,见 src/agents/sandbox/session/archive_extraction.py:WorkspaceArchiveExtractor.extract_tar_archive 逐成员调用 safe_tar_member_rel_path,并在写盘前用 ls 结果检查目标父路径中是否已存在符号链接(_ensure_no_symlink_extract_parents),所有 UnsafeTarMemberError 都被转换为带 member/reason 上下文的 WorkspaceArchiveWriteError。
此外,远程沙箱扩展(如 src/agents/extensions/sandbox/ 下的 daytona、runloop、vercel、e2b、modal、blaxel、cloudflare 等后端)也引用本模块,共享同一套安全策略,保证不同后端间工作区快照的语义一致。
测试如何锁定安全边界
tests/sandbox/test_tar_utils.py 是本模块安全行为的“契约文档”,核心用例覆盖了本文提到的所有规则,可归纳为几组:
- 路径攻击:根符号链接(
archive root symlink)、Windows 驱动器路径(C:/...、C:\...)、Windows 反斜杠分隔符(..\evil.txt、\evil.txt、nested\evil.txt)、绝对路径、..穿越; - 链接攻击:归档符号链接下嵌套成员(
descends through symlink)、严格模式下绝对符号链接目标、父目录逃逸目标、硬链接与 FIFO(rejects_unsupported_tar_member_types); - 受保护路径:成员与
reject_rel_paths重叠的拒绝、非目录祖先的拒绝、目录祖先的放行、指定符号链接路径的定向拒绝及其.前缀归一化; - 解包安全:预先存在的符号链接父路径、归档内符号链接目标重放(同路径先符号链接后文件、先文件后符号链接、先目录后文件等叶子替换矩阵)、目标目录已存在时拒绝符号链接;
- 权限与失败路径:11 组 POSIX 模式恢复断言、脚本保持可执行(
bin/start可执行而README.md不可执行)、替换已存在文件时恢复新模式、部分写入失败后文件保持0o600私有。
这些测试同时验证了 validate_tar_bytes、safe_extract_tarfile、strip_tar_member_prefix 三个入口的协同行为,是理解模块语义最直接的补充资料。
小结:一套可移植的安全归档策略
总结 tar_utils 的安全策略,可以浓缩为一张“拒绝清单”:
| 类别 | 拒绝项 |
|---|---|
| 路径形式 | 绝对路径、.. 穿越、Windows 驱动器/反斜杠路径、重复路径 |
| 成员类型 | 根位置非目录成员、硬链接、设备/FIFO 等非普通类型、默认配置下的符号链接 |
| 链接行为 | 归档内符号链接之下嵌套成员、严格模式下指向归档外部的符号链接目标、命中 reject_symlink_rel_paths 的链接 |
| 文件系统状态 | 预先存在且指向根外的符号链接父路径、目标位置已存在目录却要写符号链接 |
| 权限 | setuid/setgid/sticky 与 group/other 写位一律清除,属主始终可读写 |
整套实现以“统一异常 + 统一策略 + 流式处理”为设计骨架:UnsafeTarMemberError 让所有后端只需捕获一种异常;validate_tarfile / validate_tar_bytes / safe_extract_tarfile / strip_tar_member_prefix 四个公共函数分别覆盖“校验已打开归档、校验原始字节、安全解包、前缀归一化”四种典型场景。无论是自己实现自定义沙箱后端,还是审查现有后端的工作区快照链路,这份源码与配套测试都是可直接对照的安全参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust4.2 K634
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown300
jforgamejforgame是一个一站式游戏服务器开发框架。包含游戏服务器开发所需要的各种组件,比如网关,socket服务端与客户端,自定义高效消息编解码,游戏热更新,游戏通用工具等等。包含游戏服,跨服,匹配服,后台管理系统等实现,同时提供大量业务案例以供学习。亦可用于其他socket应用,例如及时聊天等。Java101
fizz-gateway-nodeAn Aggregation API Gateway in Java . FizzGate 是一个基于 Java开发的微服务聚合网关,是拥有自主知识产权的应用网关国产化替代方案,能够实现热服务编排聚合、自动授权选择、线上服务脚本编码、在线测试、高性能路由、API审核管理、回调管理等目的,拥有强大的自定义插件系统可以自行扩展,并且提供友好的图形化配置界面,能够快速帮助企业进行API服务治理、减少中间层胶水代码以及降低编码投入、提高 API 服务的稳定性和安全性。Java60
certd开源SSL证书管理工具;全自动证书申请、更新、续期;通配符证书,泛域名证书申请;证书自动化部署到阿里云、腾讯云、主机、群晖、宝塔;https证书,pfx证书,der证书,TLS证书,nginx证书自动续签自动部署JavaScript60
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python280