写在前面
大家好,我是三雒。
桌面 Agent 一旦能在本地执行命令,删错文件就是一个绕不过去的问题。Codex 处理这类风险的核心思路,是在动作真正落地前保留用户的确认权:先用 Sandbox 限制工具进程能够写哪些目录;命令需要额外权限,或者当前策略认为有必要确认时,再通过 Approval 让用户决定是否放行。如果目标就在允许写入的工作区内,而且当前策略不要求审批,文件删除后不可恢复;如果在工作区外,默认权限下用户又会被频繁被弹窗打断。说得直接一点它在一部分场景里把最后一道风险判断交给了用户,Agent 不确定能不能做,就让用户承担确认成本。
WorkBuddy 显然不想把每一次删除判断都抛给用户,它没有继续增加审批,而是从执行机制上保证删除默认可恢复:普通删除直接进入系统废纸篓,只有少数场景才暂停执行并让用户介入。开始我以为这个实现应该很简单,在 PATH 前面放一个假的 rm,实现换成"移动到废纸篓"就行了。但通过对安装包的反编译分析,很快就发现这个核心思路虽然没问题,真正实现起来却没有那么简单。
这篇文章就沿着这条链路拆一遍,里面既有它做得比较巧的地方,也有 shim 天然兜不住的边界。
真正难替换的不是 rm
先看一个很普通的任务:让 Agent 清理项目里的构建产物。
如果模型直接生成:
bash
rm -rf build
那确实很好处理,换掉 rm 就行。麻烦在于,真实项目里的删除入口远不止这一种:
- Shell 脚本可能执行
unlink或rmdir; - Python 可能调用
os.remove、pathlib.Path.unlink或shutil.rmtree; - Node 可能调用
fs.rm; - npm lifecycle、三方库或者项目自己的脚本,还会继续启动子进程。
这也是为什么单独给模型增加一个 safe_delete 工具不太管用,模型愿意调用这个工具时当然安全,可一旦它选择其他工具,运行自己的生成的脚本,真正的删除就绕到工具背后去了。
更实际的做法,是让调用方继续使用原来的接口,把删除保护放到执行环境里。这样模型不用记住一套新规则,项目脚本也不用为了 Agent 专门改写。
不过透明改写并不意味着随便替换,rm -f、递归删除、文件不存在、目录非空,这些原有语义都要尽量兼容;大批量删除要能停下来确认;回收失败不能悄悄退回永久删除;发生了什么还得能追到具体 Session 和 Tool Call。
WorkBuddy 具体的设计,基本都是围绕着这些问题来的。
删除保护整体实现

这张图里最值得注意的是注入时机。
模型生成 Tool Call 时,看到的仍然是普通 Shell。它不需要知道 genie-trash 在哪,也不需要在"普通删除"和"安全删除"之间做选择。真正的变化发生在 Runtime 启动这次 Tool Call 的 Shell 进程时:WorkBuddy 为它准备一组环境变量,让这个进程以及后续派生的 Python、Node 子进程自动加载各自的 shim。
在 WorkBuddy 5.3.3 的安装包里找到的主要组件有这些:
text
cli/vendor/shim/
├── genie-safe-delete.cjs
├── sitecustomize.py
├── safe-delete-bulk-guard.cjs
└── safe-bin/
├── rm
├── unlink
├── rmdir
├── safe-delete-common.sh
└── safe-delete-bash-env.sh
vendor/genie-trash/
└── darwin-arm64
大致可以分成三层:
safe-bin负责接住 Shell 里的rm、unlink和rmdir;sitecustomize.py与genie-safe-delete.cjs分别接住 Python 和 Node 的文件 API;genie-trash是最后真正把文件移动到系统废纸篓的原生 Helper。
safe-delete-bulk-guard.cjs 则处理另一件事:删除目标太多时先暂停,交给用户确认。
Runtime 怎么把保护环境注入工具进程
负责准备环境的是 buildSafeDeleteEnv()。在 Agent Shell 启动前,它会拼出类似下面这组变量:
text
PATH=<safe-bin>:$PATH
BASH_ENV=<safe-delete-bash-env.sh>
PYTHONPATH=<vendor/shim>:$PYTHONPATH
NODE_OPTIONS=--require="<genie-safe-delete.cjs>"
GENIE_TRASH_DIR=<native-helper-directory>
CODEBUDDY_SESSION_ID=<session-id>
CODEBUDDY_TOOL_CALL_ID=<tool-call-id>
CODEBUDDY_SAFE_DELETE_BULK_THRESHOLD=50
CODEBUDDY_SAFE_DELETE_REPORT_PATH=<temporary-jsonl>
这些变量只存在于 WorkBuddy 为 Tool Call 启动的工具执行进程及其子进程里,不会写进用户的 ~/.zshrc、~/.bashrc,也不会修改整台机器的默认环境。
这里有两个细节我觉得挺重要。
一个是删除保护默认开启,只有配置明确为 false 才会关闭。即使配置读取失败,它仍然保持开启。安全能力如果因为一次配置异常就静默消失,后面的所有保护其实都没有意义。
另一个是 CODEBUDDY_SESSION_ID 和 CODEBUDDY_TOOL_CALL_ID。删除最终发生在脚本甚至孙进程里,但事件仍然可以回到最初的会话和工具调用上,这给审计留出了位置。
Shell:删除命令为什么要拦两层
WorkBuddy 在 Shell 层做了两层拦截:先把删除保护目录放到 PATH 最前面,接住普通的 rm、unlink 和 rmdir;再通过 BASH_ENV 给 Bash 注入同名函数。之所以需要第二层,是因为脚本随时可以重写 PATH,只替换外部命令并不可靠。
PATH 前置:先接住普通删除命令
Runtime 先把删除保护目录放到 PATH 最前面:
text
PATH=<safe-delete-shell-dir>:$PATH
这个目录里有真正的可执行脚本 rm、unlink 和 rmdir。因此 Shell 执行:
bash
rm -rf target
按照正常的命令查找顺序,最终找到的是:
text
<safe-delete-shell-dir>/rm -rf target
这层 wrapper 不能只拿到路径就直接扔进废纸篓。它还要解析 rm -f、-r/-R、-d 和 --,尽量保留"文件不存在""目标是目录""目录非空"等原有行为。像 rm -i/-I 这种很难在非交互 Agent 环境里安全模拟的参数,当前实现会直接拒绝。
PATH 前置的好处是通用,Bash、zsh、sh,以及大多数按命令名启动 rm 的子进程,都有机会命中它。
但它也有一个很明显的问题:脚本可以自己重写 PATH。
bash
PATH=/usr/bin:/bin
rm -rf target
如果只有 PATH 前置,这里的 rm 就重新落回系统命令了。
BASH_ENV:防止脚本重写 PATH 绕过保护
非交互 Bash 启动时会读取 BASH_ENV 指向的文件。WorkBuddy 利用这个入口,在 Bash 进程里定义了同名 function:
bash
rm() { command "$CODEBUDDY_SAFE_DELETE_BIN_DIR/rm" "$@"; }
unlink() { command "$CODEBUDDY_SAFE_DELETE_BIN_DIR/unlink" "$@"; }
rmdir() { command "$CODEBUDDY_SAFE_DELETE_BIN_DIR/rmdir" "$@"; }
export -f rm unlink rmdir
这里的 function 存在于 Bash 进程内存中,不是磁盘上的命令文件。Bash 解析 rm target 时,会先命中 function;function 再使用绝对路径调用真正的 wrapper。
command 的作用是避免再次进入同名 function,绝对路径则避开第二次 PATH 查找。
于是 Shell 里实际存在两条入口:
text
Bash function:
rm target
→ Bash rm()
→ <safe-delete-shell-dir>/rm
→ safe-delete-common.sh
→ genie-trash
PATH 外部命令:
rm target
→ PATH 查找
→ <safe-delete-shell-dir>/rm
→ safe-delete-common.sh
→ genie-trash
如果 PATH 没变,command rm target 虽然会跳过 function,仍然会命中 PATH 里的 wrapper。即使脚本重写了 PATH,已经加载进 Bash 的 function 也还会走绝对路径。这就是两层拦截同时存在的意义。
zsh 和 sh 只有 PATH 这一层
虽然工具名叫 Bash,但 WorkBuddy 在 Unix 上并不强制使用 Bash。它会根据 CODEBUDDY_CODE_SHELL 或系统的 $SHELL 选择 bash、zsh 或 sh。
buildSafeDeleteEnv() 会给这几种 Shell 使用的工具进程注入同一组环境变量,其中 PATH 对三者都有效。因此 zsh 和 sh 执行普通的 rm、unlink 或 rmdir 时,同样会先命中 WorkBuddy 的 wrapper。
差别出现在第二层。BASH_ENV 只有 Bash 会自动读取,因此 zsh 和 sh 没有同名 function 兜底。脚本一旦主动重写 PATH,Shell 删除命令就可能绕过 wrapper:
bash
zsh -c 'PATH=/usr/bin:/bin; rm -rf target'
sh -c 'PATH=/usr/bin:/bin; rm -rf target'
所以 zsh 和 sh 不是完全没有删除保护,而是只有 PATH 这一层;这层机制适合覆盖常规命令,却不能独自建立不可绕过的安全边界。
Python 和 Node:删除写在脚本里怎么接住
只拦 Shell 很容易产生一种错觉:模型没执行 rm,这次就安全了。
实际上,Agent 很喜欢临时写脚本处理文件。Python 和 Node 如果还走原来的系统 API,Shell wrapper 根本看不到这些删除。
Python 利用 sitecustomize 提前打补丁
WorkBuddy 把 shim 目录加进 PYTHONPATH。普通 Python 启动时会加载标准库 site,site 随后会尝试导入固定名称的 sitecustomize。借助这个入口,删除保护可以在用户脚本执行前安装。具体加载机制可以参考 Python site 官方文档。
被替换的 API 包括:
os.remove、os.unlink、os.rmdir;shutil.rmtree;pathlib.Path.unlink、pathlib.Path.rmdir。
调用方仍然写标准 Python,shim 在内部解析绝对路径、检查批量阈值、调用原生 Helper,并把结果记入审计事件。它只会在 WorkBuddy Session ID 存在时启用,同时还会尝试兼容用户原有的 sitecustomize。
这层也不是没有缺口。python -S 不加载 site,-E/-I 会忽略 PYTHONPATH;C 扩展或者直接 syscall,同样不经过这些 Python API。
Node 利用 preload 改写 fs
Node 侧走的是另一条标准入口:
bash
NODE_OPTIONS=--require="<genie-safe-delete.cjs>"
NODE_OPTIONS 是 Node 启动时会主动读取的环境变量,其中的 --require 会让它在入口脚本之前预加载这个 CommonJS 模块。模块随后替换:
fs.unlink/unlinkSync;fs.rmdir/rmdirSync;fs.rm/rmSync;fs.promises.unlink/rmdir/rm。
因此普通的 node script.js、npm 启动的 Node 子进程以及大多数 worker,都可以在不改业务代码的情况下进入同一条回收链路。预加载机制可以参考 Node.js CLI 官方文档。
边界也很直接:子进程可以清空 NODE_OPTIONS,Native Addon 可以绕过 fs,其他运行时更不会自动受这段 preload 影响。
原生 Helper:最后谁把文件移进废纸篓
Shell、Python 和 Node 三层 shim 只负责接住不同入口,最终并不各自实现一套废纸篓逻辑。
它们会把目标整理成绝对路径,通过 GENIE_TRASH_DIR 找到原生 genie-trash,再由这个 Helper 调用系统能力,把文件或目录移进 Trash / Recycle Bin。
genie-trash 在 macOS 上做了什么
把安装包里的 darwin-arm64 拆开以后,这一层其实不复杂。它接收一个或多个路径,先检查每个路径是不是绝对路径,再逐个送进 macOS 的废纸篓接口。--quiet 只负责关闭成功提示,不会改变删除逻辑。
根据参数定义、错误字符串和反汇编结果,主流程大致可以还原成下面这样。这里是为了说明调用关系写的伪代码,并不是原始源码:
rust
let mut failed = false;
for path in paths {
if !path.is_absolute() {
report_error(path);
failed = true;
continue;
}
if let Err(error) = macos::delete(path) {
report_error(error);
failed = true;
}
}
fn delete(path) {
let fs_ref = FSPathMakeRefWithOptions(path)?;
FSMoveObjectToTrashSync(&fs_ref)?;
}
exit_nonzero_if(failed);
FSPathMakeRefWithOptions 先把绝对路径转换成 CoreServices 使用的 FSRef,FSMoveObjectToTrashSync 再把目标移进系统废纸篓。两个调用都会返回 OSStatus,只要其中一步失败,Helper 就返回错误。
这条链路没有永久删除兜底:路径无效、权限不足或系统回收失败时,本次操作直接终止,不会继续调用 unlink、rmdir 或真正的 rm。只有 WorkBuddy 明确加入 allowlist 的内部临时目录会走真实删除,避免 npm cache、安装 staging 等短命文件塞满废纸篓。
什么时候需要中断执行
删除太多时,先让用户确认
单个文件进入废纸篓,通常没必要打断用户。但如果一次命令要处理上百个对象,即使最终可恢复,也应该让用户知道发生了什么。
WorkBuddy 当前的默认阈值是 50 个对象。超过阈值以后,流程会变成:
- shim 输出结构化的
confirm-required标记; - Runtime 暂停当前执行,请求用户确认;
- 用户批准后写入一次性状态,再重新执行;
- 用户拒绝后,Tool Result 会明确告诉模型不要自动换一种等价方式继续删除。
最后这一点很重要。Agent 可能把一次拒绝理解成"这条命令不合适",然后改用 Python 或另一条 Shell 命令重试。如果 Tool Result 没有表达清楚"用户拒绝的是这次删除意图",保护很容易被 Agent 自己绕过去。
回收失败时,为什么不能继续删
删除保护里最危险的降级方式,是先尝试移动到废纸篓,失败以后再偷偷调用真正的 rm。从功能成功率来看,这似乎很合理,用户要删文件,最后也确实删掉了。但从安全语义看,结果已经完全变了------用户以为文件可恢复,实际却被永久移除。
WorkBuddy 在这里选择 fail closed,Helper 缺失、回收失败或者权限不足时,本次删除直接失败,不会退回永久删除。模型可以读取错误并继续处理,用户也可以重新授权,但文件不能在保护链路失效时悄悄消失。
我觉得这是整套实现里比"怎么 hook rm"更值得保留的原则:安全能力失败时,应该失去功能,而不是失去安全。
哪些结果需要告诉模型
拆到这里还有一个容易混淆的问题:既然 Runtime 做了这么多,模型能看到吗?
从本机 CLI、Bash Tool Schema、会话历史和运行日志来看,没有发现删除保护被写进 Prompt 的证据,也就是模型不知道实现,但会在需要继续决策时看到结果。
| 阶段 | 模型能看到什么 |
|---|---|
| 生成普通删除命令或脚本 | 不知道保护细节 。Tool Schema 里没有 genie-trash 或废纸篓说明 |
| 文件正常进入废纸篓 | 通常不知道 。safeDeleteEvents 位于 Tool 的 rawResponse,主要给客户端审计使用 |
| 批量删除等待确认 | 能看到状态。Tool Result 会告诉模型正在等待确认或已经被拒绝 |
| Helper 失败或 Sandbox 阻止 | 能看到错误。模型会拿到 fail-closed 或 permission denied 信息 |
模型主动执行 env、type rm |
可能自行发现 shim |
服务端仍可能动态追加本地看不到的内容,单靠客户端反编译无法把这种可能性完全排除。但可以确定的是,这套机制并不依赖模型理解 genie-trash 才能工作。 正常删除事件不进入 Conversation History,其实也是一种取舍。一次文件成功进了废纸篓,模型没必要知道底层 Helper 的名字;只有确认、拒绝和错误这些会影响下一步决策的信息,才需要回到 Agent Loop。这样既保留了控制能力,也不会让内部审计事件挤占模型上下文。
这套机制能做到什么,做不到什么
到这里,Shell、Python、Node 的常见删除入口基本都照顾到了。但"覆盖常见路径"和"无法绕过"完全是两回事。
| 绕过方式 | 为什么能绕过 |
|---|---|
/bin/rm、/usr/bin/unlink |
绝对路径不经过 PATH 查找 |
| 脚本重置 PATH、清除 Shell function | Shell wrapper 失去优先级 |
Python -S/-E/-I |
可能跳过 sitecustomize 或忽略 PYTHONPATH |
清空 NODE_OPTIONS、使用 Native Addon |
Node preload 无法覆盖 |
| Rust、Go、Java、C 直接 syscall | 不经过 Shell、Python 或 Node API |
sudo 或其他提权方式 |
可能逃离原有进程约束 |
因此更准确的说法是:这些 shim 是一层高覆盖率的兼容与恢复机制。它们能让绝大多数正常命令和项目脚本自动获得可恢复删除,但不能独自承担文件系统安全。
剩下的边界还得交给 Runtime 命令策略、用户审批和 OS sandbox:Runtime 识别绝对路径、提权、敏感目录与异常批量操作;Sandbox 则限制进程真正能碰到哪些文件,兜住直接 syscall 这一类绕过路径。
这也解释了为什么"在 PATH 前面放一个假的 rm"只完成了最外面的一层。它解决的是使用体验,不是最终的安全边界。
写到最后
刚看到这个功能时,我把它理解成了一个"更安全的 rm"。拆完以后再看,它更像是挂在工具执行进程树上的一层删除改写系统。Shell 里的 rm 只是最外面的一扇门。后面还有 Python、Node、子进程、批量确认、临时目录、跨平台废纸篓和审计。任何一层处理得太随意,都可能让"删除保护"重新变成永久删除。
WorkBuddy 的答案并不完美,PATH 可以被重置,preload 可以被清空,原生程序也能直接调用系统接口。但它给出了一个很实用的方向:先用 shim 覆盖大多数真实删除入口,再用 fail closed 保住语义,最后用 Runtime 策略和 Sandbox 守住不可绕过的边界。
对于桌面 Agent 来说,这种设计比反复在 Prompt 里提醒"删除时小心一点"可靠得多。真正的安全从来不是指望模型不犯错,而是模型犯错以后,系统仍然给用户留了一条回头路。