| 1 | # Hooks |
| 2 | > 阅读简体中文版:[zh_hans/HOOKS.md](zh_hans/HOOKS.md) |
| 3 | |
| 4 | Hooks run a shell command when the Codewhale **TUI** reaches a lifecycle |
| 5 | point. They are plain processes: they receive context through environment |
| 6 | variables, some receive a JSON payload on stdin, and three of them can steer |
| 7 | what Codewhale does next. |
| 8 | |
| 9 | This page is the authoritative reference for what is implemented today. |
| 10 | Configuration syntax that overlaps with the rest of `config.toml` lives in |
| 11 | [CONFIGURATION.md](CONFIGURATION.md); this file is the event-by-event |
| 12 | contract. |
| 13 | |
| 14 | ## Scope |
| 15 | |
| 16 | Hooks are a **TUI runtime feature**. Every firing point lives in the |
| 17 | interactive TUI and in the engine turn loop it drives. |
| 18 | |
| 19 | | Surface | Fires hooks | |
| 20 | | --- | --- | |
| 21 | | `codewhale` / `codew` interactive TUI | yes | |
| 22 | | `codewhale exec` (headless one-shot) | opt-in: `--hooks` fires `tool_call_before` and `shell_env` | |
| 23 | | the `codewhale` CLI dispatcher and its subcommands | no | |
| 24 | | app-server / ACP | no | |
| 25 | | the `workflow` tool and sub-agent *internals* | no — but the TUI fires `subagent_spawn` / `subagent_complete` around them | |
| 26 | | public API | there is none | |
| 27 | |
| 28 | The `crates/hooks` event-sink crate in this repository is an unrelated |
| 29 | internal mechanism. It shares no configuration, no event names, and no |
| 30 | contract with the hooks described here. |
| 31 | |
| 32 | ### `codewhale exec --hooks` |
| 33 | |
| 34 | Headless runs fire no hooks by default — a CI job should not start paging an |
| 35 | on-call rotation merely because a config exists. `codewhale exec --hooks` |
| 36 | opts the run in. The engine-side events are `tool_call_before` (exit code 2 |
| 37 | still denies the call; `ask` resolves fail-closed because nothing can prompt |
| 38 | headlessly) and `shell_env`. UI-driven events such as `session_start`, |
| 39 | `message_submit`, and `turn_end` do not fire — they live in the interactive |
| 40 | shell, not the turn loop. Fleet worker subprocesses never fire operator |
| 41 | hooks. Independently of this flag, `permissions.toml` typed rules already |
| 42 | apply to `exec` — the run drives the same turn loop, and a `deny` blocks in |
| 43 | every mode. |
| 44 | |
| 45 | ## Quick start |
| 46 | |
| 47 | ```toml |
| 48 | # ~/.codewhale/config.toml |
| 49 | [hooks] |
| 50 | enabled = true |
| 51 | |
| 52 | [[hooks.hooks]] |
| 53 | name = "announce" |
| 54 | event = "session_start" |
| 55 | command = "echo 'Codewhale session started'" |
| 56 | ``` |
| 57 | |
| 58 | Run `/hooks` in the TUI to list what is configured, whether the global switch |
| 59 | is on, and any entry that was rejected at load. Run `/hooks events` for the |
| 60 | event names. |
| 61 | |
| 62 | ## Configuration |
| 63 | |
| 64 | ```toml |
| 65 | [hooks] |
| 66 | enabled = true # global switch; false suppresses every hook |
| 67 | default_timeout_secs = 30 # see the timeout note below |
| 68 | working_dir = "/path/to/dir" # default: the session workspace |
| 69 | |
| 70 | [[hooks.hooks]] |
| 71 | event = "tool_call_before" # required; one of the 11 names below |
| 72 | command = "~/.codewhale/hooks/gate.sh" # required; `sh -c` on Unix, `cmd /C` on Windows |
| 73 | name = "gate" # optional label for /hooks and log lines |
| 74 | timeout_secs = 30 # optional, default 30 |
| 75 | background = false # optional; foreground inside the hook worker |
| 76 | continue_on_error = true # optional, default true |
| 77 | condition = { type = "tool_name", name = "exec_shell" } # optional |
| 78 | ``` |
| 79 | |
| 80 | `timeout_secs` note, stated as implemented: when `[hooks].default_timeout_secs` |
| 81 | is set it **overrides** every hook's own `timeout_secs`, it does not merely |
| 82 | supply a default for hooks that omit one. Leave it unset if you want per-hook |
| 83 | timeouts to apply. `/hooks list` shows the timeout the runtime will actually |
| 84 | apply, and names the override when one is in force. |
| 85 | |
| 86 | `default_timeout_secs = 0` is **rejected at load**. Because the value replaces |
| 87 | every hook's own `timeout_secs`, a zero there would expire every hook in the |
| 88 | config immediately — including a `tool_call_before` gate, which then denies |
| 89 | every matching tool call. The override is ignored, per-hook `timeout_secs` |
| 90 | applies, the hooks themselves still load, and the rejection is reported by |
| 91 | `/hooks list` under *configuration problems*. Per-hook `timeout_secs = 0` is |
| 92 | rejected too, but that only drops the one hook that wrote it. |
| 93 | |
| 94 | Hooks run with the workspace (or `working_dir`) as the current directory. |
| 95 | |
| 96 | ### Timeouts |
| 97 | |
| 98 | The timeout applies to **foreground and background hooks alike**. When it |
| 99 | expires: |
| 100 | |
| 101 | - the hook's whole process group is killed — Unix process groups, Windows Job |
| 102 | Objects — so a hook that spawns children does not outlive its budget; |
| 103 | - the child is then reaped, so nothing is normally left detached or zombied; |
| 104 | - a foreground hook's result is `success = false`, `exit_code = None`, empty |
| 105 | `stdout`/`stderr`, and `error = "Hook timed out after Ns"`; |
| 106 | - a background hook's timeout is logged at `warn` under the `hooks` target. |
| 107 | Nothing is reported to the caller, because the caller stopped waiting the |
| 108 | moment it submitted the hook. |
| 109 | |
| 110 | **Termination is best-effort, and the bound that is guaranteed is Codewhale's, |
| 111 | not the OS's.** The kill can fail to land — a process wedged in an |
| 112 | uninterruptible state on Unix, a `TerminateJobObject` a protected process |
| 113 | survives on Windows — and no user-space program can promise otherwise. What |
| 114 | Codewhale does guarantee is that it stops waiting: the containment handle is |
| 115 | released (which re-signals the Unix process group and closes the kill-on-close |
| 116 | Windows Job Object) and the reap gets one short bounded window. If the child |
| 117 | still cannot be confirmed dead, that is logged at `warn` and the foreground |
| 118 | result says so — `error = "hook could not be reaped after its timeout"` rather |
| 119 | than the stronger timeout wording. So a timed-out hook never blocks the turn, |
| 120 | but treat "killed" as best-effort rather than absolute. |
| 121 | |
| 122 | ### Background hooks |
| 123 | |
| 124 | `background = true` describes real scheduling, not just a config flag. A |
| 125 | background hook is **submitted, never awaited**: |
| 126 | |
| 127 | - it is enqueued without blocking into a fixed 32-entry supervisor queue, |
| 128 | drained by two persistent workers that apply the timeout above; saturation |
| 129 | or supervisor loss is a failed submission, and no invocation creates its |
| 130 | own detached supervisor thread; |
| 131 | - it receives the same environment variables and the same stdin JSON payload |
| 132 | as the foreground form of that event — the payload contract does not change, |
| 133 | only the steering does; |
| 134 | - its stdout and stderr are discarded (`Stdio::null()`), so it can never |
| 135 | return a verdict; |
| 136 | - the `HookResult` the runtime hands its caller is flagged as a background |
| 137 | submission and carries no exit code. Steering code reads |
| 138 | `observed_exit_code()`, which is `None` for a background hook, so a |
| 139 | background hook can never allow, deny, ask, or rewrite anything. |
| 140 | |
| 141 | `shell_env` ignores `background` entirely — its stdout *is* the contract, so |
| 142 | it always runs in the foreground. `/hooks list` reports that as a |
| 143 | configuration warning, and does not label the hook `[bg]`. |
| 144 | |
| 145 | Observer-only UI events are submitted with non-blocking `try_send` to one |
| 146 | 32-entry queue drained by two persistent workers. A configured foreground |
| 147 | observer is still awaited in config order inside a worker, but the terminal |
| 148 | event loop never waits on its process and never creates a thread per event. |
| 149 | Queue saturation or dispatcher loss drops that observer event and produces an |
| 150 | event-specific error toast that survives an agent's ordinary progress-status |
| 151 | update. Steering events retain their gate or transform semantics: |
| 152 | fresh/queued `message_submit` dispatch reports through a bounded result |
| 153 | channel, same-turn steering runs that transform on the blocking worker before |
| 154 | calling the engine steer path, and `tool_call_before` / `shell_env` execute on |
| 155 | the engine or tool worker rather than the terminal event loop. |
| 156 | |
| 157 | ### The hook process environment |
| 158 | |
| 159 | A hook command inherits the environment of the Codewhale process, plus the |
| 160 | `DEEPSEEK_*` variables for its event. Codewhale does not filter that |
| 161 | inheritance, so treat a hook exactly as you would treat any command you type |
| 162 | in the same shell that launched Codewhale: whatever is exported there is |
| 163 | visible to it. |
| 164 | |
| 165 | This is *not* true of the command a `shell_env` hook feeds — see |
| 166 | [`shell_env`](#shell_env) for the bounded allowlist that governs a **local** |
| 167 | `exec_shell`, and for what changes when an external sandbox backend is |
| 168 | configured instead (the backend owns its base environment, and your |
| 169 | `shell_env` values are transmitted to it). |
| 170 | |
| 171 | ### Conditions |
| 172 | |
| 173 | | Condition | Matches | Supported on | |
| 174 | | --- | --- | --- | |
| 175 | | `{ type = "always" }` | every invocation (also the default when omitted) | every event | |
| 176 | | `{ type = "tool_name", name = "exec_shell" }` | exact tool name; `*` globs are supported, e.g. `mcp__*` | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` | |
| 177 | | `{ type = "tool_category", category = "shell" }` | tool category | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` | |
| 178 | | `{ type = "mode", mode = "plan" }` | the context's mode string, case-insensitive | every event **except** `shell_env` | |
| 179 | | `{ type = "exit_code", code = 1 }` | the exit code the tool actually reported | `tool_call_after`, `on_error` | |
| 180 | | `{ type = "all", conditions = [...] }` | every nested condition | every event | |
| 181 | | `{ type = "any", conditions = [...] }` | at least one nested condition | every event | |
| 182 | |
| 183 | Three rules keep conditions from lying: |
| 184 | |
| 185 | - **`exit_code` needs a real exit code.** It matches only when the event |
| 186 | actually observed a process exit code — `tool_call_after`, or `on_error` for |
| 187 | a tool failure, in both cases for a process-backed tool such as `exec_shell`. |
| 188 | A tool that reports no exit code never matches an `exit_code` condition; the |
| 189 | condition is not satisfied by a default, a zero, or a success flag. The value |
| 190 | is a 64-bit integer, so a Windows crash code such as `3221225477` |
| 191 | (`0xC0000005`) is matchable. |
| 192 | - **Tool-scoped `on_error` hooks are supported.** `on_error` fires for |
| 193 | transport and capacity errors *and* for tool failures; the tool-failure |
| 194 | firing carries the tool name, call id, result, and reported exit code. A |
| 195 | `tool_name` / `tool_category` / `exit_code` condition on `on_error` is |
| 196 | therefore a valid configuration. An `on_error` firing with no tool behind it |
| 197 | simply does not match such a condition — it is skipped at dispatch, not |
| 198 | rejected at load. |
| 199 | - **Unsupported conditions are rejected at load.** A condition that references |
| 200 | context its event never carries can never match, and a hook wearing one is |
| 201 | silently inert — the dangerous form of that is a `deny` gate the operator |
| 202 | believes is armed. Codewhale drops those hooks at load, logs the reason |
| 203 | under the `hooks` tracing target, and shows them in `/hooks list` as |
| 204 | `rejected:`. Nested predicates inside `all` / `any` are checked too. A hook |
| 205 | with `timeout_secs = 0` or an empty `command` is rejected the same way. |
| 206 | Rejection is **per entry**: a broken hook never takes another one with it, |
| 207 | even when the two share a `name` or are both unnamed. |
| 208 | |
| 209 | ### Project-local hooks |
| 210 | |
| 211 | A repository may ship `<workspace>/.codewhale/hooks.toml` using the same shape, |
| 212 | but only its `[[hooks]]` entries are merged — a project file cannot change |
| 213 | `enabled`, `default_timeout_secs`, or `working_dir`, which always come from your |
| 214 | own config. Because hooks are executable configuration, project hooks load |
| 215 | **only** after both workspace trust and separate approval of the exact hooks file |
| 216 | in user-owned config. Use `/hooks review` to inspect the commands and digest, |
| 217 | then `/hooks approve <digest>` to enable those bytes on the next session. Review |
| 218 | any scripts the commands call too. A file change requires another approval. |
| 219 | `/hooks revoke` blocks future and queued launches; it does not stop commands |
| 220 | already running. Session `/trust on` alone does not enable project hooks. |
| 221 | Approved project hooks are appended |
| 222 | after global hooks, so they run last and win `updatedInput` ties. A malformed |
| 223 | trusted project file logs a warning and Codewhale falls back to global hooks |
| 224 | only. Validation runs over the merged set, so a rejected project hook is |
| 225 | reported the same way a rejected global one is. |
| 226 | |
| 227 | ## The 15 events |
| 228 | |
| 229 | | Event | Fires | Steering | |
| 230 | | --- | --- | --- | |
| 231 | | `session_start` | once, after the engine is up and before the first draw | observer | |
| 232 | | `session_end` | once, on graceful shutdown | observer | |
| 233 | | `turn_end` | after a turn completes and post-turn state is updated | observer | |
| 234 | | `message_submit` | before a submitted message reaches history or the model | **can replace or block the text** | |
| 235 | | `tool_call_before` | before each tool call executes | **can allow / deny / ask, rewrite input, add context** | |
| 236 | | `tool_call_after` | after each tool result settles, including completions the transcript does not redraw | observer | |
| 237 | | `mode_change` | on every applied Plan/Work/Operate transition (`Act` is a compatibility alias for Work) | observer | |
| 238 | | `on_error` | on transport, capacity, and auth errors, and on tool failures | observer | |
| 239 | | `subagent_spawn` | when a sub-agent starts | observer | |
| 240 | | `subagent_complete` | when a sub-agent completes, fails, or is cancelled | observer | |
| 241 | | `shell_env` | immediately before each `exec_shell` invocation | **contributes environment variables** | |
| 242 | | `session_idle` | when the session settles back to idle after a turn or a wait — no prompt, approval, or continuation outstanding | observer | |
| 243 | | `session_error` | when a turn ends in a terminal failure; transient tool failures the agent absorbs never fire it | observer | |
| 244 | | `waiting_for_user` | when the agent starts waiting on you: an approval prompt opens, a `request_user_input` question is presented, or a goal continuation is parked between passes | observer | |
| 245 | | `session_busy` | when an idle or waiting session begins or resumes work; startup and repeated observations of the same state stay silent | observer | |
| 246 | |
| 247 | `waiting_for_user`'s payload carries `reason`: `approval`, `user_input`, or |
| 248 | `goal_continuation`. All three state events carry `from`/`to` transition fields; |
| 249 | `session_idle` also carries `last_turn_status` when known, and `session_error` carries |
| 250 | the bounded terminal `error` text. Busy, idle, and waiting map onto the session |
| 251 | states the control socket's `status` verb already publishes |
| 252 | (`idle` / `in_progress` / `waiting`), so a hook and a supervisor never |
| 253 | disagree about what the session is doing. Hook authors that want opencode's |
| 254 | grace-period semantics for error alerts should debounce inside the hook — |
| 255 | `session_error` already excludes absorbed, transient failures, and a turn |
| 256 | that fails and is retried by the operator fires again only if the retry also |
| 257 | ends failed. |
| 258 | |
| 259 | ### What "observer" means, exactly |
| 260 | |
| 261 | Observer means Codewhale ignores the hook's **result**: stdout is discarded, a |
| 262 | non-zero exit is logged as a warning, and nothing about the turn, the tool |
| 263 | result, the sub-agent, or the error changes because of it. |
| 264 | |
| 265 | Observer does **not** mean side-effect-free. An observer hook is an arbitrary |
| 266 | shell command running with your credentials. It can write files, push commits, |
| 267 | page an on-call rotation, or delete the workspace. The only thing it cannot do |
| 268 | is change what Codewhale itself does next. |
| 269 | |
| 270 | The steering allowlist is exactly three events — `message_submit`, |
| 271 | `tool_call_before`, `shell_env` — and it is asserted by a test over every |
| 272 | variant, so a new event defaults to observer. |
| 273 | |
| 274 | ### Session identity |
| 275 | |
| 276 | Every event in one TUI session carries the same `DEEPSEEK_SESSION_ID`. The id |
| 277 | is minted once at launch, in the form `sess_xxxxxxxx`, and it survives a |
| 278 | workspace switch and a trust decision that adds project hooks — both reload |
| 279 | the hook set without starting a new session. Engine-fired `tool_call_before` |
| 280 | reports the same id as the UI-fired events, so tool records correlate with the |
| 281 | session records around them. |
| 282 | |
| 283 | `session_end` fires after the queued startup-default writes have been drained |
| 284 | and while the app is still live, so it observes the settled end state rather |
| 285 | than a half-torn-down one. |
| 286 | |
| 287 | ## Environment variables |
| 288 | |
| 289 | Every hook receives the subset of these that applies to its event. The |
| 290 | `DEEPSEEK_` prefix is retained for compatibility with hooks written before the |
| 291 | rebrand. |
| 292 | |
| 293 | | Variable | Set for | Notes | |
| 294 | | --- | --- | --- | |
| 295 | | `DEEPSEEK_SESSION_ID` | every event except `shell_env` | `sess_xxxxxxxx`, stable for the whole session | |
| 296 | | `DEEPSEEK_WORKSPACE` | every event except `shell_env` | absolute workspace path | |
| 297 | | `DEEPSEEK_MODEL` | every event except `shell_env` | active model id | |
| 298 | | `DEEPSEEK_MODE` | every event except `shell_env` | see the mode-spelling note below | |
| 299 | | `DEEPSEEK_TOTAL_TOKENS` | UI-fired events | session token total at fire time | |
| 300 | | `DEEPSEEK_MESSAGE` | `message_submit`, `subagent_*` | truncated at 5 000 bytes with a `...[truncated]` marker | |
| 301 | | `DEEPSEEK_ERROR` | `on_error` | error message, truncated at 5 000 bytes | |
| 302 | | `DEEPSEEK_PREVIOUS_MODE` | `mode_change` | mode label before the change | |
| 303 | | `DEEPSEEK_TOOL_NAME` | `tool_call_before`, `tool_call_after`, `shell_env`, `on_error` (tool failures) | | |
| 304 | | `DEEPSEEK_TOOL_CALL_ID` | `tool_call_before`, `tool_call_after`, `on_error` (tool failures) | engine call id; correlates before/after/error for one call | |
| 305 | | `DEEPSEEK_TOOL_ARGS` | `tool_call_before`, `shell_env` | tool input JSON preview, capped at 10 000 bytes | |
| 306 | | `DEEPSEEK_TOOL_RESULT` | `tool_call_after`, `on_error` (tool failures) | truncated at 10 000 bytes | |
| 307 | | `DEEPSEEK_TOOL_SUCCESS` | `tool_call_after`, `on_error` (tool failures) | `true` / `false` | |
| 308 | | `DEEPSEEK_TOOL_EXIT_CODE` | `tool_call_after` and `on_error` **when the tool reported one** | absent otherwise — never synthesized; 64-bit, so Windows crash codes such as `3221225477` survive | |
| 309 | | `DEEPSEEK_SESSION_COST` | when cost is supplied | USD, six decimal places | |
| 310 | |
| 311 | **Mode-spelling note.** UI-fired events (`session_start`, `session_end`, |
| 312 | `message_submit`, `tool_call_after`, `mode_change`, `on_error`, `turn_end`, |
| 313 | `subagent_*`, `session_busy`, `session_idle`, `session_error`, `waiting_for_user`) |
| 314 | set `DEEPSEEK_MODE` to the UI label — `ACT`, `PLAN`, `OPERATE`. |
| 315 | `tool_call_before` fires inside the engine and uses the engine's own mode |
| 316 | spelling (`Agent`, `Plan`, `Operate`). `mode` conditions compare |
| 317 | case-insensitively, so `{ type = "mode", mode = "plan" }` matches both, but a |
| 318 | hook that string-matches `$DEEPSEEK_MODE` exactly should accept both spellings. |
| 319 | |
| 320 | **`shell_env` is the narrow one.** It receives only `DEEPSEEK_TOOL_NAME` and |
| 321 | `DEEPSEEK_TOOL_ARGS` — no session id, workspace, model, or mode. A |
| 322 | `{ type = "mode", … }` condition on a `shell_env` hook is therefore rejected at |
| 323 | load; scope those with `tool_name` or `tool_category` instead. |
| 324 | |
| 325 | ## Steering events |
| 326 | |
| 327 | ### `message_submit` |
| 328 | |
| 329 | Receives JSON on stdin and may rewrite or block the submitted text. |
| 330 | |
| 331 | ```json |
| 332 | { |
| 333 | "event": "message_submit", |
| 334 | "text": "original user text", |
| 335 | "text_bytes": 18, |
| 336 | "text_original_bytes": 18, |
| 337 | "text_truncated": false, |
| 338 | "session_id": "sess_12345678", |
| 339 | "workspace": "/path/to/workspace", |
| 340 | "mode": "ACT", |
| 341 | "model": "deepseek-chat", |
| 342 | "total_tokens": 1234 |
| 343 | } |
| 344 | ``` |
| 345 | |
| 346 | The complete serialized stdin document is capped at 32 KiB. `text` is the |
| 347 | largest deterministic UTF-8 prefix that fits after JSON escaping and bounded |
| 348 | metadata are included. `text_original_bytes` records the producer's full byte |
| 349 | length, `text_bytes` records the retained prefix, and `text_truncated` states |
| 350 | whether they differ. This same boundary applies to immediate input, restored |
| 351 | queue entries, merged steers, and text produced by an earlier hook. |
| 352 | |
| 353 | - exit `0` printing `{"text": "..."}` with a non-empty string replaces the text |
| 354 | - exit `0` with empty stdout, or JSON without `text`, leaves the text unchanged |
| 355 | - `{"text": ""}` or a replacement over 32 000 characters is invalid stdout, |
| 356 | logged and ignored |
| 357 | - exit `2` blocks the submission before history or dispatch; a structured |
| 358 | `reason` field supplies a bounded, redacted message shown in the TUI. |
| 359 | Unstructured stdout/stderr/error output is never copied into the denial |
| 360 | - other non-zero exits follow `continue_on_error`: `true` warns and continues, |
| 361 | `false` blocks the submission |
| 362 | - `background = true` makes the hook observer-only — it still receives this |
| 363 | bounded payload on stdin, but it cannot transform or block |
| 364 | |
| 365 | Multiple `message_submit` hooks run in config order and each sees the previous |
| 366 | hook's output. |
| 367 | |
| 368 | ### `tool_call_before` |
| 369 | |
| 370 | Receives the tool context in environment variables and may print a JSON |
| 371 | decision on stdout with exit `0`: |
| 372 | |
| 373 | ```json |
| 374 | { |
| 375 | "decision": "allow", |
| 376 | "reason": "human-readable explanation, used for deny", |
| 377 | "updatedInput": { "command": "ls -la" }, |
| 378 | "additionalContext": "text appended to the tool result for the model" |
| 379 | } |
| 380 | ``` |
| 381 | |
| 382 | - `deny` blocks the tool; the model gets a permission-denied result carrying |
| 383 | `reason` |
| 384 | - `ask` forces the interactive approval prompt in Ask and Auto-Review. Full |
| 385 | Access does not open tool-approval prompts, so `ask` does not downgrade it |
| 386 | - `updatedInput` must be an object no larger than 32 KiB serialized and |
| 387 | replaces the tool input; last hook wins |
| 388 | - `additionalContext` is appended to the tool result as `[hook context] ...`; |
| 389 | multiple hooks concatenate |
| 390 | - `reason` and `additionalContext` are bounded and sanitized before use: each |
| 391 | field is capped at 2 000 characters, the concatenated context for one tool |
| 392 | call is capped at 8 000, control characters are stripped (so hook stdout |
| 393 | cannot repaint the TUI or forge structure in the transcript), and a clipped |
| 394 | value carries a `…[truncated]` marker. What a hook adds to the turn's context |
| 395 | budget is therefore bounded no matter what it prints |
| 396 | - exit `2` is a legacy hard deny and wins regardless of stdout |
| 397 | - empty stdout, non-JSON stdout, and JSON without `decision` all mean allow |
| 398 | - precedence across matching hooks: no-verdict-with-`continue_on_error = false` |
| 399 | > deny > ask > allow |
| 400 | - `background = true` hooks are submitted and never awaited, so they have no |
| 401 | verdict and cannot steer; Codewhale logs a warning when one is configured |
| 402 | for this event |
| 403 | |
| 404 | **A gate that could not answer is not permission.** If a foreground |
| 405 | `tool_call_before` hook produces no verdict — it timed out, the process could |
| 406 | not be started, or a strict process exited non-zero without an explicit JSON |
| 407 | decision — and *that hook* is configured with |
| 408 | `continue_on_error = false`, the tool call is denied. Strictness is read off |
| 409 | the hook that actually ran, not off the event: a strict `write_file` gate whose |
| 410 | condition did not match an `exec_shell` call has no say in whether that call |
| 411 | proceeds, and a lenient hook's timeout never denies just because some other |
| 412 | strict hook exists in config. Every no-verdict outcome is logged either way. |
| 413 | |
| 414 | The denial message names the hook and the reason and nothing else: the hook |
| 415 | name is truncated, the detail is truncated, control characters are stripped, |
| 416 | and a spawn failure is reported by error kind (`NotFound`, |
| 417 | `PermissionDenied`, …) rather than by echoing the command line or the resolved |
| 418 | interpreter path. |
| 419 | |
| 420 | ### `shell_env` |
| 421 | |
| 422 | Runs synchronously before each `exec_shell` and its stdout is parsed as |
| 423 | `KEY=VALUE` lines. A leading `export ` is stripped, `#` comment lines and blank |
| 424 | lines are skipped, and a matching pair of surrounding single or double quotes is |
| 425 | removed from the value. Later hooks override earlier ones. Use it for ephemeral |
| 426 | credentials, per-skill `PATH` adjustments, or short-lived tokens. |
| 427 | |
| 428 | `background` is ignored for this event: the hook always runs in the foreground |
| 429 | because its stdout is the contract. |
| 430 | |
| 431 | An entry a shell cannot carry is dropped rather than allowed to break the tool |
| 432 | call: an empty name, a name containing whitespace, `=`, a control character, or |
| 433 | a NUL; a value containing a NUL; a value over 32 KiB; and anything past 256 KiB |
| 434 | of accumulated output from one hook. Each drop is logged by key name only. A |
| 435 | `shell_env` hook is an ordinary process whose stdout can contain anything — |
| 436 | "the hook printed something odd" must never become "the `exec_shell` call |
| 437 | aborted". |
| 438 | |
| 439 | **Exactly what the shell command ends up with — local execution.** When |
| 440 | `exec_shell` runs the command locally (the default), it does not inherit |
| 441 | Codewhale's ambient environment. Its environment is built as: |
| 442 | |
| 443 | 1. a sanitized fixed allowlist of parent variables — `PATH`, `HOME`, `USER`, |
| 444 | `LANG` and the other `LC_*`/locale entries, `TERM`, `SHELL`, `TMPDIR`, |
| 445 | proxy variables, color/terminal entries such as `NO_COLOR`, `CARGO_HOME`/`RUSTUP_HOME`/`RUSTUP_TOOLCHAIN`, the Windows system and MSVC toolchain entries, and other platform keys (the full list lives in `crates/tui/src/child_env.rs`) — and nothing else. Variables |
| 446 | outside that allowlist, including anything that looks like a secret, are |
| 447 | dropped; |
| 448 | 2. then the `KEY=VALUE` pairs your `shell_env` hooks produced, applied on top. |
| 449 | These are explicit values you configured, so they win over the allowlist. |
| 450 | |
| 451 | So a `shell_env` hook is the supported way to get a credential into one local |
| 452 | `exec_shell` invocation. Ambient secrets exported in the terminal that launched |
| 453 | Codewhale are **not** forwarded to a local `exec_shell` on their own. |
| 454 | |
| 455 | **With an external sandbox backend configured, the allowlist above is not the |
| 456 | contract.** If `exec_shell` is routed to a configured sandbox/execution |
| 457 | backend, Codewhale does not construct the process environment at all: it hands |
| 458 | the command and your `shell_env` values to the backend as extra environment |
| 459 | variables, and the **backend owns its own base environment**. What is present |
| 460 | besides your values — an image's baked-in variables, the backend's own |
| 461 | injections, whatever a remote runner exports — is determined by that backend, |
| 462 | not by the list above. Do not assume the local allowlist applies there. |
| 463 | |
| 464 | Disclosure, because it is the part that matters for a hook that emits |
| 465 | credentials: **`shell_env` values are transmitted to the configured backend.** |
| 466 | For a remote or containerized backend that means the values leave this machine |
| 467 | and are subject to that backend's logging, retention, and access controls. |
| 468 | Codewhale's own audit log still records key names only, but that says nothing |
| 469 | about what the backend does with the values. If a `shell_env` hook emits a |
| 470 | secret, scope it to a backend you trust with that secret — for example by |
| 471 | conditioning the hook, or by not configuring an external backend for sessions |
| 472 | where those hooks are active. |
| 473 | |
| 474 | Resolved **key names — never values** — are written to `~/.codewhale/audit.log` |
| 475 | so a session can be reconciled afterwards. A hook that fails or times out |
| 476 | contributes no variables and does not abort the shell call. |
| 477 | |
| 478 | ```toml |
| 479 | [[hooks.hooks]] |
| 480 | name = "aws-creds" |
| 481 | event = "shell_env" |
| 482 | command = "aws-vault export my-profile --format=env" |
| 483 | condition = { type = "tool_category", category = "shell" } |
| 484 | ``` |
| 485 | |
| 486 | ## Structured observer payloads |
| 487 | |
| 488 | `turn_end`, `subagent_spawn`, `subagent_complete`, `session_busy`, `session_idle`, |
| 489 | `session_error`, and `waiting_for_user` receive JSON on stdin in addition to the |
| 490 | environment variables. Their stdout is ignored. Background forms of these |
| 491 | events receive the same payload on stdin. |
| 492 | |
| 493 | The remaining observer events — `session_start`, `session_end`, |
| 494 | `tool_call_after`, `mode_change`, `on_error` — receive environment variables |
| 495 | only, with no stdin payload, in both foreground and background form. |
| 496 | |
| 497 | ### Session state transitions |
| 498 | |
| 499 | The first observed state is recorded silently, whether idle, busy, or waiting. |
| 500 | Repeating the same state emits nothing. For a turn that pauses for user input |
| 501 | and then completes, the transition hooks receive these payloads in submission |
| 502 | order: |
| 503 | |
| 504 | | Event | JSON stdin | |
| 505 | | --- | --- | |
| 506 | | `session_busy` | `{"from":"idle","to":"in_progress"}` | |
| 507 | | `waiting_for_user` | `{"from":"in_progress","to":"waiting","reason":"user_input"}` | |
| 508 | | `session_busy` | `{"from":"waiting","to":"in_progress"}` | |
| 509 | | `session_idle` | `{"from":"in_progress","to":"idle","last_turn_status":"completed"}` | |
| 510 | |
| 511 | The dispatcher has two workers, so command completion order is not guaranteed. |
| 512 | `session_error` is a separate terminal-failure event, with `status` and `error` |
| 513 | fields rather than `from` and `to`. |
| 514 | |
| 515 | ### `turn_end` |
| 516 | |
| 517 | Fires after post-turn state, usage totals, cost accounting, notifications, |
| 518 | receipts, and queue recovery have been updated, and before queued follow-up |
| 519 | dispatch — so the payload can report the queued count without a hook being able |
| 520 | to change what is sent next. |
| 521 | |
| 522 | ```json |
| 523 | { |
| 524 | "event": "turn_end", |
| 525 | "session_id": "sess_12345678", |
| 526 | "workspace": "/path/to/workspace", |
| 527 | "mode": "ACT", |
| 528 | "created_at": "2026-07-12T10:30:00+00:00", |
| 529 | "model_backed": true, |
| 530 | "provider": "deepseek", |
| 531 | "billing_surface": null, |
| 532 | "model": "deepseek-chat", |
| 533 | "turn_id": "turn_12345678", |
| 534 | "status": "completed", |
| 535 | "error": null, |
| 536 | "duration_ms": 1834, |
| 537 | "usage": { |
| 538 | "input_tokens": 1200, |
| 539 | "output_tokens": 180, |
| 540 | "prompt_cache_hit_tokens": 900, |
| 541 | "prompt_cache_miss_tokens": 300, |
| 542 | "prompt_cache_write_tokens": 0, |
| 543 | "reasoning_tokens": null, |
| 544 | "reasoning_replay_tokens": null |
| 545 | }, |
| 546 | "totals": { |
| 547 | "session_tokens": 1380, |
| 548 | "conversation_tokens": 1380, |
| 549 | "input_tokens": 1200, |
| 550 | "output_tokens": 180 |
| 551 | }, |
| 552 | "tool_count": 2, |
| 553 | "queued_message_count": 1, |
| 554 | "stop_hook_active": false |
| 555 | } |
| 556 | ``` |
| 557 | |
| 558 | `created_at` anchors time-window pricing. `provider` and `model` identify the |
| 559 | effective route for model-backed turns. `billing_surface` is an optional, |
| 560 | non-secret classification of the endpoint that served the turn (recognized |
| 561 | StepFun routes emit `stepfun-payg` or `stepfun-plan`); the raw base URL is never |
| 562 | written to hook records. Shell-only, manual-compaction, and purge completions |
| 563 | have no matching `TurnStarted`, so they report `model_backed: false`, a `null` |
| 564 | provider, and a synthetic `lifecycle_<uuid>` turn id. `stop_hook_active` is |
| 565 | always `false` today; it reserves room for re-entry protection. |
| 566 | |
| 567 | ### `subagent_spawn` / `subagent_complete` |
| 568 | |
| 569 | ```json |
| 570 | { |
| 571 | "event": "subagent_complete", |
| 572 | "agent_id": "agent_1", |
| 573 | "session_id": "sess_12345678", |
| 574 | "workspace": "/path/to/workspace", |
| 575 | "mode": "ACT", |
| 576 | "model": "deepseek-chat", |
| 577 | "total_tokens": 1234, |
| 578 | "result_preview": "bounded preview of the result", |
| 579 | "result_truncated": false, |
| 580 | "status": "completed" |
| 581 | } |
| 582 | ``` |
| 583 | |
| 584 | `subagent_spawn` carries `prompt_preview` / `prompt_truncated` instead, and no |
| 585 | `status`. Both payloads are bounded on purpose: previews are truncated rather |
| 586 | than shipping full prompts or results. These hooks are observer-only — failures |
| 587 | do not affect sub-agent scheduling, prompts, or results, and `continue_on_error` |
| 588 | has no effect because later matching hooks always run. |
| 589 | |
| 590 | ## Failure behavior |
| 591 | |
| 592 | - A non-zero exit is logged at `warn` under the `hooks` tracing target with the |
| 593 | hook name, event, exit code, duration, and a generic failure category. Raw |
| 594 | stdout/stderr/error text is not persisted in the log receipt. |
| 595 | - For `execute`-path events, `continue_on_error = false` stops later hooks for |
| 596 | that event; except on `tool_call_before` (above) it does not roll back the |
| 597 | action that fired them. |
| 598 | - Structured observer events (`turn_end`, `subagent_*`, `session_busy`, |
| 599 | `session_idle`, `session_error`, `waiting_for_user`) always continue to the |
| 600 | next matching hook. |
| 601 | - Observer events use a bounded persistent dispatcher. Queue-full and |
| 602 | dispatcher-unavailable submissions are not retried silently; the TUI keeps |
| 603 | an event-specific error toast separate from the ordinary status line. |
| 604 | - A hook that exceeds its timeout has its whole process group killed and is |
| 605 | then reaped, foreground or background — best-effort, with a bounded reap |
| 606 | wait; see [Timeouts](#timeouts). |
| 607 | |
| 608 | ## Security notes |
| 609 | |
| 610 | - Hooks are arbitrary shell commands from your own config; treat |
| 611 | `~/.codewhale/config.toml` as executable. |
| 612 | - Project-supplied hooks require exact-file approval in addition to workspace trust in |
| 613 | user-owned config. |
| 614 | - Hook commands inherit Codewhale's own environment. A local `exec_shell` does |
| 615 | not — see [`shell_env`](#shell_env). |
| 616 | - `shell_env` audit records contain key names only. That covers Codewhale's own |
| 617 | logging; with an external sandbox backend configured, the values themselves |
| 618 | are transmitted to that backend and are then subject to its handling. |
| 619 | - With an external sandbox backend, the local parent-variable allowlist does |
| 620 | not apply — the backend owns its base environment. |
| 621 | - Payload previews, tool arguments/results, error messages, captured stdout and |
| 622 | stderr, replacement messages, and steering objects are bounded so hook input |
| 623 | or output cannot become an unbounded copy of the transcript. |
| 624 | - Nothing Codewhale persists in a denial echoes the stdin payload, hook |
| 625 | environment, raw stdout/stderr/error, command line, or a resolved filesystem |
| 626 | path. `/hooks list` shows a sanitized, single-line command preview capped at |
| 627 | 60 characters; it is not a verbatim copy. Structured denial reasons are |
| 628 | bounded and redact path-, argument-, command-, and secret-like tokens, |
| 629 | including quoted or `key=value` forms and `Authorization: Bearer …`. |
| 630 |