拆解桌面Agent:WorkBuddy 删除保护机制

写在前面

大家好,我是三雒。

桌面 Agent 一旦能在本地执行命令,删错文件就是一个绕不过去的问题。Codex 处理这类风险的核心思路,是在动作真正落地前保留用户的确认权:先用 Sandbox 限制工具进程能够写哪些目录;命令需要额外权限,或者当前策略认为有必要确认时,再通过 Approval 让用户决定是否放行。如果目标就在允许写入的工作区内,而且当前策略不要求审批,文件删除后不可恢复;如果在工作区外,默认权限下用户又会被频繁被弹窗打断。说得直接一点它在一部分场景里把最后一道风险判断交给了用户,Agent 不确定能不能做,就让用户承担确认成本。

WorkBuddy 显然不想把每一次删除判断都抛给用户,它没有继续增加审批,而是从执行机制上保证删除默认可恢复:普通删除直接进入系统废纸篓,只有少数场景才暂停执行并让用户介入。开始我以为这个实现应该很简单,在 PATH 前面放一个假的 rm,实现换成"移动到废纸篓"就行了。但通过对安装包的反编译分析,很快就发现这个核心思路虽然没问题,真正实现起来却没有那么简单。

这篇文章就沿着这条链路拆一遍,里面既有它做得比较巧的地方,也有 shim 天然兜不住的边界。

真正难替换的不是 rm

先看一个很普通的任务:让 Agent 清理项目里的构建产物。

如果模型直接生成:

bash 复制代码
rm -rf build

那确实很好处理,换掉 rm 就行。麻烦在于,真实项目里的删除入口远不止这一种:

  • Shell 脚本可能执行 unlinkrmdir
  • Python 可能调用 os.removepathlib.Path.unlinkshutil.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 里的 rmunlinkrmdir
  • sitecustomize.pygenie-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_IDCODEBUDDY_TOOL_CALL_ID。删除最终发生在脚本甚至孙进程里,但事件仍然可以回到最初的会话和工具调用上,这给审计留出了位置。

Shell:删除命令为什么要拦两层

WorkBuddy 在 Shell 层做了两层拦截:先把删除保护目录放到 PATH 最前面,接住普通的 rmunlinkrmdir;再通过 BASH_ENV 给 Bash 注入同名函数。之所以需要第二层,是因为脚本随时可以重写 PATH,只替换外部命令并不可靠。

PATH 前置:先接住普通删除命令

Runtime 先把删除保护目录放到 PATH 最前面:

text 复制代码
PATH=<safe-delete-shell-dir>:$PATH

这个目录里有真正的可执行脚本 rmunlinkrmdir。因此 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 执行普通的 rmunlinkrmdir 时,同样会先命中 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 启动时会加载标准库 sitesite 随后会尝试导入固定名称的 sitecustomize。借助这个入口,删除保护可以在用户脚本执行前安装。具体加载机制可以参考 Python site 官方文档

被替换的 API 包括:

  • os.removeos.unlinkos.rmdir
  • shutil.rmtree
  • pathlib.Path.unlinkpathlib.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 使用的 FSRefFSMoveObjectToTrashSync 再把目标移进系统废纸篓。两个调用都会返回 OSStatus,只要其中一步失败,Helper 就返回错误。

这条链路没有永久删除兜底:路径无效、权限不足或系统回收失败时,本次操作直接终止,不会继续调用 unlinkrmdir 或真正的 rm。只有 WorkBuddy 明确加入 allowlist 的内部临时目录会走真实删除,避免 npm cache、安装 staging 等短命文件塞满废纸篓。

什么时候需要中断执行

删除太多时,先让用户确认

单个文件进入废纸篓,通常没必要打断用户。但如果一次命令要处理上百个对象,即使最终可恢复,也应该让用户知道发生了什么。

WorkBuddy 当前的默认阈值是 50 个对象。超过阈值以后,流程会变成:

  1. shim 输出结构化的 confirm-required 标记;
  2. Runtime 暂停当前执行,请求用户确认;
  3. 用户批准后写入一次性状态,再重新执行;
  4. 用户拒绝后,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 信息
模型主动执行 envtype 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 里提醒"删除时小心一点"可靠得多。真正的安全从来不是指望模型不犯错,而是模型犯错以后,系统仍然给用户留了一条回头路。

相关推荐
修远客1 小时前
规划模块:Agent的大脑 — 决定"做什么"和"以什么顺序做"
aigc·ai编程
鱼饼Y1 小时前
DeepSeek Harness 来了!从用户界面分析DSH
agent·deepseek
canber1 小时前
DeepSeek Harness (DSH) 实战:架构、模式与插件化开发
ai编程
OpenTiny社区1 小时前
GenUI SDK v1.3.0 开发者深度解读:当生成式 UI 开始"长出"工程化骨架
前端·ai编程
全栈弄潮儿1 小时前
4 个新手就能直接套用的 AI 编程提示词模板
chatgpt·openai·ai编程
RebornL1 小时前
DeepSeek Harness (DSH) 插件机制解析:一切皆插件的 Agent 运行时
agent
用户9983834541131 小时前
给 Agent 加一个「挑刺的审稿人」——Critic 与自纠错回环
agent
用户9983834541131 小时前
用LLM + Neo4j 给生物医学文献建知识图谱
agent
晚安code1 小时前
Agent Harness 从原理到实战:大模型不干活,全靠智能体运行框架在撑
ai编程