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 TestRunnerHookNameUsesDisplayName(t *testing.T) {
480 t.Parallel()
481
482 t.Run("name field is used when set", func(t *testing.T) {
483 t.Parallel()
484 hookCfg := config.HookConfig{
485 Name: "my-hook",
486 Command: `echo '{"decision":"allow"}'`,
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.Len(t, result.Hooks, 1)
493 require.Equal(t, "my-hook", result.Hooks[0].Name)
494 })
495
496 t.Run("command is used when name is empty", func(t *testing.T) {
497 t.Parallel()
498 hookCfg := config.HookConfig{
499 Command: `echo '{"decision":"allow"}'`,
500 }
501 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
502 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
503 require.NoError(t, err)
504 require.Equal(t, DecisionAllow, result.Decision)
505 require.Len(t, result.Hooks, 1)
506 require.Equal(t, `echo '{"decision":"allow"}'`, result.Hooks[0].Name)
507 })
508}
509
510func TestRunnerParallelExecution(t *testing.T) {
511 t.Parallel()
512 // Two hooks: one allows, one denies. Deny should win.
513 hooks := []config.HookConfig{
514 {Command: `echo '{"decision":"allow","context":"hook1"}'`},
515 {Command: `echo '{"decision":"deny","reason":"nope"}' ; exit 0`},
516 }
517 r := NewRunner(hooks, t.TempDir(), t.TempDir())
518 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
519 require.NoError(t, err)
520 require.Equal(t, DecisionDeny, result.Decision)
521 require.Equal(t, "nope", result.Reason)
522}
523
524func TestRunnerEnvVarsPropagated(t *testing.T) {
525 t.Parallel()
526 hookCfg := config.HookConfig{
527 Command: `printf '{"decision":"allow","context":"%s"}' "$CRUSH_TOOL_NAME"`,
528 }
529 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
530 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
531 require.NoError(t, err)
532 require.Equal(t, DecisionAllow, result.Decision)
533 require.Equal(t, "bash", result.Context)
534}
535
536func TestParseStdoutUpdatedInput(t *testing.T) {
537 t.Parallel()
538
539 t.Run("nested object", func(t *testing.T) {
540 t.Parallel()
541 r := parseStdout(`{"decision":"allow","updated_input":{"command":"rtk cat foo.go"}}`)
542 require.Equal(t, DecisionAllow, r.Decision)
543 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
544 })
545
546 t.Run("stringified backward compat", func(t *testing.T) {
547 t.Parallel()
548 r := parseStdout(`{"decision":"allow","updated_input":"{\"command\":\"rtk cat foo.go\"}"}`)
549 require.Equal(t, DecisionAllow, r.Decision)
550 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
551 })
552
553 t.Run("no updated_input", func(t *testing.T) {
554 t.Parallel()
555 r := parseStdout(`{"decision":"allow"}`)
556 require.Empty(t, r.UpdatedInput)
557 })
558}
559
560func TestAggregationUpdatedInput(t *testing.T) {
561 t.Parallel()
562
563 t.Run("patches merge in config order with later overriding", func(t *testing.T) {
564 t.Parallel()
565 agg := aggregate([]HookResult{
566 {Decision: DecisionAllow, UpdatedInput: `{"command":"first","keep":"me"}`},
567 {Decision: DecisionAllow, UpdatedInput: `{"command":"second"}`},
568 }, `{"command":"orig","timeout":60}`)
569 require.Equal(t, DecisionAllow, agg.Decision)
570 // command overridden by second patch; keep preserved from first
571 // patch; timeout preserved from original input.
572 require.JSONEq(
573 t,
574 `{"command":"second","keep":"me","timeout":60}`,
575 agg.UpdatedInput,
576 )
577 })
578
579 t.Run("shallow: nested objects are replaced wholesale", func(t *testing.T) {
580 t.Parallel()
581 agg := aggregate([]HookResult{
582 {Decision: DecisionAllow, UpdatedInput: `{"env":{"FOO":"bar"}}`},
583 }, `{"env":{"BAZ":"qux"},"command":"ls"}`)
584 // "env" is replaced entirely; "command" preserved.
585 require.JSONEq(
586 t,
587 `{"env":{"FOO":"bar"},"command":"ls"}`,
588 agg.UpdatedInput,
589 )
590 })
591
592 t.Run("deny still reports merged input (caller ignores it)", func(t *testing.T) {
593 t.Parallel()
594 agg := aggregate([]HookResult{
595 {Decision: DecisionAllow, UpdatedInput: `{"command":"rewritten"}`},
596 {Decision: DecisionDeny, Reason: "blocked"},
597 }, `{"command":"orig"}`)
598 require.Equal(t, DecisionDeny, agg.Decision)
599 })
600
601 t.Run("no patches leaves updated_input empty", func(t *testing.T) {
602 t.Parallel()
603 agg := aggregate([]HookResult{
604 {Decision: DecisionAllow},
605 {Decision: DecisionNone},
606 }, `{"command":"orig"}`)
607 require.Empty(t, agg.UpdatedInput)
608 })
609
610 t.Run("invalid patch is ignored", func(t *testing.T) {
611 t.Parallel()
612 agg := aggregate([]HookResult{
613 {Decision: DecisionAllow, UpdatedInput: `"not-an-object"`},
614 {Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
615 }, `{"command":"orig"}`)
616 require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
617 })
618
619 t.Run("malformed patch JSON is ignored and merge continues", func(t *testing.T) {
620 t.Parallel()
621 agg := aggregate([]HookResult{
622 {Decision: DecisionAllow, UpdatedInput: `{broken json`},
623 {Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
624 }, `{"command":"orig"}`)
625 require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
626 })
627
628 t.Run("non-object tool_input rejects all patches", func(t *testing.T) {
629 t.Parallel()
630 agg := aggregate([]HookResult{
631 {Decision: DecisionAllow, UpdatedInput: `{"command":"rewrite"}`},
632 }, `"just-a-string"`)
633 require.Empty(t, agg.UpdatedInput)
634 })
635
636 t.Run("null updated_input is a no-op", func(t *testing.T) {
637 t.Parallel()
638 // parseStdout converts null updated_input to "", so aggregate
639 // never sees a patch — the merged input is empty and the
640 // original tool_input is used unchanged.
641 r := parseStdout(`{"decision":"allow","updated_input":null}`)
642 require.Empty(t, r.UpdatedInput)
643 agg := aggregate([]HookResult{r}, `{"command":"orig"}`)
644 require.Empty(t, agg.UpdatedInput)
645 })
646}
647
648// TestRunnerAbandonRaceSafety verifies that if a hook's shell execution
649// does not yield to ctx cancellation within abandonGrace, runOne returns
650// promptly and never touches the shared stdout/stderr buffers again —
651// even while the abandoned goroutine continues to write to them.
652//
653// The substitute shell executor ignores ctx entirely, writes to Stdout
654// both before and after the abandon deadline, and only then returns.
655// Under -race this catches any code path in runOne that reads those
656// buffers after returning the DecisionNone abandon result.
657func TestRunnerAbandonRaceSafety(t *testing.T) {
658 origRunShell := runShell
659 t.Cleanup(func() { runShell = origRunShell })
660
661 // Synchronize shutdown with the abandoned goroutine so the test
662 // exits cleanly even under -race.
663 var wg sync.WaitGroup
664 release := make(chan struct{})
665 t.Cleanup(func() {
666 close(release)
667 wg.Wait()
668 })
669
670 runShell = func(_ context.Context, opts shell.RunOptions) error {
671 wg.Add(1)
672 defer wg.Done()
673 // Write before the caller observes ctx.Done(); the caller will
674 // not read the buffer while we still own it.
675 _, _ = io.WriteString(opts.Stdout, "before\n")
676 // Hold past ctx deadline + abandonGrace so the caller takes
677 // the abandon branch, then continue writing. If the caller
678 // reads these buffers after abandoning, -race will flag it.
679 select {
680 case <-time.After(5 * time.Second):
681 case <-release:
682 }
683 _, _ = io.WriteString(opts.Stdout, "after\n")
684 return nil
685 }
686
687 hookCfg := config.HookConfig{
688 Command: "# irrelevant; runShell is stubbed",
689 Timeout: 1,
690 }
691 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
692
693 start := time.Now()
694 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
695 elapsed := time.Since(start)
696
697 require.NoError(t, err)
698 require.Equal(t, DecisionNone, result.Decision)
699 // Abandon must happen at ~timeout + abandonGrace. Allow generous
700 // slack so CI noise doesn't flake the test.
701 require.Less(t, elapsed, 3500*time.Millisecond,
702 "runOne should return within timeout+abandonGrace+slack")
703}
704
705func TestRunnerUpdatedInput(t *testing.T) {
706 t.Parallel()
707 hookCfg := config.HookConfig{
708 Command: `echo '{"decision":"allow","updated_input":{"command":"echo rewritten"}}'`,
709 }
710 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
711 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{"command":"echo original","timeout":60}`)
712 require.NoError(t, err)
713 require.Equal(t, DecisionAllow, result.Decision)
714 require.JSONEq(
715 t,
716 `{"command":"echo rewritten","timeout":60}`,
717 result.UpdatedInput,
718 )
719}
720
721func TestParseStdoutClaudeCodeFormat(t *testing.T) {
722 t.Parallel()
723
724 t.Run("allow with reason", func(t *testing.T) {
725 t.Parallel()
726 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"RTK auto-rewrite"}}`)
727 require.Equal(t, DecisionAllow, r.Decision)
728 require.Equal(t, "RTK auto-rewrite", r.Reason)
729 })
730
731 t.Run("allow with updatedInput", func(t *testing.T) {
732 t.Parallel()
733 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{"command":"rtk cat foo.go"}}}`)
734 require.Equal(t, DecisionAllow, r.Decision)
735 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
736 })
737
738 t.Run("deny", func(t *testing.T) {
739 t.Parallel()
740 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"not allowed"}}`)
741 require.Equal(t, DecisionDeny, r.Decision)
742 require.Equal(t, "not allowed", r.Reason)
743 })
744
745 t.Run("no permissionDecision", func(t *testing.T) {
746 t.Parallel()
747 r := parseStdout(`{"hookSpecificOutput":{}}`)
748 require.Equal(t, DecisionNone, r.Decision)
749 })
750
751 t.Run("crush format still works", func(t *testing.T) {
752 t.Parallel()
753 r := parseStdout(`{"decision":"allow","context":"hello"}`)
754 require.Equal(t, DecisionAllow, r.Decision)
755 require.Equal(t, "hello", r.Context)
756 })
757}