Hooks: Future Work
This document tracks planned features and design notes for hooks that are not yet implemented. Nothing here is part of the current contract. Treat it as a scratchpad for what's next, not as documentation of current behavior.
[!NOTE] This document was largely LLM-generated.
context_files
Status: planned, not implemented.
Motivation
Today, a hook that wants to inject reference material into the agent's context
has exactly one knob: context (string or array of strings). Whatever the hook
puts there is concatenated into what the model sees. That's fine for short notes
("current branch: main", "scrubbed secrets") but it scales badly:
- Dumping a whole
README.mdorpackage.jsonintocontextburns tokens on every tool call where the hook fires. - The model sees the file contents even if it doesn't need them.
- Large files can push the turn past the context window.
context_files is the lazy alternative: the hook returns paths, not
contents. Crush tells the agent the files exist and are relevant, and the agent
decides whether to open them with its existing view tool.
Proposed shape
Additive envelope field. Accepts a list of strings:
{
"decision": "allow",
"context": "Scrubbed one secret",
"context_files": ["README.md", "docs/ARCHITECTURE.md"],
}
Paths are resolved relative to CRUSH_CWD. Non-existent paths are dropped with
a debug log (don't fail the hook over a missing file).
How the agent sees it
Crush appends a short note to the turn's context along the lines of:
## Referenced files
- README.md
- docs/ARCHITECTURE.md
No file contents are inlined. The agent opens them with view if it decides
they're relevant. This keeps cost proportional to need.
Aggregation
Matches the existing rules for lists:
- Concatenates across matching hooks in config order.
- Deduplicates paths (same file referenced by two hooks β listed once).
- Dropped entirely if the final decision is
denyorhalt.
Backwards compatibility
Purely additive. Hooks that don't emit context_files are unaffected. Existing
envelopes keep working unchanged. No version bump required.
Open questions
- Should
context_filespaths be constrained toCRUSH_PROJECT_DIR? Probably yes, to avoid hooks smuggling in arbitrary filesystem reads. - Do we want a per-file line range (
"README.md:1-40") or keep it dead simple (whole-file references only)? Start simple; add ranges only if asked for. - Should we annotate "why this file is relevant" per entry? An object form
(
{"path": "...", "reason": "..."}) would allow that but complicates the schema. Defer until there's a real user need.
Sub-agent opt-in
Status: not implemented.
Background
Today hooks fire only on the top-level agent's tool calls. Sub-agents
(agent task tool, agentic_fetch, future delegated loops) run without hook
interception so a single delegated turn doesn't trigger the user's hook N times.
The outer sub-agent tool call itself is hooked, so blanket policy like "never spawn sub-agents" or "rewrite prompts sent to the task agent" still works from the coder's side. The sub-agent's inner loop is the part that's exempt.
Why users might want the escape hatch
- Audit logging of every tool call, including delegated ones.
- Redaction hooks that want to apply uniformly regardless of who called the tool.
- Policy that cares about the tool not the caller: "never fetch from this
domain, even in
agentic_fetch."
Until someone actually asks, don't ship this. YAGNI.
Proposed shape
Additive, per-hook. Zero-value matches current default (skip sub-agents):
{
"hooks": {
"PreToolUse": [
{
"matcher": "^bash$",
"command": "./hooks/audit.sh",
"include_sub_agents": true, // default false
},
],
},
}
Implementation changes where wrapToolsWithHooks decides to skip. Instead of a
single isSubAgent bailout, the runner filters per-hook matches by the hook's
include_sub_agents flag. Hooks that opt in get wrapped into sub-agent tool
slices too; everything else stays skipped.
Backwards Compatibility
Purely additive. Hooks that don't set include_sub_agents get the default
(false = skip sub-agents). No wire format change, no version bump. The initial
transition from "hooks fire everywhere" to "hooks skip sub-agents by default"
was a one-time behavior change; adding the opt-in is pure addition.
Side benefit: payload awareness
Extend the stdin payload with "is_sub_agent": true|false so hook scripts that
opt in can branch on caller type ("audit top-level and sub-agent calls
differently"). Also purely additive β hooks that don't read the field are
unaffected.
Open questions
- Per-hook flag (above) vs a global
hooks.include_sub_agentsdefault? A global toggle is simpler but coarse-grained; per-hook is more flexible and composable. Start per-hook; a global default can be layered on later with explicit precedence ("per-hook overrides global"). - Does an opt-in hook see hooks from nested sub-agents too (a sub-agent that itself calls a sub-agent)? Probably yes β once you've opted in you want the full tree. But call it out explicitly in docs so users aren't surprised by NΒ² explosions on pathological configs.
UserPromptSubmit event
Status: not implemented.
Motivation
Today Crush supports exactly one hook event, PreToolUse. That's enough to gate
and rewrite tool calls but nothing else. The next-most-useful event is
UserPromptSubmit: fires after the user hits Enter but before the turn hits the
LLM. Lets hooks inject context, rewrite prompts, or gate on content without the
mutation complexity of PostToolUse (output scrubbing, error coercion, size
limits β all rabbit holes).
Use cases
- Prepend project context the user didn't think to include ("current branch:
feat/x; last commit:<sha> <title>"). - Point at reference files via
context_files(when that lands) so the agent knows where to look without being force-fed contents. - Redact secrets out of the prompt before it leaves the machine.
- Refuse prompts matching a policy ("don't send anything mentioning
production.env") β withdenyand a reason the user sees. - Expand shorthand (
@TODOβ "please address the TODO in β¦").
Proposed shape
Stdin payload extends the common envelope with the prompt:
{
"event": "UserPromptSubmit",
"session_id": "β¦",
"cwd": "/home/user/project",
"prompt": "fix the login flow",
"attachments": ["screenshot.png"],
}
Output envelope reuses common fields plus one new per-event field,
updated_prompt:
{
"decision": "allow", // optional; deny blocks the submission entirely
"reason": "includes a production secret", // shown to the user when denying
"context": "Current branch: feat/login",
"updated_prompt": "fix the login flow\n\n(from @TODO on line 42)",
}
updated_prompt is a full replacement β not a merge patch β because a
prompt is a single string with no natural key structure. If multiple hooks emit
updated_prompt, later hooks in config order win.
Aggregation
Reuses the universal rules:
haltis sticky. Halts the whole turn before the LLM is called.contextconcatenates in config order.updated_prompt: last writer wins.decision: "deny"blocks the submission. The user seesreason; the turn never reaches the LLM.
Differences from PreToolUse
- No
updated_input: there are no tool inputs at this point. - No permission-prompt bypass: there's no permission prompt for a user prompt.
decision: "allow"is functionally identical to silence. It exists only for symmetry withPreToolUseand to give hook authors a consistent vocabulary. (Could be argued both ways β consider dropping it here.)- Fires on every user submission, including follow-ups in the same session. Hooks should be fast; no subprocess-per-keystroke scenarios but the per-turn overhead is real.
Implementation sketch
- New event constant
EventUserPromptSubmitininternal/hooks/hooks.go. Runner.Runalready takes an event name; no interface change.- A new call site in
sessionAgent.Run(or the coordinator's Run path) that fires hooks after creating the user message but before the first LLM call. If the aggregate decision isdenyorhalt, abort the turn and surfacereasonto the user. - If hooks return
context, prepend it to the prompt seen by the LLM (or attach as a system-message-level note β decide based on how the prompt is threaded through fantasy). - If hooks return
updated_prompt, replace the prompt body before the first LLM call. The message row in the DB should still store the original prompt so the user sees what they typed; only the outbound version is rewritten. (Or: store both, show the original, send the rewritten β mirror howupdated_inputis handled today.)
Open questions
- Store original vs rewritten prompt? Probably both, with UI showing original and a subtle indicator that a hook modified it.
- Do hooks fire on queued prompts too, or only when actually dispatched? If the user queues three prompts and the hook blocks the second, what happens to the third? Simplest rule: fire when dispatched; denial skips to the next queued prompt with a visible note.
- What about the
/commandsprefix? DoesUserPromptSubmitfire for slash commands, or are those intercepted earlier? Probably earlier β hooks see only freeform prompts that would actually reach the LLM.
Cross-platform shell (Windows support)
Status: not implemented.
Problem
Today the hook runner uses exec.Command("sh", "-c", hook.Command). On Windows
this fails without WSL or Git Bash on PATH. Even with sh.exe available,
Windows has no kernel shebang handling β ./hooks/foo.sh can't be exec'd
directly the way it can on Unix. Hooks are effectively Unix-only.
Approach
Keep the command field as a string. Tokenize it shell-style, examine
argv[0], and branch:
- If
argv[0]starts with./,../,/, or~/β treat it as a file invocation. Read the first β€128 bytes, parse a shebang if present, and dispatch to the named interpreter viaos/exec. Extra args from the command string pass through to the interpreter. - Otherwise β treat the whole string as shell code and hand it to mvdan's
in-process interpreter. mvdan resolves
node,bash,jq, builtins, pipelines, redirects, etc. via its own exec handler.
No sentinel: a script with no shebang defaults to mvdan. A script with an
explicit shebang (#!/bin/bash, #!/usr/bin/env python3, etc.) uses the named
interpreter, which the user is responsible for having on PATH. Same contract on
every platform.
Dispatch examples
command |
argv[0] |
Route |
|---|---|---|
ls -la |
ls |
mvdan |
bash -c 'ls' |
bash |
mvdan (which execs bash) |
node ./script.js |
node |
mvdan (which execs node) |
./script.sh (no shebang) |
./script.sh |
mvdan, fed the file |
./script.sh (#!/bin/bash) |
./script.sh |
bash ./script.sh |
./script.py (#!/usr/bin/env python3) |
./script.py |
python3 ./script.py |
./script.exe |
./script.exe |
os/exec direct |
Contract on Windows
- Inline shell runs through mvdan natively. No external dependency.
- Shebang-dispatched scripts require the named interpreter on PATH (
bash.exe,python.exe,node.exe, etc.). Crush does the dispatch that the Windows kernel won't. - Shebang-less scripts run through mvdan regardless of extension. CRLF line endings are tolerated.
Implementation sketch
- New function
dispatch(ctx, cmd string, env []string, stdin io.Reader) (stdout, stderr string, exitCode int, err error)ininternal/hooks/. - Tokenize using mvdan's parser (already a dep) for consistent quoting/escape behavior with shell intuition.
- Path-prefix check on
argv[0]; if path, read shebang with a boundedio.LimitReaderand parse. Support:#!/absolute/interpreter argsβ¦#!/usr/bin/env NAMEβ resolveNAMEon PATH#!/usr/bin/env -S NAME argsβ¦β treat as above;-Sis common enough to handle. Otherenvflags can error.
- Unified exit-code helper. mvdan's
interp.ExitStatusandos/exec'sProcessState.ExitCode()both become a singleint. - Context cancellation: mvdan's exec handler uses
exec.CommandContextfor its children, so a cancelled hook kills both the interpreter and any children. Verify with asleep 60test. - One fresh
interp.Runnerper hook invocation (parallel hooks must not share state).
Swap the call site
Runner.runOne in internal/hooks/runner.go replaces its
exec.Command("sh", "-c", β¦) with a call to dispatch(β¦). Everything
downstream (exit-code 2 / 49 / other dispatch, stdout JSON parsing,
stderr-as-reason) stays identical.
Tests
Cross-platform matrix:
- Inline:
echo hi;exit 2; pipelines; redirections. - File, no shebang: treated as shell source through mvdan.
- File,
#!/bin/bashon Unix β invokes system bash. - File,
#!/usr/bin/env python3β invokes python if present, skips if not. - File,
#!/usr/bin/env -S node --fooβ extra flags preserved. - File with CRLF line endings in the shebang.
./missing-fileβ non-blocking error, hook proceeds as "no opinion".- Timeout: hook that sleeps past its timeout gets killed; context cancellation kills the interpreter and its children.
- Concurrency: 10 hooks in parallel don't leak env/cwd/state between runners.
- Windows-specific:
./script.exeexec'd directly; bash-shebang script fails gracefully when bash isn't on PATH.
Pitfalls to watch
- Userland shebang parsing is now our problem. Edge cases around
envflags, args with spaces, CRLF, missing interpreter. Well-trodden but needs real tests. - The path-prefix heuristic is a heuristic.
relative/path.sh(no leading./) gets mvdan'd, not file-dispatched. Matches shell intuition β at a bash prompt,relative/path.shdoesn't run unless.is on PATH β but worth documenting. - Kernel shebang handling is bypassed on Unix. Today a chmod+x'd script is exec'd by the kernel; after this change, by our parser. Behavior should be byte-identical; verify with tests.
- Two code paths. mvdan vs direct-exec. Exit codes, stdin, signal propagation, env inheritance must be aligned. Discipline, not cleverness.
Explicit non-goals
- No bundled
bash.exeorpython.exe. Users bring their own interpreters. - No custom mvdan builtins (
crush_approveetc.). Hooks stay portable and testable under barebash. - No
.sh-extension filter on discovery. Hook file shape is driven by shebang, not filename. - No Crush-as-script-interpreter mode (users can't write
#!/usr/bin/env crushand have it mean something). If we want that later, it's an additive feature, not a dependency of this work.