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
378func TestValidateHooksInvalidRegex(t *testing.T) {
379	t.Parallel()
380	cfg := &config.Config{
381		Hooks: map[string][]config.HookConfig{
382			EventPreToolUse: {
383				{Command: "true", Matcher: "[invalid"},
384			},
385		},
386	}
387	err := cfg.ValidateHooks()
388	require.Error(t, err)
389	require.Contains(t, err.Error(), "invalid matcher regex")
390}
391
392func TestValidateHooksEmptyCommand(t *testing.T) {
393	t.Parallel()
394	cfg := &config.Config{
395		Hooks: map[string][]config.HookConfig{
396			EventPreToolUse: {
397				{Command: ""},
398			},
399		},
400	}
401	err := cfg.ValidateHooks()
402	require.Error(t, err)
403	require.Contains(t, err.Error(), "command is required")
404}
405
406func TestValidateHooksNormalizesEventNames(t *testing.T) {
407	t.Parallel()
408
409	tests := []struct {
410		name  string
411		input string
412	}{
413		{"canonical", "PreToolUse"},
414		{"lowercase", "pretooluse"},
415		{"snake_case", "pre_tool_use"},
416		{"upper_snake", "PRE_TOOL_USE"},
417		{"mixed_case", "preToolUse"},
418	}
419	for _, tt := range tests {
420		t.Run(tt.name, func(t *testing.T) {
421			t.Parallel()
422			cfg := &config.Config{
423				Hooks: map[string][]config.HookConfig{
424					tt.input: {
425						{Command: "true"},
426					},
427				},
428			}
429			require.NoError(t, cfg.ValidateHooks())
430			require.Len(t, cfg.Hooks[EventPreToolUse], 1)
431		})
432	}
433}
434
435func TestRunnerParallelExecution(t *testing.T) {
436	t.Parallel()
437	// Two hooks: one allows, one denies. Deny should win.
438	hooks := []config.HookConfig{
439		{Command: `echo '{"decision":"allow","context":"hook1"}'`},
440		{Command: `echo '{"decision":"deny","reason":"nope"}' ; exit 0`},
441	}
442	r := NewRunner(hooks, t.TempDir(), t.TempDir())
443	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
444	require.NoError(t, err)
445	require.Equal(t, DecisionDeny, result.Decision)
446	require.Equal(t, "nope", result.Reason)
447}
448
449func TestRunnerEnvVarsPropagated(t *testing.T) {
450	t.Parallel()
451	hookCfg := config.HookConfig{
452		Command: `printf '{"decision":"allow","context":"%s"}' "$CRUSH_TOOL_NAME"`,
453	}
454	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
455	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{}`)
456	require.NoError(t, err)
457	require.Equal(t, DecisionAllow, result.Decision)
458	require.Equal(t, "bash", result.Context)
459}
460
461func TestParseStdoutUpdatedInput(t *testing.T) {
462	t.Parallel()
463
464	t.Run("nested object", func(t *testing.T) {
465		t.Parallel()
466		r := parseStdout(`{"decision":"allow","updated_input":{"command":"rtk cat foo.go"}}`)
467		require.Equal(t, DecisionAllow, r.Decision)
468		require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
469	})
470
471	t.Run("stringified backward compat", func(t *testing.T) {
472		t.Parallel()
473		r := parseStdout(`{"decision":"allow","updated_input":"{\"command\":\"rtk cat foo.go\"}"}`)
474		require.Equal(t, DecisionAllow, r.Decision)
475		require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
476	})
477
478	t.Run("no updated_input", func(t *testing.T) {
479		t.Parallel()
480		r := parseStdout(`{"decision":"allow"}`)
481		require.Empty(t, r.UpdatedInput)
482	})
483}
484
485func TestAggregationUpdatedInput(t *testing.T) {
486	t.Parallel()
487
488	t.Run("patches merge in config order with later overriding", func(t *testing.T) {
489		t.Parallel()
490		agg := aggregate([]HookResult{
491			{Decision: DecisionAllow, UpdatedInput: `{"command":"first","keep":"me"}`},
492			{Decision: DecisionAllow, UpdatedInput: `{"command":"second"}`},
493		}, `{"command":"orig","timeout":60}`)
494		require.Equal(t, DecisionAllow, agg.Decision)
495		// command overridden by second patch; keep preserved from first
496		// patch; timeout preserved from original input.
497		require.JSONEq(t,
498			`{"command":"second","keep":"me","timeout":60}`,
499			agg.UpdatedInput,
500		)
501	})
502
503	t.Run("shallow: nested objects are replaced wholesale", func(t *testing.T) {
504		t.Parallel()
505		agg := aggregate([]HookResult{
506			{Decision: DecisionAllow, UpdatedInput: `{"env":{"FOO":"bar"}}`},
507		}, `{"env":{"BAZ":"qux"},"command":"ls"}`)
508		// "env" is replaced entirely; "command" preserved.
509		require.JSONEq(t,
510			`{"env":{"FOO":"bar"},"command":"ls"}`,
511			agg.UpdatedInput,
512		)
513	})
514
515	t.Run("deny still reports merged input (caller ignores it)", func(t *testing.T) {
516		t.Parallel()
517		agg := aggregate([]HookResult{
518			{Decision: DecisionAllow, UpdatedInput: `{"command":"rewritten"}`},
519			{Decision: DecisionDeny, Reason: "blocked"},
520		}, `{"command":"orig"}`)
521		require.Equal(t, DecisionDeny, agg.Decision)
522	})
523
524	t.Run("no patches leaves updated_input empty", func(t *testing.T) {
525		t.Parallel()
526		agg := aggregate([]HookResult{
527			{Decision: DecisionAllow},
528			{Decision: DecisionNone},
529		}, `{"command":"orig"}`)
530		require.Empty(t, agg.UpdatedInput)
531	})
532
533	t.Run("invalid patch is ignored", func(t *testing.T) {
534		t.Parallel()
535		agg := aggregate([]HookResult{
536			{Decision: DecisionAllow, UpdatedInput: `"not-an-object"`},
537			{Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
538		}, `{"command":"orig"}`)
539		require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
540	})
541
542	t.Run("malformed patch JSON is ignored and merge continues", func(t *testing.T) {
543		t.Parallel()
544		agg := aggregate([]HookResult{
545			{Decision: DecisionAllow, UpdatedInput: `{broken json`},
546			{Decision: DecisionAllow, UpdatedInput: `{"command":"good"}`},
547		}, `{"command":"orig"}`)
548		require.JSONEq(t, `{"command":"good"}`, agg.UpdatedInput)
549	})
550
551	t.Run("non-object tool_input rejects all patches", func(t *testing.T) {
552		t.Parallel()
553		agg := aggregate([]HookResult{
554			{Decision: DecisionAllow, UpdatedInput: `{"command":"rewrite"}`},
555		}, `"just-a-string"`)
556		require.Empty(t, agg.UpdatedInput)
557	})
558
559	t.Run("null updated_input is a no-op", func(t *testing.T) {
560		t.Parallel()
561		// parseStdout converts null updated_input to "", so aggregate
562		// never sees a patch — the merged input is empty and the
563		// original tool_input is used unchanged.
564		r := parseStdout(`{"decision":"allow","updated_input":null}`)
565		require.Empty(t, r.UpdatedInput)
566		agg := aggregate([]HookResult{r}, `{"command":"orig"}`)
567		require.Empty(t, agg.UpdatedInput)
568	})
569}
570
571func TestRunnerUpdatedInput(t *testing.T) {
572	t.Parallel()
573	hookCfg := config.HookConfig{
574		Command: `echo '{"decision":"allow","updated_input":{"command":"echo rewritten"}}'`,
575	}
576	r := NewRunner([]config.HookConfig{hookCfg}, t.TempDir(), t.TempDir())
577	result, err := r.Run(context.Background(), EventPreToolUse, "sess", "bash", `{"command":"echo original","timeout":60}`)
578	require.NoError(t, err)
579	require.Equal(t, DecisionAllow, result.Decision)
580	require.JSONEq(t,
581		`{"command":"echo rewritten","timeout":60}`,
582		result.UpdatedInput,
583	)
584}
585
586func TestParseStdoutClaudeCodeFormat(t *testing.T) {
587	t.Parallel()
588
589	t.Run("allow with reason", func(t *testing.T) {
590		t.Parallel()
591		r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","permissionDecisionReason":"RTK auto-rewrite"}}`)
592		require.Equal(t, DecisionAllow, r.Decision)
593		require.Equal(t, "RTK auto-rewrite", r.Reason)
594	})
595
596	t.Run("allow with updatedInput", func(t *testing.T) {
597		t.Parallel()
598		r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"allow","updatedInput":{"command":"rtk cat foo.go"}}}`)
599		require.Equal(t, DecisionAllow, r.Decision)
600		require.Equal(t, `{"command":"rtk cat foo.go"}`, r.UpdatedInput)
601	})
602
603	t.Run("deny", func(t *testing.T) {
604		t.Parallel()
605		r := parseStdout(`{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"not allowed"}}`)
606		require.Equal(t, DecisionDeny, r.Decision)
607		require.Equal(t, "not allowed", r.Reason)
608	})
609
610	t.Run("no permissionDecision", func(t *testing.T) {
611		t.Parallel()
612		r := parseStdout(`{"hookSpecificOutput":{}}`)
613		require.Equal(t, DecisionNone, r.Decision)
614	})
615
616	t.Run("crush format still works", func(t *testing.T) {
617		t.Parallel()
618		r := parseStdout(`{"decision":"allow","context":"hello"}`)
619		require.Equal(t, DecisionAllow, r.Decision)
620		require.Equal(t, "hello", r.Context)
621	})
622}