hooks_test.go

  1package hooks
  2
  3import (
  4	"context"
  5	"io"
  6	"strings"
  7	"sync"
  8	"testing"
  9	"time"
 10
 11	"github.com/charmbracelet/crush/internal/config"
 12	"github.com/charmbracelet/crush/internal/shell"
 13	"github.com/stretchr/testify/require"
 14)
 15
 16func TestAggregation(t *testing.T) {
 17	t.Parallel()
 18
 19	t.Run("empty results", func(t *testing.T) {
 20		t.Parallel()
 21		agg := aggregate(nil, "{}")
 22		require.Equal(t, DecisionNone, agg.Decision)
 23		require.Empty(t, agg.Reason)
 24		require.Empty(t, agg.Context)
 25		require.False(t, agg.Halt)
 26	})
 27
 28	t.Run("single allow", func(t *testing.T) {
 29		t.Parallel()
 30		agg := aggregate([]HookResult{
 31			{Decision: DecisionAllow},
 32		}, "{}")
 33		require.Equal(t, DecisionAllow, agg.Decision)
 34	})
 35
 36	t.Run("deny wins over allow", func(t *testing.T) {
 37		t.Parallel()
 38		agg := aggregate([]HookResult{
 39			{Decision: DecisionAllow, Context: "ctx1"},
 40			{Decision: DecisionDeny, Reason: "blocked"},
 41		}, "{}")
 42		require.Equal(t, DecisionDeny, agg.Decision)
 43		require.Equal(t, "blocked", agg.Reason)
 44		require.Equal(t, "ctx1", agg.Context)
 45	})
 46
 47	t.Run("multiple deny reasons concatenated", func(t *testing.T) {
 48		t.Parallel()
 49		agg := aggregate([]HookResult{
 50			{Decision: DecisionDeny, Reason: "reason1"},
 51			{Decision: DecisionDeny, Reason: "reason2"},
 52		}, "{}")
 53		require.Equal(t, DecisionDeny, agg.Decision)
 54		require.Equal(t, "reason1\nreason2", agg.Reason)
 55	})
 56
 57	t.Run("context concatenated from all hooks", func(t *testing.T) {
 58		t.Parallel()
 59		agg := aggregate([]HookResult{
 60			{Decision: DecisionAllow, Context: "ctx-a"},
 61			{Decision: DecisionNone, Context: "ctx-b"},
 62		}, "{}")
 63		require.Equal(t, DecisionAllow, agg.Decision)
 64		require.Equal(t, "ctx-a\nctx-b", agg.Context)
 65	})
 66
 67	t.Run("allow wins over none", func(t *testing.T) {
 68		t.Parallel()
 69		agg := aggregate([]HookResult{
 70			{Decision: DecisionNone},
 71			{Decision: DecisionAllow},
 72		}, "{}")
 73		require.Equal(t, DecisionAllow, agg.Decision)
 74	})
 75
 76	t.Run("halt is sticky across results", func(t *testing.T) {
 77		t.Parallel()
 78		agg := aggregate([]HookResult{
 79			{Decision: DecisionAllow},
 80			{Halt: true, Reason: "stop now"},
 81		}, "{}")
 82		require.True(t, agg.Halt)
 83		require.Contains(t, agg.Reason, "stop now")
 84	})
 85
 86	t.Run("halt with deny only records reason once", func(t *testing.T) {
 87		t.Parallel()
 88		agg := aggregate([]HookResult{
 89			{Decision: DecisionDeny, Halt: true, Reason: "stop"},
 90		}, "{}")
 91		require.True(t, agg.Halt)
 92		require.Equal(t, DecisionDeny, agg.Decision)
 93		require.Equal(t, "stop", agg.Reason)
 94	})
 95}
 96
 97func TestParseStdout(t *testing.T) {
 98	t.Parallel()
 99
100	t.Run("empty stdout", func(t *testing.T) {
101		t.Parallel()
102		r := parseStdout("")
103		require.Equal(t, DecisionNone, r.Decision)
104	})
105
106	t.Run("valid allow", func(t *testing.T) {
107		t.Parallel()
108		r := parseStdout(`{"decision":"allow","context":"some context"}`)
109		require.Equal(t, DecisionAllow, r.Decision)
110		require.Equal(t, "some context", r.Context)
111	})
112
113	t.Run("valid deny", func(t *testing.T) {
114		t.Parallel()
115		r := parseStdout(`{"decision":"deny","reason":"not allowed"}`)
116		require.Equal(t, DecisionDeny, r.Decision)
117		require.Equal(t, "not allowed", r.Reason)
118	})
119
120	t.Run("malformed JSON", func(t *testing.T) {
121		t.Parallel()
122		r := parseStdout(`{bad json}`)
123		require.Equal(t, DecisionNone, r.Decision)
124	})
125
126	t.Run("unknown decision", func(t *testing.T) {
127		t.Parallel()
128		r := parseStdout(`{"decision":"maybe"}`)
129		require.Equal(t, DecisionNone, r.Decision)
130	})
131
132	t.Run("version 1 accepted", func(t *testing.T) {
133		t.Parallel()
134		r := parseStdout(`{"version":1,"decision":"allow"}`)
135		require.Equal(t, DecisionAllow, r.Decision)
136	})
137
138	t.Run("unknown higher version still parses", func(t *testing.T) {
139		t.Parallel()
140		r := parseStdout(`{"version":99,"decision":"deny","reason":"future"}`)
141		require.Equal(t, DecisionDeny, r.Decision)
142		require.Equal(t, "future", r.Reason)
143	})
144
145	t.Run("halt true without decision", func(t *testing.T) {
146		t.Parallel()
147		r := parseStdout(`{"halt":true,"reason":"turn over"}`)
148		require.True(t, r.Halt)
149		require.Equal(t, "turn over", r.Reason)
150		require.Equal(t, DecisionNone, r.Decision)
151	})
152
153	t.Run("context string form", func(t *testing.T) {
154		t.Parallel()
155		r := parseStdout(`{"decision":"allow","context":"one note"}`)
156		require.Equal(t, "one note", r.Context)
157	})
158
159	t.Run("context array form", func(t *testing.T) {
160		t.Parallel()
161		r := parseStdout(`{"decision":"allow","context":["first","second"]}`)
162		require.Equal(t, "first\nsecond", r.Context)
163	})
164
165	t.Run("context array drops empty entries", func(t *testing.T) {
166		t.Parallel()
167		r := parseStdout(`{"decision":"allow","context":["","keep",""]}`)
168		require.Equal(t, "keep", r.Context)
169	})
170
171	t.Run("context null becomes empty", func(t *testing.T) {
172		t.Parallel()
173		r := parseStdout(`{"decision":"allow","context":null}`)
174		require.Empty(t, r.Context)
175	})
176}
177
178func TestBuildEnv(t *testing.T) {
179	t.Parallel()
180
181	env := BuildEnv(EventPreToolUse, "bash", "sess-1", "/work", "/project", `{"command":"ls","file_path":"/tmp/f.txt"}`)
182
183	envMap := make(map[string]string)
184	for _, e := range env {
185		parts := splitFirst(e, "=")
186		if len(parts) == 2 {
187			envMap[parts[0]] = parts[1]
188		}
189	}
190
191	require.Equal(t, EventPreToolUse, envMap["CRUSH_EVENT"])
192	require.Equal(t, "bash", envMap["CRUSH_TOOL_NAME"])
193	require.Equal(t, "sess-1", envMap["CRUSH_SESSION_ID"])
194	require.Equal(t, "/work", envMap["CRUSH_CWD"])
195	require.Equal(t, "/project", envMap["CRUSH_PROJECT_DIR"])
196	require.Equal(t, "ls", envMap["CRUSH_TOOL_INPUT_COMMAND"])
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"])
205}
206
207func splitFirst(s, sep string) []string {
208	before, after, found := strings.Cut(s, sep)
209	if !found {
210		return []string{s}
211	}
212	return []string{before, after}
213}
214
215func TestBuildPayload(t *testing.T) {
216	t.Parallel()
217	payload := BuildPayload(EventPreToolUse, "sess-1", "/work", "bash", `{"command":"ls"}`)
218	s := string(payload)
219	require.Contains(t, s, `"event":"`+EventPreToolUse+`"`)
220	require.Contains(t, s, `"tool_name":"bash"`)
221	// tool_input should be an object, not a string.
222	require.Contains(t, s, `"tool_input":{"command":"ls"}`)
223}
224
225func TestRunnerExitCode0Allow(t *testing.T) {
226	t.Parallel()
227	hookCfg := config.HookConfig{
228		Command: `echo '{"decision":"allow","context":"ok"}'`,
229	}
230	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
231	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
232	require.NoError(t, err)
233	require.Equal(t, DecisionAllow, result.Decision)
234	require.Equal(t, "ok", result.Context)
235}
236
237func TestRunnerExitCode2Deny(t *testing.T) {
238	t.Parallel()
239	hookCfg := config.HookConfig{
240		Command: `echo "forbidden" >&2; exit 2`,
241	}
242	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
243	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
244	require.NoError(t, err)
245	require.Equal(t, DecisionDeny, result.Decision)
246	require.False(t, result.Halt)
247	require.Equal(t, "forbidden", result.Reason)
248}
249
250func TestRunnerExitCode49Halt(t *testing.T) {
251	t.Parallel()
252	hookCfg := config.HookConfig{
253		Command: `echo "stop the turn" >&2; exit 49`,
254	}
255	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
256	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
257	require.NoError(t, err)
258	require.True(t, result.Halt)
259	require.Equal(t, DecisionDeny, result.Decision)
260	require.Equal(t, "stop the turn", result.Reason)
261}
262
263func TestRunnerHaltViaJSON(t *testing.T) {
264	t.Parallel()
265	hookCfg := config.HookConfig{
266		Command: `echo '{"halt":true,"reason":"via json"}'`,
267	}
268	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
269	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
270	require.NoError(t, err)
271	require.True(t, result.Halt)
272	require.Equal(t, "via json", result.Reason)
273}
274
275func TestRunnerExitCodeOtherNonBlocking(t *testing.T) {
276	t.Parallel()
277	hookCfg := config.HookConfig{
278		Command: `exit 1`,
279	}
280	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
281	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
282	require.NoError(t, err)
283	require.Equal(t, DecisionNone, result.Decision)
284}
285
286func TestRunnerTimeout(t *testing.T) {
287	t.Parallel()
288	hookCfg := config.HookConfig{
289		Command: `sleep 10`,
290		Timeout: 1,
291	}
292	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
293	start := time.Now()
294	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
295	elapsed := time.Since(start)
296	require.NoError(t, err)
297	require.Equal(t, DecisionNone, result.Decision)
298	require.Less(t, elapsed, 5*time.Second)
299}
300
301func TestRunnerDeduplication(t *testing.T) {
302	t.Parallel()
303	// Two hooks with the same command should only run once.
304	hookCfg := config.HookConfig{
305		Command: `echo '{"decision":"allow"}'`,
306	}
307	r := NewRunner([]config.HookConfig{hookCfg, hookCfg}, t.TempDir(), t.TempDir())
308	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
309	require.NoError(t, err)
310	require.Equal(t, DecisionAllow, result.Decision)
311}
312
313func TestRunnerNoMatchingHooks(t *testing.T) {
314	t.Parallel()
315	// Hooks are empty.
316	r := NewRunner(nil, t.TempDir(), t.TempDir())
317	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
318	require.NoError(t, err)
319	require.Equal(t, DecisionNone, result.Decision)
320}
321
322// validatedHooks builds hook configs and runs ValidateHooks to compile
323// matcher regexes, mirroring the real config-load path.
324func validatedHooks(t *testing.T, hooks []config.HookConfig) []config.HookConfig {
325	t.Helper()
326	cfg := &config.Config{
327		Hooks: map[string][]config.HookConfig{
328			EventPreToolUse: hooks,
329		},
330	}
331	require.NoError(t, cfg.ValidateHooks())
332	return cfg.Hooks[EventPreToolUse]
333}
334
335func TestRunnerMatcherFiltering(t *testing.T) {
336	t.Parallel()
337
338	t.Run("compiled regex matches", func(t *testing.T) {
339		t.Parallel()
340		hooks := validatedHooks(t, []config.HookConfig{
341			{Command: `echo '{"decision":"deny","reason":"blocked"}'`, Matcher: "^bash$"},
342		})
343		r := NewRunner(hooks, t.TempDir(), t.TempDir())
344		result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
345		require.NoError(t, err)
346		require.Equal(t, DecisionDeny, result.Decision)
347	})
348
349	t.Run("compiled regex does not match", func(t *testing.T) {
350		t.Parallel()
351		hooks := validatedHooks(t, []config.HookConfig{
352			{Command: `echo '{"decision":"deny","reason":"blocked"}'`, Matcher: "^edit$"},
353		})
354		r := NewRunner(hooks, t.TempDir(), t.TempDir())
355		result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
356		require.NoError(t, err)
357		require.Equal(t, DecisionNone, result.Decision)
358	})
359
360	t.Run("no matcher matches everything", func(t *testing.T) {
361		t.Parallel()
362		hooks := validatedHooks(t, []config.HookConfig{
363			{Command: `echo '{"decision":"allow"}'`},
364		})
365		r := NewRunner(hooks, t.TempDir(), t.TempDir())
366		result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
367		require.NoError(t, err)
368		require.Equal(t, DecisionAllow, result.Decision)
369	})
370
371	t.Run("partial regex match", func(t *testing.T) {
372		t.Parallel()
373		hooks := validatedHooks(t, []config.HookConfig{
374			{Command: `echo '{"decision":"deny","reason":"mcp blocked"}'`, Matcher: "^mcp_"},
375		})
376		r := NewRunner(hooks, t.TempDir(), t.TempDir())
377
378		result, err := r.Run(context.Background(), EventPreToolUse, "sess", "mcp_github_get_me", `{}`)
379		require.NoError(t, err)
380		require.Equal(t, DecisionDeny, result.Decision)
381
382		result, err = r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
383		require.NoError(t, err)
384		require.Equal(t, DecisionNone, result.Decision)
385	})
386
387	// Runner must compile matchers itself; it cannot rely on
388	// ValidateHooks having run first. This is the guarantee that prevents
389	// the reload-drops-matcher class of bug.
390	t.Run("runner compiles matcher without ValidateHooks", func(t *testing.T) {
391		t.Parallel()
392		raw := []config.HookConfig{
393			{Command: `echo '{"decision":"deny","reason":"blocked"}'`, Matcher: "^bash$"},
394		}
395		r := NewRunner(raw, t.TempDir(), t.TempDir())
396
397		deny, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
398		require.NoError(t, err)
399		require.Equal(t, DecisionDeny, deny.Decision)
400
401		noop, err := r.Run(context.Background(), EventPreToolUse, "sess", "view", `{}`)
402		require.NoError(t, err)
403		require.Equal(t, DecisionNone, noop.Decision)
404	})
405
406	// A matcher that fails to compile at Runner construction must not
407	// degrade to match-everything; the hook is dropped instead.
408	t.Run("runner skips hooks with invalid matcher", func(t *testing.T) {
409		t.Parallel()
410		raw := []config.HookConfig{
411			{Command: `echo '{"decision":"deny","reason":"should not fire"}'`, Matcher: "[invalid"},
412		}
413		r := NewRunner(raw, t.TempDir(), t.TempDir())
414
415		result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
416		require.NoError(t, err)
417		require.Equal(t, DecisionNone, result.Decision)
418		require.Empty(t, r.Hooks())
419	})
420}
421
422func TestValidateHooksInvalidRegex(t *testing.T) {
423	t.Parallel()
424	cfg := &config.Config{
425		Hooks: map[string][]config.HookConfig{
426			EventPreToolUse: {
427				{Command: "true", Matcher: "[invalid"},
428			},
429		},
430	}
431	err := cfg.ValidateHooks()
432	require.Error(t, err)
433	require.Contains(t, err.Error(), "invalid matcher regex")
434}
435
436func TestValidateHooksEmptyCommand(t *testing.T) {
437	t.Parallel()
438	cfg := &config.Config{
439		Hooks: map[string][]config.HookConfig{
440			EventPreToolUse: {
441				{Command: ""},
442			},
443		},
444	}
445	err := cfg.ValidateHooks()
446	require.Error(t, err)
447	require.Contains(t, err.Error(), "command is required")
448}
449
450func TestValidateHooksNormalizesEventNames(t *testing.T) {
451	t.Parallel()
452
453	tests := []struct {
454		name  string
455		input string
456	}{
457		{"canonical", "PreToolUse"},
458		{"lowercase", "pretooluse"},
459		{"snake_case", "pre_tool_use"},
460		{"upper_snake", "PRE_TOOL_USE"},
461		{"mixed_case", "preToolUse"},
462	}
463	for _, tt := range tests {
464		t.Run(tt.name, func(t *testing.T) {
465			t.Parallel()
466			cfg := &config.Config{
467				Hooks: map[string][]config.HookConfig{
468					tt.input: {
469						{Command: "true"},
470					},
471				},
472			}
473			require.NoError(t, cfg.ValidateHooks())
474			require.Len(t, cfg.Hooks[EventPreToolUse], 1)
475		})
476	}
477}
478
479func TestRunnerParallelExecution(t *testing.T) {
480	t.Parallel()
481	// Two hooks: one allows, one denies. Deny should win.
482	hooks := []config.HookConfig{
483		{Command: `echo '{"decision":"allow","context":"hook1"}'`},
484		{Command: `echo '{"decision":"deny","reason":"nope"}' ; exit 0`},
485	}
486	r := NewRunner(hooks, t.TempDir(), t.TempDir())
487	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
488	require.NoError(t, err)
489	require.Equal(t, DecisionDeny, result.Decision)
490	require.Equal(t, "nope", result.Reason)
491}
492
493func TestRunnerEnvVarsPropagated(t *testing.T) {
494	t.Parallel()
495	hookCfg := config.HookConfig{
496		Command: `printf '{"decision":"allow","context":"%s"}' "$CRUSH_TOOL_NAME"`,
497	}
498	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
499	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
500	require.NoError(t, err)
501	require.Equal(t, DecisionAllow, result.Decision)
502	require.Equal(t, "bash", result.Context)
503}
504
505func TestParseStdoutUpdatedInput(t *testing.T) {
506	t.Parallel()
507
508	t.Run("nested object", func(t *testing.T) {
509		t.Parallel()
510		r := parseStdout(`{"decision":"allow","updated_input":{"command":"rtk cat foo.go"}}`)
511		require.Equal(t, DecisionAllow, r.Decision)
512		require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
513	})
514
515	t.Run("stringified backward compat", func(t *testing.T) {
516		t.Parallel()
517		r := parseStdout(`{"decision":"allow","updated_input":"{\"command\":\"rtk cat foo.go\"}"}`)
518		require.Equal(t, DecisionAllow, r.Decision)
519		require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
520	})
521
522	t.Run("no updated_input", func(t *testing.T) {
523		t.Parallel()
524		r := parseStdout(`{"decision":"allow"}`)
525		require.Empty(t, r.UpdatedInput)
526	})
527}
528
529func TestAggregationUpdatedInput(t *testing.T) {
530	t.Parallel()
531
532	t.Run("patches merge in config order with later overriding", func(t *testing.T) {
533		t.Parallel()
534		agg := aggregate([]HookResult{
535			{Decision: DecisionAllow, UpdatedInput: `{"command":"first","keep":"me"}`},
536			{Decision: DecisionAllow, UpdatedInput: `{"command":"second"}`},
537		}, `{"command":"orig","timeout":60}`)
538		require.Equal(t, DecisionAllow, agg.Decision)
539		// command overridden by second patch; keep preserved from first
540		// patch; timeout preserved from original input.
541		require.JSONEq(t,
542			`{"command":"second","keep":"me","timeout":60}`,
543			agg.UpdatedInput,
544		)
545	})
546
547	t.Run("shallow: nested objects are replaced wholesale", func(t *testing.T) {
548		t.Parallel()
549		agg := aggregate([]HookResult{
550			{Decision: DecisionAllow, UpdatedInput: `{"env":{"FOO":"bar"}}`},
551		}, `{"env":{"BAZ":"qux"},"command":"ls"}`)
552		// "env" is replaced entirely; "command" preserved.
553		require.JSONEq(t,
554			`{"env":{"FOO":"bar"},"command":"ls"}`,
555			agg.UpdatedInput,
556		)
557	})
558
559	t.Run("deny still reports merged input (caller ignores it)", func(t *testing.T) {
560		t.Parallel()
561		agg := aggregate([]HookResult{
562			{Decision: DecisionAllow, UpdatedInput: `{"command":"rewritten"}`},
563			{Decision: DecisionDeny, Reason: "blocked"},
564		}, `{"command":"orig"}`)
565		require.Equal(t, DecisionDeny, agg.Decision)
566	})
567
568	t.Run("no patches leaves updated_input empty", func(t *testing.T) {
569		t.Parallel()
570		agg := aggregate([]HookResult{
571			{Decision: DecisionAllow},
572			{Decision: DecisionNone},
573		}, `{"command":"orig"}`)
574		require.Empty(t, agg.UpdatedInput)
575	})
576
577	t.Run("invalid patch is ignored", func(t *testing.T) {
578		t.Parallel()
579		agg := aggregate([]HookResult{
580			{Decision: DecisionAllow, UpdatedInput: `"not-an-object"`},
581			{Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
582		}, `{"command":"orig"}`)
583		require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
584	})
585
586	t.Run("malformed patch JSON is ignored and merge continues", func(t *testing.T) {
587		t.Parallel()
588		agg := aggregate([]HookResult{
589			{Decision: DecisionAllow, UpdatedInput: `{broken json`},
590			{Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
591		}, `{"command":"orig"}`)
592		require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
593	})
594
595	t.Run("non-object tool_input rejects all patches", func(t *testing.T) {
596		t.Parallel()
597		agg := aggregate([]HookResult{
598			{Decision: DecisionAllow, UpdatedInput: `{"command":"rewrite"}`},
599		}, `"just-a-string"`)
600		require.Empty(t, agg.UpdatedInput)
601	})
602
603	t.Run("null updated_input is a no-op", func(t *testing.T) {
604		t.Parallel()
605		// parseStdout converts null updated_input to "", so aggregate
606		// never sees a patch — the merged input is empty and the
607		// original tool_input is used unchanged.
608		r := parseStdout(`{"decision":"allow","updated_input":null}`)
609		require.Empty(t, r.UpdatedInput)
610		agg := aggregate([]HookResult{r}, `{"command":"orig"}`)
611		require.Empty(t, agg.UpdatedInput)
612	})
613}
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.
624func 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
672func TestRunnerUpdatedInput(t *testing.T) {
673	t.Parallel()
674	hookCfg := config.HookConfig{
675		Command: `echo '{"decision":"allow","updated_input":{"command":"echo rewritten"}}'`,
676	}
677	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
678	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{"command":"echo original","timeout":60}`)
679	require.NoError(t, err)
680	require.Equal(t, DecisionAllow, result.Decision)
681	require.JSONEq(t,
682		`{"command":"echo rewritten","timeout":60}`,
683		result.UpdatedInput,
684	)
685}
686
687func TestParseStdoutClaudeCodeFormat(t *testing.T) {
688	t.Parallel()
689
690	t.Run("allow with reason", func(t *testing.T) {
691		t.Parallel()
692		r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"RTK auto-rewrite"}}`)
693		require.Equal(t, DecisionAllow, r.Decision)
694		require.Equal(t, "RTK auto-rewrite", r.Reason)
695	})
696
697	t.Run("allow with updatedInput", func(t *testing.T) {
698		t.Parallel()
699		r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{"command":"rtk cat foo.go"}}}`)
700		require.Equal(t, DecisionAllow, r.Decision)
701		require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
702	})
703
704	t.Run("deny", func(t *testing.T) {
705		t.Parallel()
706		r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"not allowed"}}`)
707		require.Equal(t, DecisionDeny, r.Decision)
708		require.Equal(t, "not allowed", r.Reason)
709	})
710
711	t.Run("no permissionDecision", func(t *testing.T) {
712		t.Parallel()
713		r := parseStdout(`{"hookSpecificOutput":{}}`)
714		require.Equal(t, DecisionNone, r.Decision)
715	})
716
717	t.Run("crush format still works", func(t *testing.T) {
718		t.Parallel()
719		r := parseStdout(`{"decision":"allow","context":"hello"}`)
720		require.Equal(t, DecisionAllow, r.Decision)
721		require.Equal(t, "hello", r.Context)
722	})
723}