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