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}