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.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{
113			Prompt: "test prompt",
114		})
115
116		require.NoError(t, err)
117		assert.True(t, result.Continue)
118		// Contexts should be merged with \n\n separator.
119		assert.Equal(t, "Context from hook 1\n\nContext from hook 2", result.ContextContent)
120	})
121
122	t.Run("stops on first hook that sets continue=false", func(t *testing.T) {
123		tempDir := t.TempDir()
124		dataDir := filepath.Join(tempDir, ".crush")
125		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
126		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
127
128		// Hook 1: Denies.
129		hook1 := filepath.Join(hooksDir, "01-deny.sh")
130		err := os.WriteFile(hook1, []byte(`#!/bin/bash
131crush_deny "blocked"
132`), 0o755)
133		require.NoError(t, err)
134
135		// Hook 2: Should not execute.
136		hook2 := filepath.Join(hooksDir, "02-never-runs.sh")
137		err = os.WriteFile(hook2, []byte(`#!/bin/bash
138export CRUSH_MESSAGE="should not see this"
139`), 0o755)
140		require.NoError(t, err)
141
142		mgr := NewManager(tempDir, dataDir, nil)
143		ctx := context.Background()
144		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
145
146		require.NoError(t, err)
147		assert.False(t, result.Continue)
148		assert.Equal(t, "deny", result.Permission)
149		assert.Equal(t, "blocked", result.Message)
150		assert.NotContains(t, result.Message, "should not see this")
151	})
152
153	t.Run("merges permissions with deny winning", func(t *testing.T) {
154		tempDir := t.TempDir()
155		dataDir := filepath.Join(tempDir, ".crush")
156		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
157		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
158
159		// Hook 1: Approves.
160		hook1 := filepath.Join(hooksDir, "01-approve.sh")
161		err := os.WriteFile(hook1, []byte(`#!/bin/bash
162export CRUSH_PERMISSION=approve
163`), 0o755)
164		require.NoError(t, err)
165
166		// Hook 2: Denies (should win).
167		hook2 := filepath.Join(hooksDir, "02-deny.sh")
168		err = os.WriteFile(hook2, []byte(`#!/bin/bash
169export CRUSH_PERMISSION=deny
170`), 0o755)
171		require.NoError(t, err)
172
173		mgr := NewManager(tempDir, dataDir, nil)
174		ctx := context.Background()
175		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
176
177		require.NoError(t, err)
178		assert.Equal(t, "deny", result.Permission)
179	})
180
181	t.Run("disabled hooks are skipped", func(t *testing.T) {
182		tempDir := t.TempDir()
183		dataDir := filepath.Join(tempDir, ".crush")
184		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
185		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
186
187		// Hook 1: Should run.
188		hook1 := filepath.Join(hooksDir, "01-enabled.sh")
189		err := os.WriteFile(hook1, []byte(`#!/bin/bash
190export CRUSH_MESSAGE="enabled"
191`), 0o755)
192		require.NoError(t, err)
193
194		// Hook 2: Disabled.
195		hook2 := filepath.Join(hooksDir, "02-disabled.sh")
196		err = os.WriteFile(hook2, []byte(`#!/bin/bash
197export CRUSH_MESSAGE="disabled"
198`), 0o755)
199		require.NoError(t, err)
200
201		cfg := &Config{
202			TimeoutSeconds: 30,
203			Directories:    []string{filepath.Join(dataDir, "hooks")},
204			DisableHooks:   []string{"pre-tool-use/02-disabled.sh"},
205		}
206
207		mgr := NewManager(tempDir, dataDir, cfg)
208		ctx := context.Background()
209		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
210
211		require.NoError(t, err)
212		assert.Equal(t, "enabled", result.Message)
213	})
214
215	t.Run("inline hooks are executed", func(t *testing.T) {
216		tempDir := t.TempDir()
217		dataDir := filepath.Join(tempDir, ".crush")
218
219		cfg := &Config{
220			TimeoutSeconds: 30,
221			Directories:    []string{filepath.Join(dataDir, "hooks")},
222			Inline: map[string][]InlineHook{
223				"user-prompt-submit": {
224					{
225						Name: "inline-test.sh",
226						Script: `#!/bin/bash
227export CRUSH_MESSAGE="inline hook executed"
228`,
229					},
230				},
231			},
232		}
233
234		mgr := NewManager(tempDir, dataDir, cfg)
235		ctx := context.Background()
236		result, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{})
237
238		require.NoError(t, err)
239		assert.Equal(t, "inline hook executed", result.Message)
240	})
241
242	t.Run("returns empty result when hooks disabled", func(t *testing.T) {
243		tempDir := t.TempDir()
244		dataDir := filepath.Join(tempDir, ".crush")
245
246		cfg := &Config{}
247
248		mgr := NewManager(tempDir, dataDir, cfg)
249		ctx := context.Background()
250		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
251
252		require.NoError(t, err)
253		assert.True(t, result.Continue)
254		assert.Empty(t, result.Message)
255	})
256
257	t.Run("returns empty result when no hooks found", func(t *testing.T) {
258		tempDir := t.TempDir()
259		dataDir := filepath.Join(tempDir, ".crush")
260
261		mgr := NewManager(tempDir, dataDir, nil)
262		ctx := context.Background()
263		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
264
265		require.NoError(t, err)
266		assert.True(t, result.Continue)
267	})
268
269	t.Run("handles hook failure on PreToolUse by denying", func(t *testing.T) {
270		tempDir := t.TempDir()
271		dataDir := filepath.Join(tempDir, ".crush")
272		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
273		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
274
275		// Hook that fails with exit 1.
276		hook := filepath.Join(hooksDir, "01-fail.sh")
277		err := os.WriteFile(hook, []byte(`#!/bin/bash
278exit 1
279`), 0o755)
280		require.NoError(t, err)
281
282		mgr := NewManager(tempDir, dataDir, nil)
283		ctx := context.Background()
284		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
285
286		require.NoError(t, err)
287		assert.False(t, result.Continue)
288		assert.Equal(t, "deny", result.Permission)
289		assert.Contains(t, result.Message, "Hook failed")
290	})
291
292	t.Run("caches discovered hooks", func(t *testing.T) {
293		tempDir := t.TempDir()
294		dataDir := filepath.Join(tempDir, ".crush")
295		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
296		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
297
298		hook := filepath.Join(hooksDir, "01-test.sh")
299		err := os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755)
300		require.NoError(t, err)
301
302		mgr := NewManager(tempDir, dataDir, nil)
303
304		// First call - discovers.
305		hooks1 := mgr.ListHooks(HookPreToolUse)
306		assert.Len(t, hooks1, 1)
307
308		// Second call - should use cache.
309		hooks2 := mgr.ListHooks(HookPreToolUse)
310		assert.Equal(t, hooks1, hooks2)
311	})
312
313	t.Run("catch-all hooks at root execute for all types", func(t *testing.T) {
314		tempDir := t.TempDir()
315		dataDir := filepath.Join(tempDir, ".crush")
316		hooksDir := filepath.Join(dataDir, "hooks")
317		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
318
319		// Create catch-all hook at root level.
320		catchAllHook := filepath.Join(hooksDir, "00-catch-all.sh")
321		err := os.WriteFile(catchAllHook, []byte(`#!/bin/bash
322export CRUSH_MESSAGE="catch-all: $CRUSH_HOOK_TYPE"
323`), 0o755)
324		require.NoError(t, err)
325
326		// Create specific hook for pre-tool-use.
327		specificDir := filepath.Join(hooksDir, "pre-tool-use")
328		require.NoError(t, os.MkdirAll(specificDir, 0o755))
329		specificHook := filepath.Join(specificDir, "01-specific.sh")
330		err = os.WriteFile(specificHook, []byte(`#!/bin/bash
331export CRUSH_MESSAGE="$CRUSH_MESSAGE; specific hook"
332`), 0o755)
333		require.NoError(t, err)
334
335		mgr := NewManager(tempDir, dataDir, nil)
336
337		// Test PreToolUse - should execute both catch-all and specific.
338		ctx := context.Background()
339		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
340
341		require.NoError(t, err)
342		assert.Contains(t, result.Message, "catch-all: pre-tool-use")
343		assert.Contains(t, result.Message, "specific hook")
344
345		// Test UserPromptSubmit - should only execute catch-all.
346		result2, err := mgr.ExecuteUserPromptSubmit(ctx, "test", tempDir, UserPromptSubmitData{})
347
348		require.NoError(t, err)
349		assert.Equal(t, "catch-all: user-prompt-submit", result2.Message)
350		assert.NotContains(t, result2.Message, "specific hook")
351	})
352
353	t.Run("passes environment variables from config to hooks", func(t *testing.T) {
354		tempDir := t.TempDir()
355		dataDir := filepath.Join(tempDir, ".crush")
356		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
357		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
358
359		// Hook that checks for custom environment variables.
360		hook := filepath.Join(hooksDir, "01-check-env.sh")
361		err := os.WriteFile(hook, []byte(`#!/bin/bash
362if [ "$CUSTOM_API_KEY" = "test-key-123" ] && [ "$CUSTOM_ENV" = "production" ]; then
363  export CRUSH_MESSAGE="config environment variables received"
364else
365  export CRUSH_MESSAGE="environment variables missing"
366fi
367`), 0o755)
368		require.NoError(t, err)
369
370		cfg := &Config{
371			TimeoutSeconds: 30,
372			Directories:    []string{filepath.Join(dataDir, "hooks")},
373			Environment: map[string]string{
374				"CUSTOM_API_KEY": "test-key-123",
375				"CUSTOM_ENV":     "production",
376			},
377		}
378
379		mgr := NewManager(tempDir, dataDir, cfg)
380		ctx := context.Background()
381		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
382
383		require.NoError(t, err)
384		assert.Equal(t, "config environment variables received", result.Message)
385	})
386
387	t.Run("handles inline hook write failure gracefully", func(t *testing.T) {
388		tempDir := t.TempDir()
389		// Use a read-only directory as dataDir to force write failure.
390		readOnlyDir := filepath.Join(tempDir, "readonly")
391		require.NoError(t, os.MkdirAll(readOnlyDir, 0o555)) // Read-only
392
393		cfg := &Config{
394			TimeoutSeconds: 30,
395			Directories:    []string{filepath.Join(readOnlyDir, "hooks")},
396			Inline: map[string][]InlineHook{
397				"pre-tool-use": {
398					{
399						Name:   "inline-fail.sh",
400						Script: "#!/bin/bash\necho test",
401					},
402				},
403			},
404		}
405
406		mgr := NewManager(tempDir, readOnlyDir, cfg)
407		ctx := context.Background()
408
409		// Should not error even though inline hook write fails.
410		// The hook will be skipped and logged.
411		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
412
413		require.NoError(t, err)
414		assert.True(t, result.Continue) // Should continue despite write failure
415	})
416
417	t.Run("handles hooks directory read failure gracefully", func(t *testing.T) {
418		tempDir := t.TempDir()
419		dataDir := filepath.Join(tempDir, ".crush")
420		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
421		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
422
423		// Create a hook file.
424		hook := filepath.Join(hooksDir, "01-test.sh")
425		require.NoError(t, os.WriteFile(hook, []byte("#!/bin/bash\necho test"), 0o755))
426
427		mgr := NewManager(tempDir, dataDir, nil)
428
429		// Make directory unreadable after discovery to test error path.
430		// Note: This is tricky to test reliably cross-platform.
431		// On some systems, we can't make a directory unreadable if we own it.
432		// We'll test that hooks are cached and re-discovery works.
433		hooks1 := mgr.ListHooks(HookPreToolUse)
434		assert.Len(t, hooks1, 1)
435
436		// Add another hook.
437		hook2 := filepath.Join(hooksDir, "02-test.sh")
438		require.NoError(t, os.WriteFile(hook2, []byte("#!/bin/bash\necho test2"), 0o755))
439
440		// Should still return cached hooks (won't see new one).
441		hooks2 := mgr.ListHooks(HookPreToolUse)
442		assert.Len(t, hooks2, 1, "hooks are cached, new hook not seen")
443	})
444
445	t.Run("approve permission is set when accumulated is empty", func(t *testing.T) {
446		tempDir := t.TempDir()
447		dataDir := filepath.Join(tempDir, ".crush")
448		hooksDir := filepath.Join(dataDir, "hooks", "pre-tool-use")
449		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
450
451		// Single hook that approves.
452		hook := filepath.Join(hooksDir, "01-approve.sh")
453		require.NoError(t, os.WriteFile(hook, []byte(`#!/bin/bash
454export CRUSH_PERMISSION=approve
455export CRUSH_MESSAGE="auto-approved"
456`), 0o755))
457
458		mgr := NewManager(tempDir, dataDir, nil)
459		ctx := context.Background()
460		result, err := mgr.ExecutePreToolUse(ctx, "test", tempDir, PreToolUseData{})
461
462		require.NoError(t, err)
463		assert.Equal(t, "approve", result.Permission)
464		assert.Equal(t, "auto-approved", result.Message)
465	})
466}