manager_test.go

  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}