Agent 敢开写权限吗?Python 文件写入沙箱守卫实战

一、问题背景
我在香港做 FinTech,平时让 AI coding agent 自动拉取香港公开数据——政府统计处(C&SD)的通胀数字、差饷署的租金指数——然后写回本地 reports/ 做报表。给 Agent 开文件写权限,对我不是演示,是每天的真实操作。
但开写权限等于把"往哪写"的决定权交了出去。一个朴素守卫如果只做字符串前缀匹配,会被 ../ 目录回溯和符号链接绕过,把数据写到 /etc、甚至覆盖 .env。前面几篇 Agent 文章聊的是机制(怎么编排、多 agent 怎么协作),那一层我不重复;本文只谈最容易被忽略的安全边界:Agent 真正握着写权限时,它的笔尖能伸到哪里。
核心解法一句话:用 os.path.realpath() 把路径彻底规范化,再跟白名单根目录比对,每次尝试写进审计日志——十几行标准库代码,就能把越权写盘挡在门外。下面从选型到代码到真实跑出来的结果,一步步说清楚。
二、环境信息
| 项目 | 值 |
|---|---|
| Python | 3.13(隔离 venv) |
| 依赖 | 仅标准库 os / tempfile;matplotlib 仅画图 |
| 测试环境 | macOS(/var -> /private/var 符号链接已纳入考量) |
| 运行方式 | python sandbox_guard_demo.py,零外部 API key |
| 数据性质 | 真实执行输出,非 mock |
三、实现思路
判断"这次写盘安不安全",本质是回答一个问题:目标路径解析之后,还在不在白名单根目录里?
朴素做法直接拿原始字符串做 startswith 比对——这恰恰漏了路径规范化。攻击路径里的 ../ 原样透传,符号链接根本不解析,于是"看起来在根目录里"的路径,落到磁盘上已经逃了出去。
加固思路只加一层关键动作:在比对之前,先用 os.path.realpath() 把目标路径彻底解析成规范绝对路径(展开 ../、跟随符号链接),再跟同样经过 realpath 规范化的白名单根目录做前缀比对。最后把每一次放行与拦截写进审计日志,事后能回看。
四、代码实现
先上朴素版。思路是"字符串前缀匹配"——这就是 bug 的藏身之处:
import os
class GuardV1:
"""朴素版:原始字符串前缀,未规范化。这就是 bug 所在。"""
def __init__(self, root):
self.root = root.rstrip("/")
def decide(self, path):
if path == self.root or path.startswith(self.root + "/"):
return True
return False
加固版只改几行,但把上面两个洞全堵了:
import os
class GuardV2:
"""加固版:规范化 -> allowlist -> 审计日志。"""
def __init__(self, root, audit):
self.root = os.path.realpath(root) # 一次性规范化根目录
self.audit = audit
def decide(self, path):
real = os.path.realpath(path) # 解析 ../ 与符号链接
allowed = real == self.root or real.startswith(self.root + "/")
self.audit.append((path, allowed))
return allowed
接法不复杂:在 agent 的写文件工具里,真正落盘之前先调 guard.decide(target),返回 False 就直接 raise,一个字节都不写。allowlist 根目录我硬编码成绝对路径,绝不用相对路径——相对路径正是 ../ 能打穿的入口。
这段代码可以直接复用,收藏备用。
收藏本文,直接拿出来用。
五、运行效果(真实执行)
我准备了 4 类攻击用例:合法写入、目录回溯(../)、绝对路径直写、符号链接逃逸。下面是真实跑出来的结果——直接贴终端原文,一个字没改:
===============================================================
REAL EXECUTION REPORT -- AI Agent file-write sandbox guard
workspace : /var/folders/cq/2g31mds93vv9zljw_gfwrg9h0000gn/T/agent_ws_n2kzjs3j
outside : /var/folders/cq/2g31mds93vv9zljw_gfwrg9h0000gn/T/outside_ncoycwgo
===============================================================
[legit-write ] V1=ALLOW V2=ALLOW (canonical-ok)
[dotdot-traversal] V1=ALLOW V2=BLOCK (canonical-escape)
[absolute-path ] V1=BLOCK V2=BLOCK (canonical-escape)
[symlink-escape ] V1=ALLOW V2=BLOCK (canonical-escape)
------------------------------------------------------------
V1 blocked 1/4 | V2 blocked 3/4
V1 wrongly ALLOWED (real escape that V2 catches): 2
===============================================================
V2 AUDIT LOG (every attempt recorded):
1. ALLOW reason=canonical-ok real=/private/var/.../agent_ws_n2kzjs3j/reports/q3_summary.txt
2. BLOCK reason=canonical-escape real=/private/var/.../outside_secret.txt
3. BLOCK reason=canonical-escape real=/private/var/.../etc/passwd
4. BLOCK reason=canonical-escape real=/private/var/.../outside_ncoycwgo/stolen.txt
V1 只挡住 1/4,被绕的 2 次(../ 回溯 + 符号链接)是真实测试发现的缺口,不是编的。V2 拉到 3/4,把那两次逃逸都按了下来。两类拦截能力对比如下:

纵深看各层拦截数——单层 raw 前缀只能拦 1 个,加上 realpath 规范化到 3 个,审计日志补全可追溯性:

六、踩坑记录
坑 1:朴素守卫被 ../ 和符号链接绕过。 第一次测试就撞上——V1 对 ../ 回溯和符号链接逃逸都放了行。教训只有一句:判断路径在不在白名单里之前,先 realpath 规范化,再比根目录,顺序不能反。
坑 2:macOS 上 /var、/tmp 本身是符号链接。 /var 指向 /private/var,所以根目录如果不先 realpath 一次,本地能跑、上 CI 就翻车(根目录被比成了 /tmp/... 而实际路径是 /private/tmp/...)。解决:守卫初始化时对根目录做一次 os.path.realpath(),让根和目标的解析口径一致。
坑 3:TOCTOU 竞态 Python 挡不住。 realpath 在"判断允许"和"真正创建文件"之间,符号链接可能被改向。Python 层不是银弹——要真正锁死,得下沉到内核:macOS 用 Seatbelt(sandbox-exec)限制 syscall,Linux 用 bubblewrap 起最小命名空间容器。它们是不同维度:Python 守卫是"应用层门卫",Seatbelt/bubblewrap 是"墙体结构",两层该叠着用。我的流水线现状是 Seatbelt 管住 macOS 本地 runner 的 syscall,Python 守卫管住每次写调用的路径判定,单开 Python 那层至少挡住 3/4 的越权尝试,性价比够高。
七、总结
给 Agent 开写权限前,先确认它的写工具做了路径规范化——如果只做字符串匹配、没调 realpath,那 ../ 和符号链接一样能打穿。把上面这段守卫类贴进你的 Agent 工具调用前,让每次写盘都过一遍 realpath + allowlist + 审计日志,比事后补救便宜得多。这一版代码我已经跑在拉香港公开数据的流水线里。
如果这篇对你有用,收藏+点赞。

更多推荐


所有评论(0)