on main help sign in
feat(hooks): run via shell.Run instead of sh -c
commit22d6f44e0f4409c64e602841e6d90a485eb91053 authorChristian Rocha <christian@rocha.is> dateMon 27 Apr 2026, 09:01 (6 months ago) parentaf002dc
2 files changed+121−16
internal/hooks/hooks_test.go+60 internal/hooks/runner.go+61−16
internal/hooks/hooks_test.go+60
@@ -2,11 +2,14 @@ package hooks
22
33import (
44 "context"
5+ "io"
56 "strings"
7+ "sync"
68 "testing"
79 "time"
810
911 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/shell"
1013 "github.com/stretchr/testify/require"
1114)
1215
@@ -568,6 +571,63 @@ func TestAggregationUpdatedInput(t *testing.T) {
568571 })
569572}
570573
574+// TestRunnerAbandonRaceSafety verifies that if a hook's shell execution
575+// does not yield to ctx cancellation within abandonGrace, runOne returns
576+// promptly and never touches the shared stdout/stderr buffers again —
577+// even while the abandoned goroutine continues to write to them.
578+//
579+// The substitute shell executor ignores ctx entirely, writes to Stdout
580+// both before and after the abandon deadline, and only then returns.
581+// Under -race this catches any code path in runOne that reads those
582+// buffers after returning the DecisionNone abandon result.
583+func TestRunnerAbandonRaceSafety(t *testing.T) {
584+ origRunShell := runShell
585+ t.Cleanup(func() { runShell = origRunShell })
586+
587+ // Synchronize shutdown with the abandoned goroutine so the test
588+ // exits cleanly even under -race.
589+ var wg sync.WaitGroup
590+ release := make(chan struct{})
591+ t.Cleanup(func() {
592+ close(release)
593+ wg.Wait()
594+ })
595+
596+ runShell = func(_ context.Context, opts shell.RunOptions) error {
597+ wg.Add(1)
598+ defer wg.Done()
599+ // Write before the caller observes ctx.Done(); the caller will
600+ // not read the buffer while we still own it.
601+ _, _ = io.WriteString(opts.Stdout, "before\n")
602+ // Hold past ctx deadline + abandonGrace so the caller takes
603+ // the abandon branch, then continue writing. If the caller
604+ // reads these buffers after abandoning, -race will flag it.
605+ select {
606+ case <-time.After(5 * time.Second):
607+ case <-release:
608+ }
609+ _, _ = io.WriteString(opts.Stdout, "after\n")
610+ return nil
611+ }
612+
613+ hookCfg := config.HookConfig{
614+ Command: "# irrelevant; runShell is stubbed",
615+ Timeout: 1,
616+ }
617+ r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
618+
619+ start := time.Now()
620+ result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
621+ elapsed := time.Since(start)
622+
623+ require.NoError(t, err)
624+ require.Equal(t, DecisionNone, result.Decision)
625+ // Abandon must happen at ~timeout + abandonGrace. Allow generous
626+ // slack so CI noise doesn't flake the test.
627+ require.Less(t, elapsed, 3500*time.Millisecond,
628+ "runOne should return within timeout+abandonGrace+slack")
629+}
630+
571631func TestRunnerUpdatedInput(t *testing.T) {
572632 t.Parallel()
573633 hookCfg := config.HookConfig{
internal/hooks/runner.go+61−16
@@ -4,14 +4,26 @@ import (
44 "bytes"
55 "context"
66 "log/slog"
7− "os/exec"
87 "strings"
98 "sync"
109 "time"
1110
1211 "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/shell"
1313)
1414
15+// abandonGrace is how long runOne waits after ctx cancellation for the
16+// shell goroutine to yield before returning control to the caller and
17+// letting the goroutine finish on its own. Mirrors the historical
18+// cmd.WaitDelay = time.Second behavior of the previous os/exec path.
19+const abandonGrace = time.Second
20+
21+// runShell is the shell executor used by runOne. It is a package-level
22+// variable so tests can substitute a blocking or non-yielding
23+// implementation to exercise the abandon-on-timeout path without
24+// depending on the scheduling behavior of the real interpreter.
25+var runShell = shell.Run
26+
1527// Runner executes hook commands and aggregates their results.
1628type Runner struct {
1729 hooks []config.HookConfig
@@ -102,24 +114,59 @@ func (r *Runner) matchingHooks(toolName string) []config.HookConfig {
102114}
103115
104116// runOne executes a single hook command and returns its result.
117+//
118+// Execution goes through Crush's embedded POSIX shell (shell.Run) so the
119+// same interpreter, builtins, and coreutils are visible to hooks as to
120+// the bash tool. BlockFuncs are intentionally omitted: hooks are
121+// user-authored config that carry the same trust as a shell alias.
122+//
123+// A hook that fails to yield after its deadline has passed is abandoned
124+// after abandonGrace so the caller never blocks longer than
125+// timeout + abandonGrace. Ownership of the stdout and stderr buffers is
126+// strictly single-goroutine:
127+// - before receiving from `done`, only the goroutine writes to them;
128+// - after `done` delivers a value, the goroutine is finished and the
129+// outer frame reads them;
130+// - on the abandon path, the goroutine may still be writing and the
131+// outer frame must not touch them again.
105132func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVars []string, payload []byte) HookResult {
106133 timeout := hook.TimeoutDuration()
107134 ctx, cancel := context.WithTimeout(parentCtx, timeout)
108135 defer cancel()
109136
110− cmd := exec.CommandContext(ctx, "sh", "-c", hook.Command)
111− cmd.WaitDelay = time.Second
112− cmd.Env = envVars
113− cmd.Dir = r.cwd
114− cmd.Stdin = bytes.NewReader(payload)
115−
116137 var stdout, stderr bytes.Buffer
117− cmd.Stdout = &stdout
118− cmd.Stderr = &stderr
119−
120− err := cmd.Run()
138+ done := make(chan error, 1)
139+ go func() {
140+ done <- runShell(ctx, shell.RunOptions{
141+ Command: hook.Command,
142+ Cwd: r.cwd,
143+ Env: envVars,
144+ Stdin: bytes.NewReader(payload),
145+ Stdout: &stdout,
146+ Stderr: &stderr,
147+ })
148+ }()
149+
150+ var err error
151+ select {
152+ case err = <-done:
153+ // Normal path: goroutine has finished, buffers are safe to read.
154+ case <-ctx.Done():
155+ select {
156+ case err = <-done:
157+ // Interpreter yielded within the grace period; safe to read.
158+ case <-time.After(abandonGrace):
159+ slog.Warn("Hook did not yield after cancel; abandoning goroutine",
160+ "command", hook.Command,
161+ "timeout", timeout,
162+ )
163+ // The goroutine may still be writing to stdout/stderr; do
164+ // not read either buffer below this point.
165+ return HookResult{Decision: DecisionNone}
166+ }
167+ }
121168
122− if ctx.Err() != nil {
169+ if shell.IsInterrupt(err) {
123170 // Distinguish timeout from parent cancellation.
124171 if parentCtx.Err() != nil {
125172 slog.Debug("Hook cancelled by parent context", "command", hook.Command)
@@ -130,10 +177,7 @@ func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVa
130177 }
131178
132179 if err != nil {
133− exitCode := -1
134− if cmd.ProcessState != nil {
135− exitCode = cmd.ProcessState.ExitCode()
136− }
180+ exitCode := shell.ExitCode(err)
137181 switch exitCode {
138182 case 2:
139183 // Exit code 2 = block this tool call. Stderr is the reason.
@@ -162,6 +206,7 @@ func (r *Runner) runOne(parentCtx context.Context, hook config.HookConfig, envVa
162206 "command", hook.Command,
163207 "exit_code", exitCode,
164208 "stderr", strings.TrimSpace(stderr.String()),
209+ "error", err,
165210 )
166211 return HookResult{Decision: DecisionNone}
167212 }