Vibe-Trading 开源项目给 Agent 开 shell 怎么兜底:命令校验与工作区访问控制

2026-08-05

本文基于 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 -rfcurl | 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,匹配 taskkilltaskkill.exe,后面跟着 /im python... 或者 get-process [-name] python...。第二组 _UNIX_PYTHON_KILL,匹配段首的 pkillkillall(允许前置 sudo),后面带 Python 进程名。第三组要求段首是 powershellpwsh,并且段内出现 stop-process ... -name python,或者 get-process python ... | stop-process 这种管道写法。

进程名部分共用一个片段 _PYTHON_PROCESS,正则是 python(?:w|\d+(?:\.\d+)*)?(?:\.exe)?\b。也就是说 pythonwpython3python3.11python.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.exepowershell -NoProfile -Command "Stop-Process -Name python -Force"pkill -9 -f pythonkillall python3 等;放行的三条是 python --versiontaskkill /PID 4321 /T /Fecho python.exe。放行清单比拒绝清单更能说明设计意图:按 PID 精确杀进程是允许的,只有按名字广泛杀才拦。

二、拦截发生在哪一步:bash 工具的执行路径

agent/src/tools/bash_tool.py 里的 BashTool 很短。工具名就叫 bash,参数只有一个 command。类属性上标了 repeatable = Trueis_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=Trueencoding="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.killpgtaskkill /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_dirsafe_run_idresolve_safe_path 这几个围绕 run 目录做的变体。允许的根目录由 allowed_file_roots()allowed_write_roots() 计算,默认值来自 get_runtime_root()VIBE_TRADING_HOME 环境变量可覆盖,否则是 ~/.vibe-trading)等位置,另外可以用 VIBE_TRADING_ALLOWED_FILE_ROOTSVIBE_TRADING_ALLOWED_RUN_ROOTSVIBE_TRADING_ALLOWED_WRITE_ROOTS 三个环境变量追加。文件里还留了一句提醒:MCP 客户端是自己拉起 server 的,在 shell 里 export 这些变量到不了那个进程,得写在客户端的 server env 配置块里。

read_filewrite_fileedit_file 三个工具都 import 了这套东西。bash_tool.py 一个字都没引。 它的 import 只有 BaseToolbroad_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.pyWorkspaceService._scope_from_envelope:从 WebSocket 消息信封里取 workspace_scope.rootexpanduser().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.pyagent/src/security/workspace_access.py前端切换工作区目录被拒
注入告警扫描给外部内容加告警元数据,不改写不丢弃agent/src/security/scanner.py读到可疑网页/搜索结果时

四、边界与代价:这个设计放弃了什么

先说它明确不管的事。

它不管命令的破坏性。 一个字符不差地说,_shell_safety.py 里没有任何针对删除、下载执行、权限修改的模式。rm -rfformatcurl ... | 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_dirbackground_runbackground_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 输出转发出去之前先看清它管到哪一步

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