bonsai

Hooks

A hook runs a shell command, an HTTP request, or a one-shot LLM judge around a lifecycle event — formatting a file after every write, blocking edits to .env, auditing every bash call, or gating a risky change behind a fast model’s review. Hooks are defined as [[hooks]] tables in .bonsai/config.toml.

Definition

[[hooks]]
name = "cargo-fmt"                       # unique id (lowercase, [a-z0-9_-])
event = "PostFileWrite"
matcher = { path = "**/*.rs" }           # and/or { tool = "<glob>" }
action = { type = "shell", command = "cargo fmt -- \"$BONSAI_FILE_PATH\"" }
timeout_secs = 20                        # default 30
blocking = false                         # default
on_failure = "warn"                      # default; or "block"
enabled = true                           # default; false parks the entry
Field Notes
event SessionStart, SessionEnd, PreToolUse, PostToolUse, PreFileWrite, PostFileWrite, PreBash, PostBash
matcher tool and/or path glob; omitted matches every call for that event. A set matcher against a missing context field is a non-match; an invalid glob fails the hook at load.
action { type = "shell", command }, { type = "http", url, headers }, or { type = "llm_prompt", prompt }. ${VAR} in headers expands at fire time.
blocking Only meaningful on the pre events (PreToolUse/PreFileWrite/PreBash) — a blocking hook there may veto or (PreToolUse only) rewrite the call. Every other event is always non-veto.
on_failure warn (fail-open: a broken hook warns and the action proceeds) or block (fail-closed; effective only on blocking pre-events). A failed run never disables the hook — it fires again next match.

Execution model: matching vetoing hooks (blocking pre-hooks) run sequentially, first block wins, last modify wins; all other matching hooks run concurrently and can only contribute context. Hook-contributed context is folded into the tool result as untrusted data, never a system message.

The wire protocol

Shell actions receive one JSON object on stdin; HTTP actions receive it as the POST body:

{
  "event": "PreFileWrite", "session_id": "…", "cwd": "/path/to/project",
  "tool_name": "write", "file_path": "src/main.rs", "tool_args": {"path": "src/main.rs"},
  "command": null, "exit_code": null, "output_excerpt": null,
  "diff": "diff --bonsai modified src/main.rs\n-old\n+new\n"
}

Environment variables accompany shell actions for scripts that don’t parse JSON: BONSAI_HOOK_EVENT, BONSAI_PROJECT_ROOT, BONSAI_SESSION_ID, and (when applicable) BONSAI_TOOL_NAME, BONSAI_FILE_PATH, BONSAI_COMMAND.

Decision semantics:

Limits: the timeout covers stdin delivery, execution, and output draining; timeout or cancellation terminates the hook’s whole process group (SIGTERM, 200 ms grace, SIGKILL). Captured stdout and stderr are each capped at 64 KiB — an oversized JSON response fails rather than being parsed partially. Output excerpts handed to hooks are pre-redacted; hook stderr and diagnostics are redacted before display.

LLM-judge actions

An llm_prompt action sends its prompt to a fast model and requires strict JSON back: {"decision":"allow"|"block","reason":"…"}. Template markers — ,, ,, `` — are each substituted wrapped as untrusted data first, so a hostile tool result cannot steer the judge; the prompt template itself is trusted operator configuration. Unparseable responses are failures under on_failure.

[[hooks]]
name = "migration-review"
event = "PreFileWrite"
matcher = { path = "migrations/**" }
blocking = true
timeout_secs = 45
action = { type = "llm_prompt", prompt = """
You review a proposed file change. Block it only if it drops or truncates data.
Reply with strict JSON: {"decision":"allow"|"block","reason":"..."}.
Change:

""" }

Diff evidence

PreFileWrite and PostFileWrite receive the same redacted per-file diff in the JSON diff field (LLM hooks via ``). A multi-file operation runs every pre hook before the first disk write and emits post hooks only after the whole transaction commits. Deletes render against /dev/null; a move is an explicit destination creation plus source deletion. Diff evidence is capped at 2,000 rendered lines and 128 KiB with a [diff truncated …] marker and full add/remove totals when a bound is hit. The structured diff shown in the TUI and the hook evidence come from the same precondition and intended content.

Trust

Commands

Interactions

Where this lives in the code

Concern Location
Engine, matching, decisions src/hooks/mod.rs
Actions & wire protocol src/hooks/actions.rs, src/hooks/protocol.rs
Trust gate src/hooks/trust.rs
Config schema src/config/schema.rs
/hooks command src/commands/hooks_cmd.rs