on main help sign in
Merge pull request #2730 from charmbracelet/hookshell
09c83e8Christian Rocha
.agents/skills/shell-builtins/SKILL.md+34−13
@@ -11,38 +11,59 @@ emulation. Commands can be intercepted before they reach the OS by adding
11
11
12## How Builtins Work
12## How Builtins Work
13
13
14−Builtins live in `Shell.builtinHandler()` in `internal/shell/shell.go`.
14+Builtins live in `builtinHandler()` in `internal/shell/run.go`. This is an
15−This is an `interp.ExecHandlerFunc` middleware registered in
15+`interp.ExecHandlerFunc` middleware registered in `standardHandlers()`
16−`execHandlers()` **before** the block handler, so builtins run even for
16+**before** the block handler, so builtins run even for commands that would
17−commands that would otherwise be blocked.
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.
18
20
19The handler is a switch on `args[0]`. Each case either handles the command
21The handler is a switch on `args[0]`. Each case either handles the command
20inline or delegates to a helper function.
22inline or delegates to a helper function.
21
23
22## Adding a New Builtin
24## Adding a New Builtin
23
25
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`.
252. **Get I/O from the handler context**, not from `os.Stdin`/`os.Stdout`.
272. **Get I/O from the handler context**, not from `os.Stdin`/`os.Stdout`.
26 This ensures the builtin works with pipes and redirections:
28 This ensures the builtin works with pipes and redirections:
27 ```go
29 ```go
28 case "mycommand":
30 case "mycommand":
29 hc := interp.HandlerCtx(ctx)
31 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)
31 ```
33 ```
323. **Implement the handler** in its own file (e.g.,
343. **Implement the handler** in its own file (e.g.,
33− `internal/shell/mycommand.go`). The function signature should accept
35+ `internal/shell/mycommand.go`). The function signature must accept a
34− args, stdin, stdout, and stderr:
36+ `context.Context` as the first parameter, plus args, stdin, stdout, and
37+ stderr:
35 ```go
38 ```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 {
37 // args[0] is the command name ("mycommand"), args[1:] are arguments.
40 // args[0] is the command name ("mycommand"), args[1:] are arguments.
38 // Write output to stdout, errors to stderr.
41 // Write output to stdout, errors to stderr.
39 // Return nil on success, or interp.ExitStatus(n) for non-zero exit codes.
42 // Return nil on success, or interp.ExitStatus(n) for non-zero exit codes.
40 }
43 }
41 ```
44 ```
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+ ```
42−4. **Return values**: return `nil` for success, `interp.ExitStatus(n)` for
62+5. **Return values**: return `nil` for success, `interp.ExitStatus(n)` for
63+ non-zero exit codes, or `ctx.Err()` on cancellation. Write error
43− non-zero exit codes. Write error messages to `stderr` before returning.
64+ messages to `stderr` before returning.
44−5. **No extra wiring needed** — `builtinHandler()` is already registered
65+6. **No extra wiring needed** — `builtinHandler()` is already registered
45− in `execHandlers()`.
66+ in `standardHandlers()`.
46
67
47## Existing Builtins
68## Existing Builtins
48
69
docs/hooks/FUTURE.md+2−118
@@ -256,121 +256,5 @@ Reuses the universal rules:
256
256
257## Cross-platform shell (Windows support)
257## Cross-platform shell (Windows support)
258
258
259−**Status:** not implemented.
259+**Status:** implemented. See the [Execution model](README.md#execution-model)
260−
260+section in `README.md` for the current behavior and contract.
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.
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.
88
88
89---
89---
90
90
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+
91## Configuration
130## Configuration
92
131
93Hooks can be added to your `crush.json` (or `.crush.json`) at both the global
132Hooks can be added to your `crush.json` (or `.crush.json`) at both the global
@@ -143,7 +182,8 @@ When a hook fires, Crush:
1431. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
1821. Filters hooks whose `matcher` regex matches the tool name (no matcher = match
144 all).
183 all).
1452. Deduplicates by `command` (identical commands run once).
1842. 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)).
1474. Waits for all to finish (or time out), then aggregates results **in config
1874. Waits for all to finish (or time out), then aggregates results **in config
148 order**: deny wins over allow, allow wins over none; `updated_input` patches
188 order**: deny wins over allow, allow wins over none; `updated_input` patches
149 shallow-merge in order.
189 shallow-merge in order.
@@ -153,8 +193,9 @@ When a hook fires, Crush:
153 also skips the prompt. Silence (no decision) falls through to the normal
193 also skips the prompt. Silence (no decision) falls through to the normal
154 permission flow.
194 permission flow.
155
195
156−Note that you can omit `matcher` and match in your shell script instead, however
196+Note that you can omit `matcher` and match in your shell script instead,
157−you'll incur some additional overhead as Crush will `exec` each script.
197+however you'll incur some additional overhead as Crush will still parse and
198+run each hook.
158
199
159### Input
200### Input
160
201
@@ -168,6 +209,9 @@ The available environment variables are:
168
209
169| Variable | Description |
210| Variable | Description |
170| ---------------------------- | ---------------------------------------------- |
211| ---------------------------- | ---------------------------------------------- |
212+| `CRUSH` | Always `1` when running under Crush. |
213+| `AGENT` | Always `crush`. |
214+| `AI_AGENT` | Always `crush`. |
171| `CRUSH_EVENT` | The hook event name (e.g. `PreToolUse`). |
215| `CRUSH_EVENT` | The hook event name (e.g. `PreToolUse`). |
172| `CRUSH_TOOL_NAME` | The tool being called (e.g. `bash`). |
216| `CRUSH_TOOL_NAME` | The tool being called (e.g. `bash`). |
173| `CRUSH_SESSION_ID` | Current session ID. |
217| `CRUSH_SESSION_ID` | Current session ID. |
@@ -176,6 +220,10 @@ The available environment variables are:
176| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. |
220| `CRUSH_TOOL_INPUT_COMMAND` | For `bash` calls: the shell command being run. |
177| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. |
221| `CRUSH_TOOL_INPUT_FILE_PATH` | For file tools: the target file path. |
178
222
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+
179#### JSON
227#### JSON
180
228
181Standard input provides the full context as JSON:
229Standard input provides the full context as JSON:
@@ -326,9 +374,12 @@ When multiple hooks match the same tool call:
326
374
327### Timeouts
375### Timeouts
328
376
329−If a hook exceeds its timeout, the process is killed and treated as a
377+If a hook exceeds its timeout, Crush cancels its context and treats the
330−non-blocking error and the tool call proceeds. The default timeout is 30
378+result as a non-blocking error so the tool call proceeds. The default
331−seconds.
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.
332
383
333## Examples
384## Examples
334
385
internal/hooks/hooks_test.go+67
@@ -2,11 +2,14 @@ package hooks
2
2
3import (
3import (
4 "context"
4 "context"
5+ "io"
5 "strings"
6 "strings"
7+ "sync"
6 "testing"
8 "testing"
7 "time"
9 "time"
8
10
9 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/shell"
10 "github.com/stretchr/testify/require"
13 "github.com/stretchr/testify/require"
11)
14)
12
15
@@ -192,6 +195,13 @@ func TestBuildEnv(t *testing.T) {
192 require.Equal(t, "/project", envMap["CRUSH_PROJECT_DIR"])
195 require.Equal(t, "/project", envMap["CRUSH_PROJECT_DIR"])
193 require.Equal(t, "ls", envMap["CRUSH_TOOL_INPUT_COMMAND"])
196 require.Equal(t, "ls", envMap["CRUSH_TOOL_INPUT_COMMAND"])
194 require.Equal(t, "/tmp/f.txt", envMap["CRUSH_TOOL_INPUT_FILE_PATH"])
197 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"])
195}
205}
196
206
197func splitFirst(s, sep string) []string {
207func splitFirst(s, sep string) []string {
@@ -602,6 +612,63 @@ func TestAggregationUpdatedInput(t *testing.T) {
602 })
612 })
603}
613}
604
614
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+
605func TestRunnerUpdatedInput(t *testing.T) {
672func TestRunnerUpdatedInput(t *testing.T) {
606 t.Parallel()
673 t.Parallel()
607 hookCfg := config.HookConfig{
674 hookCfg := config.HookConfig{
internal/hooks/input.go+2
@@ -7,6 +7,7 @@ import (
7 "os"
7 "os"
8 "strings"
8 "strings"
9
9
10+ "github.com/charmbracelet/crush/internal/shell"
10 "github.com/tidwall/gjson"
11 "github.com/tidwall/gjson"
11)
12)
12
13
@@ -51,6 +52,7 @@ func BuildPayload(eventName, sessionID, cwd, toolName, toolInputJSON string) []b
51// It includes all current process env vars plus hook-specific ones.
52// It includes all current process env vars plus hook-specific ones.
52func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
53func BuildEnv(eventName, toolName, sessionID, cwd, projectDir, toolInputJSON string) []string {
53 env := os.Environ()
54 env := os.Environ()
55+ env = append(env, shell.CrushEnvMarkers()...)
54 env = append(env,
56 env = append(env,
55 fmt.Sprintf("CRUSH_EVENT=%s", eventName),
57 fmt.Sprintf("CRUSH_EVENT=%s", eventName),
56 fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
58 fmt.Sprintf("CRUSH_TOOL_NAME=%s", toolName),
internal/hooks/runner.go+60−15
@@ -4,15 +4,27 @@ import (
4 "bytes"
4 "bytes"
5 "context"
5 "context"
6 "log/slog"
6 "log/slog"
7− "os/exec"
8 "regexp"
7 "regexp"
9 "strings"
8 "strings"
10 "sync"
9 "sync"
11 "time"
10 "time"
12
11
13 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/shell"
14)
14)
15
15
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+
16// compiledHook pairs a HookConfig with its compiled matcher regex. A nil
28// compiledHook pairs a HookConfig with its compiled matcher regex. A nil
17// matcher means "match every tool".
29// matcher means "match every tool".
18type compiledHook struct {
30type compiledHook struct {
@@ -140,24 +152,59 @@ func (r *Runner) matchingHooks(toolName string) []config.HookConfig {
140}
152}
141
153
142// runOne executes a single hook command and returns its result.
154// 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.
143func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVars []string, payload []byte) HookResult {
170func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVars []string, payload []byte) HookResult {
144 timeout := hook.TimeoutDuration()
171 timeout := hook.TimeoutDuration()
145 ctx, cancel := context.WithTimeout(parentCtx, timeout)
172 ctx, cancel := context.WithTimeout(parentCtx, timeout)
146 defer cancel()
173 defer cancel()
147
174
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−
154 var stdout, stderr bytes.Buffer
175 var stdout, stderr bytes.Buffer
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),
155− cmd.Stdout = &stdout
183+ Stdout: &stdout,
156− cmd.Stderr = &stderr
184+ Stderr: &stderr,
185+ })
186+ }()
157
187
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+ }
159
206
160− if ctx.Err() != nil {
207+ if shell.IsInterrupt(err) {
161 // Distinguish timeout from parent cancellation.
208 // Distinguish timeout from parent cancellation.
162 if parentCtx.Err() != nil {
209 if parentCtx.Err() != nil {
163 slog.Debug("Hook cancelled by parent context", "command", hook.Command)
210 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
168 }
215 }
169
216
170 if err != nil {
217 if err != nil {
171− exitCode := -1
218+ exitCode := shell.ExitCode(err)
172− if cmd.ProcessState != nil {
173− exitCode = cmd.ProcessState.ExitCode()
174− }
175 switch exitCode {
219 switch exitCode {
176 case 2:
220 case 2:
177 // Exit code 2 = block this tool call. Stderr is the reason.
221 // 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
200 "command", hook.Command,
244 "command", hook.Command,
201 "exit_code", exitCode,
245 "exit_code", exitCode,
202 "stderr", strings.TrimSpace(stderr.String()),
246 "stderr", strings.TrimSpace(stderr.String()),
247+ "error", err,
203 )
248 )
204 return HookResult{Decision: DecisionNone}
249 return HookResult{Decision: DecisionNone}
205 }
250 }
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 @@
1package shell
1package shell
2
2
3import (
3import (
4+ "context"
4 "encoding/json"
5 "encoding/json"
5 "fmt"
6 "fmt"
6 "io"
7 "io"
@@ -36,10 +37,16 @@ Options:
36// flags: -r (raw output), -c (compact output), -s (slurp), -n (null input),
37// flags: -r (raw output), -c (compact output), -s (slurp), -n (null input),
37// -e (exit status), -R (raw input), and --arg name value.
38// -e (exit status), -R (raw input), and --arg name value.
38//
39//
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+//
39// Note that this is somewhat of a reimplmentation of the CLI of the glorious
46// Note that this is somewhat of a reimplmentation of the CLI of the glorious
40// github.com/itchyny/gojq, and we'd ideally get the CLI exposed upstream to
47// github.com/itchyny/gojq, and we'd ideally get the CLI exposed upstream to
41// avoid this falling out of sync.
48// 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 {
43 var (
50 var (
44 rawOutput bool
51 rawOutput bool
45 compact bool
52 compact bool
@@ -143,8 +150,13 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
143 }
150 }
144
151
145 // Build input values.
152 // Build input values.
146− inputs, err := readInputs(stdin, fileArgs, nullInput, rawInput, slurp)
153+ inputs, err := readInputs(ctx, stdin, fileArgs, nullInput, rawInput, slurp)
147 if err != nil {
154 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+ }
148 fmt.Fprintf(stderr, "jq: %s\n", err)
160 fmt.Fprintf(stderr, "jq: %s\n", err)
149 return interp.ExitStatus(2)
161 return interp.ExitStatus(2)
150 }
162 }
@@ -153,6 +165,12 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
153 for _, input := range inputs {
165 for _, input := range inputs {
154 iter := code.Run(input, argValues...)
166 iter := code.Run(input, argValues...)
155 for {
167 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+ }
156 v, ok := iter.Next()
174 v, ok := iter.Next()
157 if !ok {
175 if !ok {
158 break
176 break
@@ -177,7 +195,22 @@ func handleJQ(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
177}
195}
178
196
179// readInputs reads JSON (or raw) input values from stdin or files.
197// readInputs reads JSON (or raw) input values from stdin or files.
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.
180−func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool) ([]any, error) {
213+func readInputs(ctx context.Context, stdin io.Reader, files []string, nullInput, rawInput, slurp bool) ([]any, error) {
181 if nullInput {
214 if nullInput {
182 return []any{nil}, nil
215 return []any{nil}, nil
183 }
216 }
@@ -198,8 +231,16 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
198
231
199 var vals []any
232 var vals []any
200 for _, r := range readers {
233 for _, r := range readers {
234+ if err := ctx.Err(); err != nil {
235+ return nil, err
236+ }
201− data, err := io.ReadAll(r)
237+ data, err := io.ReadAll(ctxReader{ctx: ctx, r: r})
202 if err != nil {
238 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+ }
203 return nil, err
244 return nil, err
204 }
245 }
205
246
@@ -209,6 +250,9 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
209 vals = append(vals, strings.Join(lines, "\n"))
250 vals = append(vals, strings.Join(lines, "\n"))
210 } else {
251 } else {
211 for _, line := range lines {
252 for _, line := range lines {
253+ if err := ctx.Err(); err != nil {
254+ return nil, err
255+ }
212 if line != "" || !slurp {
256 if line != "" || !slurp {
213 vals = append(vals, line)
257 vals = append(vals, line)
214 }
258 }
@@ -221,6 +265,9 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
221 dec := json.NewDecoder(strings.NewReader(string(data)))
265 dec := json.NewDecoder(strings.NewReader(string(data)))
222 var streamVals []any
266 var streamVals []any
223 for {
267 for {
268+ if err := ctx.Err(); err != nil {
269+ return nil, err
270+ }
224 var v any
271 var v any
225 if err := dec.Decode(&v); err != nil {
272 if err := dec.Decode(&v); err != nil {
226 if err == io.EOF {
273 if err == io.EOF {
@@ -244,6 +291,24 @@ func readInputs(stdin io.Reader, files []string, nullInput, rawInput, slurp bool
244 return vals, nil
291 return vals, nil
245}
292}
246
293
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+
247// writeValue writes a single jq output value.
312// writeValue writes a single jq output value.
248func writeValue(w io.Writer, v any, raw, compact, join bool) error {
313func writeValue(w io.Writer, v any, raw, compact, join bool) error {
249 if raw {
314 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 (
21 "sync"
21 "sync"
22
22
23 "github.com/charmbracelet/x/exp/slice"
23 "github.com/charmbracelet/x/exp/slice"
24− "mvdan.cc/sh/moreinterp/coreutils"
25− "mvdan.cc/sh/v3/expand"
26 "mvdan.cc/sh/v3/interp"
24 "mvdan.cc/sh/v3/interp"
27 "mvdan.cc/sh/v3/syntax"
25 "mvdan.cc/sh/v3/syntax"
28)
26)
@@ -36,6 +34,20 @@ const (
36 ShellTypePowerShell
34 ShellTypePowerShell
37)
35)
38
36
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+
39// Logger interface for optional logging
51// Logger interface for optional logging
40type Logger interface {
52type Logger interface {
41 InfoPersist(msg string, keysAndValues ...any)
53 InfoPersist(msg string, keysAndValues ...any)
@@ -83,12 +95,7 @@ func NewShell(opts *Options) *Shell {
83 }
95 }
84
96
85 // Allow tools to detect execution by Crush.
97 // Allow tools to detect execution by Crush.
86− env = append(
98+ env = append(env, CrushEnvMarkers()...)
87− env,
88− "CRUSH=1",
89− "AGENT=crush",
90− "AI_AGENT=crush",
91− )
92
99
93 logger := opts.Logger
100 logger := opts.Logger
94 if logger == nil {
101 if logger == nil {
@@ -226,52 +233,10 @@ func splitArgsFlags(parts []string) (args []string, flags []string) {
226 return args, flags
233 return args, flags
227}
234}
228
235
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
236+// newInterp creates a new interpreter with the current shell state. A nil
237+// stdin is equivalent to an empty input stream.
267−func (s *Shell) newInterp(stdout, stderr io.Writer) (*interp.Runner, error) {
238+func (s *Shell) newInterp(stdin io.Reader, stdout, stderr io.Writer) (*interp.Runner, error) {
268− return interp.New(
239+ return newRunner(s.cwd, s.env, stdin, stdout, stderr, s.blockFuncs)
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− )
275}
240}
276
241
277// updateShellFromRunner updates the shell from the interpreter after execution.
242// 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
303 return fmt.Errorf("could not parse command: %w", err)
268 return fmt.Errorf("could not parse command: %w", err)
304 }
269 }
305
270
306− runner, err = s.newInterp(stdout, stderr)
271+ runner, err = s.newInterp(nil, stdout, stderr)
307 if err != nil {
272 if err != nil {
308 return fmt.Errorf("could not run command: %w", err)
273 return fmt.Errorf("could not run command: %w", err)
309 }
274 }
@@ -324,17 +289,6 @@ func (s *Shell) execStream(ctx context.Context, command string, stdout, stderr i
324 return s.execCommon(ctx, command, stdout, stderr)
289 return s.execCommon(ctx, command, stdout, stderr)
325}
290}
326
291
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−
338// IsInterrupt checks if an error is due to interruption
292// IsInterrupt checks if an error is due to interruption
339func IsInterrupt(err error) bool {
293func IsInterrupt(err error) bool {
340 return errors.Is(err, context.Canceled) ||
294 return errors.Is(err, context.Canceled) ||