on main help sign in
docs(hooks): document new embedded shell model

Specifically, we describe the embedded-shell contract, Windows behavior, permissive shebang fallback, guaranteed CRUSH/AGENT env markers, and timeout hard-return semantics.

commit5dacf9e40568e6f027ff2964c49617ddd25d75bb authorChristian Rocha <christian@rocha.is> dateMon 27 Apr 2026, 11:05 (6 months ago) parent3b3f992
2 files changed+59−124
docs/hooks/FUTURE.md+2−118 docs/hooks/README.md+57−6
docs/hooks/FUTURE.md+2−118
@@ -255,121 +255,5 @@ Reuses the universal rules:
255255
256256## Cross-platform shell (Windows support)
257257
258−**Status:** not implemented.
259−
260−### Problem
261−
262−Today the hook runner uses `exec.Command("sh", "-c", hook.Command)`. On Windows
263−this fails without WSL or Git Bash on PATH. Even with `sh.exe` available,
264−Windows has no kernel shebang handling — `./hooks/foo.sh` can't be exec'd
265−directly the way it can on Unix. Hooks are effectively Unix-only.
266−
267−### Approach
268−
269−Keep the `command` field as a string. Tokenize it shell-style, examine
270−`argv[0]`, and branch:
271−
272−- If `argv[0]` starts with `./`, `../`, `/`, or `~/` — treat it as a **file
273− invocation**. Read the first ≤128 bytes, parse a shebang if present, and
274− dispatch to the named interpreter via `os/exec`. Extra args from the command
275− string pass through to the interpreter.
276−- Otherwise — treat the whole string as **shell code** and hand it to mvdan's
277− in-process interpreter. mvdan resolves `node`, `bash`, `jq`, builtins,
278− pipelines, redirects, etc. via its own exec handler.
279−
280−No sentinel: a script with no shebang defaults to mvdan. A script with an
281−explicit shebang (`#!/bin/bash`, `#!/usr/bin/env python3`, etc.) uses the named
282−interpreter, which the user is responsible for having on PATH. Same contract on
283−every platform.
284−
285−### Dispatch examples
286−
287−| `command` | `argv[0]` | Route |
288−| ---------------------------------------- | -------------- | ------------------------ |
289−| `ls -la` | `ls` | mvdan |
290−| `bash -c 'ls'` | `bash` | mvdan (which execs bash) |
291−| `node ./script.js` | `node` | mvdan (which execs node) |
292−| `./script.sh` (no shebang) | `./script.sh` | mvdan, fed the file |
293−| `./script.sh` (`#!/bin/bash`) | `./script.sh` | `bash ./script.sh` |
294−| `./script.py` (`#!/usr/bin/env python3`) | `./script.py` | `python3 ./script.py` |
295−| `./script.exe` | `./script.exe` | `os/exec` direct |
296−
297−### Contract on Windows
298−
299−- Inline shell runs through mvdan natively. No external dependency.
300−- Shebang-dispatched scripts require the named interpreter on PATH (`bash.exe`,
301− `python.exe`, `node.exe`, etc.). Crush does the dispatch that the Windows
302− kernel won't.
303−- Shebang-less scripts run through mvdan regardless of extension. CRLF line
304− endings are tolerated.
305−
306−### Implementation sketch
307−
308−- New function
309− `dispatch(ctx, cmd string, env []string, stdin io.Reader) (stdout, stderr string, exitCode int, err error)`
310− in `internal/hooks/`.
311−- Tokenize using mvdan's parser (already a dep) for consistent quoting/escape
312− behavior with shell intuition.
313−- Path-prefix check on `argv[0]`; if path, read shebang with a bounded
314− `io.LimitReader` and parse. Support:
315− - `#!/absolute/interpreter args…`
316− - `#!/usr/bin/env NAME` → resolve `NAME` on PATH
317− - `#!/usr/bin/env -S NAME args…` → treat as above; `-S` is common enough to
318− handle. Other `env` flags can error.
319−- Unified exit-code helper. mvdan's `interp.ExitStatus` and `os/exec`'s
320− `ProcessState.ExitCode()` both become a single `int`.
321−- Context cancellation: mvdan's exec handler uses `exec.CommandContext` for its
322− children, so a cancelled hook kills both the interpreter and any children.
323− Verify with a `sleep 60` test.
324−- One fresh `interp.Runner` per hook invocation (parallel hooks must not share
325− state).
326−
327−### Swap the call site
328−
329−`Runner.runOne` in `internal/hooks/runner.go` replaces its
330−`exec.Command("sh", "-c", …)` with a call to `dispatch(…)`. Everything
331−downstream (exit-code 2 / 49 / other dispatch, stdout JSON parsing,
332−stderr-as-reason) stays identical.
333−
334−### Tests
335−
336−Cross-platform matrix:
337−
338−- Inline: `echo hi`; `exit 2`; pipelines; redirections.
339−- File, no shebang: treated as shell source through mvdan.
340−- File, `#!/bin/bash` on Unix — invokes system bash.
341−- File, `#!/usr/bin/env python3` — invokes python if present, skips if not.
342−- File, `#!/usr/bin/env -S node --foo` — extra flags preserved.
343−- File with CRLF line endings in the shebang.
344−- `./missing-file` — non-blocking error, hook proceeds as "no opinion".
345−- Timeout: hook that sleeps past its timeout gets killed; context cancellation
346− kills the interpreter and its children.
347−- Concurrency: 10 hooks in parallel don't leak env/cwd/state between runners.
348−- Windows-specific: `./script.exe` exec'd directly; bash-shebang script fails
349− gracefully when bash isn't on PATH.
350−
351−### Pitfalls to watch
352−
353−- **Userland shebang parsing is now our problem.** Edge cases around `env`
354− flags, args with spaces, CRLF, missing interpreter. Well-trodden but needs
355− real tests.
356−- **The path-prefix heuristic is a heuristic.** `relative/path.sh` (no leading
357− `./`) gets mvdan'd, not file-dispatched. Matches shell intuition — at a bash
358− prompt, `relative/path.sh` doesn't run unless `.` is on PATH — but worth
359− documenting.
360−- **Kernel shebang handling is bypassed on Unix.** Today a chmod+x'd script is
361− exec'd by the kernel; after this change, by our parser. Behavior should be
362− byte-identical; verify with tests.
363−- **Two code paths.** mvdan vs direct-exec. Exit codes, stdin, signal
364− propagation, env inheritance must be aligned. Discipline, not cleverness.
365−
366−### Explicit non-goals
367−
368−- No bundled `bash.exe` or `python.exe`. Users bring their own interpreters.
369−- No custom mvdan builtins (`crush_approve` etc.). Hooks stay portable and
370− testable under bare `bash`.
371−- No `.sh`-extension filter on discovery. Hook file shape is driven by shebang,
372− not filename.
373−- No Crush-as-script-interpreter mode (users can't write `#!/usr/bin/env crush`
374− and have it mean something). If we want that later, it's an additive feature,
375− not a dependency of this work.
258+**Status:** implemented. See the [Execution model](README.md#execution-model)
259+section in `README.md` for the current behavior and contract.
docs/hooks/README.md+57−6
@@ -29,6 +29,45 @@ languages at the end, too.
2929- Crush currently supports just one hook, `PreToolUse`, with plans to support
3030 the full gamut. If there's a hook you'd like to see, let us know.
3131
32+## Execution model
33+
34+Hooks run through Crush's embedded POSIX shell (`mvdan.cc/sh`) — the same
35+interpreter the `bash` tool uses. Inline commands and shebang-less scripts
36+execute in-process; scripts with a `#!` shebang dispatch to the named
37+interpreter via `os/exec`. This contract is identical on macOS, Linux, and
38+Windows.
39+
40+What this means in practice:
41+
42+- **Windows without Unix tooling**: inline shell (`echo`, pipelines, `jq`,
43+ `grep`), shebang-less `.sh` scripts, inline PowerShell
44+ (`powershell -Command …`), and `.exe` invocations all work out of the box
45+ with no WSL, Git Bash, Cygwin, or MSYS required.
46+- **PowerShell scripts** (`.ps1`) are not auto-dispatched by extension.
47+ Invoke them explicitly: `powershell -File ./audit.ps1` (or
48+ `pwsh -File ./audit.ps1`).
49+- **Shebang'd scripts** require the named interpreter on `PATH`. Git for
50+ Windows ships `bash.exe`, which makes `#!/bin/bash` and
51+ `#!/usr/bin/env bash` scripts work on Windows the same way they do on
52+ Unix. CRLF line endings in the shebang line are tolerated.
53+- **Permissive shebang fallback**: if the absolute path in a shebang
54+ doesn't exist (e.g. `#!/bin/bash` on Windows), Crush falls back to a
55+ `PATH` lookup of the base name (`bash`) before giving up. A debug-level
56+ log records the fallback. If the interpreter isn't on `PATH` either, the
57+ hook fails cleanly as a non-blocking warning and the agent proceeds as
58+ "no opinion".
59+- **Environment**: every hook sees `CRUSH=1`, `AGENT=crush`, and
60+ `AI_AGENT=crush` on top of the `CRUSH_*` hook-specific variables. These
61+ three markers are guaranteed and match what the `bash` tool sets, so
62+ scripts that detect "am I being run by an AI agent?" behave the same in
63+ both contexts.
64+- **Timeout behavior**: when a hook exceeds its timeout, Crush cancels the
65+ context and waits a short grace period (~1s) for the interpreter to
66+ yield. If the hook still hasn't returned, Crush abandons it, logs a
67+ warning, and treats the result as "no opinion" so the agent can proceed.
68+ Long-running work should honor context cancellation or run in a
69+ subprocess via a shebang.
70+
3271## Configuration
3372
3473Hooks can be added to your `crush.json` at both the global and project-level,
@@ -89,13 +128,15 @@ When a hook fires, Crush:
891281. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
90129 all).
911302. Deduplicates by `command` (identical commands run once).
92−3. Runs all matching hooks **in parallel** as subprocesses.
131+3. Runs all matching hooks **in parallel** through Crush's embedded POSIX
132+ shell (see [Execution model](#execution-model)).
931334. Waits for all to finish (or time out), then aggregates results **in config
94134 order**: deny wins over allow, allow wins over none; `updated_input` patches
95135 shallow-merge in order.
96136
97−Note that you can omit `matcher` and match in your shell script instead, however
98−you'll incur some additional overhead as Crush will `exec` each script.
137+Note that you can omit `matcher` and match in your shell script instead,
138+however you'll incur some additional overhead as Crush will still parse and
139+run each hook.
99140
100141### Input
101142
@@ -109,6 +150,9 @@ The available environment variables are:
109150
110151| Variable | Description |
111152| ---------------------------- | ---------------------------------------------- |
153+| `CRUSH` | Always `1` when running under Crush. |
154+| `AGENT` | Always `crush`. |
155+| `AI_AGENT` | Always `crush`. |
112156| `CRUSH_EVENT` | The hook event name (e.g. `PreToolUse`). |
113157| `CRUSH_TOOL_NAME` | The tool being called (e.g. `bash`). |
114158| `CRUSH_SESSION_ID` | Current session ID. |
@@ -117,6 +161,10 @@ The available environment variables are:
117161| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. |
118162| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. |
119163
164+The `CRUSH`, `AGENT`, and `AI_AGENT` markers are also set by the `bash`
165+tool, so a script can detect "am I running under Crush?" the same way in
166+either context.
167+
120168#### JSON
121169
122170Standard input provides the full context as JSON:
@@ -267,9 +315,12 @@ When multiple hooks match the same tool call:
267315
268316### Timeouts
269317
270−If a hook exceeds its timeout, the process is killed and treated as a
271−non-blocking error and the tool call proceeds. The default timeout is 30
272−seconds.
318+If a hook exceeds its timeout, Crush cancels its context and treats the
319+result as a non-blocking error so the tool call proceeds. The default
320+timeout is 30 seconds. Shebang-dispatched subprocesses are killed through
321+`exec.CommandContext`; in-process hooks get a short grace period to yield
322+and are then abandoned (the agent moves on regardless). Long-running work
323+should honor context cancellation or run out-of-process via a shebang.
273324
274325## Examples
275326