@tencent-ai/codebuddy-code 2.119.4 → 2.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/codebuddy-headless.js +40 -30
  3. package/dist/codebuddy.js +60 -50
  4. package/dist/web-ui/assets/{index-GcU4pZms.js → index-BfmXmm8w.js} +274 -274
  5. package/dist/web-ui/docs/cn/cli/brokered-shell-macos.md +778 -0
  6. package/dist/web-ui/docs/cn/cli/env-vars.md +10 -5
  7. package/dist/web-ui/docs/cn/cli/plugins-reference.md +114 -13
  8. package/dist/web-ui/docs/cn/cli/prewarm.md +18 -5
  9. package/dist/web-ui/docs/cn/cli/release-notes/README.md +11 -0
  10. package/dist/web-ui/docs/cn/cli/release-notes/v2.117.0.md +35 -0
  11. package/dist/web-ui/docs/cn/cli/release-notes/v2.117.1.md +18 -0
  12. package/dist/web-ui/docs/cn/cli/release-notes/v2.117.2.md +28 -0
  13. package/dist/web-ui/docs/cn/cli/release-notes/v2.118.0.md +29 -0
  14. package/dist/web-ui/docs/cn/cli/release-notes/v2.118.1.md +18 -0
  15. package/dist/web-ui/docs/cn/cli/release-notes/v2.118.2.md +13 -0
  16. package/dist/web-ui/docs/cn/cli/release-notes/v2.119.0.md +38 -0
  17. package/dist/web-ui/docs/cn/cli/release-notes/v2.119.1.md +15 -0
  18. package/dist/web-ui/docs/cn/cli/release-notes/v2.119.2.md +23 -0
  19. package/dist/web-ui/docs/cn/cli/release-notes/v2.119.3.md +15 -0
  20. package/dist/web-ui/docs/cn/cli/release-notes/v2.119.4.md +13 -0
  21. package/dist/web-ui/docs/cn/cli/settings.md +2 -0
  22. package/dist/web-ui/docs/cn/cli/sub-agents.md +38 -0
  23. package/dist/web-ui/docs/en/cli/brokered-shell-macos.md +778 -0
  24. package/dist/web-ui/docs/en/cli/env-vars.md +10 -5
  25. package/dist/web-ui/docs/en/cli/plugins-reference.md +106 -13
  26. package/dist/web-ui/docs/en/cli/prewarm.md +18 -5
  27. package/dist/web-ui/docs/en/cli/release-notes/README.md +11 -0
  28. package/dist/web-ui/docs/en/cli/release-notes/v2.117.0.md +35 -0
  29. package/dist/web-ui/docs/en/cli/release-notes/v2.117.1.md +18 -0
  30. package/dist/web-ui/docs/en/cli/release-notes/v2.117.2.md +28 -0
  31. package/dist/web-ui/docs/en/cli/release-notes/v2.118.0.md +29 -0
  32. package/dist/web-ui/docs/en/cli/release-notes/v2.118.1.md +18 -0
  33. package/dist/web-ui/docs/en/cli/release-notes/v2.118.2.md +13 -0
  34. package/dist/web-ui/docs/en/cli/release-notes/v2.119.0.md +38 -0
  35. package/dist/web-ui/docs/en/cli/release-notes/v2.119.1.md +15 -0
  36. package/dist/web-ui/docs/en/cli/release-notes/v2.119.2.md +23 -0
  37. package/dist/web-ui/docs/en/cli/release-notes/v2.119.3.md +15 -0
  38. package/dist/web-ui/docs/en/cli/release-notes/v2.119.4.md +13 -0
  39. package/dist/web-ui/docs/en/cli/settings.md +2 -0
  40. package/dist/web-ui/docs/en/cli/sub-agents.md +38 -0
  41. package/dist/web-ui/docs/search-index-en.json +1 -1
  42. package/dist/web-ui/docs/search-index-zh.json +1 -1
  43. package/dist/web-ui/docs/sidebar-en.json +1 -1
  44. package/dist/web-ui/docs/sidebar-zh.json +1 -1
  45. package/dist/web-ui/index.html +1 -1
  46. package/dist/web-ui/sw.js +1 -1
  47. package/package.json +4 -4
  48. package/product.cloudhosted.json +2 -2
  49. package/product.internal.json +2 -2
  50. package/product.ioa.json +2 -2
  51. package/product.json +2 -2
  52. package/product.selfhosted.json +2 -2
@@ -0,0 +1,778 @@
1
+ # macOS Brokered Shell 方案设计
2
+
3
+ ## 背景与目标
4
+
5
+ macOS 的 Bash 沙箱需要同时解决两个问题:
6
+
7
+ 1. 沙箱内命令必须被文件系统策略约束,越权读写要经过 CodeBuddy 的权限判断。
8
+ 2. 当命令会修改已有文件时,必须在真实修改发生前接入 `ModifyBackup`,让 `sandbox-cli` 能保存修改前版本,并在本轮结束时通过 `CommitModifyBackup` 固化备份周期。
9
+
10
+ 仅依赖普通 shell 或系统命令做不到这一点。原因是:
11
+
12
+ - `>` / `>>` 等重定向由 shell 自己执行,外层只能看到整条命令,看不到打开目标文件的准确时机。
13
+ - `sed -i`、`mv` 这类命令可能通过临时文件加 `rename` 覆盖目标文件,不一定表现为对目标文件的直接写入。
14
+ - `truncate` 会通过 `open(O_WRONLY)` 加 `ftruncate` 改变文件内容,如果命令没有进入 brokered runtime,就没有修改前备份时机。
15
+ - macOS Seatbelt 的 `sandbox_extension` token 只能由沙箱外进程签发,沙箱内进程不能自行扩大权限。
16
+
17
+ 因此当前方案采用自编 `zsh` + 自编 `toybox` + agent-cli broker 的组合:
18
+
19
+ - 自编 `zsh` 负责 shell 语义层,覆盖重定向、glob、条件判断、目录访问等 shell 内部文件访问。
20
+ - 自编 `toybox` 负责常见命令层,覆盖由 `PATH` 命中的基础命令及其 `open` / `rename` / `delete` 等文件操作。
21
+ - `agent-cli` 作为 broker 层,负责权限策略、用户审批、macOS sandbox extension token 签发、host operation 执行,以及 `ModifyBackup` 触发。
22
+ - `sandbox-cli` 作为沙箱执行与备份存储层,负责运行沙箱内进程,并持久化 `ModifyBackup` / `CommitModifyBackup` 数据。
23
+
24
+ 目标不是给每个命令写一套备份逻辑,而是在关键语义层拿到“真实修改前”的统一时机。
25
+
26
+ ## 总体架构
27
+
28
+ ```mermaid
29
+ flowchart LR
30
+ UserTurn["用户 turn"] --> Interceptor["SandboxAgentRunInterceptor"]
31
+ Interceptor --> SandboxCLI["sandbox-cli session"]
32
+ Interceptor --> BackupConfig["EnableModifyBackup"]
33
+
34
+ BashTool["Bash 工具"] --> SandboxShell["SandboxShellService"]
35
+ SandboxShell --> BrokerIPC["BrokeredSandboxIpcServer<br/>Unix socket"]
36
+ SandboxShell --> Zsh["自编 zsh"]
37
+ Zsh --> BrokerEnv["brokered-sandbox-bash-env.sh"]
38
+ BrokerEnv --> BrokeredBin["brokered-bin PATH"]
39
+ BrokeredBin --> Toybox["自编 toybox"]
40
+
41
+ Zsh -->|"text token request<br/>read/write path"| BrokerIPC
42
+ Toybox -->|"text token request<br/>open/fopen"| BrokerIPC
43
+ Toybox -->|"JSON HostFileOperation<br/>delete/rename"| BrokerIPC
44
+
45
+ BrokerIPC --> HostService["BrokeredSandboxHostService"]
46
+ HostService --> Policy["sandbox fileSafety / approval"]
47
+ HostService --> ModifyBackup["ModifyBackup"]
48
+ HostService --> Token["sandbox_extension_issue_file"]
49
+ HostService --> HostOp["host delete/rename/copy/..."]
50
+
51
+ ModifyBackup --> SandboxCLI
52
+ FinalStop["FINAL_STOP hook"] --> CommitBackup["CommitModifyBackup"]
53
+ CommitBackup --> SandboxCLI
54
+ ```
55
+
56
+ 核心代码与产物位置:
57
+
58
+ | 模块 | 位置 | 职责 |
59
+ |------|------|------|
60
+ | 沙箱 turn 配置 | `src/node/agent/interceptors/sandbox-interceptor.ts` | 读取 `sandbox.fileBackup`,启动并同步 `sandbox-cli`,发送 `EnableModifyBackup` |
61
+ | 沙箱 shell 执行 | `src/node/sandbox-cli/sandbox-shell-service.ts` | 启动 broker IPC,注入 broker env,macOS 下切到自编 `zsh` |
62
+ | runtime env | `src/node/shell/shell-runtime-env.ts` / `src/node/shell/brokered-shell-env.ts` | 注入 `CODEBUDDY_SANDBOX_BROKER_*`、toybox、zsh、brokered bin 路径 |
63
+ | broker IPC | `src/node/permission/brokered-sandbox/ipc-server.ts` | 接收 zsh/toybox 请求,分发 token request 和 host operation |
64
+ | host service | `src/node/permission/brokered-sandbox/host-service.ts` | 权限判断、token 签发、host 操作执行、`ModifyBackup` |
65
+ | host dispatcher | `src/node/permission/brokered-sandbox/host-dispatcher.ts` | JSON `HostFileOperation` 路由 |
66
+ | 收尾提交 | `src/node/hooks/finalization-modify-backup-hook.ts` | `FINAL_STOP` 时发送 `CommitModifyBackup` |
67
+ | brokered shim | `vendor/shim/brokered-sandbox-bash-env.sh` / `vendor/shim/brokered-bin/*` | 将常见命令路由到自编 toybox |
68
+ | 自编 zsh 产物 | `vendor/zsh-macos/bin/zsh` | macOS brokered shell |
69
+ | 自编 toybox 产物 | `vendor/toybox-macos/toybox` | macOS brokered command runtime |
70
+ | toybox sandbox profile | `vendor/toybox-macos/toybox.sb` | toybox 的 Seatbelt 沙箱规则(见下文) |
71
+ | zsh/toybox 源码 | `tsbx-macos` 仓库 | 可复现构建和底层 hook 源码 |
72
+
73
+ ## 启用条件与运行时注入
74
+
75
+ ### 开关生效矩阵
76
+
77
+ Bash 命令是否进入 brokered shell 以及是否启用修改前备份,取决于多个条件的组合:
78
+
79
+ | 条件 | 不满足时的行为 |
80
+ |------|---------------|
81
+ | `sandbox.enabled = true` | `SandboxOrchestrator` 直接走 local 执行,不进入沙箱,brokered shell 和备份均不生效 |
82
+ | `darwin` 平台 | brokered shell runtime 不注入(brokered shell 是 macOS 专有方案) |
83
+ | WorkBuddy Desktop 环境 | `injectBrokeredShellEnv()` 不注入托管 runtime 产物 |
84
+ | brokered shell shim 完整(`shell-runtime-bash-env.sh`、`brokered-sandbox-bash-env.sh`、`brokered-bin/codebuddy-toybox-dispatch`、`brokered-bin/ls` 存在) | 基础 brokered shell runtime 不注入;zsh、toybox、toybox.sb 是可选产物,缺 zsh 则不切自编 shell,缺 toybox/toybox.sb 则不注入 toybox runtime |
85
+ | `WORKBUDDY_MANAGED_RUNTIME_DISABLED` 不含 `brokeredShell` | 含 `brokeredShell` 时跳过 brokered shell runtime 注入 |
86
+ | 命令不在 `sandbox.excludedCommands` 中 | 命中 excludedCommands 的命令直接 fallback local 执行 |
87
+ | 非 `dangerouslyDisableSandbox` | 模型请求 bypass 时走用户审批 → 通过则 local 执行,不进沙箱 |
88
+ | 非 `rawCommand` 模式 | rawCommand 直接 spawn 可执行文件,不经 shell wrapping,不前置 `brokered-bin`,不切自编 zsh;WorkBuddy Desktop 的默认 sandbox env 仍会注入基础 broker 变量 |
89
+ | `sandbox.fileBackup.enabled !== false` | 备份不启用,brokered shell 仍然生效(权限控制和 token 签发正常),但不触发 `ModifyBackup` |
90
+
91
+ 典型正常链路:以上条件全部满足 → 命令进入自编 zsh + toybox brokered shell → 写操作经 broker 权限判断 → 已有普通文件触发 `ModifyBackup` → `FINAL_STOP` 时 `CommitModifyBackup`。
92
+
93
+ ### 文件备份开关
94
+
95
+ 文件备份开关由 `SandboxAgentRunInterceptor` 在每个真实用户 turn 开始时解析:
96
+
97
+ - 支持平台:`win32` 和 `darwin`。Windows 侧的文件备份通过独立机制实现,不涉及 brokered shell runtime,不在本文档范围内。
98
+ - macOS 上 `sandbox.fileBackup.enabled !== false` 时开启,未显式关闭时默认开启。
99
+ - 开启后会确保 `sandbox-cli` session ready,并向 `sandbox-cli` 发送 `EnableModifyBackup`。
100
+ - 如果 `sandbox-cli` 不可用或 `EnableModifyBackup` 同步失败,本轮会关闭 `enableFileBackup`,避免进入半开状态。
101
+
102
+ brokered shell runtime 只在 macOS 的沙箱 Bash 执行链路中注入。`SandboxShellService.execute()` 的关键步骤是:
103
+
104
+ 1. `waitReady()` / `ensureAlive()` / `switchToSession()` 准备 `sandbox-cli` session。
105
+ 2. `BrokeredSandboxIpcServer.ensureStarted()` 创建沙箱外 Unix socket。
106
+ 3. `buildShellRuntimeEnv()` 注入 broker env。
107
+ 4. `_resolveSandboxShellConfiguration()` 在 `darwin` 且存在自编 zsh 和 broker IPC 时使用 `vendor/zsh-macos/bin/zsh -f -c`。
108
+ 5. `buildShellRuntimePosixCommand()` source `brokered-sandbox-bash-env.sh`,把 `brokered-bin` 前置到 `PATH`。
109
+
110
+ 关键环境变量:
111
+
112
+ | 变量 | 用途 |
113
+ |------|------|
114
+ | `CODEBUDDY_SANDBOX_BROKER_IPC_ADDRESS` | agent-cli broker Unix socket 路径 |
115
+ | `CODEBUDDY_SANDBOX_BROKER_SESSION_ID` | 当前 CodeBuddy session,用于防串 session |
116
+ | `CODEBUDDY_SANDBOX_BROKER_TOOL_CALL_ID` | 当前 Bash tool call,用于审计与备份归属 |
117
+ | `CODEBUDDY_SANDBOX_BROKER_TRACE_ID` | 单次执行 trace id,便于日志关联 |
118
+ | `CODEBUDDY_SANDBOX_HOST_FILE_OPERATION_COMMAND` | JSON host-op command 名,当前为 `HostFileOperation` |
119
+ | `CODEBUDDY_TOYBOX_BIN` | 自编 toybox 二进制路径 |
120
+ | `CODEBUDDY_TOYBOX_SANDBOX_PROFILE` | toybox sandbox profile 路径 |
121
+ | `CODEBUDDY_BROKERED_SHELL_ENV` | brokered shell bootstrap 脚本 |
122
+ | `CODEBUDDY_BROKERED_BIN_DIR` | brokered command shim 目录 |
123
+ | `CODEBUDDY_SANDBOX_ZSH_BIN` | 自编 zsh 二进制路径 |
124
+ | `TOYBOX_SANDBOX_SOCK` | toybox 连接 broker 的 socket,通常由 bootstrap 从 broker IPC 地址派生 |
125
+
126
+ `injectBrokeredShellEnv()` 还会检查运行环境:
127
+
128
+ - 非 `darwin` 不注入任何 brokered 环境变量。
129
+ - **Broker IPC 环境变量**(`CODEBUDDY_BROKERED_FS_HOOK_ENABLED`、`CODEBUDDY_SANDBOX_BROKER_IPC_ADDRESS` 等)仅在 WorkBuddy Desktop 环境下注入,非 WorkBuddy Desktop 环境会清理调用方遗留的 broker IPC 和 toybox socket 地址;在 WorkBuddy Desktop 内不受 `brokeredShell` 开关影响。这使得 safe-delete shim(bash/Node.js/Python)即使在 brokeredShell 关闭时也能通过 broker IPC 发送 `HostFileOperation delete`,统一走 host service 的权限检查和 `ModifyBackup`。
130
+ - **托管 runtime 产物**(toybox、zsh、brokered-bin)仅在以下条件全部满足时注入:非 `WORKBUDDY_MANAGED_RUNTIME_DISABLED=brokeredShell`、是 WorkBuddy Desktop 环境。
131
+
132
+ ## macOS 文件修改识别链路
133
+
134
+ 当前有两类 IPC 协议。
135
+
136
+ ### text token request
137
+
138
+ 用于 zsh 和 toybox 的 `open` 类文件访问:
139
+
140
+ ```text
141
+ <extension-class>\t<absolute-path>\t<session-id>\t<tool-call-id>\n
142
+ ```
143
+
144
+ `extension-class` 当前有两种:
145
+
146
+ - `com.workbuddy.sandbox.read`
147
+ - `com.workbuddy.sandbox.read-write`
148
+
149
+ agent-cli 收到后:
150
+
151
+ 1. 校验 session id 必须等于当前 session。
152
+ 2. 根据 `fileSafety` 判断路径是 `grant-token`、`sandbox`、`deny` 还是需要审批。
153
+ 3. 如果命中需要审批的规则,broker 记录 prompt block,当前 IPC 请求返回 `DENY`,外层 `SandboxOrchestrator` 负责统一审批和 native rerun。
154
+ 4. 如果是写操作且最终不是 deny,先执行 `ModifyBackup`。
155
+ 5. 返回 sandbox extension token、`SANDBOX` 或 `DENY`。
156
+ 6. zsh/toybox 收到 token 后 `sandbox_extension_consume()`,再执行真实 `open`。
157
+
158
+ agent-cli 的响应是单行文本,有三种形式:
159
+
160
+ - sandbox extension token 字符串:zsh/toybox 调用 `sandbox_extension_consume()` 后再执行真实 `open`。
161
+ - `SANDBOX`:现有沙箱规则已经允许访问,不需要 consume token;但写操作仍然会先走 `ModifyBackup`。
162
+ - `DENY`:拒绝访问,zsh/toybox 不执行真实 `open`。
163
+
164
+ ### JSON HostFileOperation
165
+
166
+ 用于沙箱内不应该自己完成、或需要 host 语义的文件操作:
167
+
168
+ ```json
169
+ {
170
+ "id": "req-1",
171
+ "command": "HostFileOperation",
172
+ "operation": "rename",
173
+ "from": "/absolute/source",
174
+ "to": "/absolute/target",
175
+ "sessionId": "...",
176
+ "toolCallId": "...",
177
+ "brokerTraceId": "..."
178
+ }
179
+ ```
180
+
181
+ `id` 用于 request-response 匹配;`brokerTraceId` 用于日志关联。两者均可选,但推荐携带以方便审计。
182
+
183
+ agent-cli 的 JSON 响应结构:
184
+
185
+ ```json
186
+ {
187
+ "id": "req-1",
188
+ "ok": true,
189
+ "operation": "rename",
190
+ "path": "/absolute/source",
191
+ "normalisedPath": "/resolved/source",
192
+ "decision": "host-op",
193
+ "to": "/absolute/target",
194
+ "normalisedTo": "/resolved/target"
195
+ }
196
+ ```
197
+
198
+ 失败时 `ok` 为 `false`,并附带 `error` 字段。`decision` 可能为 `grant-token`、`host-op`、`sandbox` 或 `deny`。(`prompt` 是审批过程中间状态,不会作为最终 IPC 响应返回给 toybox。)
199
+
200
+ 当前 agent-cli TS 侧支持的 host operation 包括:
201
+
202
+ | operation | 额外参数 | 说明 |
203
+ |-----------|----------|------|
204
+ | `delete` | `deleteMode?: 'trash' \| 'unlink'`、`recursive?`、`force?`、`safeDeleteReportPath?` | macOS 默认 `deleteMode='trash'`(移入回收站),非 macOS 默认 `unlink` |
205
+ | `mkdir` | `recursive?`、`mode?` | 创建目录 |
206
+ | `rename` | `from`、`to` | 重命名/移动 |
207
+ | `copy` | `from`、`to`、`recursive?` | 复制文件或目录 |
208
+ | `chmod` | `path`、`mode` | 修改权限 |
209
+ | `link` | `from`、`to`、`symbolic?` | 硬链接(默认)或符号链接。硬链接校验 source 的 read 权限 + target 的 write 权限;符号链接只校验 target 的 write 权限 |
210
+ | `touch` | `path` | 创建或更新文件时间戳 |
211
+
212
+ 当前 macOS toybox runtime 明确上报的关键 host operation 是:
213
+
214
+ - `delete`:`rm` / `rmdir` / `unlink` 通过 `toybox_host_delete()` 上报,host 侧接入回收站(`deleteMode='trash'`)。
215
+ - `rename`:`rename()` 宏替换为 `toybox_rename()`,覆盖 `mv`、`sed -i` 等临时文件覆盖场景。
216
+
217
+ `copy`、`touch` 等 TS 分支用于 broker 能力完整性和未来扩展;当前 `cp` 覆盖主要来自 open 写目标文件的 text token request,而不是 toybox 主动发送 JSON `copy`。
218
+
219
+ ## 自编 zsh 的职责
220
+
221
+ 自编 zsh 的核心价值是拿到 shell 自身的文件访问时机,尤其是重定向。
222
+
223
+ 源码在 `tsbx-macos/zsh-macos`,当前 patch 基于 zsh 5.9.1,主要修改:
224
+
225
+ - `Src/exec.c`:把重定向相关 `open()` 改成 `codebuddy_brokered_open()`。
226
+ - `Src/zsh_system.h`:新增 broker 连接、路径规范化、token request、`sandbox_extension_consume()`、brokered `open/stat/access/opendir/chdir` 等 helper。
227
+ - `Src/init.c` / `Src/glob.c` / `Src/cond.c` / `Src/compat.c`:把 shell 初始化、glob、条件判断、目录切换等文件访问改成 brokered 版本。
228
+
229
+ zsh 负责的典型场景:
230
+
231
+ - `echo hi > a.txt`
232
+ - `cat < input.txt`
233
+ - `[[ -f a.txt ]]`
234
+ - `cd some-dir`
235
+ - glob 展开过程中需要 `stat` / `lstat` 的路径
236
+
237
+ 其中 `>` / `>>` 是备份链路最关键的场景。真实流程是:
238
+
239
+ 1. zsh 解析重定向。
240
+ 2. 执行 `codebuddy_brokered_open(path, O_WRONLY | O_CREAT | O_TRUNC, ...)`。
241
+ 3. `codebuddy_brokered_authorize_path(path, "write")` 向 agent-cli broker 发 text token request。
242
+ 4. agent-cli 放行前对已有普通文件发 `ModifyBackup`。
243
+ 5. zsh 拿到 token 或 `SANDBOX` 后再执行真实 `open()`。
244
+
245
+ 这就是为什么 `>` 的时机必须在自编 zsh 内暴露,而不是只靠 agent-cli 解析命令字符串。
246
+
247
+ ## 自编 toybox 的职责
248
+
249
+ 自编 toybox 的核心价值是把常见基础命令统一收口到一个可控 runtime。
250
+
251
+ agent-cli vendor 中的 `brokered-bin` 目录包含 dispatcher 入口 `codebuddy-toybox-dispatch` 和一组命令名 shim:
252
+
253
+ ```text
254
+ codebuddy-toybox-dispatch # dispatcher 入口
255
+ cat chmod cp dd find grep head ln ls mkdir mv readlink realpath rm rmdir sed tail tee touch truncate unlink wc
256
+ ```
257
+
258
+ 每个命令名 shim 都是指向 `codebuddy-toybox-dispatch` 的链接。执行流程:
259
+
260
+ 1. `brokered-sandbox-bash-env.sh` 把 `CODEBUDDY_BROKERED_BIN_DIR` 前置到 `PATH`。
261
+ 2. 用户命令里的 `sed` / `mv` / `truncate` 优先命中 brokered shim。
262
+ 3. `codebuddy-toybox-dispatch` 根据自身文件名得到 toybox applet 名。
263
+ 4. dispatcher 执行 `CODEBUDDY_TOYBOX_BIN <applet> ...`。
264
+ 5. 如果 toybox 不支持该 applet 或选项,dispatcher 会从 `PATH` 移除 brokered bin 后 fallback 到系统同名命令。
265
+
266
+ toybox 源码层的关键 hook 在 `tsbx-macos/toybox-0.8.13`:
267
+
268
+ - `toys.h` 宏替换 `open/openat/creat/fopen/freopen/rename`。
269
+ - `lib/iolog.c` 实现 `toybox_open()`、`toybox_openat()`、`toybox_creat()`、`toybox_fopen()`、`toybox_freopen()`。
270
+ - open 类 wrapper 在真实 open 前发送 text token request。
271
+ - `toybox_rename()` 发送 JSON `HostFileOperation rename`,由 host 执行真实 rename。
272
+ - `toybox_host_delete()` 发送 JSON `HostFileOperation delete`,由 host 执行安全删除。
273
+
274
+ toybox 的 open hook 是 eager 模式:不是等 macOS sandbox 拒绝后再申请 token,而是在每次 open 前主动请求 broker。这样 agent-cli 能在真实写入前完成备份。
275
+
276
+ ### toybox sandbox profile (`toybox.sb`)
277
+
278
+ `vendor/toybox-macos/toybox.sb` 是 toybox 运行时的 Seatbelt 沙箱规则,由 `sandbox-cli` 在启动 toybox 进程时通过 `sandbox-exec` 加载。核心策略:
279
+
280
+ - `(deny default)`:默认拒绝所有文件系统访问。
281
+ - `(allow file-read* (subpath "/"))`:允许全局读取(read token request 仍然会走 broker,但 Seatbelt 层不阻拦)。
282
+ - `(allow file-write* (subpath "/dev") (subpath "/private/var/folders"))`:允许写 `/dev`(stdout/stderr)和临时目录(sed -i 等中间文件)。
283
+ - `(allow file-read* (extension "com.workbuddy.sandbox.read"))`:持有 read token 后允许读取对应路径。
284
+ - `(allow file-read* file-write* (extension "com.workbuddy.sandbox.read-write"))`:持有 read-write token 后允许读写对应路径。
285
+
286
+ 这是 brokered shell 安全模型的底层执行机制:toybox 必须先从 broker 获得 sandbox extension token,Seatbelt 才会允许写操作。eager token 请求确保 broker 能在 Seatbelt 放行前完成权限判断和修改前备份。
287
+
288
+ ## 关键命令覆盖方式
289
+
290
+ ### `>`
291
+
292
+ `>` 是 zsh 重定向,不是外部命令。
293
+
294
+ ```bash
295
+ echo hi > a.txt
296
+ ```
297
+
298
+ 链路:
299
+
300
+ 1. 自编 zsh 在重定向阶段调用 `codebuddy_brokered_open()`。
301
+ 2. zsh 发送 `com.workbuddy.sandbox.read-write` text token request。
302
+ 3. agent-cli 判断权限。
303
+ 4. 如果 `a.txt` 已存在且是普通文件,agent-cli 在返回 token 或 `SANDBOX` 前发送 `ModifyBackup`。
304
+ 5. zsh 执行真实 `open(O_TRUNC)`,文件才被截断。
305
+
306
+ ### `cp`
307
+
308
+ ```bash
309
+ cp source.txt target.txt
310
+ ```
311
+
312
+ 链路:
313
+
314
+ 1. `cp` 命中 `brokered-bin/cp`。
315
+ 2. dispatcher 执行 toybox `cp`。
316
+ 3. toybox `cp` 打开源文件时发 read token request。
317
+ 4. toybox `cp` 打开目标文件时发 write token request,覆盖已有目标通常会走 `openat(..., O_TRUNC, ...)`。
318
+ 5. agent-cli 在目标写 token 放行前对已有目标文件执行 `ModifyBackup`。
319
+
320
+ 当前不依赖 JSON `copy` host-op 覆盖 `cp`。TS 侧有 `copyPath()` 能力,但 macOS toybox 的 `cp` 覆盖主要来自 open 写目标。
321
+
322
+ ### `mv`
323
+
324
+ ```bash
325
+ mv source.txt target.txt
326
+ ```
327
+
328
+ 链路:
329
+
330
+ 1. `mv` 命中 `brokered-bin/mv`。
331
+ 2. toybox `mv` 调用 `rename()`。
332
+ 3. `rename()` 被宏替换为 `toybox_rename()`。
333
+ 4. `toybox_rename()` 发送 JSON `HostFileOperation rename`,包含 `from` 和 `to`。
334
+ 5. agent-cli host service 分别校验 source 的 delete 权限和 target 的 write 权限。
335
+ 6. 执行 host rename 前,对已存在的 source 和 target 普通文件执行 `ModifyBackup`。
336
+ 7. host 侧执行真实 `rename(from, to)`,toybox 收到 ok 后认为命令成功。
337
+
338
+ 如果 host rename 返回跨设备错误,toybox 会把错误映射为 `EXDEV`,保留 `mv` 自身的 copy + delete fallback 语义。fallback 中的 copy 写目标和 delete 源文件仍会分别经过 brokered open 或 host delete。
339
+
340
+ ### `sed -i`
341
+
342
+ ```bash
343
+ sed -i '' 's/a/b/g' file.txt
344
+ ```
345
+
346
+ macOS / toybox 的 `sed -i` 常见实现是:
347
+
348
+ 1. 读取原文件。
349
+ 2. 写临时文件。
350
+ 3. 用 `rename(temp, file.txt)` 覆盖原文件。
351
+
352
+ 如果只监听写目标文件,会漏掉真正覆盖原文件的时机,因为写入发生在临时文件上。当前方案通过 toybox `rename` hook 解决:
353
+
354
+ 1. `sed` 命中 `brokered-bin/sed`。
355
+ 2. toybox `sed` 写临时文件,临时文件的 open 会走 write token;因为临时文件通常不存在,不产生备份。
356
+ 3. toybox `sed` 调用 `rename(temp, file.txt)`。
357
+ 4. `toybox_rename()` 发送 JSON `HostFileOperation rename`。
358
+ 5. agent-cli 在 host rename 前对已存在的 `file.txt` 执行 `ModifyBackup`。
359
+ 6. host 侧执行 rename 覆盖。
360
+
361
+ 这就是 `mv` 和 `sed -i` 被归为同一类问题的原因:本质都是“rename 覆盖目标文件前”的备份时机。
362
+
363
+ ### `truncate`
364
+
365
+ ```bash
366
+ truncate -s 0 file.txt
367
+ ```
368
+
369
+ toybox `truncate` 的实现不是直接 `open(O_TRUNC)`,而是:
370
+
371
+ 1. `loopfiles_rw(..., O_WRONLY | O_CLOEXEC | ...)` 打开目标文件。
372
+ 2. 对 fd 调用 `ftruncate(fd, size)`。
373
+
374
+ 当前覆盖依赖第一步:
375
+
376
+ 1. `truncate` 命中 `brokered-bin/truncate`。
377
+ 2. toybox `truncate` 打开目标文件时触发 `toybox_open()`。
378
+ 3. 因为 flags 包含 `O_WRONLY`,toybox 发送 write token request。
379
+ 4. agent-cli 在返回 token 或 `SANDBOX` 前对已有普通文件执行 `ModifyBackup`。
380
+ 5. toybox 随后调用 `ftruncate()` 修改文件长度。
381
+
382
+ 当前没有单独 hook `ftruncate()`。因此 brokered toybox 的 `truncate` 可以覆盖;外部二进制如果绕开 brokered toybox 并直接 `ftruncate()`,不属于本方案覆盖范围。
383
+
384
+ ### `dd`
385
+
386
+ ```bash
387
+ dd if=source.bin of=file.bin bs=1M
388
+ ```
389
+
390
+ toybox `dd` 对输出目标的写入方式和 `truncate`/`cp` 类似(`open` 目标路径),但 `of=` 是 `key=value` 形式的操作数,不是位置参数,`codebuddy-toybox-dispatch` 在调用 toybox 之前额外做了一次 shell 层预备份:
391
+
392
+ 1. `dd` 命中 `brokered-bin/dd`。
393
+ 2. dispatcher 解析全部操作数,找到 `of=<path>` 并对已存在的普通文件执行一次 write token 预检 + `ModifyBackup`(复用 `truncate`/`tee` 的 `__cb_prebackup_path`)。
394
+ 3. dispatcher 执行 toybox `dd`,toybox 打开 `of=` 目标时会再触发一次 `toybox_open()` write token request。
395
+ 4. 两次备份写入前内容相同(预备份和 toybox 自身 open hook 之间没有发生真实写入),不影响正确性,只是多一次 IPC 往返。
396
+
397
+ `dd` 不指定 `of=` 时输出到 stdout,不落盘,不触发预备份。
398
+
399
+ ## ModifyBackup / CommitModifyBackup 调用链路
400
+
401
+ ### 启用备份
402
+
403
+ 每个真实用户 turn 开始时:
404
+
405
+ 1. `SandboxAgentRunInterceptor` 读取 `sandbox.fileBackup`。
406
+ 2. 写入 `SandboxTurnConfig.enableFileBackup` 和 `fileBackupMaxSizeMB`。
407
+ 3. 确保 `sandbox-cli` session ready。
408
+ 4. 发送 `EnableModifyBackup`:
409
+
410
+ ```json
411
+ {
412
+ "enabled": true,
413
+ "fileBackupMaxSizeMB": 10
414
+ }
415
+ ```
416
+
417
+ `fileBackupMaxSizeMB` 会经过最小值、最大值和默认值归一化,实际存储限制由 `sandbox-cli` 执行。
418
+
419
+ ### 修改前备份
420
+
421
+ 有两条入口会发送 `ModifyBackup`。
422
+
423
+ text token request 写路径:
424
+
425
+ - 入口:`BrokeredSandboxIpcServer.handleToyboxTextLine()`。
426
+ - 条件:operation 是 `write`,权限结果不是 deny,`enableFileBackup=true`。
427
+ - 行为:如果目标已存在且是普通文件,发送 `ModifyBackup { targetPath }`。
428
+ - 失败策略:备份失败则返回 `DENY`,真实写入不继续。
429
+
430
+ JSON host operation:
431
+
432
+ - 入口:`BrokeredSandboxHostService`。
433
+ - 条件:host operation 会修改已有内容,`enableFileBackup=true`。
434
+ - `delete` / `touch`:备份 `path` 中已存在的普通文件。
435
+ - `rename`:备份 `from` 和 `to` 中已存在的普通文件。
436
+ - `copy`:备份 `to` 中已存在的普通文件。
437
+ - `chmod` / `mkdir`:不触发备份(`chmod` 只改元数据,`mkdir` 创建新目录)。
438
+ - `link`:不触发备份。
439
+ - 失败策略:备份失败则 host action 不执行,operation 返回失败。
440
+
441
+ 这两个入口都只备份“已存在的普通文件”。新建文件、目录、特殊文件、纯元数据恢复不在当前内容备份语义内。
442
+
443
+ ### 收尾提交
444
+
445
+ 本轮结束进入 `FINAL_STOP` 时,`FinalizationModifyBackupHook` 执行:
446
+
447
+ 1. 检查 `enableFileBackup`。
448
+ 2. 检查是否存在已打开的 sandbox session。
449
+ 3. 从最近真实用户消息生成短 commit message。
450
+ 4. 发送 `CommitModifyBackup { commitMsg }` 到 `sandbox-cli`。
451
+
452
+ 该 hook 不按 `final_stop_reason` 过滤。只要本轮启用了文件备份且 sandbox session 存在,就尝试提交备份周期。提交失败只记录日志,不阻断 agent 收尾。
453
+
454
+ ## 权限审批与备份的先后顺序
455
+
456
+ 顺序是安全语义的核心。
457
+
458
+ text token request 写路径:
459
+
460
+ ```text
461
+ zsh/toybox 请求 write token
462
+ -> agent-cli 校验 session
463
+ -> fileSafety 策略判断
464
+ -> 若需要审批,记录 brokered prompt block 并向 zsh/toybox 返回 DENY
465
+ -> SandboxOrchestrator 合并 prompt block,统一请求用户审批
466
+ -> 用户允许后 native rerun 整条命令;用户拒绝则保留沙箱失败结果
467
+ ```
468
+
469
+ 无需审批或已经被规则直接放行的写路径:
470
+
471
+ ```text
472
+ zsh/toybox 请求 write token
473
+ -> agent-cli 校验 session
474
+ -> fileSafety 策略判断通过
475
+ -> ModifyBackup 已有普通文件
476
+ -> 返回 token 或 SANDBOX
477
+ -> zsh/toybox 执行真实 open/write/truncate
478
+ ```
479
+
480
+ JSON host operation:
481
+
482
+ ```text
483
+ toybox 请求 HostFileOperation
484
+ -> agent-cli 校验 session
485
+ -> source/target 权限判断
486
+ -> resolved path 安全校验
487
+ -> 若需要审批,记录 brokered prompt block 并向 toybox 返回失败
488
+ -> SandboxOrchestrator 合并 prompt block,统一请求用户审批
489
+ -> 用户允许后 native rerun 整条命令;用户拒绝则保留沙箱失败结果
490
+ ```
491
+
492
+ 无需审批或已经被规则直接放行的 host operation:
493
+
494
+ ```text
495
+ toybox 请求 HostFileOperation
496
+ -> agent-cli 校验 session
497
+ -> source/target 权限判断
498
+ -> resolved path 安全校验通过
499
+ -> ModifyBackup 受影响的已有普通文件
500
+ -> host 侧执行 delete/rename/copy/...
501
+ -> 返回结果给 toybox
502
+ ```
503
+
504
+ 关键原则:
505
+
506
+ - 当前主链路不在 IPC 内等待用户审批;brokered shell 只记录 prompt block,外层审批通过后走 native rerun。
507
+ - 权限未通过或需要审批但尚未获得外层批准时,不备份、不执行真实修改。
508
+ - 权限通过但备份失败时不执行真实修改。
509
+ - 备份必须早于 token 返回或 host action 执行。
510
+ - `SANDBOX` 代表沙箱规则已允许访问,但不会跳过写前备份。
511
+
512
+ ## 运行时行为约束
513
+
514
+ ### 并发模型
515
+
516
+ 当前 agent-cli 侧的 `BrokeredSandboxIpcServer` 是单实例 Unix socket server,所有 Bash tool call 共享同一个 socket 端点。并发请求通过 Node.js 事件循环串行处理,不做显式加锁。
517
+
518
+ 隔离粒度由 `toolCallId` 提供:每次 Bash tool call 注入独立的 `CODEBUDDY_SANDBOX_BROKER_TOOL_CALL_ID`,用于审计和备份归属。`sessionId` 校验防止跨 session 串请。
519
+
520
+ TOCTOU 风险评估:对于 text token request 路径,备份完成到 token 返回之间存在时间窗口,但沙箱内进程在收到 token 前无法通过 Seatbelt 执行真实写入,因此不存在传统 TOCTOU 的"check 后 use 前被篡改"问题。对于 host-op 路径(rename/delete 等),backup + 真实操作都在 broker 侧的同一个 async 函数内顺序执行(check-then-act),Node.js 事件循环保证中间不会插入同一 socket 的其他请求,但不阻止沙箱外进程在窗口内修改文件——这是已知的信任边界。
521
+
522
+ ### 超时与阻塞
523
+
524
+ zsh 侧有 socket 级超时设置;toybox 侧当前没有显式客户端超时。当前 agent-cli 主链路不会在 broker IPC 内等待用户审批:需要审批时,broker 记录 prompt block 并返回拒绝/失败,让 sandbox attempt 结束,再由 `SandboxOrchestrator` 发起统一审批和 native rerun。
525
+
526
+ 如果 broker 进程崩溃或 socket 被关闭,zsh/toybox 侧 `read()` 返回 EOF,命令会以 I/O 错误终止。
527
+
528
+ ### 性能影响
529
+
530
+ toybox 的 open hook 是 eager 模式,每次 `open` 调用都会产生一次 IPC round-trip。对于高频 open 的命令(如 `find`、`grep` 遍历大目录树),这会引入逐文件的 socket 通信开销。
531
+
532
+ 当前没有 token 缓存或批量请求机制。read-only 命令(如 `grep`)的每次 open 也会走 broker,但 broker 侧 read 路径不触发备份、不需要审批,处理开销较低。
533
+
534
+ 实测在典型沙箱工作目录规模下(数百到数千文件),延迟影响可接受。如果未来需要覆盖大型 codebase 遍历场景,可以考虑引入 read token 缓存。
535
+
536
+ ### 安全信任边界
537
+
538
+ broker IPC 的 Unix socket 使用文件权限隔离:
539
+
540
+ - socket 目录:`chmod 0o700`(仅当前用户可进入)。
541
+ - socket 文件:`chmod 0o600`(仅当前用户可读写)。
542
+
543
+ 这意味着同机器上其他用户的进程无法连接 broker socket。但沙箱内进程与 agent-cli 运行在同一用户下,因此沙箱内任何进程只要知道 socket 路径都能连接。
544
+
545
+ `sessionId` 通过环境变量 `CODEBUDDY_SANDBOX_BROKER_SESSION_ID` 注入沙箱,broker 侧校验请求中的 `sessionId` 必须匹配当前 session。沙箱内恶意进程可以读取环境变量获得合法 session ID,但这在当前威胁模型下是可接受的:沙箱内进程已经处于 Seatbelt 约束中,即使能发送请求,仍需通过 `fileSafety` 策略和用户审批才能获取写 token。broker 不是唯一防线,而是与 Seatbelt 沙箱、文件策略、用户审批共同构成纵深防御。
546
+
547
+ ### symlink 路径解析
548
+
549
+ host service 对文件路径使用三种解析策略:
550
+
551
+ - `resolveExistingPath`:解析到真实路径,路径不存在时拒绝。用于 `chmod`(目标必须存在)和硬链接 source。
552
+ - `resolvePathThroughParent`:解析父目录到真实路径后拼接文件名,允许目标尚不存在。用于 `delete`、`mkdir`、`rename`/`copy` 的 source 和 target。
553
+ - `resolveExistingOrCreatablePath`:路径存在时解析到真实路径,不存在时解析父目录。用于 `touch`(可能创建新文件)和 token request。
554
+
555
+ 所有策略都会在解析后执行 `refuseIfResolvedPathNotAllowed`:如果 symlink 解析后的真实路径落在安全策略不允许的范围内,即使 symlink 自身路径被允许,操作也会被拒绝。这防止通过 symlink 绕过沙箱文件策略边界。
556
+
557
+ ### 路径规范化与规则匹配
558
+
559
+ 排查"为什么某条规则没有命中"时,需要了解三层路径规范化:
560
+
561
+ **zsh 侧**:自编 zsh 的 `codebuddy_brokered_authorize_path()` 在发送 token request 前会对路径做 `/private/var` → `/var`、`/private/tmp` → `/tmp` 的 alias 归一化(macOS 特有的 firmlink 映射),确保请求路径与用户感知一致。
562
+
563
+ **toybox 侧**:`toybox_open()` 等 wrapper 在发送 text token request 前使用 `realpath` 风格解析,会解析 symlink、消除 `.`/`..`。但 `toybox_rename()` 使用 lexical absolute path(不解析 symlink),以保留 rename 操作本身的语义(rename 可能作用于 symlink 自身而非其目标)。
564
+
565
+ **agent-cli 侧**:`normalisePathForRuleMatch()` 在做 fileSafety 规则匹配前执行:
566
+ - 反斜杠 → 正斜杠
567
+ - 去除 macOS BSD `sed -i` 产生的临时文件前缀(`.!<PID>!filename` → `filename`)
568
+ - home 目录前缀 → `~`
569
+ - `normaliseSandboxWritePath()` 额外做 `/private/var` → `/var`、`/private/tmp` → `/tmp`
570
+
571
+ 调试时可以在日志中搜索 `phase=ipc-token` 或 `phase=host-policy`,日志会同时输出原始路径和规范化后路径(`path=` vs `normalisedPath=`),对照规则配置判断命中逻辑。
572
+
573
+ ## 已知边界与不覆盖范围
574
+
575
+ 当前方案覆盖的是“进入 macOS brokered shell runtime 的 Bash 命令”。
576
+
577
+ ### 不进入 brokered shell 的执行路径
578
+
579
+ 以下情况命令不会经过 brokered shell runtime,brokered 层的权限控制和备份均不生效:
580
+
581
+ - `sandbox.enabled = false` 或 `SandboxOrchestrator` 判定为 local 执行。
582
+ - `sandbox.excludedCommands` 命中:`SandboxShellService` 检查命令根后 fallback local。
583
+ - `dangerouslyDisableSandbox = true`:模型请求 bypass → 用户审批通过 → local 执行。
584
+ - 用户在沙箱执行失败后批准 native rerun(session-scoped approval cache):后续相同命令直接 local。
585
+ - `rawCommand` 模式:直接 spawn 可执行文件,不经 shell bootstrap,不前置 `brokered-bin`,不切自编 zsh。基础 sandbox/broker env 仍会注入,但 brokered shell 的 hook 链路不生效。
586
+ - vendor bundle 不完整:`brokered-sandbox-bash-env.sh`、`brokered-bin/codebuddy-toybox-dispatch`、`toybox`、`toybox.sb`、`zsh` 任一缺失会导致对应能力降级——缺 zsh 则不切自编 shell(重定向 hook 失效);缺 toybox 或 brokered-bin 则命令不经 toybox hook(open/rename hook 失效)。
587
+
588
+ ### 进入 brokered shell 但不被覆盖的场景
589
+
590
+ - 绝对路径调用系统命令,例如 `/usr/bin/sed -i ...`,会绕开 `brokered-bin`,toybox hook 不生效。
591
+ - 命令显式重写 `PATH` 并把 `brokered-bin` 移到后面,可能绕开 toybox shim。
592
+ - dispatcher 遇到 toybox 不支持的 applet 或选项会 fallback 到系统命令;fallback 后不再具备 toybox open/rename hook。
593
+ - 外部二进制的内部写入、`ftruncate()`、`mmap` 写、原生 `rename()` 等不会被 toybox hook 看到。
594
+ - zsh 能覆盖 shell 自身重定向,但不能自动 hook 任意外部二进制的 libc 调用。
595
+
596
+ ### 备份语义边界
597
+
598
+ - 当前内容备份只处理已存在的普通文件;目录、socket、设备文件、symlink 元数据、权限、mtime 等不是完整恢复对象。
599
+ - `touch` 主要是元数据修改。TS host service 可以在 host-op 路径备份已有普通文件内容,但当前没有元数据级恢复语义。
600
+ - `cp` 当前主要依赖写目标的 open token 覆盖,不代表 toybox 已主动发送 JSON `copy`。
601
+ - 备份提交依赖 `FINAL_STOP` 的 `CommitModifyBackup`。如果进程异常退出,`sandbox-cli` 侧未提交备份周期的处理要按其存储策略判断。
602
+ - brokered shell 与 Write/Edit/MultiEdit 是不同链路。工具级文件编辑应该直接在工具写入前调用 `ModifyBackup`,不应依赖 shell runtime(当前 macOS 侧工具级备份尚未接入,详见"后续扩展建议")。
603
+
604
+ ## 验证方法
605
+
606
+ ### agent-cli 单元测试
607
+
608
+ 重点测试:
609
+
610
+ ```bash
611
+ TS_NODE_PROJECT=tsconfig.tsnode.json npx mocha --require ts-node/register \
612
+ --config ../../dev-packages/component/configs/mocharc.yml \
613
+ --parallel=false \
614
+ "./src/node/shell/brokered-bin-dispatch.spec.ts" \
615
+ "./src/node/permission/brokered-sandbox/ipc-server.spec.ts" \
616
+ "./src/node/permission/brokered-sandbox/host-service.spec.ts" \
617
+ "./src/node/hooks/finalization-modify-backup-hook.spec.ts"
618
+ ```
619
+
620
+ 覆盖点:
621
+
622
+ - `truncate` shim 必须存在,并指向 `codebuddy-toybox-dispatch`。
623
+ - text write token 在已有普通文件上触发 `ModifyBackup`。
624
+ - `rename` host-op 在 source / target 上触发 `ModifyBackup`。
625
+ - 备份失败时拒绝继续写入或 host operation。
626
+ - `FINAL_STOP` 时发送 `CommitModifyBackup`。
627
+
628
+ ### toybox 构建与静态检查
629
+
630
+ 在 `tsbx-macos` 仓库:
631
+
632
+ ```bash
633
+ ./build-toybox-macos.sh
634
+ lipo toybox-0.8.13/toybox -verify_arch arm64 x86_64
635
+ strings toybox-0.8.13/toybox | grep '"operation":"rename"'
636
+ ```
637
+
638
+ 覆盖点:
639
+
640
+ - 产物是 macOS universal binary。
641
+ - 二进制包含 `HostFileOperation rename` 标记。
642
+
643
+ ### fake broker 行为验证
644
+
645
+ 用本地 fake broker 接收 socket 请求,分别执行:
646
+
647
+ ```bash
648
+ echo changed > file.txt
649
+ cp source.txt file.txt
650
+ mv source.txt file.txt
651
+ sed -i '' 's/a/b/g' file.txt
652
+ truncate -s 0 file.txt
653
+ ```
654
+
655
+ 预期:
656
+
657
+ - `>` 产生 zsh text write token request。
658
+ - `cp` 对目标产生 toybox text write token request。
659
+ - `mv` 产生 toybox JSON `HostFileOperation rename`。
660
+ - `sed -i` 在覆盖原文件时产生 toybox JSON `HostFileOperation rename`。
661
+ - `truncate` 产生 toybox text write token request。
662
+
663
+ ### 产品级验证
664
+
665
+ 在 WorkBuddy 开发版中:
666
+
667
+ 1. 打开文件安全自动备份。
668
+ 2. 运行沙箱 Bash 命令覆盖已有文件。
669
+ 3. 查看 `~/.codebuddy/` 和 WorkBuddy dev 日志中的 broker shell 日志:
670
+ - `phase=ipc-token`
671
+ - `phase=ipc-token-backup`
672
+ - `phase=ipc-host-op`
673
+ - `phase=host-modify-backup`
674
+ - `finalization-modify-backup`
675
+ 4. 在备份查看入口确认备份记录存在,能定位备份并人工恢复(见下节)。
676
+
677
+ ### 备份查看与恢复入口
678
+
679
+ 当前备份查看入口在 WorkBuddy 设置页的文件安全面板(`SecurityCenterPanel` → `FileDetail`):
680
+
681
+ 1. 用户点击"查看备份" → UI 发送 `open-modify-backup-dir` 事件,携带当前会话 `sessionId`。
682
+ 2. `workbuddy-server` 的 `SecurityCenterService.openModifyBackupDir()` 拼接备份目录路径:`<configDir>/workspace/sessions/<sessionId>/modify_backup`。
683
+ 3. 调用 `openPath()` 在 Finder 中打开该目录。
684
+
685
+ 当前是"打开备份目录"操作,不是一键恢复 UI。用户需要手动从目录中找到备份文件并恢复。如果目录不存在(本轮未产生备份),UI 会提示"当前会话无备份记录"。
686
+
687
+ ## 后续扩展建议
688
+
689
+ ### Write/Edit/MultiEdit 工具级备份
690
+
691
+ Brokered shell 只覆盖 Bash 命令。Write/Edit/MultiEdit/NotebookEdit 等工具不经过 zsh/toybox,应在工具写入前直接复用 `ModifyBackup`:
692
+
693
+ ```text
694
+ 工具解析目标路径
695
+ -> 权限判断
696
+ -> 如果目标已存在且是普通文件,发送 ModifyBackup
697
+ -> 工具执行真实写入
698
+ ```
699
+
700
+ 这条链路不应该依赖 broker shell,也不应该模拟 shell 命令。它和 brokered shell 共享的是 `sandbox-cli` 的备份存储协议,而不是 zsh/toybox runtime。
701
+
702
+ **当前状态**:`SandboxWriteRuleGuard` 已为 Write/Edit/MultiEdit/NotebookEdit 实现了工具级 guard(`GUARDED_WRITE_TOOLS`),能在沙箱模式下对写目标做权限判断和规则匹配。`ModifyBackup` 由 `isModifyBackupSupportedPlatform()` 门控,当前在 `win32` 与 `darwin` 上均启用;auto-grant 由独立的 `supportsAutoGrantRuleEffect()` 门控,避免与备份平台能力耦合。
703
+
704
+ ### 更完整的 syscall 覆盖
705
+
706
+ 如果未来要覆盖任意外部二进制,toybox shim 不够,需要更底层方案,例如:
707
+
708
+ - 对外部二进制引入受控 wrapper。
709
+ - 使用可审计的动态库 interpose,但要评估 SIP、签名、稳定性和安全边界。
710
+ - 在 sandbox-cli 层增强文件事件观测,但 macOS 对“修改前”备份时机的可控性有限。
711
+
712
+ 当前方案选择 zsh + toybox,是在可控性、改动量、可维护性之间的折中。
713
+
714
+ ### 元数据备份
715
+
716
+ 当前 `ModifyBackup` 语义主要面向文件内容恢复。如果要支持 `touch` / `chmod` / `chown` / xattr 等元数据恢复,需要扩展备份记录结构:
717
+
718
+ - mode
719
+ - owner / group
720
+ - atime / mtime
721
+ - xattr
722
+ - symlink 本体和目标
723
+
724
+ 这应作为独立能力设计,避免把内容备份语义复杂化。
725
+
726
+ ### tsbx 文档同步
727
+
728
+ `tsbx-macos` 仓库应继续记录底层二进制构建、patch 和 demo。agent-cli 本文档记录产品化集成链路。后续如果修改 zsh 或 toybox hook,需要同时更新:
729
+
730
+ - `tsbx-macos/README.md`
731
+ - `tsbx-macos/zsh-macos/README.md`
732
+ - `packages/agent-cli/docs/brokered-shell-macos.md`
733
+
734
+ ## safe-delete → broker IPC 统一删除链路
735
+
736
+ ### 背景
737
+
738
+ safe-delete shim(bash `rm`/`unlink`/`rmdir`、Node.js `fs.unlinkSync` 等、Python `os.remove` 等)原本各自通过 `genie-trash` 二进制或平台回收站 API 直接移入回收站。这条链路**缺少**以下能力:
739
+
740
+ 1. fileSafety 策略检查(权限判断)
741
+ 2. 符号链接解析安全校验
742
+ 3. `ModifyBackup`(修改前备份)
743
+ 4. 集中审计
744
+
745
+ 而 brokered shell 的 toybox `rm` 已通过 `HostFileOperation delete → broker IPC → host service → trash` 实现了上述能力。
746
+
747
+ ### 设计
748
+
749
+ 将 safe-delete 的删除入口统一路由到 broker IPC,使得**无论 brokeredShell 开关是否打开**,删除操作都经过 host service 的权限检查和备份。
750
+
751
+ ```
752
+ safe-delete shim (bash/Node/Python)
753
+ └─ try_broker_delete()
754
+ ├─ broker 成功 (ok=true) → 完成,文件已移入回收站
755
+ ├─ broker 拒绝 (deny) → fail-closed,不降级
756
+ └─ broker 不可用 (exit 2) → fallback 到本地 trash_one() / trashItem()
757
+ ```
758
+
759
+ ### 关键改动
760
+
761
+ 1. **`brokered-shell-env.ts`**:broker IPC 环境变量(`CODEBUDDY_BROKERED_FS_HOOK_ENABLED`、`CODEBUDDY_SANDBOX_BROKER_IPC_ADDRESS` 等)仅在 WorkBuddy Desktop 环境下注入,但不受 `brokeredShell` disabled 开关控制。`brokeredShell` 仅控制托管 runtime 产物(toybox、zsh、brokered-bin)的注入。
762
+
763
+ 2. **`safe-delete-broker-delete.cjs`**(新增):独立 CJS 脚本,bash shim 可通过 `node safe-delete-broker-delete.cjs <path>` 调用。连接 broker IPC Unix socket,发送 `HostFileOperation delete` 请求。Exit code: 0=成功,1=拒绝(fail-closed),2=不可用(fallback)。
764
+
765
+ 3. **`safe-delete-common.sh`**:`try_trash()` 前新增 `try_broker_delete()`,优先走 broker IPC。
766
+
767
+ 4. **`node-safe-delete-shim.cjs`**:`tryTrash()` 前新增 `tryBrokerDelete()`,同步调用 broker IPC(`spawnSync` + helper script 模式,与 `node-brokered-fs-shim.cjs` 一致)。
768
+
769
+ 5. **`safe-delete-env.ts`**:注入 `CODEBUDDY_SAFE_DELETE_BROKER_DELETE` 环境变量(指向 `safe-delete-broker-delete.cjs` 路径)。
770
+
771
+ ### 旧代码的保留
772
+
773
+ 本地 trash 逻辑(`trash_darwin()` / `trashOnMac()` / `_platform_trash()` / `genie-trash` 二进制)**不删除**,作为 broker IPC 不可用时的 fallback:
774
+
775
+ - 非 macOS 平台
776
+ - sandbox 未启用(IPC server 不存在)
777
+ - agent-cli 独立 CLI 模式
778
+ - IPC server 未启动或 socket 连接失败