examples_test.go

  1package hooks
  2
  3import (
  4	"context"
  5	"os"
  6	"path/filepath"
  7	"strings"
  8	"testing"
  9
 10	"github.com/stretchr/testify/assert"
 11	"github.com/stretchr/testify/require"
 12)
 13
 14// TestReadmeExamples tests that all examples from the README work as documented.
 15func TestReadmeExamples(t *testing.T) {
 16	t.Parallel()
 17
 18	t.Run("block dangerous commands", func(t *testing.T) {
 19		t.Parallel()
 20		tempDir := t.TempDir()
 21		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
 22		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
 23
 24		hookScript := `#!/bin/bash
 25if [ "$CRUSH_TOOL_NAME" = "bash" ]; then
 26  COMMAND=$(crush_get_tool_input command)
 27  if [[ "$COMMAND" =~ "rm -rf /" ]]; then
 28    crush_deny "Blocked dangerous command"
 29  fi
 30fi
 31`
 32		hookPath := filepath.Join(hooksDir, "01-block-dangerous.sh")
 33		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
 34
 35		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 36
 37		// Test: Should block "rm -rf /"
 38		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 39			ToolName:   "bash",
 40			ToolCallID: "call-1",
 41			ToolInput: map[string]any{
 42				"command": "rm -rf /",
 43			},
 44		})
 45
 46		require.NoError(t, err)
 47		assert.False(t, result.Continue, "Should stop execution for dangerous command")
 48		assert.Equal(t, "deny", result.Permission)
 49		assert.Contains(t, result.Message, "Blocked dangerous command")
 50
 51		// Test: Should allow safe commands
 52		result2, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 53			ToolName:   "bash",
 54			ToolCallID: "call-2",
 55			ToolInput: map[string]any{
 56				"command": "ls -la",
 57			},
 58		})
 59
 60		require.NoError(t, err)
 61		assert.True(t, result2.Continue, "Should allow safe commands")
 62	})
 63
 64	t.Run("auto-approve read-only tools", func(t *testing.T) {
 65		t.Parallel()
 66		tempDir := t.TempDir()
 67		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
 68		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
 69
 70		hookScript := `#!/bin/bash
 71case "$CRUSH_TOOL_NAME" in
 72  view|ls|grep|glob)
 73    crush_approve "Auto-approved read-only tool"
 74    ;;
 75  bash)
 76    COMMAND=$(crush_get_tool_input command)
 77    if [[ "$COMMAND" =~ ^(ls|cat|grep) ]]; then
 78      crush_approve "Auto-approved safe bash command"
 79    fi
 80    ;;
 81esac
 82`
 83		hookPath := filepath.Join(hooksDir, "01-auto-approve.sh")
 84		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
 85
 86		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
 87
 88		// Test: Should auto-approve view tool
 89		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
 90			ToolName:   "view",
 91			ToolCallID: "call-1",
 92		})
 93
 94		require.NoError(t, err)
 95		assert.True(t, result.Continue)
 96		assert.Equal(t, "approve", result.Permission)
 97		assert.Contains(t, result.Message, "Auto-approved read-only tool")
 98
 99		// Test: Should auto-approve safe bash commands
100		result2, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
101			ToolName:   "bash",
102			ToolCallID: "call-2",
103			ToolInput: map[string]any{
104				"command": "ls -la",
105			},
106		})
107
108		require.NoError(t, err)
109		assert.True(t, result2.Continue)
110		assert.Equal(t, "approve", result2.Permission)
111		assert.Contains(t, result2.Message, "Auto-approved safe bash command")
112	})
113
114	t.Run("add git context", func(t *testing.T) {
115		t.Parallel()
116		tempDir := t.TempDir()
117		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
118		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
119
120		// Initialize git repo with a branch
121		gitDir := filepath.Join(tempDir, ".git")
122		require.NoError(t, os.MkdirAll(gitDir, 0o755))
123		require.NoError(t, os.WriteFile(filepath.Join(gitDir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644))
124
125		hookScript := `#!/bin/bash
126BRANCH=$(git branch --show-current 2>/dev/null)
127if [ -n "$BRANCH" ]; then
128  crush_add_context "Current branch: $BRANCH"
129fi
130
131if [ -f "README.md" ]; then
132  crush_add_context_file "README.md"
133fi
134`
135		hookPath := filepath.Join(hooksDir, "01-add-context.sh")
136		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
137
138		// Create README.md
139		readmePath := filepath.Join(tempDir, "README.md")
140		require.NoError(t, os.WriteFile(readmePath, []byte("# Test Project\n"), 0o644))
141
142		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
143
144		result, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
145			Prompt: "help me",
146		})
147
148		require.NoError(t, err)
149		assert.True(t, result.Continue)
150		// Should add context file (using relative path)
151		require.Len(t, result.ContextFiles, 1)
152		assert.Equal(t, "README.md", result.ContextFiles[0])
153	})
154
155	t.Run("audit logging", func(t *testing.T) {
156		t.Parallel()
157		tempDir := t.TempDir()
158		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "post-tool-use")
159		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
160
161		auditFile := filepath.Join(tempDir, "audit.log")
162		hookScript := `#!/bin/bash
163AUDIT_FILE="` + auditFile + `"
164TIMESTAMP=$(date -Iseconds)
165echo "$TIMESTAMP|$CRUSH_TOOL_NAME|$CRUSH_TOOL_CALL_ID" >> "$AUDIT_FILE"
166`
167		hookPath := filepath.Join(hooksDir, "01-audit.sh")
168		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
169
170		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
171
172		result, err := manager.ExecutePostToolUse(context.Background(), "test", tempDir, PostToolUseData{
173			ToolName:   "bash",
174			ToolCallID: "call-123",
175		})
176
177		require.NoError(t, err)
178		assert.True(t, result.Continue)
179
180		// Verify audit log was written
181		content, err := os.ReadFile(auditFile)
182		require.NoError(t, err)
183		assert.Contains(t, string(content), "bash|call-123")
184	})
185
186	t.Run("catch-all hook", func(t *testing.T) {
187		t.Parallel()
188		tempDir := t.TempDir()
189		hooksDir := filepath.Join(tempDir, ".crush", "hooks")
190		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
191
192		logFile := filepath.Join(tempDir, "global.log")
193		hookScript := `#!/bin/bash
194echo "Hook: $CRUSH_HOOK_TYPE" >> "` + logFile + `"
195`
196		hookPath := filepath.Join(hooksDir, "00-global-log.sh")
197		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
198
199		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
200
201		// Test with different hook types
202		_, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
203		require.NoError(t, err)
204
205		_, err = manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{})
206		require.NoError(t, err)
207
208		// Verify both hook types were logged
209		content, err := os.ReadFile(logFile)
210		require.NoError(t, err)
211		assert.Contains(t, string(content), "Hook: pre-tool-use")
212		assert.Contains(t, string(content), "Hook: user-prompt-submit")
213	})
214
215	t.Run("rate limiting", func(t *testing.T) {
216		t.Parallel()
217		tempDir := t.TempDir()
218		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
219		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
220
221		usageLog := filepath.Join(tempDir, "usage.log")
222		// Pre-populate with entries
223		today := "2024-01-15" // Fixed date for testing
224		for i := 0; i < 5; i++ {
225			f, err := os.OpenFile(usageLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
226			require.NoError(t, err)
227			_, err = f.WriteString(today + "\n")
228			require.NoError(t, err)
229			f.Close()
230		}
231
232		hookScript := `#!/bin/bash
233COUNT=$(grep -c "2024-01-15" "` + usageLog + `" 2>/dev/null || echo "0")
234if [ "$COUNT" -ge 3 ]; then
235  export CRUSH_CONTINUE=false
236  export CRUSH_MESSAGE="Rate limit exceeded"
237fi
238`
239		hookPath := filepath.Join(hooksDir, "01-rate-limit.sh")
240		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
241
242		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
243
244		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
245
246		require.NoError(t, err)
247		assert.False(t, result.Continue, "Should stop execution when rate limit exceeded")
248		assert.Contains(t, result.Message, "Rate limit exceeded")
249	})
250
251	t.Run("conditional context", func(t *testing.T) {
252		t.Parallel()
253		tempDir := t.TempDir()
254		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
255		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
256
257		// Create package.json
258		packageJSON := filepath.Join(tempDir, "package.json")
259		require.NoError(t, os.WriteFile(packageJSON, []byte(`{"name": "test"}`), 0o644))
260
261		hookScript := `#!/bin/bash
262if [ -f "package.json" ]; then
263  crush_add_context_file "package.json"
264fi
265`
266		hookPath := filepath.Join(hooksDir, "01-conditional.sh")
267		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
268
269		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
270
271		result, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{})
272
273		require.NoError(t, err)
274		assert.True(t, result.Continue)
275		require.Len(t, result.ContextFiles, 1)
276		assert.Equal(t, "package.json", result.ContextFiles[0])
277	})
278
279	t.Run("JSON output example", func(t *testing.T) {
280		t.Parallel()
281		tempDir := t.TempDir()
282		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
283		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
284
285		hookScript := `#!/bin/bash
286COMMAND=$(crush_get_tool_input command)
287SAFE_CMD=$(echo "$COMMAND" | sed 's/--force//')
288echo "{\"modified_input\": {\"command\": \"$SAFE_CMD\"}}"
289`
290		hookPath := filepath.Join(hooksDir, "01-modify.sh")
291		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
292
293		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
294
295		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{
296			ToolName:   "bash",
297			ToolCallID: "call-1",
298			ToolInput: map[string]any{
299				"command": "rm --force file.txt",
300			},
301		})
302
303		require.NoError(t, err)
304		assert.True(t, result.Continue)
305		require.NotNil(t, result.ModifiedInput)
306		assert.Equal(t, "rm  file.txt", result.ModifiedInput["command"])
307	})
308
309	t.Run("environment variables example", func(t *testing.T) {
310		t.Parallel()
311		tempDir := t.TempDir()
312		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
313		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
314
315		hookScript := `#!/bin/bash
316export CRUSH_PERMISSION=approve
317export CRUSH_MESSAGE="Auto-approved"
318`
319		hookPath := filepath.Join(hooksDir, "01-env-vars.sh")
320		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
321
322		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
323
324		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
325
326		require.NoError(t, err)
327		assert.True(t, result.Continue)
328		assert.Equal(t, "approve", result.Permission)
329		assert.Equal(t, "Auto-approved", result.Message)
330	})
331
332	t.Run("exit codes example", func(t *testing.T) {
333		t.Parallel()
334		tempDir := t.TempDir()
335		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
336		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
337
338		usageLog := filepath.Join(tempDir, "usage.log")
339		// Create usage log with entries
340		for i := 0; i < 150; i++ {
341			f, err := os.OpenFile(usageLog, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
342			require.NoError(t, err)
343			_, err = f.WriteString("2024-01-15\n")
344			require.NoError(t, err)
345			f.Close()
346		}
347
348		hookScript := `#!/bin/bash
349COUNT=$(grep -c "2024-01-15" "` + usageLog + `")
350if [ "$COUNT" -gt 100 ]; then
351  echo "Rate limit exceeded" >&2
352  exit 2  # Stops execution
353fi
354`
355		hookPath := filepath.Join(hooksDir, "01-exit-code.sh")
356		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
357
358		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
359
360		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
361
362		require.NoError(t, err)
363		assert.False(t, result.Continue, "Exit code 2 should stop execution")
364	})
365
366	t.Run("helper functions comprehensive test", func(t *testing.T) {
367		t.Parallel()
368		tempDir := t.TempDir()
369		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
370		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
371
372		// Test all helper functions in one hook
373		hookScript := `#!/bin/bash
374# Read stdin once into variable
375CONTEXT=$(cat)
376
377# Test input parsing
378PROMPT=$(echo "$CONTEXT" | crush_get_prompt)
379MODEL=$(echo "$CONTEXT" | crush_get_input model)
380
381# Test context helpers
382crush_add_context "Using model: $MODEL"
383
384# Test logging
385crush_log "Processing prompt"
386
387# Test modification
388export CRUSH_MODIFIED_PROMPT="Enhanced: $PROMPT"
389`
390		hookPath := filepath.Join(hooksDir, "01-helpers.sh")
391		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
392
393		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
394
395		result, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
396			Prompt: "original prompt",
397			Model:  "gpt-4",
398		})
399
400		require.NoError(t, err)
401		assert.True(t, result.Continue)
402		assert.Contains(t, result.ContextContent, "Using model: gpt-4")
403		require.NotNil(t, result.ModifiedPrompt)
404		// Trim any trailing whitespace/CRLF for cross-platform compatibility
405		assert.Equal(t, "Enhanced: original prompt", strings.TrimSpace(*result.ModifiedPrompt))
406	})
407
408	t.Run("is_first_message flag", func(t *testing.T) {
409		t.Parallel()
410		tempDir := t.TempDir()
411		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "user-prompt-submit")
412		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
413
414		// Hook that adds README only on first message
415		hookScript := `#!/bin/bash
416IS_FIRST=$(crush_get_input is_first_message)
417if [ "$IS_FIRST" = "true" ]; then
418  crush_add_context "This is the first message"
419else
420  crush_add_context "This is a follow-up message"
421fi
422`
423		hookPath := filepath.Join(hooksDir, "01-first-msg.sh")
424		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
425
426		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
427
428		// Test: First message
429		result1, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
430			Prompt:         "first prompt",
431			IsFirstMessage: true,
432		})
433		require.NoError(t, err)
434		assert.Contains(t, result1.ContextContent, "This is the first message")
435
436		// Test: Follow-up message
437		result2, err := manager.ExecuteUserPromptSubmit(context.Background(), "test", tempDir, UserPromptSubmitData{
438			Prompt:         "follow-up prompt",
439			IsFirstMessage: false,
440		})
441		require.NoError(t, err)
442		assert.Contains(t, result2.ContextContent, "This is a follow-up message")
443	})
444}
445
446// TestReadmeQuickExamples tests the quick examples from the quick reference.
447func TestReadmeQuickExamples(t *testing.T) {
448	t.Parallel()
449
450	t.Run("hook ordering", func(t *testing.T) {
451		t.Parallel()
452		tempDir := t.TempDir()
453		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
454		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
455
456		// Create hooks with specific order
457		hook1 := `#!/bin/bash
458export CRUSH_MESSAGE="first"
459`
460		hook2 := `#!/bin/bash
461export CRUSH_MESSAGE="${CRUSH_MESSAGE:-}; second"
462`
463		hook3 := `#!/bin/bash
464export CRUSH_MESSAGE="${CRUSH_MESSAGE:-}; third"
465`
466
467		require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "01-first.sh"), []byte(hook1), 0o755))
468		require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "02-second.sh"), []byte(hook2), 0o755))
469		require.NoError(t, os.WriteFile(filepath.Join(hooksDir, "99-third.sh"), []byte(hook3), 0o755))
470
471		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
472
473		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
474
475		require.NoError(t, err)
476		// Messages should be merged in order
477		assert.Contains(t, result.Message, "first")
478		assert.Contains(t, result.Message, "second")
479		assert.Contains(t, result.Message, "third")
480	})
481
482	t.Run("mixed env vars and JSON", func(t *testing.T) {
483		t.Parallel()
484		tempDir := t.TempDir()
485		hooksDir := filepath.Join(tempDir, ".crush", "hooks", "pre-tool-use")
486		require.NoError(t, os.MkdirAll(hooksDir, 0o755))
487
488		hookScript := `#!/bin/bash
489# Set via environment variable
490export CRUSH_PERMISSION=approve
491
492# Output via JSON
493echo '{"message": "Combined output", "modified_input": {"key": "value"}}'
494`
495		hookPath := filepath.Join(hooksDir, "01-mixed.sh")
496		require.NoError(t, os.WriteFile(hookPath, []byte(hookScript), 0o755))
497
498		manager := NewManager(tempDir, filepath.Join(tempDir, ".crush"), nil)
499
500		result, err := manager.ExecutePreToolUse(context.Background(), "test", tempDir, PreToolUseData{})
501
502		require.NoError(t, err)
503		assert.True(t, result.Continue)
504		assert.Equal(t, "approve", result.Permission)
505		assert.Equal(t, "Combined output", result.Message)
506		assert.Equal(t, "value", result.ModifiedInput["key"])
507	})
508}