Vibe-Trading 开源项目给 Agent 开 shell 怎么兜底:命令校验与工作区访问控制
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
Vibe-Trading 这层 shell 校验防的是 Agent 误杀自己,不是防 Agent 越界。 你读完 agent/src/tools/_shell_safety.py 会发现,整个文件只导出一个函数,只识别一类模式:按可执行文件名广泛终止 Python 进程。除此之外的任何命令——rm -rf、curl | sh、读取家目录里的任意文件——都会原样交给系统 shell 执行。而项目里真正做路径收敛的那套代码在另一个模块,bash 工具压根没引用它。
这个判断本身不是批评。它是你在自己的 Agent 里做同类设计时必须先想明白的定位问题:一层命令模式检查能买到什么,买不到什么。下面按照调用链的顺序拆。
站内已经写过几篇相邻的题目,分工是这样的:Agent 该用什么权限态度 讲的是拿到一个 Agent 框架先怎么给它定权限基调,最小权限的 Agent 设计 讲的是通用的权限收敛方法论,pi 的编辑安全机制 拆的是另一个项目在文件写入侧的做法;本篇只盯 Vibe-Trading 这一个仓库的 shell 执行路径,从源码里读出它挡住了什么、放过了什么。
一、它到底挡什么:一个只认按名广泛杀 Python 的检查
agent/src/tools/_shell_safety.py 的模块 docstring 写得很直白,是「host shell 命令的共享安全检查」。文件里只有一个公开函数 broad_python_kill_error(command),返回 str | None——命中就返回一条可执行的错误文案,没命中返回 None。
它的第一步是把整条命令拆成段:
for segment in re.split(r"[;\r\n]|(?<![<>])&(?![<>])", command):
normalized = segment.strip().replace('"', " ").replace("'", " ")
normalized = re.sub(r"\s+", " ", normalized)
拆分符是分号、回车换行,以及单个 &。那两个环视断言是为了不把重定向里的 >&、<& 当成命令分隔符切开。切完之后把引号替换成空格、把连续空白压成一个——这一步是关键,它让 taskkill /F /IM "python.exe" 这种带引号的写法也能被同一条正则命中。
然后每个 segment 过三组模式:
第一组 _TASKKILL_PYTHON,匹配 taskkill 或 taskkill.exe,后面跟着 /im python... 或者 get-process [-name] python...。第二组 _UNIX_PYTHON_KILL,匹配段首的 pkill 或 killall(允许前置 sudo),后面带 Python 进程名。第三组要求段首是 powershell 或 pwsh,并且段内出现 stop-process ... -name python,或者 get-process python ... | stop-process 这种管道写法。
进程名部分共用一个片段 _PYTHON_PROCESS,正则是 python(?:w|\d+(?:\.\d+)*)?(?:\.exe)?\b。也就是说 pythonw、python3、python3.11、python.exe 都在覆盖范围里。三组模式全部带 re.IGNORECASE。
命中之后返回的是一个模块级常量 _BROAD_PYTHON_KILL_ERROR,文案里明确说明了替代路径:用 background_run 返回的 task_id 去调 cancel_background。这个细节值得抄——一条拦截错误如果只说「不允许」,模型下一轮多半会换个写法再试一次;说清替代工具名和参数名,模型才有收敛的方向。
仓库里 agent/tests/test_bash_tool.py 用参数化把覆盖面钉死了。被拒的八条包括 taskkill /F /IM python.exe 2>nul & echo ...、powershell -Command "taskkill /F /IM python.exe"、start "" taskkill /F /IM python.exe、powershell -NoProfile -Command "Stop-Process -Name python -Force"、pkill -9 -f python、killall python3 等;放行的三条是 python --version、taskkill /PID 4321 /T /F、echo python.exe。放行清单比拒绝清单更能说明设计意图:按 PID 精确杀进程是允许的,只有按名字广泛杀才拦。
二、拦截发生在哪一步:bash 工具的执行路径
agent/src/tools/bash_tool.py 里的 BashTool 很短。工具名就叫 bash,参数只有一个 command。类属性上标了 repeatable = True 和 is_readonly = False。
execute 的顺序是:取出 command,从 kwargs 里取 run_dir 当作 cwd,先调 broad_python_kill_error,命中就直接返回 {"status": "error", "error": ...} 的 JSON,不进 subprocess。测试里专门用 MagicMock(side_effect=AssertionError(...)) 替换掉 subprocess.run 并断言 run.assert_not_called(),就是在钉这个「不落地执行」的性质。
没命中的路径是 subprocess.run(command, shell=True, cwd=cwd, ...),text=True、encoding="utf-8"、errors="replace",带一个默认超时常量;stdout 与 stderr 各自按文件顶部的输出上限常量截断,最后连同 exit_code 一起打包成 JSON 返回。超时走 subprocess.TimeoutExpired 分支,返回一条超时说明。
这里有两个容易被忽略的点。
一是 is_readonly = False 不是审批开关。在 agent/src/agent/loop.py 里,这个标志的唯一消费方是工具调用的分批逻辑:连续的只读工具进同一个 parallel 批次并发执行,非只读工具单独进 serial 批次串行执行。所以 bash 被标成非只读的实际效果是「不与别的工具并发」,不是「执行前要人确认」。你要是照着这个字段名去猜它有人工审批,就猜错了。
二是 cwd 的来源。bash 的 cwd 是调用参数里的 run_dir,而这个参数是 loop.py 里的 _normalize_tool_run_dir 自动注入的:模型没传就补上会话的 run_dir,传了相对路径就拼到 run_dir 下再 resolve,传了绝对路径则原样保留。同一个仓库里的后台执行工具则不一样——agent/src/tools/background_tools.py 顶部定义了 WORKDIR = Path(__file__).resolve().parents[2],_start_process 固定用它当 cwd。两条 shell 通道的工作目录来源不同,这一点在排查「同一条命令前台能跑后台报找不到文件」时会救你半小时。
后台那条通道同样在 run() 的第一行调 broad_python_kill_error,逻辑与前台一致。它额外做的事是进程组管理:_start_process 在 Windows 上带 CREATE_NEW_PROCESS_GROUP,在 POSIX 上用 start_new_session,于是 cancel_background 能通过 os.killpg 或 taskkill /PID <pid> /T /F 精确停掉整棵进程树。这才是那条拦截文案让你改用 cancel_background 的原因:它有 PID,不需要按名字扫。
三、工作区访问控制的两条线
「工作区访问控制」在这个仓库里其实是两套互不相干的东西,别混着看。
第一条线在文件工具侧,实现在 agent/src/tools/path_utils.py。这个文件的 docstring 自己就把三个威胁模型分开写了:safe_path(p, workdir) 是工具受控沙箱,safe_user_path(p) 管用户提供的券商导出文件,safe_document_path(p) 管文档阅读器输入。核心收敛就一段:
resolved.relative_to(base)
失败就抛 ValueError(f"Path {p!r} escapes the workspace root")。此外还有 _rejects_unc 直接拒掉 \\ 与 // 开头的 UNC 路径,以及 safe_run_dir、safe_run_id、resolve_safe_path 这几个围绕 run 目录做的变体。允许的根目录由 allowed_file_roots() 与 allowed_write_roots() 计算,默认值来自 get_runtime_root()(VIBE_TRADING_HOME 环境变量可覆盖,否则是 ~/.vibe-trading)等位置,另外可以用 VIBE_TRADING_ALLOWED_FILE_ROOTS、VIBE_TRADING_ALLOWED_RUN_ROOTS、VIBE_TRADING_ALLOWED_WRITE_ROOTS 三个环境变量追加。文件里还留了一句提醒:MCP 客户端是自己拉起 server 的,在 shell 里 export 这些变量到不了那个进程,得写在客户端的 server env 配置块里。
read_file、write_file、edit_file 三个工具都 import 了这套东西。bash_tool.py 一个字都没引。 它的 import 只有 BaseTool 和 broad_python_kill_error。
第二条线在会话通道侧。agent/src/security/workspace_access.py 本身只有十几行,是给 channel 适配层用的兼容壳:一个常量 WORKSPACE_SCOPE_METADATA_KEY = "_workspace_scope",一个 WorkspaceScopeError(ValueError) 并给它补了个 message 属性方便旧代码取值。同目录的 workspace_policy.py 更薄,只是把 src.channels.utils 里的 is_path_within 再导出一次。
真正判定发生在 agent/src/channelsui/gateway_services.py 的 WorkspaceService._scope_from_envelope:从 WebSocket 消息信封里取 workspace_scope.root,expanduser().resolve() 之后,如果 default_restrict_to_workspace 为真且解析结果不在 get_workspace_path() 之下,就抛 WorkspaceScopeError("workspace root must stay under ...")。通过之后包成 WorkspaceScope(root, restrict_to_workspace) 存进会话元数据。agent/src/channels/websocket.py 里有对应的 set_workspace_scope 消息类型,拒绝时回一个 workspace_scope_rejected 的错误明细。
看清楚这条线管的是什么:它约束的是客户端能把会话的工作区根声明成哪里,属于会话协商层的校验。它不会往下变成 bash 执行时的一道墙。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
broad_python_kill_error | 识别按进程名广泛终止 Python 的命令,返回带替代方案的错误文案 | agent/src/tools/_shell_safety.py | 让 Agent 清理残留进程时被拒 |
BashTool | 前台 shell 执行,先过安全检查再 subprocess.run,按常量截断输出 | agent/src/tools/bash_tool.py | 装依赖、跑脚本、看文件 |
background_run / cancel_background | 带进程组的后台执行与按 PID 精确取消 | agent/src/tools/background_tools.py | 跑耗时任务、需要中途停掉 |
safe_path / safe_run_dir 等 | 文件工具的路径收敛与 UNC 拒绝 | agent/src/tools/path_utils.py | 读写文件被判越界时 |
WorkspaceService / WorkspaceScopeError | 会话层校验客户端声明的工作区根 | agent/src/channelsui/gateway_services.py、agent/src/security/workspace_access.py | 前端切换工作区目录被拒 |
| 注入告警扫描 | 给外部内容加告警元数据,不改写不丢弃 | agent/src/security/scanner.py | 读到可疑网页/搜索结果时 |
四、边界与代价:这个设计放弃了什么
先说它明确不管的事。
它不管命令的破坏性。 一个字符不差地说,_shell_safety.py 里没有任何针对删除、下载执行、权限修改的模式。rm -rf、format、curl ... | sh 全部放行。这不是疏漏,是分工——那类风险这个文件没打算接。
它不管路径越界。 bash 的 cwd 是 run_dir,但 cwd 只是起点不是围栏。cd ..、绝对路径、符号链接,任何一种都能走出去。上一节那套 safe_path 收敛只作用于文件工具的参数,shell 里的路径根本不经过它。所以「Agent 只能改工作区里的文件」这个心智模型,在开了 shell 的前提下是不成立的。
它不管凭据暴露面。 shell 以 Agent 进程同一个用户身份运行,能读到同样的环境变量,也能读到运行时根目录(默认 ~/.vibe-trading)下的文件——agent/src/trading/profiles.py 里的 config_path() 就把 trading-connections.json 放在那儿。仓库里确实有一套 agent/src/tools/redaction.py 做脱敏,但它的作用域是「进事件流、trace、审计账本之前的 payload 与文本」,是防泄露到日志与前端,不是防 shell 读取磁盘。一旦你给这个 Agent 配了真实券商连接,agent/src/trading/connectors/ 下有 12 家连接器子目录,凭据管理就是你自己的责任,跟这层 shell 校验没关系。
它不管下单的不可逆性。 实盘委托一旦送出去就不可撤销,程序化交易的报备与合规义务因司法辖区而异,各家券商的 API 使用条款也不同。这些都不在代码层面能兜住的范围内。
再说这个设计买到了什么。它买到的是「Agent 不会在清理环境时把自己连根拔掉」——一个跑在本机、自己也是 Python 进程的 Agent,执行 taskkill /F /IM python.exe 的后果是自杀,而且这个动作在模型的训练分布里相当常见。用一层模式检查加一条指向 cancel_background 的错误文案换掉这类事故,性价比很高。
再看仓库里另一处同源的设计取向:agent/src/security/scanner.py 的 docstring 明说自己「在动作上刻意保守:从不改写或丢弃抓取到的内容,只给 JSON 信封加告警元数据」。两处都是同一个思路——不做强隔离,做显式的减害与提示。你认不认这个取向,取决于你把这个 Agent 放在什么环境里跑。放在个人开发机上,它够用;放在有别人数据的机器上,它远远不够。
真正的隔离手段是另一个层次的:容器或虚拟机、独立的低权限系统用户、只读挂载、出网白名单。这些在仓库里都没有实现,也不该指望一个 _shell_safety.py 提供。相关的通用思路可以看 Agent 工作区隔离。
补一句边界:本文只讨论工程实现,不涉及任何策略有效性判断,历史表现不代表未来。
五、上手与避坑清单
一、别把 is_readonly = False 当成审批位。
会踩是因为字段名太像权限标志。在 loop.py 里它只参与并发分批。你要人工确认,得自己在工具执行前加一层,仓库当前的调用链上没有这个环节。
二、别指望 run_dir 能圈住 shell。
会踩是因为文件工具确实被 safe_path 圈住了,很容易顺推到 shell。避法是把它当成两件事记:文件工具走 path_utils,shell 只走 _shell_safety。要限制 shell 的可达范围,只能靠操作系统层——独立用户、容器、挂载权限。
三、前台和后台 shell 的工作目录不一样。
会踩是因为两个工具的描述看起来对称。bash 用注入的 run_dir,background_run 用 background_tools.py 里写死的 WORKDIR。写命令时用绝对路径,或者先 cd 到你要的目录,别赌 cwd。
四、停后台任务只用 cancel_background。
会踩是因为模型的第一反应往往是 pkill -f。这条会被拦,而且拦得对——后台任务是带进程组启动的,cancel_background 拿 task_id 就能精确停掉整棵树,比按名字扫既安全又准。
五、按名杀进程被拦时,别绕过去。
会踩是因为拦截规则是段级正则,绕过成本很低——写进脚本文件再执行、用变量拼接进程名、换个不在 _PYTHON_PROCESS 覆盖里的写法,都能过。但你绕过的是一条保护你自己 Agent 进程的规则。真需要杀,先拿 PID。
六、加环境变量放宽路径根之前,先确认变量能到进程。
会踩是因为 MCP 场景下 server 是客户端拉起的。path_utils.py 里那句 _ENV_SCOPE_HINT 已经写明:shell 里 export 到不了那个进程,得配在客户端的 server env 块里。
七、审计口径要分清脱敏与隔离。
会踩是因为看到 redaction.py 就以为敏感信息被管住了。它管的是输出到 trace、事件流、审计账本的内容;shell 直接读文件的行为不在它的路径上。
收束
这套东西的定位可以用一句话概括:Vibe-Trading 在 shell 这条通道上做的是自伤防护,不是权限边界;权限边界只覆盖了文件工具的参数路径与会话层的工作区根声明。
给你一份自检清单,接手这类项目时逐条对:Agent 跑在什么用户身份下?shell 能读到哪些凭据文件与环境变量?工作目录的收敛有没有在 shell 之外做?拦截规则命中后返回的文案有没有指出替代路径?后台任务的停止手段是按 PID 还是按名字?
想继续往下读,顺序建议是 agent/src/tools/_shell_safety.py(不到六十行,一口气读完全貌)→ agent/src/tools/bash_tool.py(看拦截的插入点)→ agent/src/tools/path_utils.py(看真正的路径收敛长什么样)→ agent/src/tools/background_tools.py(看进程组怎么管)。四个文件加起来不到一千行,读完你就能判断这套兜底放在你的场景里够不够。至于能不能拿它接真实资金账户,以你所在司法辖区的监管要求与券商协议为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 项目怎么处理工具返回太长:分页、后台与进度三层 和 Vibe-Trading 开源项目的脱敏层:Agent 输出转发出去之前先看清它管到哪一步。