开源交易 Agent Vibe-Trading 的四层安全边界拆解
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
**如果你顺着目录名去找这套 Agent 的安全中枢,会找错地方。**这里说的 Vibe-Trading 是 HKUDS 放在 GitHub 上的那个开源交易 Agent 项目,不是”凭感觉下单”那种说法。它的 agent/src/security/ 目录下除 __init__.py 外只有四个模块文件,把它们读完你会发现:workspace_policy.py 和 network.py 都只是把别处的函数 import 进来再原样导出的兼容层,workspace_access.py 只定义了一个字符串常量和一个异常类,唯一有实质逻辑的是 scanner.py。真正决定 Agent 能读哪个目录、能写哪个目录的代码,躺在 agent/src/tools/path_utils.py 里。这个错位本身就值得先讲清楚——你要审这个项目在你机器上的行为边界,读错文件就等于什么都没审。
站内另外三篇跟这个话题挨得近,但切口不同:Agent 工作区隔离讲的是通用工程做法,MCP 的安全边界盯的是协议层的授权面,AI 数据安全风险谈的是数据进出组织的口子。这篇只做一件事:把 Vibe-Trading 这一个仓库的相关文件读完,告诉你它实际执行到了哪一行、哪些地方是空的。
一、四个文件里,只有一个在干活
先把这四个文件各自是什么讲明白,省得你被文件名带跑。
workspace_policy.py 的全部内容是从 src.channels.utils 导入 is_path_within 再原样导出,注释写着这是给”ported channel code”做向后兼容用的。全仓真正 import 它的只有 agent/src/channels/matrix.py 一处。network.py 同理,再导出 validate_url_target 和 validate_resolved_url,唯一使用方是 agent/src/channels/dingtalk.py。
workspace_access.py 稍微特别一点,它自己定义了两样东西:常量 WORKSPACE_SCOPE_METADATA_KEY(值是 _workspace_scope)和异常类 WorkspaceScopeError,后者继承 ValueError,额外挂了一个 message 属性做兼容访问。这两样东西的真实用途要到 agent/src/channels/websocket.py 和 agent/src/channelsui/gateway_services.py 里才看得到:客户端可以在消息信封里带一个 workspace_scope 字段指定 root,网关拿到后展开解析成绝对路径,如果开启了限制且这个 root 不在默认工作区之下,就抛 WorkspaceScopeError,WebSocket 侧捕获后向客户端回一个 workspace_scope_rejected 的错误事件。默认工作区路径由 agent/src/config/paths.py 的 get_workspace_path() 给出,落在运行时根目录下的 workspace 子目录。
scanner.py 是这四个里唯一有真实防御逻辑的,后面单开一节讲。
下面这张表把这块的分工和你会在什么时候撞见它们对上:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 路径包含判断的转发层 | 判断某路径是否位于某目录内,供渠道适配器复用 | agent/src/security/workspace_policy.py(实现在 agent/src/channels/utils.py) | 走 Matrix 渠道收发文件时 |
| 工作区作用域的错误契约 | 定义作用域元数据键与被拒异常类型 | agent/src/security/workspace_access.py | 客户端想把工作区根目录换到别处时 |
| 出网目标校验的转发层 | 协议白名单、域名解析、非全局路由地址拦截 | agent/src/security/network.py(实现在 agent/src/channels/utils.py) | 钉钉渠道去拉取一个媒体文件时 |
| 外部内容扫描与控制符中和 | 注入模式打警告、聊天模板控制 token 插零宽空格 | agent/src/security/scanner.py | 每次网页抓取、搜索、文档解析返回结果时 |
| 真正的文件根白名单 | 读/写/运行三套允许根,外加多个路径 helper | agent/src/tools/path_utils.py | 每次文件读写工具调用、每次产物落盘 |
| 敏感字段脱敏 | 工具参数与结果进事件流之前的脱敏 | agent/src/tools/redaction.py | 看事件流、trace、审计记录时 |
二、文件边界的真身:三套根目录,三种威胁模型
agent/src/tools/path_utils.py 的模块 docstring 开门见山写了”三个 helper,三种威胁模型”,这个划分比目录名靠谱得多。
safe_path(p, workdir) 处理的是工具自己控制的沙箱:把 p 在 workdir 下解析,支持 ~ 展开,然后用 relative_to 检查有没有跑出去,跑出去就抛 ValueError 说路径逃出了工作区根。注意它在检查前做了 resolve(),所以符号链接指向外部的情况也会在这一步被识别。
safe_user_path(p) 和 safe_document_path(p) 处理的是用户递进来的文件——券商导出、待解析文档这类。它们共用同一个内部函数,先把路径解析成候选绝对路径,再拿去跟 allowed_file_roots() 逐个比对。这个白名单由两部分拼起来:一组默认根(agent 包下的 uploads 与 runs、当前工作目录下的 uploads 与 data、家目录 .vibe-trading 下的 uploads 与 imports、运行时根下的 uploads/runs/imports),加上环境变量 VIBE_TRADING_ALLOWED_FILE_ROOTS 里逗号分隔的自定义根。
写入侧是另一套 allowed_write_roots(),环境变量换成 VIBE_TRADING_ALLOWED_WRITE_ROOTS。它的默认列表更短,但要注意它不是读侧列表的严格子集:读侧的 cwd/data 和家目录下的 imports 在写侧被拿掉了,写侧却多出一个家目录下的 runs。所以”能读的地方一定能写”和”能写的地方一定能读”两句话在这里都不成立,你要判断某个目录的可达性,得分别对着两个函数各看一遍,别靠推理。
第三套是运行目录。safe_run_dir(p) 校验的是生成代码类工具的 run_dir,白名单里除了常规的 runs 还包含 agent/.swarm/runs,代码注释直说这是”未迁移的遗留 swarm 运行目录”。还有一个 safe_run_id(run_id),它只接受裸目录名:绝对路径、多段路径、含 . 或 .. 的都直接拒。
几个细节值得你记住。所有入口都先过一个 UNC 检查,\\ 或 // 开头一律拒绝。被拒时的错误消息会把当前允许的根一条条列出来,末尾还追加一句提醒——在 MCP 客户端下面跑的时候,环境变量要写在客户端的 server env 块里,你在 shell 里 export 是到不了那个被 spawn 出来的进程的。这句提示解决的是一个真实的排障黑洞,很多人卡在这里会以为配置没生效。
再说一个降级路径,它是这块最容易被忽略的语义。resolve_safe_path() 在 run_dir 校验失败或者路径逃出 run_dir 之后,并不会立刻放弃,而是拿候选绝对路径再去 allowed_roots 里找一遍,找得到就放行。也就是说 run_dir 是”优先解析基准”而不是”唯一边界”,最终边界永远是那几个根列表。你要收紧行为,收紧的对象是根列表,不是 run_dir。
关于运行目录还有一句得说在前面:这些目录里落的是回测与因子计算的产物文件,本文只讨论这些文件落在哪、由谁校验,不讨论内容本身,历史表现不代表未来。仓库里的因子库也不是项目自研——根目录的 NOTICE 写明 Microsoft Qlib 的特征定义按 Apache 2.0 引入,另有几组公式来自公开论文与券商研报,仓库把它们当作数学事实重新实现,各因子库子目录下另有各自的 LICENSE.md。能不能商用、怎么署名,以许可证原文为准,本文不提供法律意见。
三、出网限制:两套实现,判定口径并不一样
出网这块有意思的地方在于,仓库里存在两套逻辑,服务的是两种不同场景。
第一套是 validate_url_target(),被 security/network.py 再导出、被钉钉渠道使用。流程是:解析 URL,scheme 必须是 http 或 https,netloc 和 hostname 都不能空,然后调 socket.getaddrinfo() 把主机名解析成一组 IP,逐个判定。判定函数 _is_private() 用的表达式是 not addr.is_global or addr.is_multicast,代码注释解释得很清楚:早先写成 is_loopback | is_link_local | is_private 的组合会漏掉 100.64.0.0/10(RFC 6598 共享地址段,也是 Tailscale 这类 mesh 网络的默认段),因为标准库对这一段的 is_private 返回 False;顺手也补上了 IPv4 组播这个口子。
它还有一个 allow_loopback 开关,口径卡得很窄:只在主机名字面上就是 loopback(localhost 或 127.x 字面量、::1)且所有解析出的地址都是 loopback 时才放行。一个公网域名恰好解析到 127.0.0.1,不在放行范围内。钉钉渠道的用法也值得学:拉媒体前校验一次,跟随重定向之后拿最终 URL 再校验一次。只校验第一跳的实现,重定向就是绕过口。
第二套是 agent/src/tools/web_reader_tool.py 里的 _url_allowed(),给网页读取工具用。它多拒了几样:URL 里带 username 或 password 的直接拒,主机名是 localhost、以 .localhost 或 .local 结尾的直接拒。但关键差异在于——它只在主机名本身能被解析成字面 IP 时才做私网判定,是域名就直接放行,全程不做 DNS 解析。
这不是疏漏,是架构决定的。这个工具并不自己去抓页面,它把目标 URL 拼在第三方 Reader 服务的前缀后面,由对方代抓,函数 docstring 明确写着完整 URL(含查询串)会被送到那个第三方服务,不要在里面放凭据或私网地址,返回的内容还可能是缓存快照(命中时结果里会多一个 cached 字段)。SSRF 的执行面被转移到了服务提供方那边,本地这套检查更像是”别把明显不该外发的东西发出去”的前置提醒。你要判断这条链路能不能接受,判断的对象是那个第三方服务,不是这几十行 Python。
四、注入扫描:只贴标签,不改判
scanner.py 的模块 docstring 开头就框定了它的行为取向:动作上刻意保守,从不丢弃抓回来的内容,只往 JSON 信封上加警告元数据,让下游 Agent 自己把外部文本当作不可信指令来对待。
规则一共五条,都是正则:instruction_override(要求忽略/覆盖先前指令)、system_prompt_exfiltration(要求泄露系统提示)、role_or_channel_claim(冒充系统或开发者角色)、secret_exfiltration(要求打印密钥、token、环境变量)、tool_abuse(指使调用 shell、bash、python、curl)。前两条和密钥外泄那条标 high,冒充角色和工具滥用标 medium。命中后每条规则最多产出一条 finding,字段是 type、rule_id、severity、message、match,另有可选的 field 标明命中在哪个字段,match 会被压成单行并截到 120 字符内。
比正则更有工程价值的是 neutralize_special_tokens()。它专门处理外部内容里伪造的聊天模板控制 token:在识别到的 token 开头分隔符后面插一个零宽空格(U+200B),字符串就不再匹配 tokenizer 的特殊 token 词表条目,视觉上却完全一样。覆盖的形状包括 ChatML 系的 ASCII 竖线写法、DeepSeek 的全角竖线写法(注释特意点明 DeepSeek 是项目默认下发的模型,所以这个形状必须覆盖)、Llama 的 [INST] 与 <<SYS>> 标记、<s>/</s>,以及 Gemma 的回合标记。正则里对 token 内部串做了长度上限,注释说明是为了防止对抗性输入引发灾难性回溯;整个变换是幂等的,没命中时原样返回同一个对象。
对外的统一入口是 with_security_warnings(payload, fields=...),字段选择器是点分路径,* 用来迭代列表,命中路径会被展开成 results.0.snippet 这种形式写进 finding 的 field。调用点只有三处:网页读取工具传 content,文档读取工具传 text,搜索工具在三个返回分支上都传 results.*.title 与 results.*.snippet。选中字段先扫再中和,有 finding 就往 payload 上挂一个 security_warnings 列表;已经存在这个键且是列表的话,新 finding 追加在后面而不是覆盖。
这里有个自相矛盾的地方值得你留意:模块 docstring 写的是”从不重写抓回来的内容”,但 with_security_warnings 确实会把中和后的字符串写回 parent[key]。函数自己的 docstring 补了理由——调用方传进来的恰好就是外部内容字段,所以就地改写是安全的。这两处说法并不一致,你在做二次开发时不能默认”payload 里的字符串跟上游返回的一模一样”:控制 token 那几个形状已经被插过零宽空格了。如果你的下游要拿这段文本去算哈希、做去重、或者跟原站内容比对,这个差异会以很难查的方式冒出来。
所以它的定位要说准确:这是一层可观测性,不是一道闸。它不阻断、不改判、不影响工具是否返回。真正的决策权还在模型和你的编排逻辑手里——这也是提示注入防御这类话题里反复出现的分工:检测层负责让攻击可见,处置层得单独建。
五、边界与代价:这套设计明确不管什么
把上面几层放一起看,取舍就清楚了。
**放弃的第一样是强隔离。**这几层全是同进程内的 Python 检查,没有容器、没有 seccomp、没有独立用户。检查函数本身写得不错,但它们保护的前提是所有文件访问都走这几个 helper。任何一条绕过 helper 直接用 open() 的代码路径,这套白名单就管不着。你要的如果是”这个 Agent 无论如何都出不了这个目录”,这里给不了。
**放弃的第二样是默认收紧。**默认允许根里包含当前工作目录下的 uploads 与 data,也包含用户家目录下的运行时目录。你从哪个目录启动进程,白名单就跟着漂移一次。这是为易用性做的选择,代价是你必须自己知道自己是从哪儿启的。
DNS 层面的时间差它不管。validate_url_target() 解析一次拿到 IP 做判定,真正发请求时又会解析一次,两次之间的记录变化不在它的视野里。要堵这类问题,得在实际连接的那一层拿住已校验的地址,这套代码没做到那一步。
**外部内容的处置它不管。**扫描只加元数据,中和只动那几个控制 token 的形状,正文语义一个字不改。如果你的编排把工具结果原样喂回模型而不看 security_warnings,这一层等于没开。
**shell 与进程它只管一个特例。**主机 shell 工具由 VIBE_TRADING_ENABLE_SHELL_TOOLS 控制且默认关闭。agent/src/tools/_shell_safety.py 里只有一条针对性检查:拦截宽泛按名杀 Python 进程的命令(taskkill、pkill/killall、PowerShell 的 Stop-Process 若干形状),理由是那会把 Vibe-Trading 自己也杀了,错误消息里还给了替代做法——用后台任务返回的 task_id 去取消。这是一条防自杀的护栏,不是命令白名单。开了这个开关,就是在给模型一个真实 shell。
**券商与资金侧不在这四层的管辖内。**仓库里 agent/src/trading/connectors/ 有 12 个连接器子目录,一旦你接上真实账户,风险面就不再是路径穿越了:凭据一旦落到配置文件或环境变量里,它的暴露面等于整个进程和所有能读到进程环境的东西;下错的单不可撤销,没有”回滚”这一说;程序化交易本身的合规义务因司法辖区而异。这几层安全代码一条都不解决这些问题。项目在脱敏侧确实有对应设计——agent/src/tools/redaction.py 会在工具参数与结果进入事件流、trace、审计记录之前,按凭据类键名和一组账户/PII 精确键名做脱敏,参数侧的默认 sink 是 fail closed 的那一档,而不透明的账户引用字段被刻意保留下来做问责链——但脱敏管的是”别把凭据打进日志”,管不了”凭据在磁盘上放着”。
六、上手与避坑清单
别把 agent/src/security/ 当作审计入口。 会踩是因为目录名太有暗示性,四个文件读完你会以为安全逻辑就这些。避法:审文件边界读 agent/src/tools/path_utils.py,审出网读 agent/src/channels/utils.py 和 agent/src/tools/web_reader_tool.py,security/ 目录只当索引看。
别以为设了 run_dir 就锁死了范围。 会踩是因为工具参数描述写的是”相对 run_dir 的路径”,读起来像硬边界。实际上 resolve_safe_path() 在 run_dir 判定失败后会回退去比对允许根列表。避法:把收紧动作做在三个 VIBE_TRADING_ALLOWED_*_ROOTS 环境变量和默认根上,并且清楚自己是从哪个工作目录启的进程。
在 MCP 客户端下跑时,别在 shell 里 export 环境变量。 会踩是因为本地测试时 export 一路都好使,换到 MCP 客户端就”配置没生效”。原因是客户端自己 spawn server 进程,你的 shell 环境到不了它。避法:写进客户端配置的 server env 块。代码在拒绝路径的错误消息里就提醒了这一点,遇到拒绝先把完整错误读完。
别指望 .local 之外的内网域名被出网检查拦下。 会踩是因为两套出网实现的口径不同,你在钉钉渠道那条链路上看到了 DNS 解析后判定,就默认网页读取工具也一样。实际上后者遇到域名直接放行。避法:如果内网可达性是你的红线,在网络层做出口限制,别指望应用层这几十行。
别把 security_warnings 当成已经处置过了。 会踩是因为字段名太像”已拦截”。它只是标记。避法:在你的编排里显式检查这个字段并决定动作——降权、二次确认、还是直接丢弃这条结果。
接实盘之前,先把凭据的存放位置和权限当作独立课题处理。 会踩是因为前面几层给人一种”这个项目安全上考虑得挺细”的整体印象,容易顺带认为凭据也被照顾了。实际上路径校验和注入扫描跟凭据保管是两码事。避法:凭据单独走密钥管理,最小权限授权,先在不涉及真实资金的模式下把整条链路跑通。能不能这么用、需要什么资质,以你所在司法辖区的监管要求与券商协议为准。
收尾:三个文件,按这个顺序读
想在半小时内把这个项目在你机器上的行为边界摸清楚,按下面顺序读三个文件就够:
先读 agent/src/tools/path_utils.py,把 _default_file_roots()、allowed_write_roots()、_default_run_roots() 三个函数的返回列表抄下来,对照你自己的目录结构过一遍——这决定了它能碰你哪些文件。再读 agent/src/channels/utils.py 里的 validate_url_target() 和 agent/src/tools/web_reader_tool.py 里的 _url_allowed(),看清两者口径差在哪——这决定了它能往哪儿发请求。最后读 agent/src/security/scanner.py,确认你的编排有没有真的在消费 security_warnings——这决定了检测层是不是白做的。
三个问题回答完,你就知道该自己补哪块了。剩下的判断,交给你的实际部署环境和合规要求。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 的三层配置:结构 schema、环境变量 schema、路径与限额各管什么 和 Vibe-Trading 开源项目跑不起来:自检表、限额与数据源降级。