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}