hooks_test.go

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