Vibe-Trading 开源项目的脱敏层:Agent 输出转发出去之前先看清它管到哪一步

2026-08-05

本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。

Vibe-Trading 的脱敏管的是「沉淀面」,不是「发出去的那句话」。 它覆盖的是工具调用参数、工具结果、实盘动作账本这些会被写进 trace 文件、推上事件总线、追加进审计账本的东西;模型最终生成的那段自然语言回答,在这套代码里没有再过一次 redaction。你要是把 Agent 的输出接到企业微信群、飞书群或者 Telegram,这个差别就是你必须自己处理的那一段。

先把命名说清楚:Vibe-Trading 是 HKUDS 放出的一个开源个人交易 Agent 项目名,不是「凭感觉交易」这类泛指说法。本文只讨论它的工程实现——代码怎么组织、函数怎么分工、边界画在哪里——不涉及任何投资判断。

一、这一层要挡的三件事

打开 agent/src/tools/redaction.py,模块开头的 docstring 自己就把范围划出来了:这里住着三个互相独立的关切。

第一个是路径拓扑。redact_internal_paths 把内部根前缀替换成 <redacted> 哨兵,保留相对尾巴。用户自己传进来的绝对路径、外部路径原样保留,因为那些对排障有用;被隐藏的只是「你这台机器长什么样」。

第二个是结构化载荷。redact_payload / is_sensitive_arg 递归清洗字典和列表,把敏感键的值换成 [redacted],在这些数据抵达任何事件流、trace 或实盘审计账本之前动手。

第三个是自由文本。redact_text / redact_tool_result 走模式扫描,处理 redact_payload 够不到的地方——纯文本工具结果、shell 输出、错误消息、日志行。键遍历只认键名,一个 bearer token 塞在某个正常键的值里面,它是看不见的。

三件事分开写,是因为它们的失败方式不一样。路径泄露是拓扑信息,敏感键是明确的字段,自由文本里的凭据是形状匹配。混在一个函数里,任何一次调整都会牵连另外两个。

二、结构化载荷:键怎么被判定为敏感

is_sensitive_arg 的判定分两类。一类是凭据键,api_keyauthorizationpasswordsecrettokenpassphraseenvheaders 这些,既做精确匹配也做标记子串匹配——所以 api_tokenaccess_tokenx-authorization 都会中。另一类是账户与 PII 字段,走的是精心挑过的精确匹配集合 _PII_EXACT_KEYSaccount_numberaccount_idbrokerage_account_numberssnsocial_security_numbertax_idrouting_numberbank_account_number 等等。

为什么 PII 这一类坚持精确匹配?注释写得很直白:一个宽泛的 "account" 子串标记会过度脱敏——账户余额、账户配置这类良性字段全被打掉——而且会顺手清掉审计记录自己的 account_ref 溯源字段。这个字段是不透明的券商引用,按项目内部规范是「授权→同意」问责链的一环,必须活下来。所以 account_ref 被刻意排除在敏感集之外。

命名风格差异靠折叠解决。_fold_key 把键名折成小写字母数字核心,去掉所有分隔符,于是 account_numberaccountNumberaccount-numberAccount Number 折出同一个形态。这个函数不用正则,注释里点了原因:零 ReDoS 面。整个模块在这件事上很一致——路径脱敏也是纯字符串查找,不上正则。

真正值得学的是 sink 分级。模块定义了两个 sink 常量:

ARGUMENTS_SINK = "arguments"
RESULT_SINK = "result"

ARGUMENTS_SINK 既是默认值也是严格的那个。忘了传 sink 的调用方拿到最严的键集,只有想清楚了自己面对什么表面的调用方才会主动选 RESULT_SINK;传了个不认识的值也退化成严格行为——fail closed。

两个 sink 的差别就一个键:

_RESULT_SAFE_KEYS = {"content"}

contentread_file / read_document / read_url / load_skill 结果信封的载荷字段。在结果 sink 里按名字把它抹掉,等于把 trace 存在的意义抹掉了——你留 trace 就是为了看模型读到了什么。但在参数 sink 里同一个键装的是工具输入:write_file(content=…) 可能是一整份用户文档、一个生成的凭据、一段私有技能正文,所以那边继续脱敏。而 env 被明确排除在放行名单之外,注释给了理由:它的值在两个方向上都是秘密,结果里的一次 env dump 泄露的东西和参数里的一模一样。

还有个容易忽略的细节:在 RESULT_SINK 下,活下来的字符串叶子会额外再过一遍 redact_text。因为 JSON 工具结果的常见形状是一个信封,stdouterror 字段里塞着原始输出,任何基于键名的规则都分类不了它。

三、自由文本:三条正则各管一段

redact_text 的执行顺序是固定的三步。

先是 bearer。_TEXT_BEARER_PATTERN 匹配 Authorization: Bearer <token> 和裸的 bearer <token>,必须排在键值对之前——否则键值对规则会把 bearer 这个词当成值吃掉,把真正的 token 主体留在外面。

然后是键值对。_TEXT_KV_PATTERN 处理 key=valuekey: value"key": "value" 三种写法,键的引号用反向引用保证对称。这里有两个设计点值得抄:一是负向前瞻让替换幂等——已经是 [redacted] 的值会被跳过,同一段文本重复脱敏产出完全相同的字符串;二是候选键名全部用单数并加 \b 锚定,注释解释得很好——复数是计数或集合,不是凭据,匹配它反而会毁掉常规输出,比如 LLM 用量行里的 tokens: 1204 in / 318 out

最后是无标签的裸 token。有些凭据被粘进 shell 输出或错误串时前面根本没有键名,只能按形状认:

_TEXT_TOKEN_PATTERN = re.compile(
    r"(?<![A-Za-z0-9_-])("
    r"sk-[A-Za-z0-9_-]{20,}"                 # OpenAI / Anthropic style
    r"|gh[pousr]_[A-Za-z0-9]{30,}"           # GitHub PAT family
    r"|xox[baprs]-[A-Za-z0-9-]{10,}"         # Slack
    r"|AKIA[0-9A-Z]{16}"                     # AWS access key id
    r"|pypi-[A-Za-z0-9_-]{16,}"              # PyPI upload token
    r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}"  # JWT
    r")(?![A-Za-z0-9_-])"
)

每条分支都有前后锚定和长度下限,目的是让普通散文匹配不上。上一步键值对规则的取值字符类同样做了处理:裸值分支排除了方括号,所以第二遍扫描不会去匹配自己插进去的 [redacted],也吞不掉紧随其后的下一个 JSON 键。

redact_tool_result 是工具结果的唯一收口:能解析成 JSON 的走 redact_payload 的结果 sink,不能解析的直接走文本扫描,每个字节只被其中一种机制处理一次。调用方脱敏一次,把同一个值交给所有订阅者。你在 agent/src/agent/loop.py 里能看到这个约定被写成注释:一次脱敏喂给下面每一个订阅者——持久化 trace 记录、react trace、SSE 预览。

四、组成部分速查

组成部分它负责什么对应仓库位置你什么时候会碰到它
redact_internal_paths把本机根路径换成 <redacted>,保留相对尾巴agent/src/tools/redaction.py工具报错信息外发时;edit_file_tool.py / read_file_tool.py / write_file_tool.py 的 error 字段都过它
redact_payload / is_sensitive_arg递归清洗结构化载荷的敏感键,分参数/结果两个 sinkagent/src/tools/redaction.py工具调用参数入 trace 前;实盘审计记录落盘前
redact_text / redact_tool_result自由文本的凭据形状扫描,工具结果的唯一收口agent/src/tools/redaction.py任何 shell 输出、错误串、纯文本结果进 trace 或事件预览时
提示注入扫描与控制符消毒给外部内容加告警元数据,给聊天模板控制符插零宽空格agent/src/security/scanner.pyread_url / web_search / read_document 这三个工具抓外部内容时
实盘动作账本每条实盘动作先脱敏再扇出到最多三个 sinkagent/src/live/audit.py接了券商、有真实下单动作时
访问日志过滤器抹掉 uvicorn 访问日志里查询参数的值agent/src/api/security.py起 API server,日志被采集时
群体协作侧的预览裁剪复用同一套脱敏做子任务预览agent/src/swarm/worker.py用多智能体编制跑任务时
提交前闸门五道 grep/AST 检查,拦不该提交的东西tools/ci_grep_gates.shtools/ci_env_var_gate.py每次提 PR;CI 里由 .github/workflows/test.yml 触发

五、另一个方向:进来的内容也要处理

脱敏管出去,agent/src/security/scanner.py 管进来。这个文件的态度在开头 docstring 里写得很克制:扫描器在动作上刻意保守,它从不重写或丢弃抓回来的内容,只往读取/搜索类工具返回的 JSON 信封上加告警元数据,让下游 Agent 把外部文本当作不可信指令来对待。

规则表 _RULES 里我数到 5 条:指令覆盖、系统提示外泄、角色或通道冒充、秘密外泄、工具滥用。每条带 rule_idseveritymessage,命中后每条规则最多产出一个 finding,写进 security_warnings 列表。

比告警更硬的是 neutralize_special_tokens。它在识别到的聊天模板控制符的开头分隔符后面插一个零宽空格(U+200B),于是那串字符不再匹配分词器的特殊 token 词表条目,外部文本没法伪造角色边界,而视觉上文本完全没变。覆盖的形状包括 ChatML 家族的 <|...|>(ASCII 竖线)、DeepSeek 的 <|...|>(全角竖线)、Llama 的 [INST] / <<SYS>><s> / </s>、Gemma 的 <start_of_turn> / <end_of_turn> 这些。注释里点明 DeepSeek 那一族必须覆盖,因为它是这个项目出厂默认模型。这个变换同样是幂等的,插过 ZWSP 之后再跑一遍原样返回。

with_security_warnings 用点号路径选择字段,* 分量遍历列表——results.*.snippet 会扫每一条搜索结果的摘要,并把字段路径报成 results.0.snippet。它的 docstring 里明确写了「只由不可信内容摄取工具调用」,这也解释了为什么就地改写在这里是安全的:调用方传进来的正好是外部内容字段。你在 web_reader_tool.pyfields=("content",))、doc_reader_tool.pyfields=("text",))、web_search_tool.pyfields=("results.*.title", "results.*.snippet"))里能看到这三个调用点。

如果你想系统看提示注入这一类攻击面的防守思路,站内有一篇 Agent 提示注入防御 讲通用做法。

六、提交前那道闸:找不该提交的东西

tools/ci_grep_gates.sh 在文件头自称是「仓库级的安全底线」,闸门顺序执行,任何一道失败就非零退出并点名文件。CI 里由 .github/workflows/test.yml 直接 bash tools/ci_grep_gates.sh 触发,本地也能同样一行跑。

顺带一提,这个文件头的说明写的是四道闸,往下数实际有 a 到 e 五道——注释块里 a 到 e 的清单是全的,只有那句总数没跟上。这种小落差在读别人仓库时挺常见:以可执行的部分为准,注释只当线索。下面按脚本里的实际顺序过一遍。

  • gate a:不许出现绕开 safe_loadyaml.load( 调用。
  • gate b:受控商标字符串不许出现在会发出去的产物里,脚本注释指明了要改用的中性表述;docs/ 被排除,因为那是讨论该政策本身的内部文档。
  • gate cwiki/alpha-library 树里不许混进逐个股票代码的数据,正则同时认 A 股的六位数字加交易所后缀和美股形态。这道闸的目标是批量数据落库,不是散文里出现一个示例代码——所以 HTML 扫描只限定在 wiki/alpha-library/ 下面。
  • gate d:一批指定的 Python 源文件里不许出现 datetime.utcnow( 和裸的 datetime.now(),带 timezone.utc 的写法放行。
  • gate eagent/src/config/ 之外不许直接读环境变量。这道闸交给 tools/ci_env_var_gate.py 做,是 AST 级的,不是 grep。

gate e 值得单独说。它抓 os.getenv("KEY")os.environ.get("KEY") 和 Load 上下文的 os.environ["KEY"];放行 copy() / items() / setdefault() 和 Store 上下文的写入;os.environ.pop() 在指定文件之外只给非阻断警告。逃生口是行内 # noqa: env-gate 注释。agent/tests/ 整棵树豁免,配置层自己当然豁免——那里的读取正是它存在的意义。

把凭据读取收口到一个目录,配合脱敏层,就形成了一条闭环:凭据只能从一个地方进来,出去的每一个面上都有对应的清洗函数。

顺便说清楚三篇相邻内容的分工:日志里的敏感信息怎么处理 讲通用的日志脱敏方法论,browser-use 的敏感数据处理 讲浏览器自动化场景里的凭据代入,AI 工具的数据安全风险 讲的是选型阶段的风险清单;本篇不复述这些,只做一件事——把 Vibe-Trading 这一个仓库里的具体实现读透,让你能对着代码判断哪一面被覆盖了。

七、边界与代价:它明确不管什么

它不管模型最终回答的那段自然语言。 我在 agent/src/channels/ 下面没有找到任何 redaction 调用(这个目录有 16 个具体渠道实现文件,另有 base / manager / registry 等公共文件)。脱敏发生在 trace、事件预览、审计账本这些沉淀面上。模型如果在总结里复述了一段它读到的敏感内容,那段文字走的是回答路径,不走 redact_tool_result凡是要把 Agent 输出转发到群里的场景,这是你必须自己补的那一层。

它不管键名之外的语义。 结构化清洗只认键,自由文本扫描只认形状。一个客户名单塞在名为 data 的键里,两条路都拦不住。项目自己在注释里承认了这个分工,所以才有结果 sink 额外过一遍文本扫描的补丁式设计。

形状匹配天然有漏。 裸 token 那条正则只认列出来的那几家发行方前缀加 JWT。自建系统的私有格式凭据、公司内网的会话串,一个都不认。

过度脱敏与可诊断性一直在拉扯。 content 的 sink 分级、PII 的精确匹配、单数锚定,全是为了不把有用信息一起打掉而做的让步。让步就意味着放弃了一部分覆盖率——_RESULT_SAFE_KEYS 只放行精确折叠匹配的 contentsecret_contentcontent_token 在所有地方继续脱敏,这个边界是画得很细,但它确实是一条边界。

涉及真实资金的部分,代价要单独说清。 项目里有实盘动作账本 agent/src/live/audit.py,也有券商连接器目录 agent/src/trading/connectors/(我数到 12 家连接器子目录,README 也自述 12 家)。脱敏能减少凭据出现在日志和账本里的概率,但它不减少凭据本身的暴露面——密钥仍然存在于你的配置里、你的进程内存里、你的备份里。下错的单不可撤销,程序化交易的合规义务因司法辖区而异。这些都不是一个 redaction 函数能兜的事。

SECURITY.md 里还有一段专门讲生成的回测代码:回测运行可能在本地执行生成的 Python 策略代码,要当作「你自己会先审一遍的本地代码」来对待。运行器会校验运行目录并使用一个收窄的子进程环境——保留操作系统与 Python 基础项、代理与证书设置、允许的运行根配置,以及加载器需要的只读行情数据凭据,但默认不转发 LLM 供应商密钥、API 服务 bearer token、shell 工具开关、券商交易密钥和实盘开关。文档同时提醒:这个子进程仍然可以联网,所以不要在暴露敏感文件、带秘密的代理变量或你不愿让本地代码访问的网络服务的环境里跑不可信策略。这里补一句本文的立场:历史表现不代表未来,本文只讨论工程实现。

八、上手与避坑清单

别假设「用了这个项目输出就干净了」。 会踩是因为 redact_tool_result 这个名字太像一个全局出口,实际它只在 agent/src/agent/loop.py 的工具结果分支和 agent/src/swarm/worker.py 的预览里被调用。怎么避:在你自己的转发链路入口再调一次脱敏,把最终回答文本当成不可信内容处理,成本只是一次函数调用。

别在自己的调用点省掉 sink 参数还指望宽松行为。 会踩是因为默认值是 ARGUMENTS_SINK,也就是严格的那个;你想让 content 透出来却没传 sink=RESULT_SINK,结果 trace 里一片 [redacted],然后去怀疑是不是工具没返回内容。怎么避:先想清楚这个值最终落到哪个面,再决定传哪个 sink;拿不准就别传——fail closed 是这套设计故意留给你的默认。

别给自定义工具起会被误伤的键名。 会踩是因为凭据标记是子串匹配,任何含 token 的键都会中——你要是把「文本切分后的词元数」这类字段命名成 token_count,在参数 sink 里会被打成 [redacted]。怎么避:给统计类字段避开 token / secret / password 这几个词根,或者接受它被脱敏。

别把 PII 字段名写成集合里没有的变体。 会踩是因为账户类字段走的是精确折叠匹配,不是子串匹配。customer_account_number 这种带前缀的复合名,折叠之后不等于 accountnumber,不会命中。怎么避:字段命名对齐 _PII_EXACT_KEYS 里已有的名字,或者在你自己的层面显式处理。

别在 agent/src/config/ 之外读环境变量。 会踩是因为写的时候顺手一个 os.getenv,本地跑得好好的,一提 PR 被 gate e 挡下来。怎么避:动手之前先看一眼配置层有没有现成访问器;确实有特例就加 # noqa: env-gate 并在评审里说明理由,别偷偷绕。

别把外部内容的告警当成已经处理完了。 会踩是因为 security_warnings 只是元数据,扫描器从不重写也不丢弃内容——命中一条 high 严重度规则的网页,正文原样进了上下文。怎么避:在你的编排层显式消费这个字段,决定是降权、隔离还是直接丢,别指望它自己消失。

别指望控制符消毒替你做隔离。 会踩是因为插 ZWSP 只解决「伪造角色边界」这一种攻击,不解决内容本身在说服模型做事。怎么避:外部内容仍然要放在明确标注的不可信区块里,这部分属于上下文工程的活,可以看 上下文污染怎么防

收尾

判断这一层够不够用,你只要问自己三个问题:这条数据最终落到哪个面(trace / 账本 / 日志 / 群消息)?这个面上有没有对应的清洗函数?如果它是自由文本,扫描器认不认得出里面凭据的形状?三个问题里任何一个答不上来,那条链路就还没被覆盖。

接着往下读的话,顺序建议是:agent/src/tools/redaction.py 的模块 docstring 先看一遍(它把三个关切说得比任何二手文章都清楚),然后跳到 agent/src/agent/loop.py 看调用点确认脱敏发生的确切位置,再看 agent/src/live/audit.py 理解「先脱敏再扇出到三个 sink」这个顺序为什么不能反,最后跑一次 bash tools/ci_grep_gates.sh 看看闸门在你本地是什么反应。至于要不要把这套东西接到真实账户上,以你所在司法辖区的监管要求与券商协议为准。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源项目给 Agent 开 shell 怎么兜底:命令校验与工作区访问控制Vibe-Trading 开源交易 Agent 的四道下单闸门:从强制执行层到日频计数

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。