| 1 | # 钩子(Hooks) |
| 2 | |
| 3 | > 本文翻译自英文版 [HOOKS.md](../HOOKS.md),与英文修订 `0fe366bba`(2026-08-15)同步。 |
| 4 | |
| 5 | Hooks 会在 Codewhale **TUI** 到达生命周期节点时运行一条 shell 命令。它们是普通进程:通过环境变量接收上下文,其中一些会在 stdin 上收到 JSON 载荷,还有三个可以引导 Codewhale 接下来做什么。 |
| 6 | |
| 7 | 本页是当前已实现内容的权威参考。与 `config.toml` 其余部分重叠的配置语法见 [CONFIGURATION.md](CONFIGURATION.md);本文件是逐事件约定的契约。 |
| 8 | |
| 9 | ## 适用范围 |
| 10 | |
| 11 | Hooks 是 **TUI 运行时功能**。每个触发点都位于交互式 TUI 以及它所驱动的引擎回合循环中。 |
| 12 | |
| 13 | | 界面 | 是否触发 hooks | |
| 14 | | --- | --- | |
| 15 | | `codewhale` / `codew` 交互式 TUI | 是 | |
| 16 | | `codewhale exec`(无头一次性执行) | 否 | |
| 17 | | `codewhale` CLI 分发器及其子命令 | 否 | |
| 18 | | app-server / ACP | 否 | |
| 19 | | `workflow` 工具和子代理 *内部机制* | 否——但 TUI 会在它们周围触发 `subagent_spawn` / `subagent_complete` | |
| 20 | | 公共 API | 不存在 | |
| 21 | |
| 22 | 本仓库中的 `crates/hooks` event-sink crate 是一个无关的内部机制。它与这里描述的 hooks 不共享任何配置、事件名称或契约。 |
| 23 | |
| 24 | ## 快速开始 |
| 25 | |
| 26 | ```toml |
| 27 | # ~/.codewhale/config.toml |
| 28 | [hooks] |
| 29 | enabled = true |
| 30 | |
| 31 | [[hooks.hooks]] |
| 32 | name = "announce" |
| 33 | event = "session_start" |
| 34 | command = "echo 'Codewhale session started'" |
| 35 | ``` |
| 36 | |
| 37 | 在 TUI 中运行 `/hooks` 可以列出已配置的内容、全局开关是否开启,以及任何在加载时被拒绝的条目。运行 `/hooks events` 可查看事件名称。 |
| 38 | |
| 39 | ## 配置 |
| 40 | |
| 41 | ```toml |
| 42 | [hooks] |
| 43 | enabled = true # 全局开关;false 会抑制所有 hook |
| 44 | default_timeout_secs = 30 # 见下面的超时说明 |
| 45 | working_dir = "/path/to/dir" # 默认:会话工作区 |
| 46 | |
| 47 | [[hooks.hooks]] |
| 48 | event = "tool_call_before" # 必填;下面是 11 个名称之一 |
| 49 | command = "~/.codewhale/hooks/gate.sh" # 必填;Unix 上是 `sh -c`,Windows 上是 `cmd /C` |
| 50 | name = "gate" # 可选;/hooks 和日志行中的标签 |
| 51 | timeout_secs = 30 # 可选,默认 30 |
| 52 | background = false # 可选;在 hook worker 内前台运行 |
| 53 | continue_on_error = true # 可选,默认 true |
| 54 | condition = { type = "tool_name", name = "exec_shell" } # 可选 |
| 55 | ``` |
| 56 | |
| 57 | `timeout_secs` 说明(按实现陈述):当设置了 `[hooks].default_timeout_secs` 时,它会**覆盖**每个 hook 自己的 `timeout_secs`,而不仅仅是给省略该项的 hook 提供默认值。如果你希望各 hook 各自的超时生效,请保持不设置它。`/hooks list` 会显示运行时实际应用的超时,并在有覆盖生效时指明该覆盖。 |
| 58 | |
| 59 | `default_timeout_secs = 0` 会在**加载时被拒绝**。由于该值会替换每个 hook 自己的 `timeout_secs`,这里的零会让配置中的每个 hook 立即超时——包括 `tool_call_before` 门,从而拒绝每个匹配的工具调用。该覆盖会被忽略,各 hook 自己的 `timeout_secs` 生效,hooks 本身仍然会加载,拒绝情况由 `/hooks list` 在 *configuration problems* 下列出。每个 hook 自己的 `timeout_secs = 0` 也会被拒绝,但那只会丢弃写出它的那一个 hook。 |
| 60 | |
| 61 | Hooks 以工作区(或 `working_dir`)作为当前目录运行。 |
| 62 | |
| 63 | ### 超时 |
| 64 | |
| 65 | 超时对**前台和后台 hooks 一视同仁**。超时发生时: |
| 66 | |
| 67 | - hook 的整个进程组会被杀死——Unix 进程组、Windows Job Objects——因此会派生子进程的 hook 不会活过它的预算; |
| 68 | - 子进程随后被回收,所以通常不会留下脱离或僵死的进程; |
| 69 | - 前台 hook 的结果为 `success = false`、`exit_code = None`、空的 `stdout`/`stderr`,以及 `error = "Hook timed out after Ns"`; |
| 70 | - 后台 hook 的超时会在 `hooks` 目标下以 `warn` 级别记录日志。不会向调用方报告任何内容,因为调用方在提交 hook 的那一刻就已停止等待。 |
| 71 | |
| 72 | **终止是尽力而为的,且被保证的边界是 Codewhale 的,而非操作系统的。** kill 可能无法落地——Unix 上进程卡在不可中断状态,Windows 上受保护进程能扛过 `TerminateJobObject`——任何用户态程序都无法承诺更多。Codewhale 保证的是它停止等待:释放 containment handle(这会重新向 Unix 进程组发信号,并关闭随关闭即杀的 Windows Job Object),回收只有一个短暂的有界窗口。如果子进程仍无法确认已死,会以 `warn` 级别记录,前台结果也会如实说明——`error = "hook could not be reaped after its timeout"` 而不是更强的超时措辞。因此,超时的 hook 永远不会阻塞回合,但请把"已杀死"视为尽力而为,而非绝对保证。 |
| 73 | |
| 74 | ### 后台 hooks |
| 75 | |
| 76 | `background = true` 描述的是真实的调度,而不只是一个配置标志。后台 hook 是**提交后绝不等待**的: |
| 77 | |
| 78 | - 它以非阻塞方式进入固定的 32 项 supervisor 队列,由两个持续运行的 worker 消费并应用上述超时;队列饱和或 supervisor 丢失是一次失败的提交,任何一次调用都不会创建自己独立的分离 supervisor 线程; |
| 79 | - 它收到与该事件前台形式相同的环境变量和相同的 stdin JSON 载荷——载荷契约不变,变的只是引导能力; |
| 80 | - 它的 stdout 和 stderr 会被丢弃(`Stdio::null()`),因此它永远无法返回判定; |
| 81 | - 运行时交给调用方的 `HookResult` 会被标记为后台提交,且不携带退出码。引导代码读取 `observed_exit_code()`,对后台 hook 而言它是 `None`,因此后台 hook 永远无法 allow、deny、ask 或改写任何内容。 |
| 82 | |
| 83 | `shell_env` 完全忽略 `background`——它的 stdout *就是*契约,所以它总是前台运行。`/hooks list` 会将其报告为配置警告,并且不把该 hook 标注为 `[bg]`。 |
| 84 | |
| 85 | 仅观察的 UI 事件通过非阻塞 `try_send` 提交到一个 32 项队列,由两个持续运行的 worker 消费。已配置的前台观察者仍会在某个 worker 内按配置顺序被等待,但终端事件循环从不等待它的进程,也从不按事件创建线程。队列饱和或分发器丢失会丢弃该观察者事件,并产生一个事件专属的错误 toast,它能挺过代理普通的进度状态更新。引导事件保留其门或变换语义:fresh/queued `message_submit` 分发通过有界结果通道报告,同回合的引导在调用引擎引导路径之前于阻塞 worker 上执行变换,而 `tool_call_before` / `shell_env` 在引擎或工具 worker 上执行,而非终端事件循环。 |
| 86 | |
| 87 | ### hook 进程环境 |
| 88 | |
| 89 | hook 命令继承 Codewhale 进程的环境,外加该事件对应的 `DEEPSEEK_*` 变量。Codewhale 不会过滤这种继承,所以请像对待你在启动 Codewhale 的同一个 shell 中键入的任何命令那样对待 hook:那里导出的任何内容对它都可见。 |
| 90 | |
| 91 | `shell_env` hook 提供的命令则*不是*这样——参见 [`shell_env`](#shell_env) 中管辖**本地** `exec_shell` 的有界 allowlist,以及改配置为外部 sandbox 后端时会发生什么变化(后端拥有自己的基础环境,你的 `shell_env` 值会被传输给它)。 |
| 92 | |
| 93 | ### 条件 |
| 94 | |
| 95 | | 条件 | 匹配 | 支持于 | |
| 96 | | --- | --- | --- | |
| 97 | | `{ type = "always" }` | 每次调用(省略时的默认值也是它) | 每个事件 | |
| 98 | | `{ type = "tool_name", name = "exec_shell" }` | 精确工具名;支持 `*` 通配,例如 `mcp__*` | `tool_call_before`、`tool_call_after`、`shell_env`、`on_error` | |
| 99 | | `{ type = "tool_category", category = "shell" }` | 工具类别 | `tool_call_before`、`tool_call_after`、`shell_env`、`on_error` | |
| 100 | | `{ type = "mode", mode = "plan" }` | 上下文的模式字符串,不区分大小写 | 除 `shell_env` 外的每个事件 | |
| 101 | | `{ type = "exit_code", code = 1 }` | 工具实际报告的退出码 | `tool_call_after`、`on_error` | |
| 102 | | `{ type = "all", conditions = [...] }` | 每个嵌套条件 | 每个事件 | |
| 103 | | `{ type = "any", conditions = [...] }` | 至少一个嵌套条件 | 每个事件 | |
| 104 | |
| 105 | 有三条规则防止条件撒谎: |
| 106 | |
| 107 | - **`exit_code` 需要真实的退出码。** 它只在事件确实观察到进程退出码时匹配——`tool_call_after`,或工具失败时的 `on_error`,两种情况都针对 `exec_shell` 这类由进程支撑的工具。不报告退出码的工具永远不会匹配 `exit_code` 条件;默认值、零或成功标志都不能满足该条件。该值是 64 位整数,因此 `3221225477`(`0xC0000005`)这样的 Windows 崩溃码也可以匹配。 |
| 108 | - **支持工具作用域的 `on_error` hooks。** `on_error` 会因传输和容量错误*以及*工具失败而触发;工具失败的触发会携带工具名、调用 id、结果和报告的退出码。因此,`on_error` 上的 `tool_name` / `tool_category` / `exit_code` 条件是有效的配置。背后没有工具的 `on_error` 触发只是不匹配这样的条件——它在分发时被跳过,而不是在加载时被拒绝。 |
| 109 | - **不支持的条件会在加载时被拒绝。** 引用其事件永远不会携带的上下文的条件永远无法匹配,佩戴这种条件的 hook 会静默失效——危险的形式是操作员以为已武装的 `deny` 门。Codewhale 会在加载时丢弃这些 hooks,在 `hooks` tracing 目标下记录原因,并在 `/hooks list` 中显示为 `rejected:`。`all` / `any` 内的嵌套谓词也会被检查。带 `timeout_secs = 0` 或空 `command` 的 hook 也会以同样的方式被拒绝。拒绝是**逐条**的:一个坏 hook 永远不会连累另一个,即使两者共享同一个 `name` 或都未命名。 |
| 110 | |
| 111 | ### 项目本地 hooks |
| 112 | |
| 113 | 仓库可以附带 `<workspace>/.codewhale/hooks.toml`,使用相同的结构,但只有它的 `[[hooks]]` 条目会被合并——项目文件不能更改 `enabled`、`default_timeout_secs` 或 `working_dir`,这些始终来自你自己的配置。由于 hooks 是可执行配置,项目 hooks 只有在用户自有配置中信任该工作区**之后**才会加载;仅靠会话 `/trust on` 不会启用它们。受信任的项目 hooks 会追加在全局 hooks 之后,因此它们最后运行,并在 `updatedInput` 平局时胜出。格式错误的受信任项目文件会记录一条警告,Codewhale 只回退到全局 hooks。校验针对合并后的集合运行,因此被拒绝的项目 hook 与被拒绝的全局 hook 报告方式相同。 |
| 114 | |
| 115 | ## 11 个事件 |
| 116 | |
| 117 | | 事件 | 触发时机 | 引导 | |
| 118 | | --- | --- | --- | |
| 119 | | `session_start` | 一次,引擎就绪后、首次绘制前 | observer | |
| 120 | | `session_end` | 一次,优雅关闭时 | observer | |
| 121 | | `message_submit` | 在提交的消息到达历史或模型之前 | **可以替换或阻止文本** | |
| 122 | | `tool_call_before` | 每次工具调用执行之前 | **可以 allow / deny / ask、改写输入、添加上下文** | |
| 123 | | `tool_call_after` | 每个工具结果落定后,包括 transcript 不重绘的完成 | observer | |
| 124 | | `mode_change` | 每次应用的 Plan/Work/Operate 转换(`Act` 是 Work 的兼容别名) | observer | |
| 125 | | `on_error` | 传输、容量和认证错误,以及工具失败时 | observer | |
| 126 | | `turn_end` | 回合完成且回合后状态更新后 | observer | |
| 127 | | `subagent_spawn` | 子代理启动时 | observer | |
| 128 | | `subagent_complete` | 子代理完成、失败或被取消时 | observer | |
| 129 | | `shell_env` | 每次 `exec_shell` 调用之前 | **贡献环境变量** | |
| 130 | |
| 131 | ### “observer”到底意味着什么 |
| 132 | |
| 133 | Observer 意味着 Codewhale 会忽略 hook 的**结果**:stdout 被丢弃,非零退出被记录为警告,回合、工具结果、子代理或错误都不会因它而改变。 |
| 134 | |
| 135 | Observer 并**不**意味着无副作用。observer hook 是以你的凭据运行的任意 shell 命令。它可以写文件、推送提交、呼叫值班轮换,或删除工作区。它唯一做不到的是改变 Codewhale 自己接下来要做的事。 |
| 136 | |
| 137 | 引导 allowlist 恰好是三个事件——`message_submit`、`tool_call_before`、`shell_env`——并且由一个覆盖每个变体的测试断言,因此新事件默认是 observer。 |
| 138 | |
| 139 | ### 会话身份 |
| 140 | |
| 141 | 同一个 TUI 会话中的每个事件携带相同的 `DEEPSEEK_SESSION_ID`。该 id 在启动时铸造一次,形式为 `sess_xxxxxxxx`,并且能挺过工作区切换和添加项目 hooks 的信任决策——两者都会重新加载 hook 集,而不会开始新会话。引擎触发的 `tool_call_before` 与 UI 触发的事件报告相同的 id,因此工具记录可以与周围的会话记录关联。 |
| 142 | |
| 143 | `session_end` 在排队的启动默认写入被排空后、应用仍然存活时触发,因此它观察到的是落定的结束状态,而不是半拆除的状态。 |
| 144 | |
| 145 | ## 环境变量 |
| 146 | |
| 147 | 每个 hook 都会收到这些变量中适用于其事件的那一部分。`DEEPSEEK_` 前缀为兼容改版前编写的 hooks 而保留。 |
| 148 | |
| 149 | | 变量 | 设置于 | 说明 | |
| 150 | | --- | --- | --- | |
| 151 | | `DEEPSEEK_SESSION_ID` | 除 `shell_env` 外的每个事件 | `sess_xxxxxxxx`,整个会话保持稳定 | |
| 152 | | `DEEPSEEK_WORKSPACE` | 除 `shell_env` 外的每个事件 | 工作区绝对路径 | |
| 153 | | `DEEPSEEK_MODEL` | 除 `shell_env` 外的每个事件 | 当前生效的模型 id | |
| 154 | | `DEEPSEEK_MODE` | 除 `shell_env` 外的每个事件 | 见下面的模式拼写说明 | |
| 155 | | `DEEPSEEK_TOTAL_TOKENS` | UI 触发的事件 | 触发时的会话 token 总量 | |
| 156 | | `DEEPSEEK_MESSAGE` | `message_submit`、`subagent_*` | 截断至 5 000 字节并带 `...[truncated]` 标记 | |
| 157 | | `DEEPSEEK_ERROR` | `on_error` | 错误消息,截断至 5 000 字节 | |
| 158 | | `DEEPSEEK_PREVIOUS_MODE` | `mode_change` | 变更前的模式标签 | |
| 159 | | `DEEPSEEK_TOOL_NAME` | `tool_call_before`、`tool_call_after`、`shell_env`、`on_error`(工具失败) | | |
| 160 | | `DEEPSEEK_TOOL_CALL_ID` | `tool_call_before`、`tool_call_after`、`on_error`(工具失败) | 引擎调用 id;关联一次调用的 before/after/error | |
| 161 | | `DEEPSEEK_TOOL_ARGS` | `tool_call_before`、`shell_env` | 工具输入 JSON 预览,上限 10 000 字节 | |
| 162 | | `DEEPSEEK_TOOL_RESULT` | `tool_call_after`、`on_error`(工具失败) | 截断至 10 000 字节 | |
| 163 | | `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`、`on_error`(工具失败) | `true` / `false` | |
| 164 | | `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` 和 `on_error` **当工具报告了退出码时** | 否则不存在——绝不合成;64 位,因此 `3221225477` 这样的 Windows 崩溃码能完好保留 | |
| 165 | | `DEEPSEEK_SESSION_COST` | 提供成本时 | USD,六位小数 | |
| 166 | |
| 167 | **模式拼写说明。** UI 触发的事件(`session_start`、`session_end`、`message_submit`、`tool_call_after`、`mode_change`、`on_error`、`turn_end`、`subagent_*`)会将 `DEEPSEEK_MODE` 设为 UI 标签——`ACT`、`PLAN`、`OPERATE`。`tool_call_before` 在引擎内部触发,并使用引擎自己的模式拼写(`Agent`、`Plan`、`Operate`)。`mode` 条件不区分大小写比较,因此 `{ type = "mode", mode = "plan" }` 两者都能匹配,但精确字符串匹配 `$DEEPSEEK_MODE` 的 hook 应同时接受两种拼写。 |
| 168 | |
| 169 | **`shell_env` 是受限的那个。** 它只接收 `DEEPSEEK_TOOL_NAME` 和 `DEEPSEEK_TOOL_ARGS`——没有会话 id、工作区、模型或模式。因此,`shell_env` hook 上的 `{ type = "mode", … }` 条件会在加载时被拒绝;请改用 `tool_name` 或 `tool_category` 来限定作用域。 |
| 170 | |
| 171 | ## 引导事件 |
| 172 | |
| 173 | ### `message_submit` |
| 174 | |
| 175 | 在 stdin 上接收 JSON,并可能改写或阻止提交的文本。 |
| 176 | |
| 177 | ```json |
| 178 | { |
| 179 | "event": "message_submit", |
| 180 | "text": "original user text", |
| 181 | "text_bytes": 18, |
| 182 | "text_original_bytes": 18, |
| 183 | "text_truncated": false, |
| 184 | "session_id": "sess_12345678", |
| 185 | "workspace": "/path/to/workspace", |
| 186 | "mode": "ACT", |
| 187 | "model": "deepseek-chat", |
| 188 | "total_tokens": 1234 |
| 189 | } |
| 190 | ``` |
| 191 | |
| 192 | 完整的序列化 stdin 文档上限为 32 KiB。`text` 是在包含 JSON 转义和有界元数据后能容纳的最大确定性 UTF-8 前缀。`text_original_bytes` 记录生产者的完整字节长度,`text_bytes` 记录保留的前缀,`text_truncated` 说明两者是否不同。同样的边界适用于即时输入、恢复的队列条目、合并的引导,以及先前 hook 产生的文本。 |
| 193 | |
| 194 | - 以退出码 `0` 打印带非空字符串的 `{"text": "..."}` 会替换文本 |
| 195 | - 退出码 `0` 但 stdout 为空,或 JSON 中没有 `text`,文本保持不变 |
| 196 | - `{"text": ""}` 或超过 32 000 字符的替换是无效 stdout,会被记录并忽略 |
| 197 | - 退出码 `2` 会在进入历史或分发之前阻止提交;结构化的 `reason` 字段提供一条有界、脱敏的消息显示在 TUI 中。非结构化的 stdout/stderr/error 输出绝不会被复制进拒绝信息 |
| 198 | - 其他非零退出遵循 `continue_on_error`:`true` 警告并继续,`false` 阻止提交 |
| 199 | - `background = true` 使 hook 仅观察——它仍然会在 stdin 上收到这个有界载荷,但无法变换或阻止 |
| 200 | |
| 201 | 多个 `message_submit` hooks 按配置顺序运行,每个都会看到前一个 hook 的输出。 |
| 202 | |
| 203 | ### `tool_call_before` |
| 204 | |
| 205 | 通过环境变量接收工具上下文,并可以退出码 `0` 在 stdout 上打印 JSON 判定: |
| 206 | |
| 207 | ```json |
| 208 | { |
| 209 | "decision": "allow", |
| 210 | "reason": "human-readable explanation, used for deny", |
| 211 | "updatedInput": { "command": "ls -la" }, |
| 212 | "additionalContext": "text appended to the tool result for the model" |
| 213 | } |
| 214 | ``` |
| 215 | |
| 216 | - `deny` 阻止该工具;模型会收到携带 `reason` 的权限拒绝结果 |
| 217 | - `ask` 在 Ask 和 Auto-Review 中强制交互式审批提示。Full Access 不会打开工具审批提示,因此 `ask` 不会降级它 |
| 218 | - `updatedInput` 必须是序列化后不超过 32 KiB 的对象,并替换工具输入;最后一个 hook 胜出 |
| 219 | - `additionalContext` 以 `[hook context] ...` 追加到工具结果;多个 hooks 会拼接 |
| 220 | - `reason` 和 `additionalContext` 在使用前有界并净化:每个字段上限 2 000 字符,一次工具调用拼接后的上下文上限 8 000,控制字符会被剥离(因此 hook stdout 无法重绘 TUI 或在 transcript 中伪造结构),被截断的值携带 `…[truncated]` 标记。因此,无论 hook 打印什么,它为回合上下文预算贡献的内容都是有界的 |
| 221 | - 退出码 `2` 是遗留的硬拒绝,无论 stdout 是什么都胜出 |
| 222 | - 空 stdout、非 JSON stdout 以及没有 `decision` 的 JSON 都意味着 allow |
| 223 | - 匹配 hooks 之间的优先级:无判定且 `continue_on_error = false` > deny > ask > allow |
| 224 | - `background = true` 的 hooks 会被提交且从不等待,因此它们没有判定,也无法引导;Codewhale 在为此事件配置了这样的 hook 时会记录一条警告 |
| 225 | |
| 226 | **无法作答的门不是许可。** 如果前台 `tool_call_before` hook 没有产生判定——它超时了、进程无法启动,或严格进程在没有显式 JSON 判定的情况下以非零退出——并且*那个 hook* 配置了 `continue_on_error = false`,则该工具调用会被拒绝。严格性从实际运行的 hook 读取,而非从事件读取:条件未匹配 `exec_shell` 调用的严格 `write_file` 门,对该调用是否继续没有发言权;宽容 hook 的超时也绝不会仅仅因为配置中存在其他严格 hook 就拒绝。无论哪种情况,每个无判定结果都会被记录。 |
| 227 | |
| 228 | 拒绝消息只指名 hook 和原因,别无其他:hook 名被截断,细节被截断,控制字符被剥离,spawn 失败按错误种类(`NotFound`、`PermissionDenied`、…)报告,而不是回显命令行或解析后的解释器路径。 |
| 229 | |
| 230 | ### `shell_env` |
| 231 | |
| 232 | 在每次 `exec_shell` 之前同步运行,其 stdout 被解析为 `KEY=VALUE` 行。开头的 `export ` 会被剥离,`#` 注释行和空行会被跳过,值周围成对的单引号或双引号会被移除。后运行的 hooks 覆盖先运行的。用它来处理临时凭据、按 skill 调整 `PATH`,或短命 token。 |
| 233 | |
| 234 | `background` 对此事件被忽略:hook 总是前台运行,因为它的 stdout 就是契约。 |
| 235 | |
| 236 | shell 无法承载的条目会被丢弃,而不是放任其破坏工具调用:空名称;含空白、`=`、控制字符或 NUL 的名称;含 NUL 的值;超过 32 KiB 的值;以及单个 hook 累计输出超过 256 KiB 的任何内容。每次丢弃只按键名记录日志。`shell_env` hook 是普通进程,其 stdout 可以包含任何内容——"hook 打印了奇怪的东西"绝不能变成"`exec_shell` 调用中止了"。 |
| 237 | |
| 238 | **shell 命令最终确切得到什么——本地执行。** 当 `exec_shell` 在本地运行命令(默认情况)时,它不继承 Codewhale 的环境。它的环境按如下方式构建: |
| 239 | |
| 240 | 1. 一份净化的固定父变量 allowlist——`PATH`、`HOME`、`USER`、`LANG` 和其他 `LC_*`/locale 条目、`TERM`、`SHELL`、`TMPDIR`、Windows 系统与 MSVC 工具链条目——仅此而已。allowlist 之外的变量,包括任何看起来像秘密的内容,都会被丢弃; |
| 241 | 2. 然后,你的 `shell_env` hooks 产生的 `KEY=VALUE` 对叠加应用在上面。这些是你配置的显式值,因此它们胜过 allowlist。 |
| 242 | |
| 243 | 因此,`shell_env` hook 是把凭据送进一次本地 `exec_shell` 调用的受支持方式。启动 Codewhale 的终端中导出的环境秘密**不会**自行转发给本地 `exec_shell`。 |
| 244 | |
| 245 | **配置了外部 sandbox 后端时,上面的 allowlist 不是契约。** 如果 `exec_shell` 被路由到已配置的 sandbox/执行后端,Codewhale 根本不会构建进程环境:它把命令和你的 `shell_env` 值作为额外环境变量交给后端,**后端拥有自己的基础环境**。除了你的值之外还存在什么——镜像内置的变量、后端自己的注入、远程 runner 导出的任何内容——由该后端决定,而非由上面的列表决定。不要假定本地 allowlist 在那里适用。 |
| 246 | |
| 247 | 披露说明,因为这对发出凭据的 hook 才是关键部分:**`shell_env` 值会被传输到已配置的后端。** 对远程或容器化后端而言,这意味着这些值会离开本机,并受该后端的日志记录、保留和访问控制约束。Codewhale 自己的审计日志仍然只记录键名,但这并不能说明后端会对这些值做什么。如果 `shell_env` hook 发出秘密,请将其限定在你信任该秘密的后端上——例如给 hook 加条件,或在这些 hooks 生效的会话中不配置外部后端。 |
| 248 | |
| 249 | 解析出的**键名——绝不是值**——会写入 `~/.codewhale/audit.log`,以便事后对会话进行核对。失败或超时的 hook 不贡献任何变量,也不会中止 shell 调用。 |
| 250 | |
| 251 | ```toml |
| 252 | [[hooks.hooks]] |
| 253 | name = "aws-creds" |
| 254 | event = "shell_env" |
| 255 | command = "aws-vault export my-profile --format=env" |
| 256 | condition = { type = "tool_category", category = "shell" } |
| 257 | ``` |
| 258 | |
| 259 | ## 结构化 observer 载荷 |
| 260 | |
| 261 | `turn_end`、`subagent_spawn` 和 `subagent_complete` 除了环境变量外,还会在 stdin 上接收 JSON。它们的 stdout 被忽略。这些事件的后台形式会在 stdin 上收到相同的载荷。 |
| 262 | |
| 263 | 其余 observer 事件——`session_start`、`session_end`、`tool_call_after`、`mode_change`、`on_error`——无论前台还是后台形式,都只接收环境变量,没有 stdin 载荷。 |
| 264 | |
| 265 | ### `turn_end` |
| 266 | |
| 267 | 在回合后状态、用量总计、成本核算、通知、回执和队列恢复都已更新之后、排队的后续分发之前触发——这样载荷可以报告排队数量,而 hook 无法改变接下来要发送的内容。 |
| 268 | |
| 269 | ```json |
| 270 | { |
| 271 | "event": "turn_end", |
| 272 | "session_id": "sess_12345678", |
| 273 | "workspace": "/path/to/workspace", |
| 274 | "mode": "ACT", |
| 275 | "created_at": "2026-07-12T10:30:00+00:00", |
| 276 | "model_backed": true, |
| 277 | "provider": "deepseek", |
| 278 | "billing_surface": null, |
| 279 | "model": "deepseek-chat", |
| 280 | "turn_id": "turn_12345678", |
| 281 | "status": "completed", |
| 282 | "error": null, |
| 283 | "duration_ms": 1834, |
| 284 | "usage": { |
| 285 | "input_tokens": 1200, |
| 286 | "output_tokens": 180, |
| 287 | "prompt_cache_hit_tokens": 900, |
| 288 | "prompt_cache_miss_tokens": 300, |
| 289 | "prompt_cache_write_tokens": 0, |
| 290 | "reasoning_tokens": null, |
| 291 | "reasoning_replay_tokens": null |
| 292 | }, |
| 293 | "totals": { |
| 294 | "session_tokens": 1380, |
| 295 | "conversation_tokens": 1380, |
| 296 | "input_tokens": 1200, |
| 297 | "output_tokens": 180 |
| 298 | }, |
| 299 | "tool_count": 2, |
| 300 | "queued_message_count": 1, |
| 301 | "stop_hook_active": false |
| 302 | } |
| 303 | ``` |
| 304 | |
| 305 | `created_at` 锚定时间窗口定价。`provider` 和 `model` 标识模型支撑回合的有效路由。`billing_surface` 是对服务该回合的端点的一种可选、非秘密的分类(已识别的 StepFun 路由会发出 `stepfun-payg` 或 `stepfun-plan`);原始 base URL 永远不会写入 hook 记录。仅 shell、手动压缩和 purge 完成没有对应的 `TurnStarted`,因此它们报告 `model_backed: false`、`null` provider 和合成的 `lifecycle_<uuid>` 回合 id。`stop_hook_active` 目前始终为 `false`;它为防重入保护预留了空间。 |
| 306 | |
| 307 | ### `subagent_spawn` / `subagent_complete` |
| 308 | |
| 309 | ```json |
| 310 | { |
| 311 | "event": "subagent_complete", |
| 312 | "agent_id": "agent_1", |
| 313 | "session_id": "sess_12345678", |
| 314 | "workspace": "/path/to/workspace", |
| 315 | "mode": "ACT", |
| 316 | "model": "deepseek-chat", |
| 317 | "total_tokens": 1234, |
| 318 | "result_preview": "bounded preview of the result", |
| 319 | "result_truncated": false, |
| 320 | "status": "completed" |
| 321 | } |
| 322 | ``` |
| 323 | |
| 324 | `subagent_spawn` 改为携带 `prompt_preview` / `prompt_truncated`,且没有 `status`。两个载荷都有意设了界:预览被截断,而不是传送完整提示或结果。这些 hooks 仅观察——失败不会影响子代理调度、提示或结果,`continue_on_error` 没有效果,因为后面匹配的 hooks 总是会运行。 |
| 325 | |
| 326 | ## 失败行为 |
| 327 | |
| 328 | - 非零退出会在 `hooks` tracing 目标下以 `warn` 级别记录日志,包含 hook 名、事件、退出码、时长和一个通用失败类别。原始 stdout/stderr/error 文本不会持久化在日志回执中。 |
| 329 | - 对于 `execute` 路径的事件,`continue_on_error = false` 会停止该事件后续的 hooks;除 `tool_call_before`(见上文)外,它不会回滚触发它们的行为。 |
| 330 | - 结构化 observer 事件(`turn_end`、`subagent_*`)总是继续到下一个匹配的 hook。 |
| 331 | - Observer 事件使用有界的持久分发器。队列已满和分发器不可用的提交不会静默重试;TUI 会保留一条事件专属的错误 toast,与普通状态行分开。 |
| 332 | - 超过超时的 hook,其整个进程组会被杀死,然后被回收,前台或后台皆然——尽力而为,回收等待有界;见[超时](#超时)。 |
| 333 | |
| 334 | ## 安全说明 |
| 335 | |
| 336 | - Hooks 是来自你自己配置的任意 shell 命令;请把 `~/.codewhale/config.toml` 当作可执行文件对待。 |
| 337 | - 项目提供的 hooks 需要在用户自有配置中作出明确的工作区信任决策。 |
| 338 | - hook 命令继承 Codewhale 自己的环境。本地 `exec_shell` 不会——见 [`shell_env`](#shell_env)。 |
| 339 | - `shell_env` 审计记录只包含键名。这覆盖 Codewhale 自己的日志记录;配置了外部 sandbox 后端时,值本身会被传输到该后端,之后受其处理方式约束。 |
| 340 | - 使用外部 sandbox 后端时,本地父变量 allowlist 不适用——后端拥有自己的基础环境。 |
| 341 | - 载荷预览、工具参数/结果、错误消息、捕获的 stdout 和 stderr、替换消息和引导对象都有界,因此 hook 输入或输出不可能成为 transcript 的无界副本。 |
| 342 | - Codewhale 在拒绝信息中持久化的任何内容都不会回显 stdin 载荷、hook 环境、原始 stdout/stderr/error、命令行或解析后的文件系统路径。`/hooks list` 显示净化后的单行命令预览,上限 60 字符;它不是逐字副本。结构化拒绝原因有界,并对类似路径、参数、命令和秘密的 token 脱敏,包括带引号或 `key=value` 的形式以及 `Authorization: Bearer …`。 |
| 343 |