1package hooks
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12)
13
14func TestManager(t *testing.T) {
15 t.Run("discovers hooks in order", func(t *testing.T) {
16 tempDir := t.TempDir()
17 dataDir := filepath.Join(tempDir, ".crush")
18 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
19 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
20
21 // Create hooks with numeric prefixes.
22 hooks := []string{"02-second.sh", "01-first.sh", "03-third.sh"}
23 for _, name := range hooks {
24 path := filepath.Join(hooksDir, name)
25 err := os.WriteFile(path, []byte("#!/bin/bash\necho test"), 0o755)
26 require.NoError(t, err)
27 }
28
29 mgr := NewManager(tempDir, dataDir, nil)
30 discovered := mgr.ListHooks(HookPreToolUse)
31
32 assert.Len(t, discovered, 3)
33 // Should be sorted alphabetically.
34 assert.Contains(t, discovered[0], "01-first.sh")
35 assert.Contains(t, discovered[1], "02-second.sh")
36 assert.Contains(t, discovered[2], "03-third.sh")
37 })
38
39 t.Run("skips non-executable files", func(t *testing.T) {
40 tempDir := t.TempDir()
41 dataDir := filepath.Join(tempDir, ".crush")
42 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
43 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
44
45 // Create non-executable file.
46 path := filepath.Join(hooksDir, "non-executable.sh")
47 err := os.WriteFile(path, []byte("#!/bin/bash\necho test"), 0o644)
48 require.NoError(t, err)
49
50 mgr := NewManager(tempDir, dataDir, nil)
51 discovered := mgr.ListHooks(HookPreToolUse)
52
53 // On Windows, .sh files are always considered executable
54 // On Unix, non-executable files (0o644) should be skipped
55 if runtime.GOOS == "windows" {
56 assert.Len(t, discovered, 1, "On Windows, .sh files are executable regardless of permissions")
57 } else {
58 assert.Len(t, discovered, 0, "On Unix, non-executable files should be skipped")
59 }
60 })
61
62 t.Run("discovers hooks by extension on all platforms", func(t *testing.T) {
63 tempDir := t.TempDir()
64 dataDir := filepath.Join(tempDir, ".crush")
65 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
66 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
67
68 // Only .sh files are recognized as hooks.
69 // On Unix, they need execute permission. On Windows, extension is enough.
70 validHook := filepath.Join(hooksDir, "valid-hook.sh")
71 err := os.WriteFile(validHook, []byte("#!/bin/bash\necho test"), 0o755)
72 require.NoError(t, err)
73
74 // These should NOT be discovered (wrong extensions).
75 invalidFiles := []string{"hook.bat", "hook.cmd", "hook.ps1", "hook.txt"}
76 for _, name := range invalidFiles {
77 path := filepath.Join(hooksDir, name)
78 err := os.WriteFile(path, []byte("echo test"), 0o755)
79 require.NoError(t, err)
80 }
81
82 mgr := NewManager(tempDir, dataDir, nil)
83 discovered := mgr.ListHooks(HookPreToolUse)
84
85 // Only the .sh file should be discovered.
86 assert.Len(t, discovered, 1)
87 assert.Contains(t, discovered[0], "valid-hook.sh")
88 })
89
90 t.Run("executes multiple hooks and merges results", func(t *testing.T) {
91 tempDir := t.TempDir()
92 dataDir := filepath.Join(tempDir, ".crush")
93 hooksDir := filepath.Join(dataDir, "hooks", "user-prompt-submit")
94 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
95
96 // Hook 1: Adds context.
97 hook1 := filepath.Join(hooksDir, "01-add-context.sh")
98 err := os.WriteFile(hook1, []byte(`#!/bin/bash
99crush_add_context "Context from hook 1"
100`), 0o755)
101 require.NoError(t, err)
102
103 // Hook 2: Adds more context.
104 hook2 := filepath.Join(hooksDir, "02-add-more.sh")
105 err = os.WriteFile(hook2, []byte(`#!/bin/bash
106crush_add_context "Context from hook 2"
107`), 0o755)
108 require.NoError(t, err)
109
110 mgr := NewManager(tempDir, dataDir, nil)
111 ctx := context.Background()
112 result, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
113 SessionID: "test",
114 WorkingDir: tempDir,
115 Data: map[string]any{
116 "prompt": "test prompt",
117 },
118 })
119
120 require.NoError(t, err)
121 assert.True(t, result.Continue)
122 // Contexts should be merged with \n\n separator.
123 assert.Equal(t, "Context from hook 1\n\nContext from hook 2", result.ContextContent)
124 })
125
126 t.Run("stops on first hook that sets continue=false", func(t *testing.T) {
127 tempDir := t.TempDir()
128 dataDir := filepath.Join(tempDir, ".crush")
129 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
130 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
131
132 // Hook 1: Denies.
133 hook1 := filepath.Join(hooksDir, "01-deny.sh")
134 err := os.WriteFile(hook1, []byte(`#!/bin/bash
135crush_deny "blocked"
136`), 0o755)
137 require.NoError(t, err)
138
139 // Hook 2: Should not execute.
140 hook2 := filepath.Join(hooksDir, "02-never-runs.sh")
141 err = os.WriteFile(hook2, []byte(`#!/bin/bash
142export CRUSH_MESSAGE="should not see this"
143`), 0o755)
144 require.NoError(t, err)
145
146 mgr := NewManager(tempDir, dataDir, nil)
147 ctx := context.Background()
148 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
149 SessionID: "test",
150 WorkingDir: tempDir,
151 Data: map[string]any{},
152 })
153
154 require.NoError(t, err)
155 assert.False(t, result.Continue)
156 assert.Equal(t, "deny", result.Permission)
157 assert.Equal(t, "blocked", result.Message)
158 assert.NotContains(t, result.Message, "should not see this")
159 })
160
161 t.Run("merges permissions with deny winning", func(t *testing.T) {
162 tempDir := t.TempDir()
163 dataDir := filepath.Join(tempDir, ".crush")
164 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
165 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
166
167 // Hook 1: Approves.
168 hook1 := filepath.Join(hooksDir, "01-approve.sh")
169 err := os.WriteFile(hook1, []byte(`#!/bin/bash
170export CRUSH_PERMISSION=approve
171`), 0o755)
172 require.NoError(t, err)
173
174 // Hook 2: Denies (should win).
175 hook2 := filepath.Join(hooksDir, "02-deny.sh")
176 err = os.WriteFile(hook2, []byte(`#!/bin/bash
177export CRUSH_PERMISSION=deny
178`), 0o755)
179 require.NoError(t, err)
180
181 mgr := NewManager(tempDir, dataDir, nil)
182 ctx := context.Background()
183 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
184 SessionID: "test",
185 WorkingDir: tempDir,
186 Data: map[string]any{},
187 })
188
189 require.NoError(t, err)
190 assert.Equal(t, "deny", result.Permission)
191 })
192
193 t.Run("disabled hooks are skipped", func(t *testing.T) {
194 tempDir := t.TempDir()
195 dataDir := filepath.Join(tempDir, ".crush")
196 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
197 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
198
199 // Hook 1: Should run.
200 hook1 := filepath.Join(hooksDir, "01-enabled.sh")
201 err := os.WriteFile(hook1, []byte(`#!/bin/bash
202export CRUSH_MESSAGE="enabled"
203`), 0o755)
204 require.NoError(t, err)
205
206 // Hook 2: Disabled.
207 hook2 := filepath.Join(hooksDir, "02-disabled.sh")
208 err = os.WriteFile(hook2, []byte(`#!/bin/bash
209export CRUSH_MESSAGE="disabled"
210`), 0o755)
211 require.NoError(t, err)
212
213 cfg := &Config{
214 Enabled: true,
215 TimeoutSeconds: 30,
216 Directories: []string{filepath.Join(dataDir, "hooks")},
217 Disabled: []string{"pre-tool-use/02-disabled.sh"},
218 }
219
220 mgr := NewManager(tempDir, dataDir, cfg)
221 ctx := context.Background()
222 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
223 SessionID: "test",
224 WorkingDir: tempDir,
225 Data: map[string]any{},
226 })
227
228 require.NoError(t, err)
229 assert.Equal(t, "enabled", result.Message)
230 })
231
232 t.Run("inline hooks are executed", func(t *testing.T) {
233 tempDir := t.TempDir()
234 dataDir := filepath.Join(tempDir, ".crush")
235
236 cfg := &Config{
237 Enabled: true,
238 TimeoutSeconds: 30,
239 Directories: []string{filepath.Join(dataDir, "hooks")},
240 Inline: map[string][]InlineHook{
241 "user-prompt-submit": {
242 {
243 Name: "inline-test.sh",
244 Script: `#!/bin/bash
245export CRUSH_MESSAGE="inline hook executed"
246`,
247 },
248 },
249 },
250 }
251
252 mgr := NewManager(tempDir, dataDir, cfg)
253 ctx := context.Background()
254 result, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
255 SessionID: "test",
256 WorkingDir: tempDir,
257 Data: map[string]any{},
258 })
259
260 require.NoError(t, err)
261 assert.Equal(t, "inline hook executed", result.Message)
262 })
263
264 t.Run("returns empty result when hooks disabled", func(t *testing.T) {
265 tempDir := t.TempDir()
266 dataDir := filepath.Join(tempDir, ".crush")
267
268 cfg := &Config{
269 Enabled: false,
270 }
271
272 mgr := NewManager(tempDir, dataDir, cfg)
273 ctx := context.Background()
274 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
275 SessionID: "test",
276 WorkingDir: tempDir,
277 Data: map[string]any{},
278 })
279
280 require.NoError(t, err)
281 assert.True(t, result.Continue)
282 assert.Empty(t, result.Message)
283 })
284
285 t.Run("returns empty result when no hooks found", func(t *testing.T) {
286 tempDir := t.TempDir()
287 dataDir := filepath.Join(tempDir, ".crush")
288
289 mgr := NewManager(tempDir, dataDir, nil)
290 ctx := context.Background()
291 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
292 SessionID: "test",
293 WorkingDir: tempDir,
294 Data: map[string]any{},
295 })
296
297 require.NoError(t, err)
298 assert.True(t, result.Continue)
299 })
300
301 t.Run("handles hook failure on PreToolUse by denying", func(t *testing.T) {
302 tempDir := t.TempDir()
303 dataDir := filepath.Join(tempDir, ".crush")
304 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
305 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
306
307 // Hook that fails with exit 1.
308 hook := filepath.Join(hooksDir, "01-fail.sh")
309 err := os.WriteFile(hook, []byte(`#!/bin/bash
310exit 1
311`), 0o755)
312 require.NoError(t, err)
313
314 mgr := NewManager(tempDir, dataDir, nil)
315 ctx := context.Background()
316 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
317 SessionID: "test",
318 WorkingDir: tempDir,
319 Data: map[string]any{},
320 })
321
322 require.NoError(t, err)
323 assert.False(t, result.Continue)
324 assert.Equal(t, "deny", result.Permission)
325 assert.Contains(t, result.Message, "Hook failed")
326 })
327
328 t.Run("caches discovered hooks", func(t *testing.T) {
329 tempDir := t.TempDir()
330 dataDir := filepath.Join(tempDir, ".crush")
331 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
332 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
333
334 hook := filepath.Join(hooksDir, "01-test.sh")
335 err := os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755)
336 require.NoError(t, err)
337
338 mgr := NewManager(tempDir, dataDir, nil)
339
340 // First call - discovers.
341 hooks1 := mgr.ListHooks(HookPreToolUse)
342 assert.Len(t, hooks1, 1)
343
344 // Second call - should use cache.
345 hooks2 := mgr.ListHooks(HookPreToolUse)
346 assert.Equal(t, hooks1, hooks2)
347 })
348
349 t.Run("catch-all hooks at root execute for all types", func(t *testing.T) {
350 tempDir := t.TempDir()
351 dataDir := filepath.Join(tempDir, ".crush")
352 hooksDir := filepath.Join(dataDir, "hooks")
353 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
354
355 // Create catch-all hook at root level.
356 catchAllHook := filepath.Join(hooksDir, "00-catch-all.sh")
357 err := os.WriteFile(catchAllHook, []byte(`#!/bin/bash
358export CRUSH_MESSAGE="catch-all: $CRUSH_HOOK_TYPE"
359`), 0o755)
360 require.NoError(t, err)
361
362 // Create specific hook for pre-tool-use.
363 specificDir := filepath.Join(hooksDir, "pre-tool-use")
364 require.NoError(t, os.MkdirAll(specificDir, 0o755))
365 specificHook := filepath.Join(specificDir, "01-specific.sh")
366 err = os.WriteFile(specificHook, []byte(`#!/bin/bash
367export CRUSH_MESSAGE="$CRUSH_MESSAGE; specific hook"
368`), 0o755)
369 require.NoError(t, err)
370
371 mgr := NewManager(tempDir, dataDir, nil)
372
373 // Test PreToolUse - should execute both catch-all and specific.
374 ctx := context.Background()
375 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
376 SessionID: "test",
377 WorkingDir: tempDir,
378 Data: map[string]any{},
379 })
380
381 require.NoError(t, err)
382 assert.Contains(t, result.Message, "catch-all: pre-tool-use")
383 assert.Contains(t, result.Message, "specific hook")
384
385 // Test UserPromptSubmit - should only execute catch-all.
386 result2, err := mgr.ExecuteHooks(ctx, HookUserPromptSubmit, HookContext{
387 SessionID: "test",
388 WorkingDir: tempDir,
389 Data: map[string]any{},
390 })
391
392 require.NoError(t, err)
393 assert.Equal(t, "catch-all: user-prompt-submit", result2.Message)
394 assert.NotContains(t, result2.Message, "specific hook")
395 })
396
397 t.Run("passes environment variables from config to hooks", func(t *testing.T) {
398 tempDir := t.TempDir()
399 dataDir := filepath.Join(tempDir, ".crush")
400 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
401 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
402
403 // Hook that checks for custom environment variables.
404 hook := filepath.Join(hooksDir, "01-check-env.sh")
405 err := os.WriteFile(hook, []byte(`#!/bin/bash
406if [ "$CUSTOM_API_KEY" = "test-key-123" ] && [ "$CUSTOM_ENV" = "production" ]; then
407 export CRUSH_MESSAGE="config environment variables received"
408else
409 export CRUSH_MESSAGE="environment variables missing"
410fi
411`), 0o755)
412 require.NoError(t, err)
413
414 cfg := &Config{
415 Enabled: true,
416 TimeoutSeconds: 30,
417 Directories: []string{filepath.Join(dataDir, "hooks")},
418 Environment: map[string]string{
419 "CUSTOM_API_KEY": "test-key-123",
420 "CUSTOM_ENV": "production",
421 },
422 }
423
424 mgr := NewManager(tempDir, dataDir, cfg)
425 ctx := context.Background()
426 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
427 SessionID: "test",
428 WorkingDir: tempDir,
429 Data: map[string]any{},
430 })
431
432 require.NoError(t, err)
433 assert.Equal(t, "config environment variables received", result.Message)
434 })
435
436 t.Run("handles inline hook write failure gracefully", func(t *testing.T) {
437 tempDir := t.TempDir()
438 // Use a read-only directory as dataDir to force write failure.
439 readOnlyDir := filepath.Join(tempDir, "readonly")
440 require.NoError(t, os.MkdirAll(readOnlyDir, 0o555)) // Read-only
441
442 cfg := &Config{
443 Enabled: true,
444 TimeoutSeconds: 30,
445 Directories: []string{filepath.Join(readOnlyDir, "hooks")},
446 Inline: map[string][]InlineHook{
447 "pre-tool-use": {
448 {
449 Name: "inline-fail.sh",
450 Script: "#!/bin/bash\necho test",
451 },
452 },
453 },
454 }
455
456 mgr := NewManager(tempDir, readOnlyDir, cfg)
457 ctx := context.Background()
458
459 // Should not error even though inline hook write fails.
460 // The hook will be skipped and logged.
461 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
462 SessionID: "test",
463 WorkingDir: tempDir,
464 Data: map[string]any{},
465 })
466
467 require.NoError(t, err)
468 assert.True(t, result.Continue) // Should continue despite write failure
469 })
470
471 t.Run("handles hooks directory read failure gracefully", func(t *testing.T) {
472 tempDir := t.TempDir()
473 dataDir := filepath.Join(tempDir, ".crush")
474 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
475 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
476
477 // Create a hook file.
478 hook := filepath.Join(hooksDir, "01-test.sh")
479 require.NoError(t, os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755))
480
481 mgr := NewManager(tempDir, dataDir, nil)
482
483 // Make directory unreadable after discovery to test error path.
484 // Note: This is tricky to test reliably cross-platform.
485 // On some systems, we can't make a directory unreadable if we own it.
486 // We'll test that hooks are cached and re-discovery works.
487 hooks1 := mgr.ListHooks(HookPreToolUse)
488 assert.Len(t, hooks1, 1)
489
490 // Add another hook.
491 hook2 := filepath.Join(hooksDir, "02-test.sh")
492 require.NoError(t, os.WriteFile(hook2, []byte("#!/bin/bash\necho test2"), 0o755))
493
494 // Should still return cached hooks (won't see new one).
495 hooks2 := mgr.ListHooks(HookPreToolUse)
496 assert.Len(t, hooks2, 1, "hooks are cached, new hook not seen")
497 })
498
499 t.Run("approve permission is set when accumulated is empty", func(t *testing.T) {
500 tempDir := t.TempDir()
501 dataDir := filepath.Join(tempDir, ".crush")
502 hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
503 require.NoError(t, os.MkdirAll(hooksDir, 0o755))
504
505 // Single hook that approves.
506 hook := filepath.Join(hooksDir, "01-approve.sh")
507 require.NoError(t, os.WriteFile(hook, []byte(`#!/bin/bash
508export CRUSH_PERMISSION=approve
509export CRUSH_MESSAGE="auto-approved"
510`), 0o755))
511
512 mgr := NewManager(tempDir, dataDir, nil)
513 ctx := context.Background()
514 result, err := mgr.ExecuteHooks(ctx, HookPreToolUse, HookContext{
515 SessionID: "test",
516 WorkingDir: tempDir,
517 Data: map[string]any{},
518 })
519
520 require.NoError(t, err)
521 assert.Equal(t, "approve", result.Permission)
522 assert.Equal(t, "auto-approved", result.Message)
523 })
524}