on main help sign in
Merge pull request #2730 from charmbracelet/hookshell

feat(hooks): use embedded shell by default

commit09c83e839d810f6e1e12896e137afa64389b0fe5 authorChristian Rocha <christian@rocha.is> dateSat 9 May 2026, 20:53 (5 months ago) parentac79e5b9af81e3
14 files changed+1980−222
.agents/skills/shell-builtins/SKILL.md+34−13 docs/hooks/FUTURE.md+2−118 docs/hooks/README.md+57−6 internal/hooks/hooks_test.go+67 internal/hooks/input.go+2 internal/hooks/runner.go+60−15 internal/shell/dispatch.go+426 internal/shell/dispatch_test.go+594 internal/shell/dispatch_windows_test.go+40 internal/shell/jq.go+69−4 internal/shell/jq_test.go+199 internal/shell/run.go+158 internal/shell/run_test.go+252 internal/shell/shell.go+20−66
.agents/skills/shell-builtins/SKILL.md+34−13
@@ -11,38 +11,59 @@ emulation. Commands can be intercepted before they reach the OS by adding
1111
1212## How Builtins Work
1313
14−Builtins live in `Shell.builtinHandler()` in `internal/shell/shell.go`.
15−This is an `interp.ExecHandlerFunc` middleware registered in
16−`execHandlers()` **before** the block handler, so builtins run even for
17−commands that would otherwise be blocked.
14+Builtins live in `builtinHandler()` in `internal/shell/run.go`. This is an
15+`interp.ExecHandlerFunc` middleware registered in `standardHandlers()`
16+**before** the block handler, so builtins run even for commands that would
17+otherwise be blocked. The same handler chain is shared by the stateful
18+`Shell` type and the stateless `Run` entrypoint used by the hook runner,
19+so builtins are available identically in the `bash` tool and in hooks.
1820
1921The handler is a switch on `args[0]`. Each case either handles the command
2022inline or delegates to a helper function.
2123
2224## Adding a New Builtin
2325
24−1. **Add the case** to the switch in `builtinHandler()` in `shell.go`.
26+1. **Add the case** to the switch in `builtinHandler()` in `run.go`.
25272. **Get I/O from the handler context**, not from `os.Stdin`/`os.Stdout`.
2628 This ensures the builtin works with pipes and redirections:
2729 ```go
2830 case "mycommand":
2931 hc := interp.HandlerCtx(ctx)
30− return handleMyCommand(args, hc.Stdin, hc.Stdout, hc.Stderr)
32+ return handleMyCommand(ctx, args, hc.Stdin, hc.Stdout, hc.Stderr)
3133 ```
32343. **Implement the handler** in its own file (e.g.,
33− `internal/shell/mycommand.go`). The function signature should accept
34− args, stdin, stdout, and stderr:
35+ `internal/shell/mycommand.go`). The function signature must accept a
36+ `context.Context` as the first parameter, plus args, stdin, stdout, and
37+ stderr:
3538 ```go
36− func handleMyCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
39+ func handleMyCommand(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
3740 // args[0] is the command name ("mycommand"), args[1:] are arguments.
3841 // Write output to stdout, errors to stderr.
3942 // Return nil on success, or interp.ExitStatus(n) for non-zero exit codes.
4043 }
4144 ```
42−4. **Return values**: return `nil` for success, `interp.ExitStatus(n)` for
43− non-zero exit codes. Write error messages to `stderr` before returning.
44−5. **No extra wiring needed** — `builtinHandler()` is already registered
45− in `execHandlers()`.
45+4. **Poll `ctx` in every unbounded loop.** Builtins that iterate over
46+ input, emit values in a generator-style loop, or do any other work
47+ that can exceed a few milliseconds MUST check `ctx.Err()` on each
48+ iteration and return it verbatim when non-nil. Hook timeouts rely on
49+ this: an unbounded builtin that never polls ctx cannot be interrupted
50+ by a hook's `timeout_sec`, and the hook runner will have to abandon
51+ the goroutine (see `internal/hooks/runner.go`). Returning `ctx.Err()`
52+ (not `interp.ExitStatus(n)`) lets callers distinguish "command exited
53+ non-zero" from "we ran out of time".
54+ ```go
55+ for _, item := range items {
56+ if err := ctx.Err(); err != nil {
57+ return err
58+ }
59+ // ... process item
60+ }
61+ ```
62+5. **Return values**: return `nil` for success, `interp.ExitStatus(n)` for
63+ non-zero exit codes, or `ctx.Err()` on cancellation. Write error
64+ messages to `stderr` before returning.
65+6. **No extra wiring needed** — `builtinHandler()` is already registered
66+ in `standardHandlers()`.
4667
4768## Existing Builtins
4869
docs/hooks/FUTURE.md+2−118
@@ -256,121 +256,5 @@ Reuses the universal rules:
256256
257257## Cross-platform shell (Windows support)
258258
259−**Status:** not implemented.
260−
261−### Problem
262−
263−Today the hook runner uses `exec.Command("sh", "-c", hook.Command)`. On Windows
264−this fails without WSL or Git Bash on PATH. Even with `sh.exe` available,
265−Windows has no kernel shebang handling — `./hooks/foo.sh` can't be exec'd
266−directly the way it can on Unix. Hooks are effectively Unix-only.
267−
268−### Approach
269−
270−Keep the `command` field as a string. Tokenize it shell-style, examine
271−`argv[0]`, and branch:
272−
273−- If `argv[0]` starts with `./`, `../`, `/`, or `~/` — treat it as a **file
274− invocation**. Read the first ≤128 bytes, parse a shebang if present, and
275− dispatch to the named interpreter via `os/exec`. Extra args from the command
276− string pass through to the interpreter.
277−- Otherwise — treat the whole string as **shell code** and hand it to mvdan's
278− in-process interpreter. mvdan resolves `node`, `bash`, `jq`, builtins,
279− pipelines, redirects, etc. via its own exec handler.
280−
281−No sentinel: a script with no shebang defaults to mvdan. A script with an
282−explicit shebang (`#!/bin/bash`, `#!/usr/bin/env python3`, etc.) uses the named
283−interpreter, which the user is responsible for having on PATH. Same contract on
284−every platform.
285−
286−### Dispatch examples
287−
288−| `command` | `argv[0]` | Route |
289−| ---------------------------------------- | -------------- | ------------------------ |
290−| `ls -la` | `ls` | mvdan |
291−| `bash -c 'ls'` | `bash` | mvdan (which execs bash) |
292−| `node ./script.js` | `node` | mvdan (which execs node) |
293−| `./script.sh` (no shebang) | `./script.sh` | mvdan, fed the file |
294−| `./script.sh` (`#!/bin/bash`) | `./script.sh` | `bash ./script.sh` |
295−| `./script.py` (`#!/usr/bin/env python3`) | `./script.py` | `python3 ./script.py` |
296−| `./script.exe` | `./script.exe` | `os/exec` direct |
297−
298−### Contract on Windows
299−
300−- Inline shell runs through mvdan natively. No external dependency.
301−- Shebang-dispatched scripts require the named interpreter on PATH (`bash.exe`,
302− `python.exe`, `node.exe`, etc.). Crush does the dispatch that the Windows
303− kernel won't.
304−- Shebang-less scripts run through mvdan regardless of extension. CRLF line
305− endings are tolerated.
306−
307−### Implementation sketch
308−
309−- New function
310− `dispatch(ctx, cmd string, env []string, stdin io.Reader) (stdout, stderr string, exitCode int, err error)`
311− in `internal/hooks/`.
312−- Tokenize using mvdan's parser (already a dep) for consistent quoting/escape
313− behavior with shell intuition.
314−- Path-prefix check on `argv[0]`; if path, read shebang with a bounded
315− `io.LimitReader` and parse. Support:
316− - `#!/absolute/interpreter args…`
317− - `#!/usr/bin/env NAME` → resolve `NAME` on PATH
318− - `#!/usr/bin/env -S NAME args…` → treat as above; `-S` is common enough to
319− handle. Other `env` flags can error.
320−- Unified exit-code helper. mvdan's `interp.ExitStatus` and `os/exec`'s
321− `ProcessState.ExitCode()` both become a single `int`.
322−- Context cancellation: mvdan's exec handler uses `exec.CommandContext` for its
323− children, so a cancelled hook kills both the interpreter and any children.
324− Verify with a `sleep 60` test.
325−- One fresh `interp.Runner` per hook invocation (parallel hooks must not share
326− state).
327−
328−### Swap the call site
329−
330−`Runner.runOne` in `internal/hooks/runner.go` replaces its
331−`exec.Command("sh", "-c", …)` with a call to `dispatch(…)`. Everything
332−downstream (exit-code 2 / 49 / other dispatch, stdout JSON parsing,
333−stderr-as-reason) stays identical.
334−
335−### Tests
336−
337−Cross-platform matrix:
338−
339−- Inline: `echo hi`; `exit 2`; pipelines; redirections.
340−- File, no shebang: treated as shell source through mvdan.
341−- File, `#!/bin/bash` on Unix — invokes system bash.
342−- File, `#!/usr/bin/env python3` — invokes python if present, skips if not.
343−- File, `#!/usr/bin/env -S node --foo` — extra flags preserved.
344−- File with CRLF line endings in the shebang.
345−- `./missing-file` — non-blocking error, hook proceeds as "no opinion".
346−- Timeout: hook that sleeps past its timeout gets killed; context cancellation
347− kills the interpreter and its children.
348−- Concurrency: 10 hooks in parallel don't leak env/cwd/state between runners.
349−- Windows-specific: `./script.exe` exec'd directly; bash-shebang script fails
350− gracefully when bash isn't on PATH.
351−
352−### Pitfalls to watch
353−
354−- **Userland shebang parsing is now our problem.** Edge cases around `env`
355− flags, args with spaces, CRLF, missing interpreter. Well-trodden but needs
356− real tests.
357−- **The path-prefix heuristic is a heuristic.** `relative/path.sh` (no leading
358− `./`) gets mvdan'd, not file-dispatched. Matches shell intuition — at a bash
359− prompt, `relative/path.sh` doesn't run unless `.` is on PATH — but worth
360− documenting.
361−- **Kernel shebang handling is bypassed on Unix.** Today a chmod+x'd script is
362− exec'd by the kernel; after this change, by our parser. Behavior should be
363− byte-identical; verify with tests.
364−- **Two code paths.** mvdan vs direct-exec. Exit codes, stdin, signal
365− propagation, env inheritance must be aligned. Discipline, not cleverness.
366−
367−### Explicit non-goals
368−
369−- No bundled `bash.exe` or `python.exe`. Users bring their own interpreters.
370−- No custom mvdan builtins (`crush_approve` etc.). Hooks stay portable and
371− testable under bare `bash`.
372−- No `.sh`-extension filter on discovery. Hook file shape is driven by shebang,
373− not filename.
374−- No Crush-as-script-interpreter mode (users can't write `#!/usr/bin/env crush`
375− and have it mean something). If we want that later, it's an additive feature,
376− not a dependency of this work.
259+**Status:** implemented. See the [Execution model](README.md#execution-model)
260+section in `README.md` for the current behavior and contract.
docs/hooks/README.md+57−6
@@ -88,6 +88,45 @@ That's basically it. For the full guide on how hooks work, however, read on.
8888
8989---
9090
91+## Execution model
92+
93+Hooks run through Crush's embedded POSIX shell (`mvdan.cc/sh`) — the same
94+interpreter the `bash` tool uses. Inline commands and shebang-less scripts
95+execute in-process; scripts with a `#!` shebang dispatch to the named
96+interpreter via `os/exec`. This contract is identical on macOS, Linux, and
97+Windows.
98+
99+What this means in practice:
100+
101+- **Windows without Unix tooling**: inline shell (`echo`, pipelines, `jq`,
102+ `grep`), shebang-less `.sh` scripts, inline PowerShell
103+ (`powershell -Command …`), and `.exe` invocations all work out of the box
104+ with no WSL, Git Bash, Cygwin, or MSYS required.
105+- **PowerShell scripts** (`.ps1`) are not auto-dispatched by extension.
106+ Invoke them explicitly: `powershell -File ./audit.ps1` (or
107+ `pwsh -File ./audit.ps1`).
108+- **Shebang'd scripts** require the named interpreter on `PATH`. Git for
109+ Windows ships `bash.exe`, which makes `#!/bin/bash` and
110+ `#!/usr/bin/env bash` scripts work on Windows the same way they do on
111+ Unix. CRLF line endings in the shebang line are tolerated.
112+- **Permissive shebang fallback**: if the absolute path in a shebang
113+ doesn't exist (e.g. `#!/bin/bash` on Windows), Crush falls back to a
114+ `PATH` lookup of the base name (`bash`) before giving up. A debug-level
115+ log records the fallback. If the interpreter isn't on `PATH` either, the
116+ hook fails cleanly as a non-blocking warning and the agent proceeds as
117+ "no opinion".
118+- **Environment**: every hook sees `CRUSH=1`, `AGENT=crush`, and
119+ `AI_AGENT=crush` on top of the `CRUSH_*` hook-specific variables. These
120+ three markers are guaranteed and match what the `bash` tool sets, so
121+ scripts that detect "am I being run by an AI agent?" behave the same in
122+ both contexts.
123+- **Timeout behavior**: when a hook exceeds its timeout, Crush cancels the
124+ context and waits a short grace period (~1s) for the interpreter to
125+ yield. If the hook still hasn't returned, Crush abandons it, logs a
126+ warning, and treats the result as "no opinion" so the agent can proceed.
127+ Long-running work should honor context cancellation or run in a
128+ subprocess via a shebang.
129+
91130## Configuration
92131
93132Hooks can be added to your `crush.json` (or `.crush.json`) at both the global
@@ -143,7 +182,8 @@ When a hook fires, Crush:
1431821. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
144183 all).
1451842. Deduplicates by `command` (identical commands run once).
146−3. Runs all matching hooks **in parallel** as subprocesses.
185+3. Runs all matching hooks **in parallel** through Crush's embedded POSIX
186+ shell (see [Execution model](#execution-model)).
1471874. Waits for all to finish (or time out), then aggregates results **in config
148188 order**: deny wins over allow, allow wins over none; `updated_input` patches
149189 shallow-merge in order.
@@ -153,8 +193,9 @@ When a hook fires, Crush:
153193 also skips the prompt. Silence (no decision) falls through to the normal
154194 permission flow.
155195
156−Note that you can omit `matcher` and match in your shell script instead, however
157−you'll incur some additional overhead as Crush will `exec` each script.
196+Note that you can omit `matcher` and match in your shell script instead,
197+however you'll incur some additional overhead as Crush will still parse and
198+run each hook.
158199
159200### Input
160201
@@ -168,6 +209,9 @@ The available environment variables are:
168209
169210| Variable | Description |
170211| ---------------------------- | ---------------------------------------------- |
212+| `CRUSH` | Always `1` when running under Crush. |
213+| `AGENT` | Always `crush`. |
214+| `AI_AGENT` | Always `crush`. |
171215| `CRUSH_EVENT` | The hook event name (e.g. `PreToolUse`). |
172216| `CRUSH_TOOL_NAME` | The tool being called (e.g. `bash`). |
173217| `CRUSH_SESSION_ID` | Current session ID. |
@@ -176,6 +220,10 @@ The available environment variables are:
176220| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. |
177221| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. |
178222
223+The `CRUSH`, `AGENT`, and `AI_AGENT` markers are also set by the `bash`
224+tool, so a script can detect "am I running under Crush?" the same way in
225+either context.
226+
179227#### JSON
180228
181229Standard input provides the full context as JSON:
@@ -326,9 +374,12 @@ When multiple hooks match the same tool call:
326374
327375### Timeouts
328376
329−If a hook exceeds its timeout, the process is killed and treated as a
330−non-blocking error and the tool call proceeds. The default timeout is 30
331−seconds.
377+If a hook exceeds its timeout, Crush cancels its context and treats the
378+result as a non-blocking error so the tool call proceeds. The default
379+timeout is 30 seconds. Shebang-dispatched subprocesses are killed through
380+`exec.CommandContext`; in-process hooks get a short grace period to yield
381+and are then abandoned (the agent moves on regardless). Long-running work
382+should honor context cancellation or run out-of-process via a shebang.
332383
333384## Examples
334385
internal/hooks/hooks_test.go+67
@@ -2,11 +2,14 @@ package hooks
22
33import (
44 "context"
5+ "io"
56 "strings"
7+ "sync"
68 "testing"
79 "time"
810
911 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/shell"
1013 "github.com/stretchr/testify/require"
1114)
1215
@@ -192,6 +195,13 @@ func TestBuildEnv(t *testing.T) {
192195 require.Equal(t, "/project", envMap["CRUSH_PROJECT_DIR"])
193196 require.Equal(t, "ls", envMap["CRUSH_TOOL_INPUT_COMMAND"])
194197 require.Equal(t, "/tmp/f.txt", envMap["CRUSH_TOOL_INPUT_FILE_PATH"])
198+
199+ // Shared Crush markers must be present so hook-authored scripts can
200+ // detect they're running under Crush the same way bash-tool-invoked
201+ // scripts can.
202+ require.Equal(t, "1", envMap["CRUSH"])
203+ require.Equal(t, "crush", envMap["AGENT"])
204+ require.Equal(t, "crush", envMap["AI_AGENT"])
195205}
196206
197207func splitFirst(s, sep string) []string {
@@ -602,6 +612,63 @@ func TestAggregationUpdatedInput(t *testing.T) {
602612 })
603613}
604614
615+// TestRunnerAbandonRaceSafety verifies that if a hook's shell execution
616+// does not yield to ctx cancellation within abandonGrace, runOne returns
617+// promptly and never touches the shared stdout/stderr buffers again —
618+// even while the abandoned goroutine continues to write to them.
619+//
620+// The substitute shell executor ignores ctx entirely, writes to Stdout
621+// both before and after the abandon deadline, and only then returns.
622+// Under -race this catches any code path in runOne that reads those
623+// buffers after returning the DecisionNone abandon result.
624+func TestRunnerAbandonRaceSafety(t *testing.T) {
625+ origRunShell := runShell
626+ t.Cleanup(func() { runShell = origRunShell })
627+
628+ // Synchronize shutdown with the abandoned goroutine so the test
629+ // exits cleanly even under -race.
630+ var wg sync.WaitGroup
631+ release := make(chan struct{})
632+ t.Cleanup(func() {
633+ close(release)
634+ wg.Wait()
635+ })
636+
637+ runShell = func(_ context.Context, opts shell.RunOptions) error {
638+ wg.Add(1)
639+ defer wg.Done()
640+ // Write before the caller observes ctx.Done(); the caller will
641+ // not read the buffer while we still own it.
642+ _, _ = io.WriteString(opts.Stdout, "before\n")
643+ // Hold past ctx deadline + abandonGrace so the caller takes
644+ // the abandon branch, then continue writing. If the caller
645+ // reads these buffers after abandoning, -race will flag it.
646+ select {
647+ case <-time.After(5 * time.Second):
648+ case <-release:
649+ }
650+ _, _ = io.WriteString(opts.Stdout, "after\n")
651+ return nil
652+ }
653+
654+ hookCfg := config.HookConfig{
655+ Command: "# irrelevant; runShell is stubbed",
656+ Timeout: 1,
657+ }
658+ r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
659+
660+ start := time.Now()
661+ result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
662+ elapsed := time.Since(start)
663+
664+ require.NoError(t, err)
665+ require.Equal(t, DecisionNone, result.Decision)
666+ // Abandon must happen at ~timeout + abandonGrace. Allow generous
667+ // slack so CI noise doesn't flake the test.
668+ require.Less(t, elapsed, 3500*time.Millisecond,
669+ "runOne should return within timeout+abandonGrace+slack")
670+}
671+
605672func TestRunnerUpdatedInput(t *testing.T) {
606673 t.Parallel()
607674 hookCfg := config.HookConfig{
internal/hooks/input.go+2
@@ -7,6 +7,7 @@ import (
77 "os"
88 "strings"
99
10+ "github.com/charmbracelet/crush/internal/shell"
1011 "github.com/tidwall/gjson"
1112)
1213
@@ -51,6 +52,7 @@ func BuildPayload(eventName, sessionID, cwd, toolName, toolInputJSON string) []b
5152// It includes all current process env vars plus hook-specific ones.
5253func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
5354 env := os.Environ()
55+ env = append(env, shell.CrushEnvMarkers()...)
5456 env = append(env,
5557 fmt.Sprintf("CRUSH_EVENT=%s", eventName),
5658 fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
internal/hooks/runner.go+60−15
@@ -4,15 +4,27 @@ import (
44 "bytes"
55 "context"
66 "log/slog"
7− "os/exec"
87 "regexp"
98 "strings"
109 "sync"
1110 "time"
1211
1312 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/shell"
1414)
1515
16+// abandonGrace is how long runOne waits after ctx cancellation for the
17+// shell goroutine to yield before returning control to the caller and
18+// letting the goroutine finish on its own. Mirrors the historical
19+// cmd.WaitDelay = time.Second behavior of the previous os/exec path.
20+const abandonGrace = time.Second
21+
22+// runShell is the shell executor used by runOne. It is a package-level
23+// variable so tests can substitute a blocking or non-yielding
24+// implementation to exercise the abandon-on-timeout path without
25+// depending on the scheduling behavior of the real interpreter.
26+var runShell = shell.Run
27+
1628// compiledHook pairs a HookConfig with its compiled matcher regex. A nil
1729// matcher means "match every tool".
1830type compiledHook struct {
@@ -140,24 +152,59 @@ func (r *Runner) matchingHooks(toolName string) []config.HookConfig {
140152}
141153
142154// runOne executes a single hook command and returns its result.
155+//
156+// Execution goes through Crush's embedded POSIX shell (shell.Run) so the
157+// same interpreter, builtins, and coreutils are visible to hooks as to
158+// the bash tool. BlockFuncs are intentionally omitted: hooks are
159+// user-authored config that carry the same trust as a shell alias.
160+//
161+// A hook that fails to yield after its deadline has passed is abandoned
162+// after abandonGrace so the caller never blocks longer than
163+// timeout + abandonGrace. Ownership of the stdout and stderr buffers is
164+// strictly single-goroutine:
165+// - before receiving from `done`, only the goroutine writes to them;
166+// - after `done` delivers a value, the goroutine is finished and the
167+// outer frame reads them;
168+// - on the abandon path, the goroutine may still be writing and the
169+// outer frame must not touch them again.
143170func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVars []string, payload []byte) HookResult {
144171 timeout := hook.TimeoutDuration()
145172 ctx, cancel := context.WithTimeout(parentCtx, timeout)
146173 defer cancel()
147174
148− cmd := exec.CommandContext(ctx, "sh", "-c", hook.Command)
149− cmd.WaitDelay = time.Second
150− cmd.Env = envVars
151− cmd.Dir = r.cwd
152− cmd.Stdin = bytes.NewReader(payload)
153−
154175 var stdout, stderr bytes.Buffer
155− cmd.Stdout = &stdout
156− cmd.Stderr = &stderr
176+ done := make(chan error, 1)
177+ go func() {
178+ done <- runShell(ctx, shell.RunOptions{
179+ Command: hook.Command,
180+ Cwd: r.cwd,
181+ Env: envVars,
182+ Stdin: bytes.NewReader(payload),
183+ Stdout: &stdout,
184+ Stderr: &stderr,
185+ })
186+ }()
157187
158− err := cmd.Run()
188+ var err error
189+ select {
190+ case err = <-done:
191+ // Normal path: goroutine has finished, buffers are safe to read.
192+ case <-ctx.Done():
193+ select {
194+ case err = <-done:
195+ // Interpreter yielded within the grace period; safe to read.
196+ case <-time.After(abandonGrace):
197+ slog.Warn("Hook did not yield after cancel; abandoning goroutine",
198+ "command", hook.Command,
199+ "timeout", timeout,
200+ )
201+ // The goroutine may still be writing to stdout/stderr; do
202+ // not read either buffer below this point.
203+ return HookResult{Decision: DecisionNone}
204+ }
205+ }
159206
160− if ctx.Err() != nil {
207+ if shell.IsInterrupt(err) {
161208 // Distinguish timeout from parent cancellation.
162209 if parentCtx.Err() != nil {
163210 slog.Debug("Hook cancelled by parent context", "command", hook.Command)
@@ -168,10 +215,7 @@ func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVa
168215 }
169216
170217 if err != nil {
171− exitCode := -1
172− if cmd.ProcessState != nil {
173− exitCode = cmd.ProcessState.ExitCode()
174− }
218+ exitCode := shell.ExitCode(err)
175219 switch exitCode {
176220 case 2:
177221 // Exit code 2 = block this tool call. Stderr is the reason.
@@ -200,6 +244,7 @@ func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVa
200244 "command", hook.Command,
201245 "exit_code", exitCode,
202246 "stderr", strings.TrimSpace(stderr.String()),
247+ "error", err,
203248 )
204249 return HookResult{Decision: DecisionNone}
205250 }
internal/shell/dispatch.gonew file+426
@@ -0,0 +1,426 @@
1+package shell
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "io/fs"
10+ "log/slog"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "runtime"
15+ "strings"
16+
17+ "mvdan.cc/sh/v3/expand"
18+ "mvdan.cc/sh/v3/interp"
19+ "mvdan.cc/sh/v3/syntax"
20+)
21+
22+// probeWindow is how many bytes we read from the head of a file to decide
23+// how to dispatch it. 128 is plenty for a shebang line and for magic-byte
24+// inspection, while small enough to make the probe cheap for users whose
25+// hooks invoke many scripts.
26+const probeWindow = 128
27+
28+// scriptDispatchHandler returns middleware that intercepts exec of a
29+// path-prefixed argv[0] (e.g. ./foo.sh, /opt/bin/tool, C:\foo\bar.exe) and
30+// dispatches based on the file's contents:
31+//
32+// 1. Shebang line (#!...) → exec the named interpreter via os/exec. The
33+// interpreter is resolved literally first, then via PATH on the
34+// basename as a permissive fallback (so #!/bin/bash works on Windows
35+// boxes where Git for Windows puts bash.exe on PATH).
36+// 2. Known binary magic (MZ, ELF, Mach-O) or a NUL byte in the probe
37+// window → pass through to the next handler (mvdan's default exec).
38+// 3. Otherwise → treat the file as shell source and run it in-process via
39+// a nested interp.Runner that reuses the same handler stack.
40+//
41+// Non-path-prefixed argv[0] and empty args are passed straight through; this
42+// handler is a no-op for ordinary commands like `echo` or `jq`.
43+//
44+// blockFuncs is the block list used when building the nested runner for the
45+// shell-source case, so deny rules apply recursively to commands invoked
46+// from in-process scripts.
47+func scriptDispatchHandler(blockFuncs []BlockFunc) func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
48+ return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
49+ return func(ctx context.Context, args []string) error {
50+ if len(args) == 0 || !isPathPrefixed(args[0]) {
51+ return next(ctx, args)
52+ }
53+
54+ scriptPath := args[0]
55+ // Resolve relative paths against the interpreter's cwd, not
56+ // the process cwd — hook commands are authored with the hook
57+ // Runner's cwd in mind and sub-shells can cd before an exec.
58+ if !filepath.IsAbs(scriptPath) {
59+ scriptPath = filepath.Join(interp.HandlerCtx(ctx).Dir, scriptPath)
60+ }
61+ probe, err := probeFile(scriptPath)
62+ if err != nil {
63+ return err
64+ }
65+
66+ switch {
67+ case hasShebang(probe):
68+ return dispatchShebang(ctx, scriptPath, probe, args)
69+ case isBinary(probe):
70+ return next(ctx, args)
71+ default:
72+ return runShellSource(ctx, scriptPath, args, blockFuncs)
73+ }
74+ }
75+ }
76+}
77+
78+// isPathPrefixed reports whether argv[0] is a file reference (as opposed
79+// to a bare command to be resolved via PATH). A path reference starts with
80+// `./`, `../`, `/`, or — on Windows — a drive-letter prefix.
81+//
82+// Note: mvdan already performs tilde expansion during word expansion, so
83+// `~/script.sh` arrives here as an absolute path. We still call the helper
84+// on the raw string to stay robust if a future change ever bypasses that
85+// expansion; cover that path with a regression test.
86+func isPathPrefixed(arg string) bool {
87+ switch {
88+ case strings.HasPrefix(arg, "./"),
89+ strings.HasPrefix(arg, "../"),
90+ strings.HasPrefix(arg, "/"):
91+ return true
92+ }
93+ if runtime.GOOS == "windows" {
94+ // Drive-letter paths: C:\foo or C:/foo (length check avoids
95+ // accidentally matching a single letter followed by a colon).
96+ if len(arg) >= 3 && isDriveLetter(arg[0]) && arg[1] == ':' &&
97+ (arg[2] == '\\' || arg[2] == '/') {
98+ return true
99+ }
100+ // Also treat backslash-prefixed UNC-like paths as path-prefixed.
101+ if strings.HasPrefix(arg, "\\") {
102+ return true
103+ }
104+ }
105+ return false
106+}
107+
108+func isDriveLetter(b byte) bool {
109+ return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
110+}
111+
112+// probeFile reads the first probeWindow bytes of the target path. It
113+// deliberately does not slurp the whole file: callers that need the full
114+// contents (only the shell-source branch) re-open via os.ReadFile. This
115+// keeps memory bounded when argv[0] turns out to be a large binary.
116+//
117+// Returns errors surfaced by os.Open/os.Stat directly so callers see the
118+// real reason: ENOENT, EACCES, EISDIR, ELOOP, etc.
119+func probeFile(path string) ([]byte, error) {
120+ f, err := os.Open(path)
121+ if err != nil {
122+ return nil, err
123+ }
124+ defer f.Close()
125+ fi, err := f.Stat()
126+ if err != nil {
127+ return nil, err
128+ }
129+ if fi.IsDir() {
130+ return nil, fmt.Errorf("%s: is a directory", path)
131+ }
132+ probe := make([]byte, probeWindow)
133+ n, err := io.ReadFull(f, probe)
134+ if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
135+ return nil, err
136+ }
137+ return probe[:n], nil
138+}
139+
140+// hasShebang reports whether probe starts with the `#!` marker. A
141+// one-byte file that happens to be `#` is not a shebang.
142+func hasShebang(probe []byte) bool {
143+ return len(probe) >= 2 && probe[0] == '#' && probe[1] == '!'
144+}
145+
146+// isBinary heuristically classifies probe as an executable or otherwise
147+// non-text file. A NUL byte in the first probeWindow bytes is the classic
148+// Unix-y text-vs-binary signal; we additionally recognize known magic
149+// numbers so we can fast-path well-formed binaries that happen to have no
150+// NUL in the first 128 bytes (rare but possible for small binaries).
151+func isBinary(probe []byte) bool {
152+ if bytes.IndexByte(probe, 0) >= 0 {
153+ return true
154+ }
155+ magics := [][]byte{
156+ {'M', 'Z'}, // Windows PE / DOS MZ.
157+ {0x7F, 'E', 'L', 'F'}, // ELF.
158+ {0xFE, 0xED, 0xFA, 0xCE}, // Mach-O 32-bit BE.
159+ {0xFE, 0xED, 0xFA, 0xCF}, // Mach-O 64-bit BE.
160+ {0xCF, 0xFA, 0xED, 0xFE}, // Mach-O 64-bit LE.
161+ {0xCE, 0xFA, 0xED, 0xFE}, // Mach-O 32-bit LE.
162+ {0xCA, 0xFE, 0xBA, 0xBE}, // Mach-O fat binary.
163+ }
164+ for _, m := range magics {
165+ if bytes.HasPrefix(probe, m) {
166+ return true
167+ }
168+ }
169+ return false
170+}
171+
172+// dispatchShebang parses probe's shebang line and execs the resolved
173+// interpreter via os/exec, inheriting the parent runner's cwd, env, and
174+// stdio. Returns interp.ExitStatus on non-zero interpreter exit so the
175+// parent interpreter sees it as a normal non-zero status.
176+func dispatchShebang(ctx context.Context, scriptPath string, probe []byte, args []string) error {
177+ sb, err := parseShebang(probe)
178+ if err != nil {
179+ hc := interp.HandlerCtx(ctx)
180+ fmt.Fprintf(hc.Stderr, "crush: %s: %s\n", scriptPath, err)
181+ return interp.ExitStatus(126)
182+ }
183+
184+ interpreter, err := resolveInterpreter(sb.interpreter)
185+ if err != nil {
186+ hc := interp.HandlerCtx(ctx)
187+ fmt.Fprintf(hc.Stderr, "crush: %s: %s\n", scriptPath, err)
188+ return interp.ExitStatus(127)
189+ }
190+
191+ cmdArgs := append([]string{}, sb.args...)
192+ cmdArgs = append(cmdArgs, scriptPath)
193+ cmdArgs = append(cmdArgs, args[1:]...)
194+
195+ cmd := exec.CommandContext(ctx, interpreter, cmdArgs...)
196+ hc := interp.HandlerCtx(ctx)
197+ cmd.Dir = hc.Dir
198+ cmd.Env = execEnvList(hc.Env)
199+ cmd.Stdin = hc.Stdin
200+ cmd.Stdout = hc.Stdout
201+ cmd.Stderr = hc.Stderr
202+
203+ if err := cmd.Run(); err != nil {
204+ var exitErr *exec.ExitError
205+ if errors.As(err, &exitErr) {
206+ code := exitErr.ExitCode()
207+ if code < 0 {
208+ code = 1
209+ }
210+ return interp.ExitStatus(uint8(code))
211+ }
212+ return err
213+ }
214+ return nil
215+}
216+
217+// resolveInterpreter tries the literal shebang path first, then falls back
218+// to PATH-lookup on its basename — but only when the literal path is
219+// genuinely missing. A file that exists but fails stat for another reason
220+// (EACCES, ELOOP, etc.) surfaces the real error: silently resolving a
221+// different binary off PATH in that case would hide a real problem and
222+// produce surprising behavior for the user.
223+//
224+// The permissive fallback is what makes #!/bin/bash portable to Windows
225+// boxes where Git for Windows puts bash.exe on PATH but there is no
226+// /bin/bash on disk.
227+func resolveInterpreter(path string) (string, error) {
228+ _, statErr := os.Stat(path)
229+ if statErr == nil {
230+ return path, nil
231+ }
232+ if !errors.Is(statErr, fs.ErrNotExist) {
233+ return "", statErr
234+ }
235+
236+ base := filepath.Base(path)
237+ if base == "" || base == path && !strings.ContainsAny(path, `/\`) {
238+ // Already a bare name — just do a PATH lookup.
239+ resolved, err := exec.LookPath(path)
240+ if err != nil {
241+ return "", fmt.Errorf("interpreter %q not found in PATH", path)
242+ }
243+ return resolved, nil
244+ }
245+ resolved, err := exec.LookPath(base)
246+ if err != nil {
247+ return "", fmt.Errorf("interpreter %q not found and %q not in PATH", path, base)
248+ }
249+ slog.Debug("Shebang interpreter not found; falling back to PATH",
250+ "requested", path, "resolved", resolved)
251+ return resolved, nil
252+}
253+
254+// shebang captures the parsed `#!` line. interpreter is the program to
255+// invoke; args is the list of extra arguments to pass before the script
256+// path. The kernel's single-arg semantics (for literal paths and for env
257+// without `-S`) is encoded by returning a single-element args slice
258+// containing the un-tokenized remainder.
259+type shebang struct {
260+ interpreter string
261+ args []string
262+}
263+
264+// parseShebang extracts the interpreter invocation from probe. It tolerates
265+// CRLF line endings and a single leading space between `#!` and the path.
266+// env special-cases: `/usr/bin/env NAME [args...]` unwraps to NAME with
267+// kernel single-arg semantics; `-S` enables tokenized argument splitting.
268+func parseShebang(probe []byte) (*shebang, error) {
269+ if !hasShebang(probe) {
270+ return nil, errors.New("not a shebang")
271+ }
272+ line := probe[2:]
273+ // Take up to the first newline.
274+ if idx := bytes.IndexByte(line, '\n'); idx >= 0 {
275+ line = line[:idx]
276+ }
277+ // Strip trailing CR (CRLF-authored scripts).
278+ line = bytes.TrimRight(line, "\r")
279+ // Strip leading whitespace ("#! /usr/bin/env bash" is legal).
280+ line = bytes.TrimLeft(line, " \t")
281+ if len(line) == 0 {
282+ return nil, errors.New("empty shebang")
283+ }
284+
285+ var pathStr, rest string
286+ if idx := bytes.IndexAny(line, " \t"); idx >= 0 {
287+ pathStr = string(line[:idx])
288+ rest = strings.TrimLeft(string(line[idx+1:]), " \t")
289+ } else {
290+ pathStr = string(line)
291+ }
292+
293+ if isEnvShebang(pathStr) {
294+ return parseEnvShebang(rest)
295+ }
296+
297+ // Literal-path shebang: kernel semantics pass the remainder as a
298+ // single argv[1], not tokenized.
299+ sb := &shebang{interpreter: pathStr}
300+ if rest != "" {
301+ sb.args = []string{rest}
302+ }
303+ return sb, nil
304+}
305+
306+// isEnvShebang reports whether the shebang path targets `env`. We accept
307+// both common absolute paths and a bare `env` so that unusual setups
308+// (NixOS, BSDs) still work.
309+func isEnvShebang(p string) bool {
310+ if p == "/usr/bin/env" || p == "/bin/env" {
311+ return true
312+ }
313+ return filepath.Base(p) == "env"
314+}
315+
316+// parseEnvShebang handles `/usr/bin/env` rewriting. Without `-S`, the
317+// remainder after the program name is a single argv[1] (kernel
318+// single-arg semantics via env, even though real env would fail to find a
319+// program named "bash -x"). With `-S`, the remainder is tokenized on
320+// whitespace. Any other `env` flag is rejected — forwarding unknown flags
321+// to a /usr/bin/env on disk is a subtle portability footgun we don't want.
322+func parseEnvShebang(rest string) (*shebang, error) {
323+ if rest == "" {
324+ return nil, errors.New("env: missing program name")
325+ }
326+
327+ useSplit := false
328+ if strings.HasPrefix(rest, "-") {
329+ var flag, after string
330+ if idx := strings.IndexAny(rest, " \t"); idx >= 0 {
331+ flag = rest[:idx]
332+ after = strings.TrimLeft(rest[idx+1:], " \t")
333+ } else {
334+ flag = rest
335+ after = ""
336+ }
337+ if flag != "-S" {
338+ return nil, fmt.Errorf("unsupported env flag: %s", flag)
339+ }
340+ useSplit = true
341+ rest = after
342+ if rest == "" {
343+ return nil, errors.New("env -S requires a program")
344+ }
345+ }
346+
347+ if rest == "" {
348+ return nil, errors.New("env: missing program name")
349+ }
350+
351+ var prog, remainder string
352+ if idx := strings.IndexAny(rest, " \t"); idx >= 0 {
353+ prog = rest[:idx]
354+ remainder = strings.TrimLeft(rest[idx+1:], " \t")
355+ } else {
356+ prog = rest
357+ }
358+
359+ sb := &shebang{interpreter: prog}
360+ if remainder != "" {
361+ if useSplit {
362+ sb.args = strings.Fields(remainder)
363+ } else {
364+ sb.args = []string{remainder}
365+ }
366+ }
367+ return sb, nil
368+}
369+
370+// runShellSource parses path's contents as POSIX shell and runs it
371+// in-process via a nested interp.Runner. It reuses the parent runner's cwd,
372+// env, and stdio, and rebuilds the Crush handler stack so builtins and the
373+// dispatch handler itself remain available to anything the script invokes.
374+// Positional parameters ($1, $2, …) come from args[1:].
375+//
376+// This is the only branch that reads the full file; probeFile keeps its
377+// read to probeWindow bytes so the binary/shebang paths never touch more
378+// than 128 bytes of I/O.
379+func runShellSource(ctx context.Context, path string, args []string, blockFuncs []BlockFunc) error {
380+ data, err := os.ReadFile(path)
381+ if err != nil {
382+ return err
383+ }
384+
385+ file, err := syntax.NewParser().Parse(bytes.NewReader(data), path)
386+ if err != nil {
387+ return fmt.Errorf("could not parse %s: %w", path, err)
388+ }
389+
390+ hc := interp.HandlerCtx(ctx)
391+
392+ opts := []interp.RunnerOption{
393+ interp.StdIO(hc.Stdin, hc.Stdout, hc.Stderr),
394+ interp.Interactive(false),
395+ interp.Env(hc.Env),
396+ interp.Dir(hc.Dir),
397+ interp.ExecHandlers(standardHandlers(blockFuncs)...),
398+ }
399+ if len(args) > 1 {
400+ // Params with a leading "--" avoids any of args[1:] being
401+ // misinterpreted as set-options (e.g. a user passing "-e" as
402+ // a positional arg to their script).
403+ params := append([]string{"--"}, args[1:]...)
404+ opts = append(opts, interp.Params(params...))
405+ }
406+
407+ runner, err := interp.New(opts...)
408+ if err != nil {
409+ return fmt.Errorf("could not build runner for %s: %w", path, err)
410+ }
411+ return runner.Run(ctx, file)
412+}
413+
414+// execEnvList converts an expand.Environ to the []string form that
415+// os/exec.Cmd.Env expects. Only exported string variables are included,
416+// matching what a real shell would pass to a child process.
417+func execEnvList(env expand.Environ) []string {
418+ var out []string
419+ env.Each(func(name string, vr expand.Variable) bool {
420+ if vr.Exported && vr.Kind == expand.String {
421+ out = append(out, name+"="+vr.Str)
422+ }
423+ return true
424+ })
425+ return out
426+}
internal/shell/dispatch_test.gonew file+594
@@ -0,0 +1,594 @@
1+package shell
2+
3+import (
4+ "bytes"
5+ "crypto/rand"
6+ "encoding/hex"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "reflect"
11+ "runtime"
12+ "strings"
13+ "testing"
14+)
15+
16+// writeScript is a small helper that drops a file with the given contents
17+// and executable mode into dir. Tests that need exec semantics rely on the
18+// 0o755 mode on Unix; Windows ignores file modes but doesn't need them
19+// because dispatch decides what to do from file contents, not permissions.
20+func writeScript(t *testing.T, dir, name, contents string) string {
21+ t.Helper()
22+ path := filepath.Join(dir, name)
23+ if err := os.WriteFile(path, []byte(contents), 0o755); err != nil {
24+ t.Fatalf("write %s: %v", name, err)
25+ }
26+ return filepath.ToSlash(path)
27+}
28+
29+// randSuffix returns a short random hex string, used to build
30+// intentionally-unique paths that won't collide with anything on disk.
31+func randSuffix() string {
32+ var b [4]byte
33+ _, _ = rand.Read(b[:])
34+ return hex.EncodeToString(b[:])
35+}
36+
37+// TestIsPathPrefixed covers the classification rules used by the dispatch
38+// handler to decide whether argv[0] is a file reference.
39+func TestIsPathPrefixed(t *testing.T) {
40+ cases := []struct {
41+ in string
42+ want bool
43+ }{
44+ {"./foo.sh", true},
45+ {"../foo.sh", true},
46+ {"/usr/bin/foo", true},
47+ {"foo", false},
48+ {"foo.sh", false},
49+ {"jq", false},
50+ {"", false},
51+ }
52+ for _, c := range cases {
53+ if got := isPathPrefixed(c.in); got != c.want {
54+ t.Errorf("isPathPrefixed(%q) = %v, want %v", c.in, got, c.want)
55+ }
56+ }
57+
58+ if runtime.GOOS == "windows" {
59+ winCases := []struct {
60+ in string
61+ want bool
62+ }{
63+ {`C:\foo\bar.exe`, true},
64+ {`C:/foo/bar.exe`, true},
65+ {`c:\foo`, true},
66+ {`Z:/x`, true},
67+ {`C:`, false}, // just a drive, no path.
68+ {`\\server\share`, true},
69+ }
70+ for _, c := range winCases {
71+ if got := isPathPrefixed(c.in); got != c.want {
72+ t.Errorf("isPathPrefixed(%q) = %v, want %v", c.in, got, c.want)
73+ }
74+ }
75+ }
76+}
77+
78+// TestParseShebang covers the shebang grammar: literal paths, env,
79+// env -S, kernel single-arg semantics, CRLF tolerance, and every
80+// enumerated error case.
81+func TestParseShebang(t *testing.T) {
82+ type want struct {
83+ interp string
84+ args []string
85+ errSub string // substring expected in error message (empty → no error)
86+ }
87+ cases := []struct {
88+ name string
89+ in string
90+ want want
91+ }{
92+ {
93+ name: "literal-no-args",
94+ in: "#!/bin/bash\necho body\n",
95+ want: want{interp: "/bin/bash"},
96+ },
97+ {
98+ name: "literal-kernel-single-arg",
99+ in: "#!/bin/bash -x -y\n",
100+ want: want{interp: "/bin/bash", args: []string{"-x -y"}},
101+ },
102+ {
103+ name: "env-basic",
104+ in: "#!/usr/bin/env bash\n",
105+ want: want{interp: "bash"},
106+ },
107+ {
108+ name: "env-kernel-single-arg",
109+ in: "#!/usr/bin/env bash -x\n",
110+ want: want{interp: "bash", args: []string{"-x"}},
111+ },
112+ {
113+ name: "env-dash-S-splits",
114+ in: "#!/usr/bin/env -S bash -x\n",
115+ want: want{interp: "bash", args: []string{"-x"}},
116+ },
117+ {
118+ name: "env-dash-S-multi-args",
119+ in: "#!/usr/bin/env -S bash -x --noprofile\n",
120+ want: want{interp: "bash", args: []string{"-x", "--noprofile"}},
121+ },
122+ {
123+ name: "leading-space",
124+ in: "#! /usr/bin/env bash\n",
125+ want: want{interp: "bash"},
126+ },
127+ {
128+ name: "crlf",
129+ in: "#!/bin/bash\r\n",
130+ want: want{interp: "/bin/bash"},
131+ },
132+ {
133+ name: "bare-env-name",
134+ in: "#!env bash\n",
135+ want: want{interp: "bash"},
136+ },
137+ {
138+ name: "empty-after-hashbang",
139+ in: "#!\n",
140+ want: want{errSub: "empty shebang"},
141+ },
142+ {
143+ name: "env-alone",
144+ in: "#!/usr/bin/env\n",
145+ want: want{errSub: "missing program name"},
146+ },
147+ {
148+ name: "env-dash-S-alone",
149+ in: "#!/usr/bin/env -S\n",
150+ want: want{errSub: "env -S requires a program"},
151+ },
152+ {
153+ name: "env-unknown-flag",
154+ in: "#!/usr/bin/env -x bash\n",
155+ want: want{errSub: "unsupported env flag"},
156+ },
157+ }
158+ for _, c := range cases {
159+ t.Run(c.name, func(t *testing.T) {
160+ sb, err := parseShebang([]byte(c.in))
161+ if c.want.errSub != "" {
162+ if err == nil || !strings.Contains(err.Error(), c.want.errSub) {
163+ t.Fatalf("expected error containing %q, got: %v", c.want.errSub, err)
164+ }
165+ return
166+ }
167+ if err != nil {
168+ t.Fatalf("unexpected error: %v", err)
169+ }
170+ if sb.interpreter != c.want.interp {
171+ t.Errorf("interpreter = %q, want %q", sb.interpreter, c.want.interp)
172+ }
173+ if !equalStringSlice(sb.args, c.want.args) {
174+ t.Errorf("args = %v, want %v", sb.args, c.want.args)
175+ }
176+ })
177+ }
178+}
179+
180+func equalStringSlice(a, b []string) bool {
181+ if len(a) == 0 && len(b) == 0 {
182+ return true
183+ }
184+ return reflect.DeepEqual(a, b)
185+}
186+
187+// TestIsBinary covers the NUL-byte and magic-byte classification used to
188+// keep compiled executables off the in-process shell-source path.
189+func TestIsBinary(t *testing.T) {
190+ cases := []struct {
191+ name string
192+ in []byte
193+ want bool
194+ }{
195+ {"shell", []byte("echo hi\n"), false},
196+ {"nul", []byte("hello\x00world"), true},
197+ {"elf", []byte{0x7F, 'E', 'L', 'F', 0x02, 0x01}, true},
198+ {"mz", []byte("MZ\x90\x00"), true},
199+ {"macho-64-le", []byte{0xCF, 0xFA, 0xED, 0xFE}, true},
200+ {"short-non-binary", []byte("a"), false},
201+ }
202+ for _, c := range cases {
203+ if got := isBinary(c.in); got != c.want {
204+ t.Errorf("%s: isBinary = %v, want %v", c.name, got, c.want)
205+ }
206+ }
207+}
208+
209+// TestDispatch_ShellSourceNoShebang exercises the in-process shell-source
210+// branch: a file without a shebang runs via a nested runner and sees
211+// positional params from argv[1:].
212+func TestDispatch_ShellSourceNoShebang(t *testing.T) {
213+ dir := t.TempDir()
214+ script := writeScript(t, dir, "args.sh", `echo "$1 $2"`)
215+
216+ var stdout bytes.Buffer
217+ err := Run(t.Context(), RunOptions{
218+ Command: script + " alpha beta",
219+ Cwd: dir,
220+ Stdout: &stdout,
221+ })
222+ if err != nil {
223+ t.Fatalf("Run returned error: %v", err)
224+ }
225+ if got := stdout.String(); got != "alpha beta\n" {
226+ t.Fatalf("stdout = %q, want %q", got, "alpha beta\n")
227+ }
228+}
229+
230+// TestDispatch_EmptyFile confirms a zero-byte script runs as empty shell
231+// source (exit 0, no output).
232+func TestDispatch_EmptyFile(t *testing.T) {
233+ dir := t.TempDir()
234+ script := writeScript(t, dir, "empty.sh", "")
235+
236+ var stdout, stderr bytes.Buffer
237+ err := Run(t.Context(), RunOptions{
238+ Command: script,
239+ Cwd: dir,
240+ Stdout: &stdout,
241+ Stderr: &stderr,
242+ })
243+ if err != nil {
244+ t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
245+ }
246+ if stdout.Len() != 0 || stderr.Len() != 0 {
247+ t.Fatalf("expected empty output, got stdout=%q stderr=%q", stdout.String(), stderr.String())
248+ }
249+}
250+
251+// TestDispatch_ShellSourceComposesWithPipe confirms the dispatch handler
252+// plays nicely with mvdan's pipeline logic: a shell-source script on the
253+// left feeds the jq builtin on the right.
254+func TestDispatch_ShellSourceComposesWithPipe(t *testing.T) {
255+ dir := t.TempDir()
256+ script := writeScript(t, dir, "emit.sh", `printf '"value"'`)
257+
258+ var stdout bytes.Buffer
259+ err := Run(t.Context(), RunOptions{
260+ Command: script + ` | jq -r .`,
261+ Cwd: dir,
262+ Stdout: &stdout,
263+ })
264+ if err != nil {
265+ t.Fatalf("Run returned error: %v", err)
266+ }
267+ if got := stdout.String(); got != "value\n" {
268+ t.Fatalf("stdout = %q, want %q", got, "value\n")
269+ }
270+}
271+
272+// TestDispatch_MissingFile returns a clean error for a non-existent path.
273+func TestDispatch_MissingFile(t *testing.T) {
274+ dir := t.TempDir()
275+ missing := filepath.Join(dir, "nope.sh")
276+ err := Run(t.Context(), RunOptions{
277+ Command: missing,
278+ Cwd: dir,
279+ })
280+ if err == nil {
281+ t.Fatal("expected error for missing script, got nil")
282+ }
283+}
284+
285+// TestDispatch_DirectoryNotFile surfaces a distinct error when the path
286+// resolves to a directory.
287+func TestDispatch_DirectoryNotFile(t *testing.T) {
288+ dir := t.TempDir()
289+ subDir := filepath.Join(dir, "adir")
290+ if err := os.MkdirAll(subDir, 0o755); err != nil {
291+ t.Fatalf("mkdir: %v", err)
292+ }
293+
294+ var stderr bytes.Buffer
295+ err := Run(t.Context(), RunOptions{
296+ Command: "./adir",
297+ Cwd: dir,
298+ Stderr: &stderr,
299+ })
300+ if err == nil {
301+ t.Fatal("expected error when invoking a directory, got nil")
302+ }
303+ if !strings.Contains(err.Error(), "is a directory") {
304+ t.Fatalf("expected 'is a directory' in error, got: %v", err)
305+ }
306+}
307+
308+// TestDispatch_BashShebang runs a #!/bin/bash script via os/exec. Skipped
309+// if bash isn't available (rare in CI, but keep the test robust).
310+func TestDispatch_BashShebang(t *testing.T) {
311+ bash, err := exec.LookPath("bash")
312+ if err != nil {
313+ t.Skipf("bash not in PATH: %v", err)
314+ }
315+ _ = bash
316+
317+ dir := t.TempDir()
318+ script := writeScript(t, dir, "bash-echo.sh", "#!/usr/bin/env bash\necho bashout\n")
319+
320+ var stdout, stderr bytes.Buffer
321+ err = Run(t.Context(), RunOptions{
322+ Command: script,
323+ Cwd: dir,
324+ Stdout: &stdout,
325+ Stderr: &stderr,
326+ })
327+ if err != nil {
328+ t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
329+ }
330+ if got := stdout.String(); got != "bashout\n" {
331+ t.Fatalf("stdout = %q, want %q", got, "bashout\n")
332+ }
333+}
334+
335+// TestDispatch_ShebangPassesExitCode maps interpreter exit codes through to
336+// interp.ExitStatus so the caller can inspect them with ExitCode.
337+func TestDispatch_ShebangPassesExitCode(t *testing.T) {
338+ if _, err := exec.LookPath("bash"); err != nil {
339+ t.Skipf("bash not in PATH: %v", err)
340+ }
341+ dir := t.TempDir()
342+ script := writeScript(t, dir, "fail.sh", "#!/usr/bin/env bash\nexit 5\n")
343+
344+ err := Run(t.Context(), RunOptions{
345+ Command: script,
346+ Cwd: dir,
347+ })
348+ if err == nil {
349+ t.Fatal("expected non-nil error from exit 5")
350+ }
351+ if code := ExitCode(err); code != 5 {
352+ t.Fatalf("ExitCode = %d, want 5", code)
353+ }
354+}
355+
356+// TestDispatch_MissingInterpreter surfaces a clear error (and non-zero
357+// exit) when the shebang points to a binary that doesn't exist and has
358+// no PATH fallback.
359+func TestDispatch_MissingInterpreter(t *testing.T) {
360+ dir := t.TempDir()
361+ script := writeScript(t, dir, "bad.sh", "#!/no/such/interpreter-"+randSuffix()+"\n:\n")
362+
363+ var stderr bytes.Buffer
364+ err := Run(t.Context(), RunOptions{
365+ Command: script,
366+ Cwd: dir,
367+ Stderr: &stderr,
368+ })
369+ if err == nil {
370+ t.Fatal("expected error for missing interpreter, got nil")
371+ }
372+ if ExitCode(err) == 0 {
373+ t.Fatalf("expected non-zero exit code, got 0")
374+ }
375+ if !strings.Contains(stderr.String(), "not found") {
376+ t.Fatalf("expected 'not found' in stderr, got: %q", stderr.String())
377+ }
378+}
379+
380+// TestDispatch_BarePathNotHandled confirms the handler ignores
381+// non-path-prefixed argv[0] entirely: a benign bare `true` command must
382+// not try to open a file in cwd. If dispatch were (incorrectly) firing
383+// on bare commands, this test would see probeFile's ENOENT.
384+func TestDispatch_BarePathNotHandled(t *testing.T) {
385+ dir := t.TempDir()
386+ err := Run(t.Context(), RunOptions{
387+ Command: "true",
388+ Cwd: dir,
389+ })
390+ if err != nil {
391+ t.Fatalf("bare `true` should not trigger dispatch: %v", err)
392+ }
393+}
394+
395+// TestDispatch_ProbeWindowClassifiesByHead confirms that classification is
396+// done on the first probeWindow bytes even when the file is much larger;
397+// a file whose head is shell source but whose tail contains NUL bytes is
398+// classified as shell source, not binary.
399+func TestDispatch_ProbeWindowClassifiesByHead(t *testing.T) {
400+ dir := t.TempDir()
401+ head := "echo prefix\n"
402+ // Pad past probeWindow, then append some NULs.
403+ padding := strings.Repeat(" ", probeWindow)
404+ contents := head + padding + "\x00\x00\x00"
405+ script := writeScript(t, dir, "long.sh", contents)
406+
407+ var stdout bytes.Buffer
408+ err := Run(t.Context(), RunOptions{
409+ Command: script,
410+ Cwd: dir,
411+ Stdout: &stdout,
412+ })
413+ if err != nil {
414+ t.Fatalf("Run returned error: %v", err)
415+ }
416+ if got := stdout.String(); !strings.HasPrefix(got, "prefix\n") {
417+ t.Fatalf("stdout = %q, want prefix %q", got, "prefix\n")
418+ }
419+}
420+
421+// TestDispatch_BinaryPassthroughExecutes copies a real binary from PATH
422+// into a tempdir, invokes it via a path-prefixed argv[0], and verifies it
423+// ran — i.e. the binary branch correctly returns through `next` to the
424+// default exec handler. We use whichever of `true`/`echo` is available on
425+// PATH so the test works on any Unix-y system; it skips on Windows where
426+// the stock binaries don't share names and the Go test binary approach
427+// is heavier than this test deserves.
428+func TestDispatch_BinaryPassthroughExecutes(t *testing.T) {
429+ if runtime.GOOS == "windows" {
430+ t.Skip("relies on a Unix-style PATH binary")
431+ }
432+ src, err := exec.LookPath("true")
433+ if err != nil {
434+ t.Skipf("no `true` binary on PATH: %v", err)
435+ }
436+ data, err := os.ReadFile(src)
437+ if err != nil {
438+ t.Fatalf("read %s: %v", src, err)
439+ }
440+ dir := t.TempDir()
441+ dst := filepath.Join(dir, "copied-true")
442+ if err := os.WriteFile(dst, data, 0o755); err != nil {
443+ t.Fatalf("write %s: %v", dst, err)
444+ }
445+
446+ runErr := Run(t.Context(), RunOptions{
447+ Command: dst,
448+ Cwd: dir,
449+ // Default handler needs PATH to resolve dynamic linker / loader
450+ // helpers on some systems; inherit the process env so the copy
451+ // can actually start.
452+ Env: os.Environ(),
453+ })
454+ if runErr != nil {
455+ t.Fatalf("expected copy of /bin/true to exit 0, got: %v", runErr)
456+ }
457+}
458+
459+// TestDispatch_UnreadableFile confirms an EACCES on the script surfaces
460+// as a clean error rather than a silent fallback or a mis-classified
461+// shell-source attempt. POSIX-only: Windows doesn't have the same
462+// permission model and running as root would bypass the check anyway.
463+func TestDispatch_UnreadableFile(t *testing.T) {
464+ if runtime.GOOS == "windows" {
465+ t.Skip("POSIX permission model")
466+ }
467+ if os.Geteuid() == 0 {
468+ t.Skip("root bypasses file mode permission checks")
469+ }
470+ dir := t.TempDir()
471+ script := writeScript(t, dir, "unreadable.sh", "echo nope\n")
472+ if err := os.Chmod(script, 0o000); err != nil {
473+ t.Fatalf("chmod: %v", err)
474+ }
475+ t.Cleanup(func() { _ = os.Chmod(script, 0o644) })
476+
477+ err := Run(t.Context(), RunOptions{
478+ Command: script,
479+ Cwd: dir,
480+ })
481+ if err == nil {
482+ t.Fatal("expected permission error, got nil")
483+ }
484+ if !strings.Contains(err.Error(), "permission") {
485+ t.Fatalf("expected 'permission' in error, got: %v", err)
486+ }
487+}
488+
489+// TestDispatch_SymlinkLoop confirms that an ELOOP-returning path surfaces
490+// cleanly. POSIX-only: creating symlinks reliably on Windows requires
491+// elevated privileges or developer mode, and neither is guaranteed in CI.
492+func TestDispatch_SymlinkLoop(t *testing.T) {
493+ if runtime.GOOS == "windows" {
494+ t.Skip("symlink creation requires special privileges on Windows")
495+ }
496+ dir := t.TempDir()
497+ a := filepath.Join(dir, "a")
498+ b := filepath.Join(dir, "b")
499+ if err := os.Symlink(b, a); err != nil {
500+ t.Fatalf("symlink a→b: %v", err)
501+ }
502+ if err := os.Symlink(a, b); err != nil {
503+ t.Fatalf("symlink b→a: %v", err)
504+ }
505+
506+ err := Run(t.Context(), RunOptions{
507+ Command: a,
508+ Cwd: dir,
509+ })
510+ if err == nil {
511+ t.Fatal("expected loop error, got nil")
512+ }
513+ // The exact error varies by OS; any of these message fragments is
514+ // acceptable evidence that the loop was detected.
515+ msg := err.Error()
516+ if !strings.Contains(msg, "too many") &&
517+ !strings.Contains(msg, "loop") &&
518+ !strings.Contains(msg, "level") {
519+ t.Fatalf("expected symlink-loop-ish error, got: %v", err)
520+ }
521+}
522+
523+// TestResolveInterpreter_PermissiveFallback confirms the key portability
524+// behavior: a literal shebang path that doesn't exist falls back to a
525+// PATH-lookup on its basename. This is what makes #!/bin/bash work on a
526+// Windows box where bash.exe lives somewhere else on PATH. We construct a
527+// fake PATH in a tempdir rather than depending on what the host has
528+// installed so the test is deterministic everywhere.
529+func TestResolveInterpreter_PermissiveFallback(t *testing.T) {
530+ if runtime.GOOS == "windows" {
531+ // exec.LookPath on Windows requires a recognized extension
532+ // (.exe/.bat/.cmd). Producing one of those without a compiler
533+ // run is more ceremony than this smoke test deserves; the
534+ // logic under test is exercised by the Unix run.
535+ t.Skip("Windows PATH lookup requires an extension-matched binary")
536+ }
537+ dir := t.TempDir()
538+ fake := filepath.Join(dir, "bash")
539+ if err := os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
540+ t.Fatalf("write fake bash: %v", err)
541+ }
542+ t.Setenv("PATH", dir)
543+
544+ // Basename must match the fake we planted on PATH; the directory
545+ // prefix must not exist so the literal stat fails.
546+ missingDir := filepath.Join(dir, "definitely-not-here-"+randSuffix())
547+ resolved, err := resolveInterpreter(filepath.Join(missingDir, "bash"))
548+ if err != nil {
549+ t.Fatalf("expected fallback to succeed, got: %v", err)
550+ }
551+ if resolved != fake {
552+ t.Fatalf("resolved = %q, want %q", resolved, fake)
553+ }
554+}
555+
556+// TestResolveInterpreter_NonENOENTErrorsSurface guards against silently
557+// falling back to PATH when stat fails for a reason other than the file
558+// being missing. With a directory at the shebang path, os.Stat succeeds
559+// (no fallback needed), but with an EACCES'd file it fails with a non-
560+// ENOENT error that must be surfaced — otherwise we'd silently resolve a
561+// different binary off PATH and hide the real problem.
562+func TestResolveInterpreter_NonENOENTErrorsSurface(t *testing.T) {
563+ if runtime.GOOS == "windows" {
564+ t.Skip("POSIX permission model")
565+ }
566+ if os.Geteuid() == 0 {
567+ t.Skip("root bypasses dir mode permission checks")
568+ }
569+ dir := t.TempDir()
570+ // Put a candidate interpreter inside an unreadable/untraversable dir.
571+ inner := filepath.Join(dir, "private")
572+ if err := os.Mkdir(inner, 0o755); err != nil {
573+ t.Fatalf("mkdir: %v", err)
574+ }
575+ interp := filepath.Join(inner, "bash")
576+ if err := os.WriteFile(interp, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
577+ t.Fatalf("write interpreter: %v", err)
578+ }
579+ // Drop search permission on inner so os.Stat(interp) returns EACCES.
580+ if err := os.Chmod(inner, 0o000); err != nil {
581+ t.Fatalf("chmod: %v", err)
582+ }
583+ t.Cleanup(func() { _ = os.Chmod(inner, 0o755) })
584+
585+ _, err := resolveInterpreter(interp)
586+ if err == nil {
587+ t.Fatal("expected error for unreadable interpreter, got nil")
588+ }
589+ // Must NOT have silently fallen back — the returned path shouldn't
590+ // be a valid resolution; either way, the error has to surface.
591+ if !strings.Contains(err.Error(), "permission") {
592+ t.Fatalf("expected permission-denied error to surface, got: %v", err)
593+ }
594+}
internal/shell/dispatch_windows_test.gonew file+40
@@ -0,0 +1,40 @@
1+//go:build windows
2+
3+package shell
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+)
10+
11+// TestResolveInterpreter_PermissiveFallback_Windows is the Windows-native
12+// counterpart to the POSIX permissive-fallback test. It proves the one
13+// behavior that makes `#!/bin/bash` hooks work on a stock Windows box
14+// with Git for Windows installed: when the literal interpreter path does
15+// not exist, we fall back to a PATH-lookup on the basename and that
16+// lookup accepts any executable extension Windows honors (here, `.bat`).
17+//
18+// We plant a bash.bat in a tempdir rather than a .exe because producing
19+// a .exe would require a toolchain step; LookPath on Windows resolves
20+// PATHEXT extensions, so .bat is just as valid for the lookup codepath.
21+func TestResolveInterpreter_PermissiveFallback_Windows(t *testing.T) {
22+ dir := t.TempDir()
23+ fake := filepath.Join(dir, "bash.bat")
24+ contents := "@echo off\r\nexit /b 0\r\n"
25+ if err := os.WriteFile(fake, []byte(contents), 0o755); err != nil {
26+ t.Fatalf("write fake bash.bat: %v", err)
27+ }
28+ t.Setenv("PATH", dir)
29+ t.Setenv("PATHEXT", ".BAT;.CMD;.EXE")
30+
31+ // Literal path must be absent so the stat fails with ENOENT.
32+ missing := filepath.Join(dir, "definitely-not-here-"+randSuffix(), "bash")
33+ resolved, err := resolveInterpreter(missing)
34+ if err != nil {
35+ t.Fatalf("expected fallback to succeed, got: %v", err)
36+ }
37+ if resolved != fake {
38+ t.Fatalf("resolved = %q, want %q", resolved, fake)
39+ }
40+}
internal/shell/jq.go+69−4
@@ -1,6 +1,7 @@
11package shell
22
33import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "io"
@@ -36,10 +37,16 @@ Options:
3637// flags: -r (raw output), -c (compact output), -s (slurp), -n (null input),
3738// -e (exit status), -R (raw input), and --arg name value.
3839//
40+// ctx is polled at each iteration of the output loop and at each reader in
41+// [readInputs] so that hook timeouts or other cancellations can interrupt
42+// long-running queries. A cancelled context surfaces as ctx.Err(), not an
43+// [interp.ExitStatus], so callers (e.g. the hook runner) can distinguish
44+// "filter exited non-zero" from "we ran out of time".
45+//
3946// Note that this is somewhat of a reimplmentation of the CLI of the glorious
4047// github.com/itchyny/gojq, and we'd ideally get the CLI exposed upstream to
4148// avoid this falling out of sync.
42−func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
49+func handleJQ(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
4350 var (
4451 rawOutput bool
4552 compact bool
@@ -143,8 +150,13 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
143150 }
144151
145152 // Build input values.
146− inputs, err := readInputs(stdin, fileArgs, nullInput, rawInput, slurp)
153+ inputs, err := readInputs(ctx, stdin, fileArgs, nullInput, rawInput, slurp)
147154 if err != nil {
155+ // Prefer surfacing ctx cancellation verbatim so timeouts are
156+ // distinguishable from user input errors.
157+ if ctxErr := ctx.Err(); ctxErr != nil {
158+ return ctxErr
159+ }
148160 fmt.Fprintf(stderr, "jq: %s\n", err)
149161 return interp.ExitStatus(2)
150162 }
@@ -153,6 +165,12 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
153165 for _, input := range inputs {
154166 iter := code.Run(input, argValues...)
155167 for {
168+ // Poll ctx on every value so a long-running filter (e.g. a
169+ // generator over a slurped array) can be interrupted by hook
170+ // timeouts without waiting for iter.Next to yield.
171+ if err := ctx.Err(); err != nil {
172+ return err
173+ }
156174 v, ok := iter.Next()
157175 if !ok {
158176 break
@@ -177,7 +195,22 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
177195}
178196
179197// readInputs reads JSON (or raw) input values from stdin or files.
180−func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool) ([]any, error) {
198+//
199+// ctx is polled in three places so that a cancellation observed mid-read
200+// short-circuits promptly:
201+// - between readers (before opening the next file / consuming stdin);
202+// - on every io.Read call via ctxReader, so io.ReadAll on a large but
203+// non-blocking source (e.g. the bytes.NewReader payload the hook
204+// runner supplies) returns ctx.Err() on the next chunk boundary;
205+// - inside the post-read value accumulation loops (raw-input line
206+// split and JSON stream decode), which are otherwise unbounded in
207+// the size of the input.
208+//
209+// A reader that blocks forever in Read (e.g. an unterminated pipe) can
210+// still outlast ctx; the outer abandon-goroutine path in the hook
211+// runner (internal/hooks/runner.go) is the authoritative enforcer for
212+// that case.
213+func readInputs(ctx context.Context, stdin io.Reader, files []string, nullInput, rawInput, slurp bool) ([]any, error) {
181214 if nullInput {
182215 return []any{nil}, nil
183216 }
@@ -198,8 +231,16 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
198231
199232 var vals []any
200233 for _, r := range readers {
201− data, err := io.ReadAll(r)
234+ if err := ctx.Err(); err != nil {
235+ return nil, err
236+ }
237+ data, err := io.ReadAll(ctxReader{ctx: ctx, r: r})
202238 if err != nil {
239+ // ctxReader surfaces ctx.Err() verbatim; preserve it so the
240+ // caller can distinguish cancellation from a parse error.
241+ if ctxErr := ctx.Err(); ctxErr != nil {
242+ return nil, ctxErr
243+ }
203244 return nil, err
204245 }
205246
@@ -209,6 +250,9 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
209250 vals = append(vals, strings.Join(lines, "\n"))
210251 } else {
211252 for _, line := range lines {
253+ if err := ctx.Err(); err != nil {
254+ return nil, err
255+ }
212256 if line != "" || !slurp {
213257 vals = append(vals, line)
214258 }
@@ -221,6 +265,9 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
221265 dec := json.NewDecoder(strings.NewReader(string(data)))
222266 var streamVals []any
223267 for {
268+ if err := ctx.Err(); err != nil {
269+ return nil, err
270+ }
224271 var v any
225272 if err := dec.Decode(&v); err != nil {
226273 if err == io.EOF {
@@ -244,6 +291,24 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
244291 return vals, nil
245292}
246293
294+// ctxReader wraps an io.Reader so that each Read call checks ctx first.
295+// This makes io.ReadAll over a large but non-blocking source (e.g. a
296+// bytes.Reader of the hook stdin payload) cancellable on the next chunk
297+// boundary. A reader that itself blocks in Read will still outlast ctx —
298+// the hook runner's abandon-goroutine path is the enforcer of last resort
299+// for that case.
300+type ctxReader struct {
301+ ctx context.Context
302+ r io.Reader
303+}
304+
305+func (cr ctxReader) Read(p []byte) (int, error) {
306+ if err := cr.ctx.Err(); err != nil {
307+ return 0, err
308+ }
309+ return cr.r.Read(p)
310+}
311+
247312// writeValue writes a single jq output value.
248313func writeValue(w io.Writer, v any, raw, compact, join bool) error {
249314 if raw {
internal/shell/jq_test.gonew file+199
@@ -0,0 +1,199 @@
1+package shell
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "strings"
9+ "testing"
10+ "time"
11+)
12+
13+// TestJQ_CtxCancel verifies that handleJQ polls ctx during iteration and
14+// returns ctx.Err() (not an interp.ExitStatus) when the context is
15+// cancelled. This is what lets hook timeouts interrupt long-running jq
16+// filters rather than waiting for the iterator to terminate naturally.
17+func TestJQ_CtxCancel(t *testing.T) {
18+ t.Parallel()
19+
20+ // `range(N)` generates a large stream of values. With a slurped input
21+ // the filter produces all N values in sequence; ctx cancellation
22+ // between values should short-circuit the loop.
23+ const filter = "range(10000000)"
24+ stdin := strings.NewReader("null\n")
25+
26+ ctx, cancel := context.WithCancel(t.Context())
27+ // Cancel almost immediately so we catch the next iteration check.
28+ cancel()
29+
30+ err := handleJQ(ctx, []string{"jq", filter}, stdin, io.Discard, io.Discard)
31+ if err == nil {
32+ t.Fatal("expected ctx cancel error, got nil")
33+ }
34+ if !errors.Is(err, context.Canceled) {
35+ t.Fatalf("expected context.Canceled, got %v", err)
36+ }
37+}
38+
39+// TestJQ_CtxCancel_DuringFilter verifies cancellation mid-stream: ctx is
40+// cancelled after jq has started producing output, and the loop must
41+// observe the cancel on the next iteration rather than running to
42+// completion.
43+func TestJQ_CtxCancel_DuringFilter(t *testing.T) {
44+ t.Parallel()
45+
46+ ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
47+ defer cancel()
48+
49+ // 100M values; without ctx polling this would take many seconds to
50+ // fully emit. With ctx polling the loop exits shortly after the
51+ // deadline.
52+ stdin := strings.NewReader("null\n")
53+ var stdout, stderr bytes.Buffer
54+
55+ start := time.Now()
56+ err := handleJQ(ctx, []string{"jq", "-c", "range(100000000)"}, stdin, &stdout, &stderr)
57+ elapsed := time.Since(start)
58+
59+ if err == nil {
60+ t.Fatal("expected ctx timeout error, got nil")
61+ }
62+ if !errors.Is(err, context.DeadlineExceeded) {
63+ t.Fatalf("expected context.DeadlineExceeded, got %v", err)
64+ }
65+ // Allow generous slack for slow CI; the important invariant is that we
66+ // don't run all 100M iterations (which would take orders of magnitude
67+ // longer than 1s).
68+ if elapsed > time.Second {
69+ t.Fatalf("handleJQ took %v after 50ms timeout; ctx polling is not tight enough", elapsed)
70+ }
71+}
72+
73+// slowReader serves bytes in small chunks with a fixed delay between
74+// Read calls. It never blocks indefinitely — each Read returns after
75+// chunkDelay — so cancellation must be observed via ctxReader's ctx
76+// check, not by the underlying reader itself. That isolates the
77+// behavior we want to test: the wrapper polling ctx between chunks.
78+type slowReader struct {
79+ remaining []byte
80+ chunk int
81+ chunkDelay time.Duration
82+}
83+
84+func (s *slowReader) Read(p []byte) (int, error) {
85+ if len(s.remaining) == 0 {
86+ return 0, io.EOF
87+ }
88+ time.Sleep(s.chunkDelay)
89+ n := min(len(p), min(s.chunk, len(s.remaining)))
90+ copy(p, s.remaining[:n])
91+ s.remaining = s.remaining[n:]
92+ return n, nil
93+}
94+
95+// TestJQ_CtxCancel_MidReadAll verifies that ctx cancellation observed
96+// *during* io.ReadAll — after several chunks have already been consumed
97+// — short-circuits the read via ctxReader, rather than draining the
98+// whole source. This is the guarantee the hook runner relies on when
99+// it feeds a large bytes.Reader payload.
100+//
101+// The reader serves bytes in 512-byte chunks with a 5ms gap between
102+// reads. ctx is cancelled after ~50ms, so several chunks have already
103+// been read when ctxReader first observes the cancellation. The test
104+// asserts that (a) we got a context.Canceled error and (b) the call
105+// returned well before the reader would have been fully drained.
106+func TestJQ_CtxCancel_MidReadAll(t *testing.T) {
107+ t.Parallel()
108+
109+ const (
110+ size = 64 * 1024 * 1024 // 64 MiB
111+ chunk = 512
112+ chunkDelay = 5 * time.Millisecond
113+ )
114+ // At 512 bytes / 5ms, draining 64 MiB would take ~11 minutes. Any
115+ // return within a second proves cancel was observed mid-stream, not
116+ // after EOF.
117+ reader := &slowReader{
118+ remaining: bytes.Repeat([]byte("a"), size),
119+ chunk: chunk,
120+ chunkDelay: chunkDelay,
121+ }
122+
123+ ctx, cancel := context.WithCancel(t.Context())
124+ defer cancel()
125+
126+ // Cancel after enough time that several Read calls have completed
127+ // and io.ReadAll is actively consuming the source.
128+ go func() {
129+ time.Sleep(50 * time.Millisecond)
130+ cancel()
131+ }()
132+
133+ start := time.Now()
134+ err := handleJQ(ctx, []string{"jq", "-R", "."}, reader, io.Discard, io.Discard)
135+ elapsed := time.Since(start)
136+
137+ if !errors.Is(err, context.Canceled) {
138+ t.Fatalf("expected context.Canceled, got %v", err)
139+ }
140+ // Generous slack for slow CI; the invariant is orders-of-magnitude
141+ // faster than draining the full source.
142+ if elapsed > time.Second {
143+ t.Fatalf("mid-ReadAll cancel took %v; ctxReader is not polling between chunks", elapsed)
144+ }
145+ // Sanity check: we should have been cancelled mid-stream, not
146+ // before any reads happened. If remaining == size, cancel fired so
147+ // early nothing was consumed — that's a fast-fail path, not the
148+ // mid-read guarantee we want to verify.
149+ consumed := size - len(reader.remaining)
150+ if consumed == 0 {
151+ t.Fatal("reader was never read from; test did not exercise mid-ReadAll cancel")
152+ }
153+ if consumed >= size {
154+ t.Fatal("reader was fully drained; cancel was not observed mid-read")
155+ }
156+}
157+
158+// TestJQ_CtxCancel_PreCancel verifies the fast-fail path: a ctx already
159+// cancelled before handleJQ is called returns context.Canceled
160+// immediately via the outer-loop guard, never entering io.ReadAll.
161+// Complements TestJQ_CtxCancel_MidReadAll.
162+func TestJQ_CtxCancel_PreCancel(t *testing.T) {
163+ t.Parallel()
164+
165+ ctx, cancel := context.WithCancel(t.Context())
166+ cancel()
167+
168+ start := time.Now()
169+ err := handleJQ(ctx, []string{"jq", "-R", "."},
170+ bytes.NewReader(bytes.Repeat([]byte("a"), 1024)),
171+ io.Discard, io.Discard)
172+ elapsed := time.Since(start)
173+
174+ if !errors.Is(err, context.Canceled) {
175+ t.Fatalf("expected context.Canceled, got %v", err)
176+ }
177+ if elapsed > 100*time.Millisecond {
178+ t.Fatalf("pre-cancel fast-fail took %v; outer guard is not firing", elapsed)
179+ }
180+}
181+
182+// TestJQ_Success confirms the ctx-aware refactor did not regress the
183+// success path.
184+func TestJQ_Success(t *testing.T) {
185+ t.Parallel()
186+
187+ var stdout bytes.Buffer
188+ err := handleJQ(t.Context(),
189+ []string{"jq", "-c", ".a"},
190+ strings.NewReader(`{"a":1}`),
191+ &stdout, io.Discard,
192+ )
193+ if err != nil {
194+ t.Fatalf("handleJQ returned error: %v", err)
195+ }
196+ if got := stdout.String(); got != "1\n" {
197+ t.Fatalf("stdout = %q, want %q", got, "1\n")
198+ }
199+}
internal/shell/run.gonew file+158
@@ -0,0 +1,158 @@
1+package shell
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strings"
8+
9+ "mvdan.cc/sh/moreinterp/coreutils"
10+ "mvdan.cc/sh/v3/expand"
11+ "mvdan.cc/sh/v3/interp"
12+ "mvdan.cc/sh/v3/syntax"
13+)
14+
15+// RunOptions configures a single stateless shell execution via [Run].
16+//
17+// The zero value is not useful; at minimum Command must be set. Stdin,
18+// Stdout, and Stderr may be nil (nil readers/writers are treated as
19+// empty/discard). BlockFuncs may be nil to disable block-list enforcement —
20+// hooks use this to run user-authored commands with the same trust level as
21+// a shell alias.
22+type RunOptions struct {
23+ // Command is the shell source to parse and execute.
24+ Command string
25+ // Cwd is the working directory for the execution. Required: callers
26+ // must supply a non-empty value. Run does not silently fall back to
27+ // the Crush process cwd — hooks and the bash tool have different
28+ // notions of "default" and each owns that decision.
29+ Cwd string
30+ // Env is the full environment visible to the command. The caller is
31+ // responsible for inheriting from os.Environ() if that's desired.
32+ Env []string
33+ // Stdin is the command's standard input. nil is equivalent to an empty
34+ // input stream.
35+ Stdin io.Reader
36+ // Stdout receives the command's standard output. nil discards output.
37+ Stdout io.Writer
38+ // Stderr receives the command's standard error. nil discards output.
39+ Stderr io.Writer
40+ // BlockFuncs is an optional list of deny-list matchers applied before
41+ // each command reaches the exec layer. nil disables blocking entirely.
42+ BlockFuncs []BlockFunc
43+}
44+
45+// Run parses and executes a shell command using the same mvdan.cc/sh
46+// interpreter stack that the stateful [Shell] type uses (builtins,
47+// optional block list, optional Go coreutils). It is safe to call
48+// concurrently from multiple goroutines: each call builds its own
49+// [interp.Runner] and shares no state with other callers or with any
50+// [Shell] instance.
51+//
52+// Errors returned from the command itself (non-zero exit, context
53+// cancellation, parse failures) follow the same conventions as
54+// [Shell.Exec]: inspect with [IsInterrupt] and [ExitCode].
55+func Run(ctx context.Context, opts RunOptions) (err error) {
56+ defer func() {
57+ if r := recover(); r != nil {
58+ err = fmt.Errorf("command execution panic: %v", r)
59+ }
60+ }()
61+
62+ if opts.Cwd == "" {
63+ return fmt.Errorf("shell.Run: Cwd is required")
64+ }
65+
66+ stdout := opts.Stdout
67+ if stdout == nil {
68+ stdout = io.Discard
69+ }
70+ stderr := opts.Stderr
71+ if stderr == nil {
72+ stderr = io.Discard
73+ }
74+
75+ line, err := syntax.NewParser().Parse(strings.NewReader(opts.Command), "")
76+ if err != nil {
77+ return fmt.Errorf("could not parse command: %w", err)
78+ }
79+
80+ runner, err := newRunner(opts.Cwd, opts.Env, opts.Stdin, stdout, stderr, opts.BlockFuncs)
81+ if err != nil {
82+ return fmt.Errorf("could not run command: %w", err)
83+ }
84+
85+ return runner.Run(ctx, line)
86+}
87+
88+// newRunner constructs an [interp.Runner] configured with the standard
89+// Crush handler stack. Shared by the stateless [Run] entrypoint and the
90+// stateful [Shell] so the two surfaces cannot drift.
91+func newRunner(cwd string, env []string, stdin io.Reader, stdout, stderr io.Writer, blockFuncs []BlockFunc) (*interp.Runner, error) {
92+ return interp.New(
93+ interp.StdIO(stdin, stdout, stderr),
94+ interp.Interactive(false),
95+ interp.Env(expand.ListEnviron(env...)),
96+ interp.Dir(cwd),
97+ interp.ExecHandlers(standardHandlers(blockFuncs)...),
98+ )
99+}
100+
101+// standardHandlers returns the exec-handler middleware chain used by both
102+// [Run] and [Shell]. Order matters:
103+// 1. builtins first (so Crush's in-process jq wins over any PATH binary);
104+// 2. script dispatch (shebang / binary / shell-source for path-prefixed
105+// argv[0], no-op for bare commands) — runs before the block list so
106+// that deny rules see the already-resolved argv of anything the
107+// script exec's rather than the outer path-prefixed wrapper;
108+// 3. block list;
109+// 4. optional Go coreutils (only when useGoCoreUtils is on).
110+func standardHandlers(blockFuncs []BlockFunc) []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
111+ handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
112+ builtinHandler(),
113+ scriptDispatchHandler(blockFuncs),
114+ blockHandler(blockFuncs),
115+ }
116+ if useGoCoreUtils {
117+ handlers = append(handlers, coreutils.ExecHandler)
118+ }
119+ return handlers
120+}
121+
122+// builtinHandler returns middleware that dispatches recognized Crush
123+// builtins to their in-process Go implementations. Currently: jq.
124+func builtinHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
125+ return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
126+ return func(ctx context.Context, args []string) error {
127+ if len(args) == 0 {
128+ return next(ctx, args)
129+ }
130+ switch args[0] {
131+ case "jq":
132+ hc := interp.HandlerCtx(ctx)
133+ return handleJQ(ctx, args, hc.Stdin, hc.Stdout, hc.Stderr)
134+ default:
135+ return next(ctx, args)
136+ }
137+ }
138+ }
139+}
140+
141+// blockHandler returns middleware that rejects commands matched by any of
142+// the provided [BlockFunc]s before they reach the underlying exec path.
143+// A nil or empty blockFuncs slice is a no-op.
144+func blockHandler(blockFuncs []BlockFunc) func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
145+ return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
146+ return func(ctx context.Context, args []string) error {
147+ if len(args) == 0 {
148+ return next(ctx, args)
149+ }
150+ for _, blockFunc := range blockFuncs {
151+ if blockFunc(args) {
152+ return fmt.Errorf("command is not allowed for security reasons: %q", args[0])
153+ }
154+ }
155+ return next(ctx, args)
156+ }
157+ }
158+}
internal/shell/run_test.gonew file+252
@@ -0,0 +1,252 @@
1+package shell
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+)
13+
14+func TestRun_Echo(t *testing.T) {
15+ var stdout, stderr bytes.Buffer
16+ err := Run(t.Context(), RunOptions{
17+ Command: "echo hi",
18+ Cwd: t.TempDir(),
19+ Stdout: &stdout,
20+ Stderr: &stderr,
21+ })
22+ if err != nil {
23+ t.Fatalf("Run returned error: %v (stderr=%q)", err, stderr.String())
24+ }
25+ if got := stdout.String(); got != "hi\n" {
26+ t.Fatalf("stdout = %q, want %q", got, "hi\n")
27+ }
28+}
29+
30+func TestRun_ExitCode(t *testing.T) {
31+ err := Run(t.Context(), RunOptions{
32+ Command: "exit 7",
33+ Cwd: t.TempDir(),
34+ })
35+ if err == nil {
36+ t.Fatal("expected error for exit 7, got nil")
37+ }
38+ if code := ExitCode(err); code != 7 {
39+ t.Fatalf("ExitCode = %d, want 7", code)
40+ }
41+}
42+
43+func TestRun_Stdin(t *testing.T) {
44+ // Use the `read` shell builtin so the test doesn't depend on any
45+ // external binary being on PATH (we pass an empty Env here).
46+ var stdout bytes.Buffer
47+ err := Run(t.Context(), RunOptions{
48+ Command: "read line; echo got:$line",
49+ Cwd: t.TempDir(),
50+ Stdin: strings.NewReader("hello\n"),
51+ Stdout: &stdout,
52+ })
53+ if err != nil {
54+ t.Fatalf("Run returned error: %v", err)
55+ }
56+ if got := stdout.String(); got != "got:hello\n" {
57+ t.Fatalf("stdout = %q, want %q", got, "got:hello\n")
58+ }
59+}
60+
61+func TestRun_Env(t *testing.T) {
62+ var stdout bytes.Buffer
63+ err := Run(t.Context(), RunOptions{
64+ Command: `echo "$FOO"`,
65+ Cwd: t.TempDir(),
66+ Env: []string{"FOO=bar"},
67+ Stdout: &stdout,
68+ })
69+ if err != nil {
70+ t.Fatalf("Run returned error: %v", err)
71+ }
72+ if got := stdout.String(); got != "bar\n" {
73+ t.Fatalf("stdout = %q, want %q", got, "bar\n")
74+ }
75+}
76+
77+func TestRun_Cwd(t *testing.T) {
78+ dir := t.TempDir()
79+ var stdout bytes.Buffer
80+ err := Run(t.Context(), RunOptions{
81+ Command: "pwd",
82+ Cwd: dir,
83+ Stdout: &stdout,
84+ })
85+ if err != nil {
86+ t.Fatalf("Run returned error: %v", err)
87+ }
88+ // mvdan's pwd builtin resolves symlinks (e.g. /var -> /private/var on
89+ // macOS). Compare against a suffix so we don't get bitten by that.
90+ got := strings.TrimRight(stdout.String(), "\n")
91+ if !strings.HasSuffix(got, dir) && !strings.HasSuffix(dir, got) {
92+ t.Fatalf("pwd = %q, want it to match %q", got, dir)
93+ }
94+}
95+
96+func TestRun_JqBuiltin(t *testing.T) {
97+ var stdout bytes.Buffer
98+ err := Run(t.Context(), RunOptions{
99+ Command: `echo '{"a":1}' | jq .a`,
100+ Cwd: t.TempDir(),
101+ Stdout: &stdout,
102+ })
103+ if err != nil {
104+ t.Fatalf("Run returned error: %v", err)
105+ }
106+ if got := stdout.String(); got != "1\n" {
107+ t.Fatalf("stdout = %q, want %q", got, "1\n")
108+ }
109+}
110+
111+func TestRun_ParallelIsolation(t *testing.T) {
112+ const n = 10
113+ var wg sync.WaitGroup
114+ wg.Add(n)
115+ errs := make([]error, n)
116+ outs := make([]string, n)
117+ dirs := make([]string, n)
118+ for i := range n {
119+ dirs[i] = t.TempDir()
120+ go func(i int) {
121+ defer wg.Done()
122+ var stdout bytes.Buffer
123+ errs[i] = Run(t.Context(), RunOptions{
124+ Command: `echo "$MARKER"`,
125+ Cwd: dirs[i],
126+ Env: []string{fmt.Sprintf("MARKER=id-%d", i)},
127+ Stdout: &stdout,
128+ })
129+ outs[i] = stdout.String()
130+ }(i)
131+ }
132+ wg.Wait()
133+ for i := range n {
134+ if errs[i] != nil {
135+ t.Errorf("goroutine %d: err = %v", i, errs[i])
136+ continue
137+ }
138+ want := fmt.Sprintf("id-%d\n", i)
139+ if outs[i] != want {
140+ t.Errorf("goroutine %d: stdout = %q, want %q", i, outs[i], want)
141+ }
142+ }
143+}
144+
145+// TestRun_CtxCancel_BusyLoop verifies that a pure-shell loop respects ctx
146+// cancellation. mvdan's interpreter checks ctx between statements, so this
147+// should return quickly even without any external command. The test bounds
148+// its own wait via a select so a regression can't hang CI.
149+func TestRun_CtxCancel_BusyLoop(t *testing.T) {
150+ ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond)
151+ t.Cleanup(cancel)
152+
153+ done := make(chan error, 1)
154+ go func() {
155+ done <- Run(ctx, RunOptions{
156+ Command: "while true; do :; done",
157+ Cwd: t.TempDir(),
158+ })
159+ }()
160+
161+ select {
162+ case err := <-done:
163+ if !IsInterrupt(err) && !errors.Is(err, context.DeadlineExceeded) {
164+ t.Fatalf("expected interrupt/deadline error, got: %v", err)
165+ }
166+ case <-time.After(1500 * time.Millisecond):
167+ t.Fatal("Run did not return within 1.5s after ctx cancel")
168+ }
169+}
170+
171+// TestRun_CtxCancel_ExternalSleep verifies ctx cancellation reaches an
172+// external process via mvdan's default exec. Uses sleep, which lives in
173+// coreutils on Windows and /bin on Unix.
174+func TestRun_CtxCancel_ExternalSleep(t *testing.T) {
175+ ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
176+ t.Cleanup(cancel)
177+
178+ done := make(chan error, 1)
179+ start := time.Now()
180+ go func() {
181+ done <- Run(ctx, RunOptions{
182+ Command: "sleep 30",
183+ Cwd: t.TempDir(),
184+ })
185+ }()
186+
187+ select {
188+ case err := <-done:
189+ elapsed := time.Since(start)
190+ if elapsed > time.Second {
191+ t.Fatalf("sleep took too long to cancel: %v", elapsed)
192+ }
193+ if err == nil {
194+ t.Fatal("expected non-nil error from cancelled sleep")
195+ }
196+ case <-time.After(time.Second):
197+ t.Fatal("Run did not return within 1s after ctx cancel")
198+ }
199+}
200+
201+func TestRun_ParseError(t *testing.T) {
202+ err := Run(t.Context(), RunOptions{
203+ Command: "echo 'unterminated",
204+ Cwd: t.TempDir(),
205+ })
206+ if err == nil {
207+ t.Fatal("expected parse error, got nil")
208+ }
209+ if !strings.Contains(err.Error(), "parse") {
210+ t.Fatalf("error should mention parse: %v", err)
211+ }
212+}
213+
214+func TestRun_BlockFuncs(t *testing.T) {
215+ block := CommandsBlocker([]string{"forbidden"})
216+ var stderr bytes.Buffer
217+ err := Run(t.Context(), RunOptions{
218+ Command: "forbidden",
219+ Cwd: t.TempDir(),
220+ Stderr: &stderr,
221+ BlockFuncs: []BlockFunc{block},
222+ })
223+ if err == nil {
224+ t.Fatal("expected error when running blocked command")
225+ }
226+ if !strings.Contains(err.Error(), "not allowed") {
227+ t.Fatalf("expected 'not allowed' error, got: %v", err)
228+ }
229+}
230+
231+func TestRun_RequiresCwd(t *testing.T) {
232+ err := Run(t.Context(), RunOptions{
233+ Command: "echo hi",
234+ })
235+ if err == nil {
236+ t.Fatal("expected error when Cwd is empty, got nil")
237+ }
238+ if !strings.Contains(err.Error(), "Cwd is required") {
239+ t.Fatalf("error should mention Cwd requirement: %v", err)
240+ }
241+}
242+
243+func TestRun_DiscardsNilWriters(t *testing.T) {
244+ // No panic when Stdout/Stderr are nil.
245+ err := Run(t.Context(), RunOptions{
246+ Command: "echo hi; echo err >&2",
247+ Cwd: t.TempDir(),
248+ })
249+ if err != nil {
250+ t.Fatalf("Run returned error: %v", err)
251+ }
252+}
internal/shell/shell.go+20−66
@@ -21,8 +21,6 @@ import (
2121 "sync"
2222
2323 "github.com/charmbracelet/x/exp/slice"
24− "mvdan.cc/sh/moreinterp/coreutils"
25− "mvdan.cc/sh/v3/expand"
2624 "mvdan.cc/sh/v3/interp"
2725 "mvdan.cc/sh/v3/syntax"
2826)
@@ -36,6 +34,20 @@ const (
3634 ShellTypePowerShell
3735)
3836
37+// CrushEnvMarkers returns a fresh slice of the environment variables that
38+// Crush unconditionally sets on every shell it spawns — both the interactive
39+// bash tool's [Shell] and the hook runner's [Run] calls. Tools that want to
40+// detect "am I being invoked by an AI agent?" can check any of these.
41+// Keeping them in one place guarantees the two shell surfaces cannot drift.
42+// A fresh slice is returned on every call so callers may append freely.
43+func CrushEnvMarkers() []string {
44+ return []string{
45+ "CRUSH=1",
46+ "AGENT=crush",
47+ "AI_AGENT=crush",
48+ }
49+}
50+
3951// Logger interface for optional logging
4052type Logger interface {
4153 InfoPersist(msg string, keysAndValues ...any)
@@ -83,12 +95,7 @@ func NewShell(opts *Options) *Shell {
8395 }
8496
8597 // Allow tools to detect execution by Crush.
86− env = append(
87− env,
88− "CRUSH=1",
89− "AGENT=crush",
90− "AI_AGENT=crush",
91− )
98+ env = append(env, CrushEnvMarkers()...)
9299
93100 logger := opts.Logger
94101 if logger == nil {
@@ -226,52 +233,10 @@ func splitArgsFlags(parts []string) (args []string, flags []string) {
226233 return args, flags
227234}
228235
229−func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
230− return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
231− return func(ctx context.Context, args []string) error {
232− if len(args) == 0 {
233− return next(ctx, args)
234− }
235−
236− for _, blockFunc := range s.blockFuncs {
237− if blockFunc(args) {
238− return fmt.Errorf("command is not allowed for security reasons: %q", args[0])
239− }
240− }
241−
242− return next(ctx, args)
243− }
244− }
245−}
246−
247−func (s *Shell) builtinHandler() func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
248− return func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
249− return func(ctx context.Context, args []string) error {
250− if len(args) == 0 {
251− return next(ctx, args)
252− }
253−
254− // Builtins.
255− switch args[0] {
256− case "jq":
257− hc := interp.HandlerCtx(ctx)
258− return handleJQ(args, hc.Stdin, hc.Stdout, hc.Stderr)
259− default:
260− return next(ctx, args)
261− }
262− }
263− }
264−}
265−
266−// newInterp creates a new interpreter with the current shell state
267−func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
268− return interp.New(
269− interp.StdIO(nil, stdout, stderr),
270− interp.Interactive(false),
271− interp.Env(expand.ListEnviron(s.env...)),
272− interp.Dir(s.cwd),
273− interp.ExecHandlers(s.execHandlers()...),
274− )
236+// newInterp creates a new interpreter with the current shell state. A nil
237+// stdin is equivalent to an empty input stream.
238+func (s *Shell) newInterp(stdin io.Reader, stdout, stderr io.Writer) (*interp.Runner, error) {
239+ return newRunner(s.cwd, s.env, stdin, stdout, stderr, s.blockFuncs)
275240}
276241
277242// updateShellFromRunner updates the shell from the interpreter after execution.
@@ -303,7 +268,7 @@ func (s *Shell) execCommon(ctx context.Context, command string, stdout, stderr i
303268 return fmt.Errorf("could not parse command: %w", err)
304269 }
305270
306− runner, err = s.newInterp(stdout, stderr)
271+ runner, err = s.newInterp(nil, stdout, stderr)
307272 if err != nil {
308273 return fmt.Errorf("could not run command: %w", err)
309274 }
@@ -324,17 +289,6 @@ func (s *Shell) execStream(ctx context.Context, command string, stdout, stderr i
324289 return s.execCommon(ctx, command, stdout, stderr)
325290}
326291
327−func (s *Shell) execHandlers() []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc {
328− handlers := []func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc{
329− s.builtinHandler(),
330− s.blockHandler(),
331− }
332− if useGoCoreUtils {
333− handlers = append(handlers, coreutils.ExecHandler)
334− }
335− return handlers
336−}
337−
338292// IsInterrupt checks if an error is due to interruption
339293func IsInterrupt(err error) bool {
340294 return errors.Is(err, context.Canceled) ||