executor_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. package hooks
  2. import (
  3. "context"
  4. "os"
  5. "path/filepath"
  6. "testing"
  7. "github.com/stretchr/testify/assert"
  8. "github.com/stretchr/testify/require"
  9. )
  10. func TestExecutor(t *testing.T) {
  11. // Create temp directory for test hooks.
  12. tempDir := t.TempDir()
  13. t.Run("executes simple hook with env vars", func(t *testing.T) {
  14. hookPath := filepath.Join(tempDir, "test-hook.sh")
  15. hookScript := `#!/bin/bash
  16. export CRUSH_PERMISSION=approve
  17. export CRUSH_MESSAGE="test message"
  18. `
  19. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  20. require.NoError(t, err)
  21. executor := NewExecutor(tempDir)
  22. ctx := context.Background()
  23. hookCtx := HookContext{
  24. HookType: HookPreToolUse,
  25. SessionID: "test-session",
  26. WorkingDir: tempDir,
  27. Data: map[string]any{
  28. "tool_input": map[string]any{
  29. "command": "ls",
  30. },
  31. },
  32. }
  33. result, err := executor.Execute(ctx, hookPath, hookCtx)
  34. require.NoError(t, err)
  35. assert.True(t, result.Continue)
  36. assert.Equal(t, "approve", result.Permission)
  37. assert.Equal(t, "test message", result.Message)
  38. })
  39. t.Run("helper functions are available", func(t *testing.T) {
  40. hookPath := filepath.Join(tempDir, "helper-test.sh")
  41. hookScript := `#!/bin/bash
  42. crush_approve "auto approved"
  43. `
  44. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  45. require.NoError(t, err)
  46. executor := NewExecutor(tempDir)
  47. ctx := context.Background()
  48. hookCtx := HookContext{
  49. HookType: HookPreToolUse,
  50. SessionID: "test-session",
  51. WorkingDir: tempDir,
  52. Data: map[string]any{},
  53. }
  54. result, err := executor.Execute(ctx, hookPath, hookCtx)
  55. require.NoError(t, err)
  56. assert.Equal(t, "approve", result.Permission)
  57. assert.Equal(t, "auto approved", result.Message)
  58. })
  59. t.Run("crush_deny sets continue=false and exits", func(t *testing.T) {
  60. hookPath := filepath.Join(tempDir, "deny-test.sh")
  61. hookScript := `#!/bin/bash
  62. crush_deny "blocked"
  63. `
  64. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  65. require.NoError(t, err)
  66. executor := NewExecutor(tempDir)
  67. ctx := context.Background()
  68. hookCtx := HookContext{
  69. HookType: HookPreToolUse,
  70. SessionID: "test-session",
  71. WorkingDir: tempDir,
  72. Data: map[string]any{},
  73. }
  74. result, err := executor.Execute(ctx, hookPath, hookCtx)
  75. require.NoError(t, err)
  76. assert.False(t, result.Continue)
  77. assert.Equal(t, "deny", result.Permission)
  78. assert.Equal(t, "blocked", result.Message)
  79. })
  80. t.Run("reads JSON from stdin", func(t *testing.T) {
  81. hookPath := filepath.Join(tempDir, "stdin-test.sh")
  82. hookScript := `#!/bin/bash
  83. COMMAND=$(crush_get_tool_input command)
  84. if [ "$COMMAND" = "dangerous" ]; then
  85. crush_deny "dangerous command"
  86. fi
  87. `
  88. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  89. require.NoError(t, err)
  90. executor := NewExecutor(tempDir)
  91. ctx := context.Background()
  92. hookCtx := HookContext{
  93. HookType: HookPreToolUse,
  94. SessionID: "test-session",
  95. WorkingDir: tempDir,
  96. Data: map[string]any{
  97. "tool_input": map[string]any{
  98. "command": "dangerous",
  99. },
  100. },
  101. }
  102. result, err := executor.Execute(ctx, hookPath, hookCtx)
  103. require.NoError(t, err)
  104. assert.False(t, result.Continue)
  105. assert.Equal(t, "deny", result.Permission)
  106. })
  107. t.Run("env variables are set correctly", func(t *testing.T) {
  108. hookPath := filepath.Join(tempDir, "env-test.sh")
  109. hookScript := `#!/bin/bash
  110. if [ "$CRUSH_HOOK_TYPE" = "pre-tool-use" ] && \
  111. [ "$CRUSH_SESSION_ID" = "test-123" ] && \
  112. [ "$CRUSH_TOOL_NAME" = "bash" ]; then
  113. export CRUSH_MESSAGE="env vars correct"
  114. fi
  115. `
  116. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  117. require.NoError(t, err)
  118. executor := NewExecutor(tempDir)
  119. ctx := context.Background()
  120. hookCtx := HookContext{
  121. HookType: HookPreToolUse,
  122. SessionID: "test-123",
  123. WorkingDir: tempDir,
  124. ToolName: "bash",
  125. ToolCallID: "call-123",
  126. Data: map[string]any{},
  127. }
  128. result, err := executor.Execute(ctx, hookPath, hookCtx)
  129. require.NoError(t, err)
  130. assert.Equal(t, "env vars correct", result.Message)
  131. })
  132. t.Run("supports JSON output for complex mutations", func(t *testing.T) {
  133. hookPath := filepath.Join(tempDir, "json-test.sh")
  134. hookScript := `#!/bin/bash
  135. cat <<EOF
  136. {
  137. "permission": "approve",
  138. "modified_input": {
  139. "command": "ls -la",
  140. "safe": true
  141. }
  142. }
  143. EOF
  144. `
  145. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  146. require.NoError(t, err)
  147. executor := NewExecutor(tempDir)
  148. ctx := context.Background()
  149. hookCtx := HookContext{
  150. HookType: HookPreToolUse,
  151. SessionID: "test-session",
  152. WorkingDir: tempDir,
  153. Data: map[string]any{},
  154. }
  155. result, err := executor.Execute(ctx, hookPath, hookCtx)
  156. require.NoError(t, err)
  157. assert.Equal(t, "approve", result.Permission)
  158. assert.Equal(t, "ls -la", result.ModifiedInput["command"])
  159. assert.Equal(t, true, result.ModifiedInput["safe"])
  160. })
  161. t.Run("handles exit code 1 as error", func(t *testing.T) {
  162. hookPath := filepath.Join(tempDir, "error-test.sh")
  163. hookScript := `#!/bin/bash
  164. echo "error occurred" >&2
  165. exit 1
  166. `
  167. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  168. require.NoError(t, err)
  169. executor := NewExecutor(tempDir)
  170. ctx := context.Background()
  171. hookCtx := HookContext{
  172. HookType: HookPreToolUse,
  173. SessionID: "test-session",
  174. WorkingDir: tempDir,
  175. Data: map[string]any{},
  176. }
  177. _, err = executor.Execute(ctx, hookPath, hookCtx)
  178. assert.Error(t, err)
  179. assert.Contains(t, err.Error(), "hook failed with exit code 1")
  180. })
  181. t.Run("context files helper", func(t *testing.T) {
  182. hookPath := filepath.Join(tempDir, "files-test.sh")
  183. hookScript := `#!/bin/bash
  184. crush_add_context_file "file1.md"
  185. crush_add_context_file "file2.txt"
  186. `
  187. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  188. require.NoError(t, err)
  189. executor := NewExecutor(tempDir)
  190. ctx := context.Background()
  191. hookCtx := HookContext{
  192. HookType: HookUserPromptSubmit,
  193. SessionID: "test-session",
  194. WorkingDir: tempDir,
  195. Data: map[string]any{},
  196. }
  197. result, err := executor.Execute(ctx, hookPath, hookCtx)
  198. require.NoError(t, err)
  199. assert.Equal(t, []string{"file1.md", "file2.txt"}, result.ContextFiles)
  200. })
  201. t.Run("context content helper", func(t *testing.T) {
  202. hookPath := filepath.Join(tempDir, "content-test.sh")
  203. hookScript := `#!/bin/bash
  204. crush_add_context "This is additional context"
  205. `
  206. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  207. require.NoError(t, err)
  208. executor := NewExecutor(tempDir)
  209. ctx := context.Background()
  210. hookCtx := HookContext{
  211. HookType: HookUserPromptSubmit,
  212. SessionID: "test-session",
  213. WorkingDir: tempDir,
  214. Data: map[string]any{},
  215. }
  216. result, err := executor.Execute(ctx, hookPath, hookCtx)
  217. require.NoError(t, err)
  218. assert.Equal(t, "This is additional context", result.ContextContent)
  219. })
  220. t.Run("returns error if hook file doesn't exist", func(t *testing.T) {
  221. executor := NewExecutor(tempDir)
  222. ctx := context.Background()
  223. hookCtx := HookContext{
  224. HookType: HookPreToolUse,
  225. SessionID: "test-session",
  226. WorkingDir: tempDir,
  227. Data: map[string]any{},
  228. }
  229. _, err := executor.Execute(ctx, "/nonexistent/hook.sh", hookCtx)
  230. assert.Error(t, err)
  231. assert.Contains(t, err.Error(), "failed to read hook")
  232. })
  233. t.Run("passes custom environment variables", func(t *testing.T) {
  234. hookPath := filepath.Join(tempDir, "custom-env-test.sh")
  235. hookScript := `#!/bin/bash
  236. if [ "$CUSTOM_API_KEY" = "secret123" ] && [ "$CUSTOM_REGION" = "us-west-2" ]; then
  237. export CRUSH_MESSAGE="custom env vars set correctly"
  238. fi
  239. `
  240. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  241. require.NoError(t, err)
  242. executor := NewExecutor(tempDir)
  243. ctx := context.Background()
  244. hookCtx := HookContext{
  245. HookType: HookPreToolUse,
  246. SessionID: "test-session",
  247. WorkingDir: tempDir,
  248. Data: map[string]any{},
  249. Environment: map[string]string{
  250. "CUSTOM_API_KEY": "secret123",
  251. "CUSTOM_REGION": "us-west-2",
  252. },
  253. }
  254. result, err := executor.Execute(ctx, hookPath, hookCtx)
  255. require.NoError(t, err)
  256. assert.Equal(t, "custom env vars set correctly", result.Message)
  257. })
  258. t.Run("modify input helper function", func(t *testing.T) {
  259. hookPath := filepath.Join(tempDir, "modify-input-test.sh")
  260. hookScript := `#!/bin/bash
  261. crush_modify_input "command" "ls -la"
  262. crush_modify_input "working_dir" "/tmp"
  263. `
  264. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  265. require.NoError(t, err)
  266. executor := NewExecutor(tempDir)
  267. ctx := context.Background()
  268. hookCtx := HookContext{
  269. HookType: HookPreToolUse,
  270. SessionID: "test-session",
  271. WorkingDir: tempDir,
  272. Data: map[string]any{},
  273. }
  274. result, err := executor.Execute(ctx, hookPath, hookCtx)
  275. require.NoError(t, err)
  276. require.NotNil(t, result.ModifiedInput)
  277. assert.Equal(t, "ls -la", result.ModifiedInput["command"])
  278. assert.Equal(t, "/tmp", result.ModifiedInput["working_dir"])
  279. })
  280. t.Run("modify output helper function", func(t *testing.T) {
  281. hookPath := filepath.Join(tempDir, "modify-output-test.sh")
  282. hookScript := `#!/bin/bash
  283. crush_modify_output "status" "redacted"
  284. crush_modify_output "data" "[REDACTED]"
  285. `
  286. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  287. require.NoError(t, err)
  288. executor := NewExecutor(tempDir)
  289. ctx := context.Background()
  290. hookCtx := HookContext{
  291. HookType: HookPostToolUse,
  292. SessionID: "test-session",
  293. WorkingDir: tempDir,
  294. Data: map[string]any{},
  295. }
  296. result, err := executor.Execute(ctx, hookPath, hookCtx)
  297. require.NoError(t, err)
  298. require.NotNil(t, result.ModifiedOutput)
  299. assert.Equal(t, "redacted", result.ModifiedOutput["status"])
  300. assert.Equal(t, "[REDACTED]", result.ModifiedOutput["data"])
  301. })
  302. t.Run("modify input with JSON types", func(t *testing.T) {
  303. hookPath := filepath.Join(tempDir, "modify-input-json-test.sh")
  304. hookScript := `#!/bin/bash
  305. crush_modify_input "offset" "100"
  306. crush_modify_input "limit" "50"
  307. crush_modify_input "run_in_background" "true"
  308. crush_modify_input "ignore" '["*.log","*.tmp"]'
  309. `
  310. err := os.WriteFile(hookPath, []byte(hookScript), 0o755)
  311. require.NoError(t, err)
  312. executor := NewExecutor(tempDir)
  313. ctx := context.Background()
  314. hookCtx := HookContext{
  315. HookType: HookPreToolUse,
  316. SessionID: "test-session",
  317. WorkingDir: tempDir,
  318. Data: map[string]any{},
  319. }
  320. result, err := executor.Execute(ctx, hookPath, hookCtx)
  321. require.NoError(t, err)
  322. require.NotNil(t, result.ModifiedInput)
  323. assert.Equal(t, float64(100), result.ModifiedInput["offset"])
  324. assert.Equal(t, float64(50), result.ModifiedInput["limit"])
  325. assert.Equal(t, true, result.ModifiedInput["run_in_background"])
  326. assert.Equal(t, []any{"*.log", "*.tmp"}, result.ModifiedInput["ignore"])
  327. })
  328. }
  329. func TestGetHelpersScript(t *testing.T) {
  330. script := GetHelpersScript()
  331. assert.NotEmpty(t, script)
  332. assert.Contains(t, script, "crush_approve")
  333. assert.Contains(t, script, "crush_deny")
  334. assert.Contains(t, script, "crush_add_context")
  335. }