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
200func splitFirst(s, sep string) []string {
201 before, after, found := strings.Cut(s, sep)
202 if !found {
203 return []string{s}
204 }
205 return []string{before, after}
206}
207
208func TestBuildPayload(t *testing.T) {
209 t.Parallel()
210 payload := BuildPayload(EventPreToolUse, "sess-1", "/work", "bash", `{"command":"ls"}`)
211 s := string(payload)
212 require.Contains(t, s, `"event":"`+EventPreToolUse+`"`)
213 require.Contains(t, s, `"tool_name":"bash"`)
214 // tool_input should be an object, not a string.
215 require.Contains(t, s, `"tool_input":{"command":"ls"}`)
216}
217
218func TestRunnerExitCode0Allow(t *testing.T) {
219 t.Parallel()
220 hookCfg := config.HookConfig{
221 Command: `echo '{"decision":"allow","context":"ok"}'`,
222 }
223 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
224 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
225 require.NoError(t, err)
226 require.Equal(t, DecisionAllow, result.Decision)
227 require.Equal(t, "ok", result.Context)
228}
229
230func TestRunnerExitCode2Deny(t *testing.T) {
231 t.Parallel()
232 hookCfg := config.HookConfig{
233 Command: `echo "forbidden" >&2; exit 2`,
234 }
235 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
236 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
237 require.NoError(t, err)
238 require.Equal(t, DecisionDeny, result.Decision)
239 require.False(t, result.Halt)
240 require.Equal(t, "forbidden", result.Reason)
241}
242
243func TestRunnerExitCode49Halt(t *testing.T) {
244 t.Parallel()
245 hookCfg := config.HookConfig{
246 Command: `echo "stop the turn" >&2; exit 49`,
247 }
248 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
249 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
250 require.NoError(t, err)
251 require.True(t, result.Halt)
252 require.Equal(t, DecisionDeny, result.Decision)
253 require.Equal(t, "stop the turn", result.Reason)
254}
255
256func TestRunnerHaltViaJSON(t *testing.T) {
257 t.Parallel()
258 hookCfg := config.HookConfig{
259 Command: `echo '{"halt":true,"reason":"via json"}'`,
260 }
261 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
262 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
263 require.NoError(t, err)
264 require.True(t, result.Halt)
265 require.Equal(t, "via json", result.Reason)
266}
267
268func TestRunnerExitCodeOtherNonBlocking(t *testing.T) {
269 t.Parallel()
270 hookCfg := config.HookConfig{
271 Command: `exit 1`,
272 }
273 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
274 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
275 require.NoError(t, err)
276 require.Equal(t, DecisionNone, result.Decision)
277}
278
279func TestRunnerTimeout(t *testing.T) {
280 t.Parallel()
281 hookCfg := config.HookConfig{
282 Command: `sleep 10`,
283 Timeout: 1,
284 }
285 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
286 start := time.Now()
287 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
288 elapsed := time.Since(start)
289 require.NoError(t, err)
290 require.Equal(t, DecisionNone, result.Decision)
291 require.Less(t, elapsed, 5*time.Second)
292}
293
294func TestRunnerDeduplication(t *testing.T) {
295 t.Parallel()
296 // Two hooks with the same command should only run once.
297 hookCfg := config.HookConfig{
298 Command: `echo '{"decision":"allow"}'`,
299 }
300 r := NewRunner([]config.HookConfig{hookCfg, hookCfg}, t.TempDir(), t.TempDir())
301 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
302 require.NoError(t, err)
303 require.Equal(t, DecisionAllow, result.Decision)
304}
305
306func TestRunnerNoMatchingHooks(t *testing.T) {
307 t.Parallel()
308 // Hooks are empty.
309 r := NewRunner(nil, t.TempDir(), t.TempDir())
310 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
311 require.NoError(t, err)
312 require.Equal(t, DecisionNone, result.Decision)
313}
314
315// validatedHooks builds hook configs and runs ValidateHooks to compile
316// matcher regexes, mirroring the real config-load path.
317func validatedHooks(t *testing.T, hooks []config.HookConfig) []config.HookConfig {
318 t.Helper()
319 cfg := &config.Config{
320 Hooks: map[string][]config.HookConfig{
321 EventPreToolUse: hooks,
322 },
323 }
324 require.NoError(t, cfg.ValidateHooks())
325 return cfg.Hooks[EventPreToolUse]
326}
327
328func TestRunnerMatcherFiltering(t *testing.T) {
329 t.Parallel()
330
331 t.Run("compiled regex matches", func(t *testing.T) {
332 t.Parallel()
333 hooks := validatedHooks(t, []config.HookConfig{
334 {Command: `echo '{"decision":"deny","reason":"blocked"}'`, Matcher: "^bash$"},
335 })
336 r := NewRunner(hooks, t.TempDir(), t.TempDir())
337 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
338 require.NoError(t, err)
339 require.Equal(t, DecisionDeny, result.Decision)
340 })
341
342 t.Run("compiled regex does not match", func(t *testing.T) {
343 t.Parallel()
344 hooks := validatedHooks(t, []config.HookConfig{
345 {Command: `echo '{"decision":"deny","reason":"blocked"}'`, Matcher: "^edit$"},
346 })
347 r := NewRunner(hooks, t.TempDir(), t.TempDir())
348 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
349 require.NoError(t, err)
350 require.Equal(t, DecisionNone, result.Decision)
351 })
352
353 t.Run("no matcher matches everything", func(t *testing.T) {
354 t.Parallel()
355 hooks := validatedHooks(t, []config.HookConfig{
356 {Command: `echo '{"decision":"allow"}'`},
357 })
358 r := NewRunner(hooks, t.TempDir(), t.TempDir())
359 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
360 require.NoError(t, err)
361 require.Equal(t, DecisionAllow, result.Decision)
362 })
363
364 t.Run("partial regex match", func(t *testing.T) {
365 t.Parallel()
366 hooks := validatedHooks(t, []config.HookConfig{
367 {Command: `echo '{"decision":"deny","reason":"mcp blocked"}'`, Matcher: "^mcp_"},
368 })
369 r := NewRunner(hooks, t.TempDir(), t.TempDir())
370
371 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "mcp_github_get_me", `{}`)
372 require.NoError(t, err)
373 require.Equal(t, DecisionDeny, result.Decision)
374
375 result, err = r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
376 require.NoError(t, err)
377 require.Equal(t, DecisionNone, result.Decision)
378 })
379}
380
381func TestValidateHooksInvalidRegex(t *testing.T) {
382 t.Parallel()
383 cfg := &config.Config{
384 Hooks: map[string][]config.HookConfig{
385 EventPreToolUse: {
386 {Command: "true", Matcher: "[invalid"},
387 },
388 },
389 }
390 err := cfg.ValidateHooks()
391 require.Error(t, err)
392 require.Contains(t, err.Error(), "invalid matcher regex")
393}
394
395func TestValidateHooksEmptyCommand(t *testing.T) {
396 t.Parallel()
397 cfg := &config.Config{
398 Hooks: map[string][]config.HookConfig{
399 EventPreToolUse: {
400 {Command: ""},
401 },
402 },
403 }
404 err := cfg.ValidateHooks()
405 require.Error(t, err)
406 require.Contains(t, err.Error(), "command is required")
407}
408
409func TestValidateHooksNormalizesEventNames(t *testing.T) {
410 t.Parallel()
411
412 tests := []struct {
413 name string
414 input string
415 }{
416 {"canonical", "PreToolUse"},
417 {"lowercase", "pretooluse"},
418 {"snake_case", "pre_tool_use"},
419 {"upper_snake", "PRE_TOOL_USE"},
420 {"mixed_case", "preToolUse"},
421 }
422 for _, tt := range tests {
423 t.Run(tt.name, func(t *testing.T) {
424 t.Parallel()
425 cfg := &config.Config{
426 Hooks: map[string][]config.HookConfig{
427 tt.input: {
428 {Command: "true"},
429 },
430 },
431 }
432 require.NoError(t, cfg.ValidateHooks())
433 require.Len(t, cfg.Hooks[EventPreToolUse], 1)
434 })
435 }
436}
437
438func TestRunnerParallelExecution(t *testing.T) {
439 t.Parallel()
440 // Two hooks: one allows, one denies. Deny should win.
441 hooks := []config.HookConfig{
442 {Command: `echo '{"decision":"allow","context":"hook1"}'`},
443 {Command: `echo '{"decision":"deny","reason":"nope"}' ; exit 0`},
444 }
445 r := NewRunner(hooks, t.TempDir(), t.TempDir())
446 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
447 require.NoError(t, err)
448 require.Equal(t, DecisionDeny, result.Decision)
449 require.Equal(t, "nope", result.Reason)
450}
451
452func TestRunnerEnvVarsPropagated(t *testing.T) {
453 t.Parallel()
454 hookCfg := config.HookConfig{
455 Command: `printf '{"decision":"allow","context":"%s"}' "$CRUSH_TOOL_NAME"`,
456 }
457 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
458 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
459 require.NoError(t, err)
460 require.Equal(t, DecisionAllow, result.Decision)
461 require.Equal(t, "bash", result.Context)
462}
463
464func TestParseStdoutUpdatedInput(t *testing.T) {
465 t.Parallel()
466
467 t.Run("nested object", func(t *testing.T) {
468 t.Parallel()
469 r := parseStdout(`{"decision":"allow","updated_input":{"command":"rtk cat foo.go"}}`)
470 require.Equal(t, DecisionAllow, r.Decision)
471 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
472 })
473
474 t.Run("stringified backward compat", func(t *testing.T) {
475 t.Parallel()
476 r := parseStdout(`{"decision":"allow","updated_input":"{\"command\":\"rtk cat foo.go\"}"}`)
477 require.Equal(t, DecisionAllow, r.Decision)
478 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
479 })
480
481 t.Run("no updated_input", func(t *testing.T) {
482 t.Parallel()
483 r := parseStdout(`{"decision":"allow"}`)
484 require.Empty(t, r.UpdatedInput)
485 })
486}
487
488func TestAggregationUpdatedInput(t *testing.T) {
489 t.Parallel()
490
491 t.Run("patches merge in config order with later overriding", func(t *testing.T) {
492 t.Parallel()
493 agg := aggregate([]HookResult{
494 {Decision: DecisionAllow, UpdatedInput: `{"command":"first","keep":"me"}`},
495 {Decision: DecisionAllow, UpdatedInput: `{"command":"second"}`},
496 }, `{"command":"orig","timeout":60}`)
497 require.Equal(t, DecisionAllow, agg.Decision)
498 // command overridden by second patch; keep preserved from first
499 // patch; timeout preserved from original input.
500 require.JSONEq(t,
501 `{"command":"second","keep":"me","timeout":60}`,
502 agg.UpdatedInput,
503 )
504 })
505
506 t.Run("shallow: nested objects are replaced wholesale", func(t *testing.T) {
507 t.Parallel()
508 agg := aggregate([]HookResult{
509 {Decision: DecisionAllow, UpdatedInput: `{"env":{"FOO":"bar"}}`},
510 }, `{"env":{"BAZ":"qux"},"command":"ls"}`)
511 // "env" is replaced entirely; "command" preserved.
512 require.JSONEq(t,
513 `{"env":{"FOO":"bar"},"command":"ls"}`,
514 agg.UpdatedInput,
515 )
516 })
517
518 t.Run("deny still reports merged input (caller ignores it)", func(t *testing.T) {
519 t.Parallel()
520 agg := aggregate([]HookResult{
521 {Decision: DecisionAllow, UpdatedInput: `{"command":"rewritten"}`},
522 {Decision: DecisionDeny, Reason: "blocked"},
523 }, `{"command":"orig"}`)
524 require.Equal(t, DecisionDeny, agg.Decision)
525 })
526
527 t.Run("no patches leaves updated_input empty", func(t *testing.T) {
528 t.Parallel()
529 agg := aggregate([]HookResult{
530 {Decision: DecisionAllow},
531 {Decision: DecisionNone},
532 }, `{"command":"orig"}`)
533 require.Empty(t, agg.UpdatedInput)
534 })
535
536 t.Run("invalid patch is ignored", func(t *testing.T) {
537 t.Parallel()
538 agg := aggregate([]HookResult{
539 {Decision: DecisionAllow, UpdatedInput: `"not-an-object"`},
540 {Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
541 }, `{"command":"orig"}`)
542 require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
543 })
544
545 t.Run("malformed patch JSON is ignored and merge continues", func(t *testing.T) {
546 t.Parallel()
547 agg := aggregate([]HookResult{
548 {Decision: DecisionAllow, UpdatedInput: `{broken json`},
549 {Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
550 }, `{"command":"orig"}`)
551 require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
552 })
553
554 t.Run("non-object tool_input rejects all patches", func(t *testing.T) {
555 t.Parallel()
556 agg := aggregate([]HookResult{
557 {Decision: DecisionAllow, UpdatedInput: `{"command":"rewrite"}`},
558 }, `"just-a-string"`)
559 require.Empty(t, agg.UpdatedInput)
560 })
561
562 t.Run("null updated_input is a no-op", func(t *testing.T) {
563 t.Parallel()
564 // parseStdout converts null updated_input to "", so aggregate
565 // never sees a patch — the merged input is empty and the
566 // original tool_input is used unchanged.
567 r := parseStdout(`{"decision":"allow","updated_input":null}`)
568 require.Empty(t, r.UpdatedInput)
569 agg := aggregate([]HookResult{r}, `{"command":"orig"}`)
570 require.Empty(t, agg.UpdatedInput)
571 })
572}
573
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.
583func 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
631func TestRunnerUpdatedInput(t *testing.T) {
632 t.Parallel()
633 hookCfg := config.HookConfig{
634 Command: `echo '{"decision":"allow","updated_input":{"command":"echo rewritten"}}'`,
635 }
636 r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
637 result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{"command":"echo original","timeout":60}`)
638 require.NoError(t, err)
639 require.Equal(t, DecisionAllow, result.Decision)
640 require.JSONEq(t,
641 `{"command":"echo rewritten","timeout":60}`,
642 result.UpdatedInput,
643 )
644}
645
646func TestParseStdoutClaudeCodeFormat(t *testing.T) {
647 t.Parallel()
648
649 t.Run("allow with reason", func(t *testing.T) {
650 t.Parallel()
651 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"RTK auto-rewrite"}}`)
652 require.Equal(t, DecisionAllow, r.Decision)
653 require.Equal(t, "RTK auto-rewrite", r.Reason)
654 })
655
656 t.Run("allow with updatedInput", func(t *testing.T) {
657 t.Parallel()
658 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{"command":"rtk cat foo.go"}}}`)
659 require.Equal(t, DecisionAllow, r.Decision)
660 require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
661 })
662
663 t.Run("deny", func(t *testing.T) {
664 t.Parallel()
665 r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"not allowed"}}`)
666 require.Equal(t, DecisionDeny, r.Decision)
667 require.Equal(t, "not allowed", r.Reason)
668 })
669
670 t.Run("no permissionDecision", func(t *testing.T) {
671 t.Parallel()
672 r := parseStdout(`{"hookSpecificOutput":{}}`)
673 require.Equal(t, DecisionNone, r.Decision)
674 })
675
676 t.Run("crush format still works", func(t *testing.T) {
677 t.Parallel()
678 r := parseStdout(`{"decision":"allow","context":"hello"}`)
679 require.Equal(t, DecisionAllow, r.Decision)
680 require.Equal(t, "hello", r.Context)
681 })
682}