最近半年在做一件具体的事:让一个桌面端 Agent 能真正操作本机文件、跑命令、改表格。功能跑通花了两周,把权限收住花了两个月。
这篇只讲一件事,目录白名单到底该怎么写。那些「加个 startswith 判断就行了」的方案,我全部试过,全部被绕过。
最直觉的写法:
ALLOWED = [r"D:\workspace", r"C:\Users\me\Documents\proj"]
def check(path: str) -> bool:
p = os.path.abspath(path)
return any(p.startswith(root) for root in ALLOWED)三条安全边界,全错。
os.path.abspath 只做 .. 和 . 的字符串折叠,不碰软链接。在 D:\workspace 下建一个指向 C:\Windows 的 junction,一条命令就够:mklink /J D:\workspace\sys C:\Windows。
此时 os.path.abspath(r"D:\workspace\sys\System32\config\SAM") 返回的仍然是 D:\workspace\...,startswith 通过,文件读到了。攻击面不需要多高的技术,一个被 prompt 注入的模型自己建这个 junction 就够。
D:\workspace 和 D:\workspace_evil 都能通过 startswith。目标路径写成 D:\workspace_evil\x.txt 就出去了。修法是补分隔符:
return any(p == root or p.startswith(root + os.sep) for root in roots)同一个文件至少有六种写法:
C:\Users\me\a.txtc:\users\ME\A.TXTC:\Users\me\a.txt.C:\Users\me\A~1.TXT\\?\C:\Users\me\a.txt\\localhost\C$\Users\me\a.txt前三个用 normcase 加 realpath 能处理,后三个不行。\\?\ 前缀会跳过所有路径规范化,\\localhost\C$ 是 UNC 形式的管理共享,两个都是独立通道。
import os
from pathlib import Path
ROOTS = [
Path(r"D:\workspace").resolve(),
Path.home() / "Documents" / "proj",
]
def is_allowed(target: str, write: bool = False) -> bool:
# 1. 规范化,strict=False 保证路径不存在时也不抛异常
p = Path(target).resolve(strict=False)
# 2. 任何 UNC / 设备路径直接拒绝
if str(p).startswith("\\\\"):
return False
# 3. 带边界的前缀判断
for root in ROOTS:
r = root.resolve(strict=False)
if p == r:
return not write # 根目录本身只读
if str(p).lower().startswith(str(r).lower() + os.sep):
return True
return Falseresolve(strict=False) 是这版的核心,它会解析符号链接和 junction,同时容忍不存在的路径。旧版 os.path.realpath 在 Python 3.8 之后行为也有变化,跨版本部署要注意。
我写了 43 条绕过用例,分五组跑:
组别 | 用例数 | 修复前通过 | 修复后通过 |
|---|---|---|---|
路径穿越 | 9 | 0 | 0 |
符号链接 / junction | 8 | 5 | 0 |
前缀同名目录 | 6 | 6 | 0 |
Windows 等价路径 | 12 | 6 | 0 |
UNC / 管理共享 | 8 | 4 | 0 |
合计 43 条,修复前 21 条能出去,修复后 0 条。../ 那组一开始就是 0,因为 abspath 确实处理了它,这也是最容易被误判为「方案没问题」的原因。
第一,白名单只防路径选错,防不住路径选对。
模型在 D:\workspace 里删掉 300 个文件,白名单全程放行。这是权限模型的固有边界,不是实现 bug。要挡这一层,只能靠写前 diff、操作回放、可撤销。
第二,读写必须分成两套白名单。
我最初读写共用一个根目录,结果一个「把这份 Excel 汇总一下」的任务,顺手改掉了同目录下 40 个源文件。现在的规则是所有写操作走一个更窄的根目录,且写之前必须落一份 diff 给人看。
第三,操作回放不做,前面两条都白搭。
每次工具调用记下完整 payload:路径、模式、参数、返回值摘要。出问题时能倒查是哪一步开始的。这个存储成本很低,几千次调用也就几 MB。
这套设计后来固化进了 PRIVO 的桌面端,它默认就是窄权限加写前 diff,每个写操作要人点一次确认。代价很明显,批量任务要多点几十次鼠标,赶工期的时候确实烦。
但四十五分钟的返工和几十次点击之间,我现在会选后者。
你们给 Agent 提权的时候,是直接放开整个用户目录,还是也做了这种收窄?中间那层边界是怎么划的?
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。