返回 DeepSeek-Reasonix
hook_test.go
根目录 / internal / hook / hook_test.go
1 package hook
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "reflect"
10 "runtime"
11 "strings"
12 "testing"
13 "time"
14
15 fileencoding "reasonix/internal/fileutil/encoding"
16 "reasonix/internal/pluginpkg"
17 "reasonix/internal/sandbox"
18 )
19
20 func writeSettings(t *testing.T, dir, json string) {
21 t.Helper()
22 d := filepath.Join(dir, SettingsDirname)
23 if err := os.MkdirAll(d, 0o755); err != nil {
24 t.Fatal(err)
25 }
26 if err := os.WriteFile(filepath.Join(d, SettingsFilename), []byte(json), 0o644); err != nil {
27 t.Fatal(err)
28 }
29 }
30
31 func writeHookTestFile(t *testing.T, path, body string) {
32 t.Helper()
33 writeHookTestBytes(t, path, []byte(body))
34 }
35
36 func writeHookTestBytes(t *testing.T, path string, body []byte) {
37 t.Helper()
38 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
39 t.Fatal(err)
40 }
41 if err := os.WriteFile(path, body, 0o644); err != nil {
42 t.Fatal(err)
43 }
44 }
45
46 func TestContextFileUsableRequiresReadableRegularFile(t *testing.T) {
47 root := t.TempDir()
48 if ContextFileUsable("") {
49 t.Fatal("empty context path should be unusable")
50 }
51 if ContextFileUsable(root) {
52 t.Fatal("context directory should be unusable")
53 }
54 path := filepath.Join(root, "CLAUDE.md")
55 writeHookTestFile(t, path, "Use the bundled workflow.")
56 if !ContextFileUsable(path) {
57 t.Fatal("readable regular context file should be usable")
58 }
59 }
60
61 func requireNode(t *testing.T) {
62 t.Helper()
63 if _, err := exec.LookPath("node"); err != nil {
64 t.Skip("node not available")
65 }
66 }
67
68 // realSpawnTimeout bounds tests that assert a real child process completes.
69 // Generous on purpose: node cold starts and first-run scans of a freshly
70 // built test binary can stall for seconds on a loaded machine, and these
71 // tests assert behavior, not latency. Tests asserting the timeout path keep
72 // their own tight budgets — that direction cannot flake under load.
73 //
74 // 15s proved insufficient on a loaded Windows GitHub runner
75 // (TestLoadNormalizesQuotedNodeEvalHooksPerProject timed out at 15.03s in
76 // CI), so the budget is 60s; the ceiling only fires on a genuine hang, so
77 // a larger value costs nothing when children exit normally.
78 const realSpawnTimeout = 60 * time.Second
79
80 const sampleSettings = `{"hooks":{"PreToolUse":[{"match":"bash","command":"echo pre"}],"Stop":[{"command":"echo stop"}]}}`
81
82 func hookSettingsWithCommand(t *testing.T, event Event, command string) string {
83 t.Helper()
84 body, err := json.Marshal(Settings{Hooks: map[Event][]HookConfig{
85 event: []HookConfig{{Match: "bash", Command: command, Timeout: int(realSpawnTimeout / time.Millisecond)}},
86 }})
87 if err != nil {
88 t.Fatal(err)
89 }
90 return string(body)
91 }
92
93 func TestLoadDecodesGB18030GlobalSettings(t *testing.T) {
94 home := t.TempDir()
95 body := `{"hooks":{"Stop":[{"command":"echo 中文","description":"全局"}]}}`
96 writeHookTestBytes(t, GlobalSettingsPath(home), fileencoding.MustEncode(body, fileencoding.GB18030))
97
98 got := Load(LoadOptions{HomeDir: home})
99 if len(got) != 1 {
100 t.Fatalf("Load hooks = %+v, want one decoded global hook", got)
101 }
102 if got[0].Scope != ScopeGlobal || got[0].Event != Stop || got[0].Command != "echo 中文" || got[0].Description != "全局" {
103 t.Fatalf("decoded global hook = %+v", got[0])
104 }
105 }
106
107 func TestLoadDecodesUTF8BOMProjectSettings(t *testing.T) {
108 home := t.TempDir()
109 proj := t.TempDir()
110 body := `{"hooks":{"PreToolUse":[{"match":"bash","command":"echo pre"}]}}`
111 writeHookTestBytes(t, ProjectSettingsPath(proj), fileencoding.MustEncode(body, fileencoding.UTF8BOM))
112
113 approveProjectHooks(t, LoadOptions{HomeDir: home, ProjectRoot: proj})
114 got := Load(LoadOptions{HomeDir: home, ProjectRoot: proj})
115 if len(got) != 1 {
116 t.Fatalf("Load hooks = %+v, want one decoded project hook", got)
117 }
118 if got[0].Scope != ScopeProject || got[0].Event != PreToolUse || got[0].Match != "bash" || got[0].Command != "echo pre" {
119 t.Fatalf("decoded project hook = %+v", got[0])
120 }
121 }
122
123 func TestLoadNormalizesQuotedNodeEvalHooksPerProject(t *testing.T) {
124 requireNode(t)
125
126 home := t.TempDir()
127 projA := t.TempDir()
128 projB := t.TempDir()
129 script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
130 bad := `node -e "\"` + script + `\""`
131 want := NormalizeCommand(bad)
132 if want == bad {
133 t.Fatal("test command did not normalize")
134 }
135 writeSettings(t, projA, hookSettingsWithCommand(t, PreToolUse, bad))
136 writeSettings(t, projB, hookSettingsWithCommand(t, PreToolUse, bad))
137
138 for _, project := range []string{projA, projB, projB} {
139 approveProjectHooks(t, LoadOptions{HomeDir: home, ProjectRoot: project})
140 hooks := Load(LoadOptions{HomeDir: home, ProjectRoot: project})
141 if len(hooks) != 1 {
142 t.Fatalf("Load(%q) hooks = %+v, want one", project, hooks)
143 }
144 if hooks[0].Command != want {
145 t.Fatalf("Load(%q) command = %q, want %q", project, hooks[0].Command, want)
146 }
147 rep := Run(context.Background(), Payload{Event: PreToolUse, Cwd: project, ToolName: "bash"}, hooks, nil)
148 if len(rep.Outcomes) != 1 || rep.Outcomes[0].Decision != DecisionPass || rep.Outcomes[0].Stdout != "bash" {
149 t.Fatalf("normalized hook outcome = %+v, want pass with bash stdout", rep)
150 }
151 }
152 }
153
154 func TestNormalizeCommandRepairsOnlyStdinNodeEvalQuoting(t *testing.T) {
155 script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
156 doubleQuoteScript := `const payload = JSON.parse(require(\"fs\").readFileSync(0, \"utf8\")); console.log(payload.toolName)`
157 tests := []struct {
158 name string
159 command string
160 repair bool
161 }{
162 {
163 name: "quoted script argument",
164 command: `node -e "\"` + script + `\""`,
165 repair: true,
166 },
167 {
168 name: "json escaped shell quotes",
169 command: `node -e \"` + script + `\"`,
170 repair: true,
171 },
172 {
173 name: "json escaped shell and script quotes",
174 command: `node -e \"` + doubleQuoteScript + `\"`,
175 repair: true,
176 },
177 {
178 name: "normal hook command",
179 command: `node -e "` + script + `"`,
180 },
181 {
182 name: "intentional string literal",
183 command: `node -e '"hello"'`,
184 },
185 {
186 name: "not stdin hook script",
187 command: `node -e "\"console.log(1)\""`,
188 },
189 {
190 name: "compound command",
191 command: `node -e "\"` + script + `\"" && echo done`,
192 },
193 }
194 for _, tt := range tests {
195 t.Run(tt.name, func(t *testing.T) {
196 got := NormalizeCommand(tt.command)
197 if tt.repair {
198 if got == tt.command {
199 t.Fatalf("NormalizeCommand(%q) did not repair", tt.command)
200 }
201 if strings.Contains(got, `\""`) {
202 t.Fatalf("NormalizeCommand(%q) left accidental escaped quotes in %q", tt.command, got)
203 }
204 requireNode(t)
205 r := DefaultSpawner(context.Background(), SpawnInput{
206 Command: got,
207 Stdin: `{"toolName":"bash"}`,
208 Timeout: realSpawnTimeout,
209 })
210 if r.ExitCode != 0 || r.Stdout != "bash" {
211 t.Fatalf("normalized command did not execute: command=%q result=%+v", got, r)
212 }
213 return
214 }
215 if got != tt.command {
216 t.Fatalf("NormalizeCommand(%q) = %q, want unchanged", tt.command, got)
217 }
218 })
219 }
220 }
221
222 func TestNormalizeCommandRepairsOnlyPowerShellFileEscapedQuotes(t *testing.T) {
223 tests := []struct {
224 name string
225 command string
226 want string
227 }{
228 {
229 name: "powershell file path copied with json escaped quotes",
230 command: `powershell -File \"C:\Users\Example\.reasonix\hooks\archive-attachments.ps1\"`,
231 want: `powershell -File "C:\Users\Example\.reasonix\hooks\archive-attachments.ps1"`,
232 },
233 {
234 name: "pwsh file path with spaces",
235 command: `pwsh.exe -NoProfile -NonInteractive -File \"C:\Program Files\Reasonix Hooks\archive attachments.ps1\"`,
236 want: `pwsh.exe -NoProfile -NonInteractive -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`,
237 },
238 {
239 name: "doubly escaped copied quotes",
240 command: `pwsh -File \\\"C:\Program Files\Reasonix Hooks\archive attachments.ps1\\\" \"arg with spaces\"`,
241 want: `pwsh -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1" "arg with spaces"`,
242 },
243 {
244 name: "powershell executable path copied with escaped quotes",
245 command: `\"C:\Program Files\PowerShell\7\pwsh.exe\" -File \"C:\hooks\archive.ps1\"`,
246 want: `"C:\Program Files\PowerShell\7\pwsh.exe" -File "C:\hooks\archive.ps1"`,
247 },
248 {
249 name: "well formed file command stays unchanged",
250 command: `powershell -NoProfile -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`,
251 want: `powershell -NoProfile -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`,
252 },
253 {
254 name: "command mode may intentionally contain escaped quotes",
255 command: `powershell -Command \"Write-Output hi\"`,
256 want: `powershell -Command \"Write-Output hi\"`,
257 },
258 {
259 name: "compound command is left alone",
260 command: `powershell -File \"C:\hooks\archive.ps1\" && echo done`,
261 want: `powershell -File \"C:\hooks\archive.ps1\" && echo done`,
262 },
263 {
264 name: "multiline command is left alone",
265 command: "powershell -File \\\"C:\\hooks\\archive.ps1\\\"\necho done",
266 want: "powershell -File \\\"C:\\hooks\\archive.ps1\\\"\necho done",
267 },
268 {
269 name: "well formed sibling argument keeps its escaped quotes",
270 command: `powershell -File \"C:\hooks\archive.ps1\" "say \"hi\""`,
271 want: `powershell -File "C:\hooks\archive.ps1" "say \"hi\""`,
272 },
273 {
274 name: "single quoted sibling argument stays literal",
275 command: `powershell -File \"C:\hooks\archive.ps1\" 'keep \" literal'`,
276 want: `powershell -File "C:\hooks\archive.ps1" 'keep \" literal'`,
277 },
278 {
279 name: "non powershell command is left alone",
280 command: `python \"C:\hooks\archive.py\"`,
281 want: `python \"C:\hooks\archive.py\"`,
282 },
283 {
284 name: "missing file argument is left alone",
285 command: `powershell -NoProfile -File`,
286 want: `powershell -NoProfile -File`,
287 },
288 }
289 for _, tt := range tests {
290 t.Run(tt.name, func(t *testing.T) {
291 if got := NormalizeCommand(tt.command); got != tt.want {
292 t.Fatalf("NormalizeCommand(%q) = %q, want %q", tt.command, got, tt.want)
293 }
294 })
295 }
296 }
297
298 func TestLoadNormalizesPowerShellFileEscapedQuotes(t *testing.T) {
299 home := t.TempDir()
300 bad := `powershell -File \"C:\Program Files\Reasonix Hooks\archive attachments.ps1\"`
301 want := `powershell -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`
302 writeSettings(t, home, hookSettingsWithCommand(t, SessionStart, bad))
303
304 hooks := Load(LoadOptions{HomeDir: home})
305 if len(hooks) != 1 {
306 t.Fatalf("Load hooks = %+v, want one", hooks)
307 }
308 if hooks[0].Command != want {
309 t.Fatalf("loaded command = %q, want %q", hooks[0].Command, want)
310 }
311 }
312
313 func TestRepairablePowerShellFileArgs(t *testing.T) {
314 command := `powershell -NoProfile -NonInteractive -File \"C:\Program Files\Reasonix Hooks\archive attachments.ps1\" -Mode \"startup\"`
315 name, args, ok := repairablePowerShellFileArgs(command)
316 if !ok {
317 t.Fatalf("repairablePowerShellFileArgs(%q) ok = false, want true", command)
318 }
319 if name != "powershell" {
320 t.Fatalf("name = %q, want powershell", name)
321 }
322 wantArgs := []string{"-NoProfile", "-NonInteractive", "-File", `C:\Program Files\Reasonix Hooks\archive attachments.ps1`, "-Mode", "startup"}
323 if strings.Join(args, "\x00") != strings.Join(wantArgs, "\x00") {
324 t.Fatalf("args = %#v, want %#v", args, wantArgs)
325 }
326 if _, _, ok := repairablePowerShellFileArgs(`powershell -File "C:\hooks\archive.ps1"`); ok {
327 t.Fatal("well formed PowerShell command should keep shell execution")
328 }
329 if _, _, ok := repairablePowerShellFileArgs(`powershell -File \"C:\hooks\archive.ps1\" && echo done`); ok {
330 t.Fatal("compound PowerShell command should not be direct-exec repaired")
331 }
332 if _, _, ok := repairablePowerShellFileArgs("powershell -File \\\"C:\\hooks\\archive.ps1\\\"\necho done"); ok {
333 t.Fatal("multiline PowerShell command should not be direct-exec repaired")
334 }
335 }
336
337 // installSuperpowersV611HookFixture reproduces the package shape reported in
338 // #6602: a Codex-kind installation of superpowers 6.1.1 whose Claude
339 // compatibility manifest launches a quoted, mixed-separator run-hook.cmd.
340 // Keep the hooks document byte-for-byte equivalent to the upstream v6.1.1
341 // declaration so changes in parsing, root expansion, or execution mode cannot
342 // silently fall back to a synthetic contract that the affected plugin did not
343 // use.
344 func installSuperpowersV611HookFixture(t *testing.T, home string) string {
345 t.Helper()
346 reasonixHome := filepath.Join(home, ".reasonix")
347 root := filepath.Join(reasonixHome, "plugins", "superpowers fixture")
348 writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
349 "name": "superpowers",
350 "description": "Core skills library for Claude Code",
351 "version": "6.1.1"
352 }`)
353 writeHookTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{
354 "hooks": {
355 "SessionStart": [
356 {
357 "matcher": "startup|clear|compact",
358 "hooks": [
359 {
360 "type": "command",
361 "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
362 "async": false
363 }
364 ]
365 }
366 ]
367 }
368 }`)
369 writeHookTestFile(t, filepath.Join(root, "hooks", "run-hook.cmd"),
370 "@echo off\r\nset /p hook_input=\r\necho %1:%hook_input%\r\n")
371 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
372 Name: "superpowers",
373 Root: "plugins/superpowers fixture",
374 Version: "6.1.1",
375 ManifestKind: "codex",
376 Enabled: true,
377 }); err != nil {
378 t.Fatal(err)
379 }
380 return root
381 }
382
383 func TestLoadPermissionRequestHook(t *testing.T) {
384 home := t.TempDir()
385 writeSettings(t, home, `{"hooks":{"PermissionRequest":[{"match":"bash","command":"notify"}]}}`)
386
387 got := Load(LoadOptions{HomeDir: home})
388 if len(got) != 1 {
389 t.Fatalf("hooks count = %d, want 1", len(got))
390 }
391 if got[0].Event != PermissionRequest || got[0].Match != "bash" || got[0].Command != "notify" {
392 t.Fatalf("loaded hook = %+v, want PermissionRequest/bash/notify", got[0])
393 }
394 }
395
396 func TestLoadSuperpowersV611SessionStartExecutionContract(t *testing.T) {
397 home := t.TempDir()
398 root := installSuperpowersV611HookFixture(t, home)
399
400 got := Load(LoadOptions{HomeDir: home, ProjectRoot: filepath.Join(home, "workspace")})
401 if len(got) != 1 {
402 t.Fatalf("hooks = %+v, want the upstream superpowers SessionStart hook", got)
403 }
404 h := got[0]
405 if h.Scope != ScopePlugin || h.Event != SessionStart || h.Match != "startup|clear|compact" {
406 t.Fatalf("loaded hook identity = %+v", h)
407 }
408 if h.ExecutionMode != ExecutionShell || h.Shell != "" || h.Argv != nil {
409 t.Fatalf("execution contract = mode %q shell %q argv %#v, want automatic shell form",
410 h.ExecutionMode, h.Shell, h.Argv)
411 }
412 wantCommand := `"` + root + `/hooks/run-hook.cmd" session-start`
413 if h.Command != wantCommand {
414 t.Fatalf("command = %q, want exact expanded upstream command %q", h.Command, wantCommand)
415 }
416 if h.Cwd != root || h.PayloadFormat != "claude" || h.Env["CLAUDE_PLUGIN_ROOT"] != root {
417 t.Fatalf("Claude execution metadata = %+v", h)
418 }
419 }
420
421 func TestLoadSuperpowersV620PreservesExplicitBashRequirement(t *testing.T) {
422 home := t.TempDir()
423 root := filepath.Join(home, ".reasonix", "plugins", "superpowers")
424 writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
425 "name": "superpowers",
426 "version": "6.2.0",
427 "skills": "./skills/"
428 }`)
429 writeHookTestFile(t, filepath.Join(root, "hooks", "hooks.json"), `{
430 "hooks": {
431 "SessionStart": [{
432 "matcher": "startup|clear|compact",
433 "hooks": [{
434 "type": "command",
435 "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
436 "shell": "bash",
437 "async": false
438 }]
439 }]
440 }
441 }`)
442 writeHookTestFile(t, filepath.Join(root, "hooks", "run-hook.cmd"), "@echo off\r\n")
443 if err := pluginpkg.Upsert(filepath.Join(home, ".reasonix"), pluginpkg.InstalledPlugin{
444 Name: "superpowers",
445 Root: "plugins/superpowers",
446 Version: "6.2.0",
447 ManifestKind: "codex",
448 Enabled: true,
449 }); err != nil {
450 t.Fatal(err)
451 }
452
453 got := Load(LoadOptions{HomeDir: home, ProjectRoot: filepath.Join(home, "workspace")})
454 if len(got) != 1 {
455 t.Fatalf("hooks = %+v, want the upstream superpowers 6.2.0 SessionStart hook", got)
456 }
457 h := got[0]
458 if h.ExecutionMode != ExecutionShell || h.Shell != "bash" || h.Argv != nil {
459 t.Fatalf("execution contract = mode %q shell %q argv %#v, want explicit Bash shell form",
460 h.ExecutionMode, h.Shell, h.Argv)
461 }
462 if !requiresWindowsBash(h.HookConfig) {
463 t.Fatal("superpowers 6.2.0 hook should declare a Windows Bash runtime dependency")
464 }
465 if want := `"` + filepath.ToSlash(root) + `/hooks/run-hook.cmd" session-start`; h.Command != want {
466 t.Fatalf("command = %q, want %q", h.Command, want)
467 }
468 }
469
470 func TestPluginExplicitBashCommandUsesPOSIXCompatibleRoot(t *testing.T) {
471 root := `C:\Users\Test User\AppData\Roaming\reasonix\plugins\superpowers`
472 config := pluginHookExecutionConfigForPlatform(pluginpkg.Hook{
473 Command: `"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd" session-start`,
474 ShellCommand: true,
475 Shell: "bash",
476 }, root, "windows")
477 want := `"C:/Users/Test User/AppData/Roaming/reasonix/plugins/superpowers/hooks/run-hook.cmd" session-start`
478 if config.Command != want {
479 t.Fatalf("explicit Bash command = %q, want POSIX-compatible root %q", config.Command, want)
480 }
481 }
482
483 func TestExplicitBashRuntimeUsesConfiguredPath(t *testing.T) {
484 wantPath := filepath.Join(t.TempDir(), "PortableGit", "bin", "bash.exe")
485 var resolvedPath string
486 options := RuntimeOptionsForShell("bash", wantPath)
487 err := checkRuntimeForPlatform(HookConfig{
488 Command: `"C:\\Users\\Test User\\plugins\\superpowers\\hooks\\run-hook.cmd" session-start`,
489 ExecutionMode: ExecutionShell,
490 Shell: "bash",
491 }, options, "windows", func(path string) (string, error) {
492 resolvedPath = path
493 return path, nil
494 })
495 if err != nil {
496 t.Fatal(err)
497 }
498 if resolvedPath != wantPath {
499 t.Fatalf("resolved Bash path = %q, want configured path %q", resolvedPath, wantPath)
500 }
501 }
502
503 func TestExplicitBashRuntimeReportsMissingDependency(t *testing.T) {
504 err := checkRuntimeForPlatform(HookConfig{
505 Command: `"C:\\Users\\Test User\\plugins\\superpowers\\hooks\\run-hook.cmd" session-start`,
506 ExecutionMode: ExecutionShell,
507 Shell: "bash",
508 }, RuntimeOptions{}, "windows", func(string) (string, error) {
509 return "", missingWindowsHookBashError()
510 })
511 if err == nil || !strings.Contains(err.Error(), "Git Bash") {
512 t.Fatalf("missing Bash runtime error = %v", err)
513 }
514 }
515
516 func TestLoadIncludesPluginSessionStartHook(t *testing.T) {
517 home := t.TempDir()
518 reasonixHome := filepath.Join(home, ".reasonix")
519 root := filepath.Join(reasonixHome, "plugins", "superpowers")
520 writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo global"}]}}`)
521 writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
522 "name": "superpowers",
523 "version": "6.1.0",
524 "skills": "./skills/"
525 }`)
526 writeHookTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/usr/bin/env bash\necho ok\n")
527 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
528 Name: "superpowers",
529 Root: "plugins/superpowers",
530 Version: "6.1.0",
531 ManifestKind: "codex",
532 Enabled: true,
533 }); err != nil {
534 t.Fatal(err)
535 }
536
537 got := Load(LoadOptions{HomeDir: home, ProjectRoot: "/workspace", Trusted: true})
538 if len(got) != 2 {
539 t.Fatalf("hooks = %+v, want plugin + global", got)
540 }
541 if got[0].Scope != ScopePlugin || got[0].Event != SessionStart {
542 t.Fatalf("first hook = %+v, want plugin SessionStart", got[0])
543 }
544 if got[0].Env["REASONIX_PLUGIN_NAME"] != "superpowers" || got[0].Env["REASONIX_WORKSPACE_ROOT"] != "/workspace" {
545 t.Fatalf("plugin env = %#v", got[0].Env)
546 }
547 if got[1].Scope != ScopeGlobal {
548 t.Fatalf("second hook = %+v, want global", got[1])
549 }
550 }
551
552 // TestInspectNoHomeDirResolvesPluginRootFromPlatformHome: with HomeDir empty
553 // (the hook-machine default after #7420) and an isolated REASONIX_HOME, the
554 // plugin probe and global settings resolve from the platform Reasonix home —
555 // <home>/plugins and <home>/settings.json — not a doubled .reasonix segment.
556 func TestInspectNoHomeDirResolvesPluginRootFromPlatformHome(t *testing.T) {
557 home := t.TempDir()
558 t.Setenv("REASONIX_HOME", home)
559 root := filepath.Join(home, "plugins", "superpowers")
560 // Global settings live directly under the platform Reasonix home
561 // (writeSettings would add .reasonix, the OS-home convention #7420 fixes).
562 if err := os.MkdirAll(home, 0o755); err != nil {
563 t.Fatal(err)
564 }
565 if err := os.WriteFile(filepath.Join(home, "settings.json"), []byte(`{"hooks":{"PostToolUse":[{"command":"echo global"}]}}`), 0o644); err != nil {
566 t.Fatal(err)
567 }
568 writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
569 "name": "superpowers",
570 "version": "6.1.0",
571 "skills": "./skills/"
572 }`)
573 writeHookTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/usr/bin/env bash\necho ok\n")
574 if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{
575 Name: "superpowers",
576 Root: "plugins/superpowers",
577 Version: "6.1.0",
578 ManifestKind: "codex",
579 Enabled: true,
580 }); err != nil {
581 t.Fatal(err)
582 }
583
584 insp := Inspect(LoadOptions{ProjectRoot: "/workspace"})
585 // Inspect reports project + plugin + global sources; assertions focus on
586 // the two that #7420 broke: plugin and global must resolve from the
587 // platform Reasonix home, not a doubled .reasonix segment.
588 var pluginOK, globalOK bool
589 for _, s := range insp.Sources {
590 switch s.Scope {
591 case ScopePlugin:
592 pluginOK = s.Status == "ok" && strings.Contains(s.Path, filepath.Join(home, "plugins"))
593 case ScopeGlobal:
594 globalOK = s.Status == "ok" && strings.Contains(s.Path, filepath.Join(home, "settings.json"))
595 }
596 }
597 if !pluginOK {
598 t.Fatalf("plugin source not resolved from platform home: %+v", insp.Sources)
599 }
600 if !globalOK {
601 t.Fatalf("global source not resolved from platform home: %+v", insp.Sources)
602 }
603 }
604
605 func TestLoadIncludesPluginClaudeCompatibilityHooks(t *testing.T) {
606 home := t.TempDir()
607 reasonixHome := filepath.Join(home, ".reasonix")
608 root := filepath.Join(reasonixHome, "plugins", "claude-pack")
609 writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
610 "name": "claude-pack",
611 "version": "1.0.0",
612 "skills": "skills"
613 }`)
614 writeHookTestFile(t, filepath.Join(root, "CLAUDE.md"), "Use the bundled workflow.")
615 writeHookTestFile(t, filepath.Join(root, ".claude", "settings.json"), `{
616 "hooks": {
617 "PostToolUse": [
618 {
619 "matcher": "bash",
620 "hooks": [
621 { "type": "command", "command": "node hooks/post-tool.js", "timeout": 2 }
622 ]
623 }
624 ],
625 "UserPromptSubmit": [
626 {
627 "hooks": [
628 { "type": "command", "command": "node hooks/prompt.js" }
629 ]
630 }
631 ]
632 }
633 }`)
634 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
635 Name: "claude-pack",
636 Root: "plugins/claude-pack",
637 Version: "1.0.0",
638 ManifestKind: "codex",
639 Enabled: true,
640 }); err != nil {
641 t.Fatal(err)
642 }
643
644 got := Load(LoadOptions{HomeDir: home, ProjectRoot: "/workspace", Trusted: true})
645 if len(got) != 3 {
646 t.Fatalf("hooks = %+v, want three plugin hooks", got)
647 }
648 byEvent := map[Event]ResolvedHook{}
649 for _, h := range got {
650 if h.Scope != ScopePlugin {
651 t.Fatalf("hook scope = %s, want plugin: %+v", h.Scope, h)
652 }
653 byEvent[h.Event] = h
654 }
655 if h := byEvent[SessionStart]; h.ContextFile != filepath.Join(root, "CLAUDE.md") || h.Command != "" {
656 t.Fatalf("SessionStart hook = %+v, want CLAUDE.md context file", h)
657 }
658 if h := byEvent[PostToolUse]; h.Match != "bash" || h.Command != "node hooks/post-tool.js" || h.Timeout != 2000 || h.Cwd != root {
659 t.Fatalf("PostToolUse hook = %+v", h)
660 }
661 if h := byEvent[UserPromptSubmit]; h.Command != "node hooks/prompt.js" || h.Cwd != root {
662 t.Fatalf("UserPromptSubmit hook = %+v", h)
663 }
664 if h := byEvent[PostToolUse]; h.Env["CLAUDE_PROJECT_DIR"] != "/workspace" || h.Env["REASONIX_PLUGIN_NAME"] != "claude-pack" {
665 t.Fatalf("plugin env = %#v", h.Env)
666 }
667 if h := byEvent[PostToolUse]; h.PayloadFormat != "claude" || h.Env["CLAUDE_PLUGIN_ROOT"] != root {
668 t.Fatalf("Claude compatibility metadata = %+v", h)
669 }
670 }
671
672 func TestLoadPluginHooksPreservesExecutionContract(t *testing.T) {
673 home := t.TempDir()
674 reasonixHome := filepath.Join(home, ".reasonix")
675 root := filepath.Join(reasonixHome, "plugins", "hook-contract")
676 writeHookTestFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{
677 "apiVersion": "reasonix.io/plugin/v2", "name": "hook-contract",
678 "hooks": {
679 "SessionStart": [
680 {"command":"bin/check","args":[],"shellCommand":true},
681 {"command":"printf 'one' && printf 'two'","shell":"bash"}
682 ]
683 }
684 }`)
685 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
686 Name: "hook-contract",
687 Root: "plugins/hook-contract",
688 ManifestKind: "native",
689 Enabled: true,
690 }); err != nil {
691 t.Fatal(err)
692 }
693
694 got := Load(LoadOptions{HomeDir: home})
695 if len(got) != 2 {
696 t.Fatalf("hooks = %+v, want two plugin hooks", got)
697 }
698 if got[0].ExecutionMode != ExecutionExec || got[0].Argv == nil || len(got[0].Argv) != 0 {
699 t.Fatalf("empty args hook = %+v, want explicit exec form", got[0])
700 }
701 if want := filepath.Join(root, "bin", "check"); got[0].Command != want {
702 t.Fatalf("exec command = %q, want plugin-relative %q", got[0].Command, want)
703 }
704 if got[1].ExecutionMode != ExecutionShell || got[1].Shell != "bash" ||
705 got[1].Command != "printf 'one' && printf 'two'" {
706 t.Fatalf("shell hook = %+v, want raw Bash shell form", got[1])
707 }
708 }
709
710 func TestLoadExpandsReasonixPluginRootBeforeShellLaunch(t *testing.T) {
711 home := t.TempDir()
712 reasonixHome := filepath.Join(home, ".reasonix")
713 root := filepath.Join(reasonixHome, "plugins", "impeccable")
714 projectRoot := filepath.Join(home, "$CLAUDE_PLUGIN_ROOT-project")
715 writeHookTestFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{
716 "apiVersion": "reasonix.io/plugin/v2", "name": "impeccable",
717 "version": "3.9.1",
718 "hooks": {
719 "PostToolUse": [{
720 "match": "edit",
721 "command": "node \"${REASONIX_PLUGIN_ROOT}/skills/impeccable/scripts/hook.mjs\"",
722 "shellCommand": true,
723 "cwd": "${REASONIX_PLUGIN_ROOT}/work",
724 "env": {"IMPECCABLE_CACHE": "%REASONIX_PLUGIN_ROOT%/cache"}
725 }]
726 }
727 }`)
728 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
729 Name: "impeccable",
730 Root: "plugins/impeccable",
731 Version: "3.9.1",
732 ManifestKind: "native",
733 Enabled: true,
734 }); err != nil {
735 t.Fatal(err)
736 }
737
738 got := Load(LoadOptions{HomeDir: home, ProjectRoot: projectRoot, Trusted: true})
739 if len(got) != 1 {
740 t.Fatalf("hooks = %+v, want one plugin hook", got)
741 }
742 want := `node "` + root + `/skills/impeccable/scripts/hook.mjs"`
743 if got[0].Command != want {
744 t.Fatalf("plugin command = %q, want %q", got[0].Command, want)
745 }
746 if strings.Contains(got[0].Command, "PLUGIN_ROOT") {
747 t.Fatalf("plugin root token reached the shell: %q", got[0].Command)
748 }
749 if got[0].Cwd != filepath.Join(root, "work") || got[0].Env["IMPECCABLE_CACHE"] != root+"/cache" {
750 t.Fatalf("expanded plugin cwd/env = cwd %q env %#v", got[0].Cwd, got[0].Env)
751 }
752 if got[0].Env["CLAUDE_PROJECT_DIR"] != projectRoot || got[0].Env["REASONIX_WORKSPACE_ROOT"] != projectRoot {
753 t.Fatalf("host-provided workspace paths were expanded: %#v", got[0].Env)
754 }
755 }
756
757 func TestExpandPluginRootSupportsClaudeReasonixAndCmdAliases(t *testing.T) {
758 root := `C:\Program Files\Reasonix\plugins\impeccable`
759 for _, token := range []string{
760 "${CLAUDE_PLUGIN_ROOT}", "$CLAUDE_PLUGIN_ROOT", "%CLAUDE_PLUGIN_ROOT%",
761 "${REASONIX_PLUGIN_ROOT}", "$REASONIX_PLUGIN_ROOT", "%REASONIX_PLUGIN_ROOT%",
762 } {
763 t.Run(token, func(t *testing.T) {
764 command := `node "` + token + `/skills/impeccable/scripts/hook.mjs"`
765 want := `node "` + root + `/skills/impeccable/scripts/hook.mjs"`
766 if got := expandPluginRoot(command, root); got != want {
767 t.Fatalf("expandPluginRoot(%q) = %q, want %q", token, got, want)
768 }
769 })
770 }
771 // Shell parameter expressions belong to an explicitly requested POSIX
772 // shell and must stay intact for that shell to evaluate.
773 guard := `${CLAUDE_PLUGIN_ROOT:-missing}`
774 if got := expandPluginRoot(guard, root); got != guard {
775 t.Fatalf("shell parameter expression = %q, want unchanged %q", got, guard)
776 }
777 for _, longerName := range []string{
778 "$CLAUDE_PLUGIN_ROOT_SUFFIX",
779 "$REASONIX_PLUGIN_ROOT_OLD",
780 "$CLAUDE_PLUGIN_ROOT2",
781 } {
782 if got := expandPluginRoot(longerName, root); got != longerName {
783 t.Fatalf("longer variable name %q was partially expanded to %q", longerName, got)
784 }
785 }
786 if got, want := expandPluginRoot(`$CLAUDE_PLUGIN_ROOT-child`, root), root+"-child"; got != want {
787 t.Fatalf("delimited unbraced variable = %q, want %q", got, want)
788 }
789 if got, want := expandPluginRoot(`$CLAUDE_PLUGIN_ROOT/$REASONIX_PLUGIN_ROOT`, root), root+"/"+root; got != want {
790 t.Fatalf("both root aliases = %q, want %q", got, want)
791 }
792 }
793
794 func TestExpandPluginRootDoesNotReprocessResolvedRoot(t *testing.T) {
795 root := `/tmp/$REASONIX_PLUGIN_ROOT/%CLAUDE_PLUGIN_ROOT%/${CLAUDE_PLUGIN_ROOT}`
796 value := `${CLAUDE_PLUGIN_ROOT}|$REASONIX_PLUGIN_ROOT|%CLAUDE_PLUGIN_ROOT%`
797 want := root + "|" + root + "|" + root
798 if got := expandPluginRoot(value, root); got != want {
799 t.Fatalf("resolved root was expanded again: got %q, want %q", got, want)
800 }
801 }
802
803 func TestWindowsPOSIXShellInvocationPreservesQuotedScript(t *testing.T) {
804 command := `sh -c '[ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || { echo "plugin root missing" >&2; exit 1; }'`
805 wantShell := `C:\Program Files\Git\bin\bash.exe`
806 gotShell, gotArgs, matched, err := windowsPOSIXShellInvocationWith(command, func() (string, error) {
807 return wantShell, nil
808 })
809 if err != nil || !matched {
810 t.Fatalf("explicit sh command matched=%v err=%v", matched, err)
811 }
812 if gotShell != wantShell {
813 t.Fatalf("shell = %q, want %q", gotShell, wantShell)
814 }
815 wantArgs := []string{"-c", `[ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || { echo "plugin root missing" >&2; exit 1; }`}
816 if !reflect.DeepEqual(gotArgs, wantArgs) {
817 t.Fatalf("args = %#v, want %#v", gotArgs, wantArgs)
818 }
819 }
820
821 func TestWindowsPOSIXShellInvocationRejectsMissingBashClearly(t *testing.T) {
822 _, _, matched, err := windowsPOSIXShellInvocationWith(`bash -lc 'printf ok'`, func() (string, error) {
823 return "", missingWindowsHookBashError()
824 })
825 if !matched || err == nil || !strings.Contains(err.Error(), "Git Bash") {
826 t.Fatalf("missing Bash matched=%v err=%v", matched, err)
827 }
828 called := false
829 _, _, matched, err = windowsPOSIXShellInvocationWith(`node hook.mjs`, func() (string, error) {
830 called = true
831 return "", nil
832 })
833 if matched || err != nil || called {
834 t.Fatalf("non-shell command matched=%v err=%v resolver_called=%v", matched, err, called)
835 }
836 }
837
838 func TestWindowsPOSIXShellExecFormUsesDiscoveredBash(t *testing.T) {
839 wantShell := `C:\Program Files\Git\bin\bash.exe`
840 wantArgs := []string{"-lc", `printf "%s" "$HOOK_TEST_MARKER"`}
841 gotShell, gotArgs, matched, err := windowsPOSIXShellArgvInvocationWith("sh", wantArgs, func() (string, error) {
842 return wantShell, nil
843 })
844 if err != nil || !matched || gotShell != wantShell || !reflect.DeepEqual(gotArgs, wantArgs) {
845 t.Fatalf("exec-form sh = shell %q args %#v matched=%v err=%v", gotShell, gotArgs, matched, err)
846 }
847 }
848
849 func TestWindowsBatchCommandLinePreservesQuotedPluginPath(t *testing.T) {
850 command := `"C:\Users\Test User\AppData\Roaming\reasonix\plugins\superpowers/hooks/run-hook.cmd" session-start`
851 got, ok := windowsBatchCommandLine(command)
852 if !ok {
853 t.Fatal("quoted plugin batch command was not recognized")
854 }
855 want := `cmd.exe /d /s /c ""C:\Users\Test User\AppData\Roaming\reasonix\plugins\superpowers\hooks\run-hook.cmd" session-start"`
856 if got != want {
857 t.Fatalf("batch command line = %q, want %q", got, want)
858 }
859 }
860
861 func TestWindowsBatchCommandLinePreservesArgumentText(t *testing.T) {
862 command := `"C:\plugins\hook.cmd" plain "argument with spaces" caret^ escaped`
863 got, ok := windowsBatchCommandLine(command)
864 if !ok {
865 t.Fatal("quoted batch command was not recognized")
866 }
867 want := `cmd.exe /d /s /c ""C:\plugins\hook.cmd" plain "argument with spaces" caret^ escaped"`
868 if got != want {
869 t.Fatalf("batch argument text changed: got %q, want %q", got, want)
870 }
871 }
872
873 func TestWindowsBatchArgvCommandLineSupportsNativePluginHooks(t *testing.T) {
874 got, ok := windowsBatchArgvCommandLine(
875 `C:\Program Files\Reasonix\plugins\example/hooks/run-hook.cmd`,
876 []string{"session-start", "argument with spaces"},
877 )
878 if !ok {
879 t.Fatal("native plugin batch command was not recognized")
880 }
881 want := `cmd.exe /d /s /c ""C:\Program Files\Reasonix\plugins\example\hooks\run-hook.cmd" session-start "argument with spaces""`
882 if got != want {
883 t.Fatalf("batch argv command line = %q, want %q", got, want)
884 }
885 }
886
887 func TestWindowsBatchArgvCommandLineRejectsCmdExpansionSyntax(t *testing.T) {
888 for _, arg := range []string{`%PATH%`, `!HOOK_MODE!`, `embedded"quote`} {
889 if got, ok := windowsBatchArgvCommandLine(`C:\plugins\hook.cmd`, []string{arg}); ok {
890 t.Errorf("argv argument %q unexpectedly matched as %q", arg, got)
891 }
892 }
893 }
894
895 func TestWindowsBatchCommandLineLeavesOtherShellContractsAlone(t *testing.T) {
896 commands := []string{
897 `"C:\plugins\hook.cmd" session-start && echo chained`,
898 `C:\plugins\hook.cmd session-start`,
899 `powershell -File "C:\plugins\hook.ps1"`,
900 `node "C:\plugins\hook.js"`,
901 `echo hook.cmd`,
902 }
903 for _, command := range commands {
904 if got, ok := windowsBatchCommandLine(command); ok {
905 t.Errorf("windowsBatchCommandLine(%q) unexpectedly matched as %q", command, got)
906 }
907 }
908 }
909
910 func TestWindowsCmdCommandLinePreservesCompoundShellScript(t *testing.T) {
911 command := `"C:\plugins\hook.cmd" "argument with spaces" && echo "chained" | findstr chained`
912 want := `cmd.exe /d /s /c ""C:\plugins\hook.cmd" "argument with spaces" && echo "chained" | findstr chained"`
913 if got := windowsCmdCommandLine(command); got != want {
914 t.Fatalf("cmd command line = %q, want %q", got, want)
915 }
916 }
917
918 func TestWindowsPOSIXShellPreservesExplicitInterpreterPaths(t *testing.T) {
919 called := false
920 resolve := func() (string, error) {
921 called = true
922 return `C:\Program Files\Git\bin\bash.exe`, nil
923 }
924 command := `"C:\Custom MSYS2\usr\bin\bash.exe" -c 'printf ok'`
925 if _, _, matched, err := windowsPOSIXShellInvocationWith(command, resolve); matched || err != nil || called {
926 t.Fatalf("explicit shell command matched=%v err=%v resolver_called=%v", matched, err, called)
927 }
928 if _, _, matched, err := windowsPOSIXShellArgvInvocationWith(`C:\Custom\bin\bash.exe`, []string{"-c", "printf ok"}, resolve); matched || err != nil || called {
929 t.Fatalf("explicit shell argv matched=%v err=%v resolver_called=%v", matched, err, called)
930 }
931 }
932
933 func TestHasCommandStringFlagParsesBashOptions(t *testing.T) {
934 tests := []struct {
935 name string
936 args []string
937 want bool
938 }{
939 {name: "short", args: []string{"-c", "printf ok"}, want: true},
940 {name: "short cluster", args: []string{"-lc", "printf ok"}, want: true},
941 {name: "long option containing c", args: []string{"--norc", "script.sh"}, want: false},
942 {name: "inline set option operand", args: []string{"-oc", "script.sh"}, want: false},
943 {name: "shopt before command", args: []string{"-O", "extglob", "-c", "printf ok"}, want: true},
944 {name: "set option before command", args: []string{"-o", "pipefail", "-c", "printf ok"}, want: true},
945 {name: "rcfile before command", args: []string{"--rcfile", "custom.bashrc", "-c", "printf ok"}, want: true},
946 {name: "option terminator", args: []string{"--", "-c", "printf ok"}, want: false},
947 {name: "missing command string", args: []string{"-c"}, want: false},
948 }
949 for _, tt := range tests {
950 t.Run(tt.name, func(t *testing.T) {
951 if got := hasCommandStringFlag(tt.args); got != tt.want {
952 t.Fatalf("hasCommandStringFlag(%q) = %v, want %v", tt.args, got, tt.want)
953 }
954 })
955 }
956 }
957
958 func TestDefaultSpawnerUsesGitBashForExplicitShOnWindows(t *testing.T) {
959 if runtime.GOOS != "windows" {
960 t.Skip("exercises Git for Windows Bash discovery")
961 }
962 r := DefaultSpawner(context.Background(), SpawnInput{
963 Command: `sh -c 'printf "%s" "$HOOK_TEST_MARKER"'`,
964 Timeout: realSpawnTimeout,
965 Env: map[string]string{"HOOK_TEST_MARKER": "git-bash-ok"},
966 })
967 if r.ExitCode != 0 || r.Stdout != "git-bash-ok" {
968 t.Fatalf("explicit sh hook did not run through Git Bash: %+v", r)
969 }
970 }
971
972 func TestDecodeHookOutputRecoversGB18030WindowsErrors(t *testing.T) {
973 want := `'sh' 不是内部或外部命令,也不是可运行的程序`
974 raw := fileencoding.MustEncode(want, fileencoding.GB18030)
975 if got := decodeHookOutput(raw, false); got != want {
976 t.Fatalf("decoded hook stderr = %q, want %q", got, want)
977 }
978 utf8Text := "Error: Cannot find module 'hook.mjs'\nNode.js v24"
979 if got := decodeHookOutput([]byte(utf8Text), false); got != utf8Text {
980 t.Fatalf("UTF-8 hook stderr changed: %q", got)
981 }
982 }
983
984 func TestDecodeHookOutputPreservesTruncatedUTF8Prefix(t *testing.T) {
985 raw := []byte("中文")[:5]
986 if got, want := decodeHookOutput(raw, true), "中"; got != want {
987 t.Fatalf("truncated UTF-8 output = %q, want %q", got, want)
988 }
989 }
990
991 func TestReasonixHomeOverridesGlobalHookPaths(t *testing.T) {
992 home := t.TempDir()
993 reasonixHome := filepath.Join(t.TempDir(), "rx-home")
994 t.Setenv("HOME", home)
995 t.Setenv("USERPROFILE", home)
996 t.Setenv("REASONIX_HOME", reasonixHome)
997 if err := os.MkdirAll(reasonixHome, 0o755); err != nil {
998 t.Fatal(err)
999 }
1000 if err := os.WriteFile(filepath.Join(reasonixHome, SettingsFilename), []byte(`{"hooks":{"PostToolUse":[{"command":"echo rx"}]}}`), 0o644); err != nil {
1001 t.Fatal(err)
1002 }
1003 writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo old"}]}}`)
1004
1005 if got := GlobalSettingsPath(""); got != filepath.Join(reasonixHome, SettingsFilename) {
1006 t.Fatalf("GlobalSettingsPath = %q, want Reasonix home", got)
1007 }
1008 hooks := Load(LoadOptions{})
1009 if len(hooks) != 1 || hooks[0].Command != "echo rx" {
1010 t.Fatalf("Load hooks = %+v, want Reasonix home hook only", hooks)
1011 }
1012 }
1013
1014 func TestLoadOptionsReasonixHomeDirUsesExactGlobalHookPath(t *testing.T) {
1015 home := t.TempDir()
1016 reasonixHome := filepath.Join(home, "AppData", "Roaming", "reasonix")
1017 settingsPath := filepath.Join(reasonixHome, SettingsFilename)
1018 if err := os.MkdirAll(reasonixHome, 0o755); err != nil {
1019 t.Fatal(err)
1020 }
1021 if err := os.WriteFile(settingsPath, []byte(`{"hooks":{"Stop":[{"command":"echo exact"}]}}`), 0o644); err != nil {
1022 t.Fatal(err)
1023 }
1024
1025 hooks := Load(LoadOptions{HomeDir: home, ReasonixHomeDir: reasonixHome})
1026 if len(hooks) != 1 || hooks[0].Command != "echo exact" || hooks[0].Source != settingsPath {
1027 t.Fatalf("Load hooks = %+v, want exact Reasonix home hook", hooks)
1028 }
1029 }
1030
1031 func TestReasonixHomeDoesNotFallBackToLegacyWhenIsolated(t *testing.T) {
1032 home := t.TempDir()
1033 reasonixHome := filepath.Join(t.TempDir(), "rx-home")
1034 t.Setenv("HOME", home)
1035 t.Setenv("USERPROFILE", home)
1036 t.Setenv("REASONIX_HOME", reasonixHome)
1037 writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo old"}]}}`)
1038
1039 hooks := Load(LoadOptions{})
1040 if len(hooks) != 0 {
1041 t.Fatalf("Load hooks = %+v, want empty (isolated REASONIX_HOME must not load legacy hooks)", hooks)
1042 }
1043
1044 }
1045
1046 func TestProjectDefinesHooks(t *testing.T) {
1047 proj := t.TempDir()
1048 if ProjectDefinesHooks(proj) {
1049 t.Error("empty project should define no hooks")
1050 }
1051 writeSettings(t, proj, sampleSettings)
1052 if !ProjectDefinesHooks(proj) {
1053 t.Error("project with settings.json should define hooks")
1054 }
1055 }
1056
1057 func TestMalformedSettingsIgnored(t *testing.T) {
1058 home := t.TempDir()
1059 writeSettings(t, home, `{not valid json`)
1060 if got := Load(LoadOptions{HomeDir: home}); len(got) != 0 {
1061 t.Errorf("malformed settings should yield no hooks, got %d", len(got))
1062 }
1063 }
1064
1065 func TestMatchesTool(t *testing.T) {
1066 pre := func(match string) ResolvedHook {
1067 return ResolvedHook{HookConfig: HookConfig{Match: match}, Event: PreToolUse}
1068 }
1069 if MatchesTool(pre("file"), "read_file") {
1070 t.Error(`anchored "file" must not match "read_file"`)
1071 }
1072 if !MatchesTool(pre(".*file"), "read_file") {
1073 t.Error(`".*file" should match "read_file"`)
1074 }
1075 if !MatchesTool(pre("bash"), "bash") {
1076 t.Error(`"bash" should match "bash"`)
1077 }
1078 if !MatchesTool(pre("*"), "anything") || !MatchesTool(pre(""), "anything") {
1079 t.Error(`"*"/"" should match every tool`)
1080 }
1081 if MatchesTool(pre("["), "bash") {
1082 t.Error("malformed regex should not fire")
1083 }
1084 perm := func(match string) ResolvedHook {
1085 return ResolvedHook{HookConfig: HookConfig{Match: match}, Event: PermissionRequest}
1086 }
1087 if !MatchesTool(perm("bash"), "bash") {
1088 t.Error(`PermissionRequest "bash" should match "bash"`)
1089 }
1090 if MatchesTool(perm("bash"), "read_file") {
1091 t.Error(`PermissionRequest "bash" must not match "read_file"`)
1092 }
1093 if MatchesTool(perm("["), "bash") {
1094 t.Error("malformed PermissionRequest regex should not fire")
1095 }
1096 // Non-tool events always match regardless of the match field.
1097 prompt := ResolvedHook{HookConfig: HookConfig{Match: "bash"}, Event: UserPromptSubmit}
1098 if !MatchesTool(prompt, "") {
1099 t.Error("non-tool events should always match")
1100 }
1101 }
1102
1103 func TestMatchesToolTranslatesClaudeToolNames(t *testing.T) {
1104 claude := func(match string) ResolvedHook {
1105 return ResolvedHook{HookConfig: HookConfig{Match: match, PayloadFormat: "claude"}, Event: PreToolUse}
1106 }
1107 if !MatchesTool(claude("Bash"), "bash") {
1108 t.Error(`Claude matcher "Bash" should match Reasonix tool "bash"`)
1109 }
1110 if !MatchesTool(claude("Write|Edit"), "write_file") {
1111 t.Error(`Claude matcher "Write|Edit" should match Reasonix tool "write_file"`)
1112 }
1113 if !MatchesTool(claude("Write|Edit"), "edit_file") {
1114 t.Error(`Claude matcher "Write|Edit" should match Reasonix tool "edit_file"`)
1115 }
1116 if MatchesTool(claude("Bash"), "write_file") {
1117 t.Error(`Claude matcher "Bash" must not match Reasonix tool "write_file"`)
1118 }
1119 // A native (non-Claude) hook's matcher stays in Reasonix's own vocabulary.
1120 native := ResolvedHook{HookConfig: HookConfig{Match: "bash"}, Event: PreToolUse}
1121 if MatchesTool(native, "Bash") {
1122 t.Error("native hook matcher must not be interpreted against Claude tool names")
1123 }
1124 // The subagent tool was renamed "Task" -> "Agent" by Claude; a matcher
1125 // using either name must still fire against Reasonix's "task" tool.
1126 if !MatchesTool(claude("Agent"), "task") {
1127 t.Error(`Claude matcher "Agent" (current name) should match Reasonix tool "task"`)
1128 }
1129 if !MatchesTool(claude("Task"), "task") {
1130 t.Error(`Claude matcher "Task" (legacy alias) should still match Reasonix tool "task"`)
1131 }
1132 if !MatchesTool(claude("AskUserQuestion"), "ask") {
1133 t.Error(`Claude matcher "AskUserQuestion" should match Reasonix tool "ask"`)
1134 }
1135 for _, name := range []string{"bash_output", "wait"} {
1136 if !MatchesTool(claude("TaskOutput"), name) || !MatchesTool(claude("BashOutput"), name) {
1137 t.Errorf(`current "TaskOutput" and legacy "BashOutput" matchers should match Reasonix tool %q`, name)
1138 }
1139 }
1140 if !MatchesTool(claude("TaskStop"), "kill_shell") || !MatchesTool(claude("KillShell"), "kill_shell") {
1141 t.Error(`current "TaskStop" and legacy "KillShell" matchers should match Reasonix tool "kill_shell"`)
1142 }
1143 }
1144
1145 func TestClaudeFacingToolNameUsesCurrentNames(t *testing.T) {
1146 if got := claudeFacingToolName("task"); got != "Agent" {
1147 t.Errorf(`claudeFacingToolName("task") = %q, want "Agent" (current Claude tool name)`, got)
1148 }
1149 if got := claudeFacingToolName("ask"); got != "AskUserQuestion" {
1150 t.Errorf(`claudeFacingToolName("ask") = %q, want "AskUserQuestion"`, got)
1151 }
1152 if got := claudeFacingToolName("run_skill"); got != "Skill" {
1153 t.Errorf(`claudeFacingToolName("run_skill") = %q, want "Skill"`, got)
1154 }
1155 if got := claudeFacingToolName("read_only_skill"); got != "Skill" {
1156 t.Errorf(`claudeFacingToolName("read_only_skill") = %q, want "Skill"`, got)
1157 }
1158 if got := claudeFacingToolName("bash_output"); got != "TaskOutput" {
1159 t.Errorf(`claudeFacingToolName("bash_output") = %q, want "TaskOutput"`, got)
1160 }
1161 if got := claudeFacingToolName("kill_shell"); got != "TaskStop" {
1162 t.Errorf(`claudeFacingToolName("kill_shell") = %q, want "TaskStop"`, got)
1163 }
1164 if got := claudeFacingToolName("wait"); got != "TaskOutput" {
1165 t.Errorf(`claudeFacingToolName("wait") = %q, want "TaskOutput"`, got)
1166 }
1167 // Every subagent-spawning entry point — not just "task" — corresponds to
1168 // Claude's single "Agent" tool, and a matcher can still use the legacy
1169 // "Task" name.
1170 for _, name := range []string{"task", "read_only_task", "parallel_tasks", "explore", "research", "review", "security_review"} {
1171 if got := claudeFacingToolName(name); got != "Agent" {
1172 t.Errorf(`claudeFacingToolName(%q) = %q, want "Agent"`, name, got)
1173 }
1174 claude := ResolvedHook{HookConfig: HookConfig{Match: "Agent", PayloadFormat: "claude"}, Event: PreToolUse}
1175 if !MatchesTool(claude, name) {
1176 t.Errorf(`Claude matcher "Agent" should match Reasonix tool %q`, name)
1177 }
1178 legacy := ResolvedHook{HookConfig: HookConfig{Match: "Task", PayloadFormat: "claude"}, Event: PreToolUse}
1179 if !MatchesTool(legacy, name) {
1180 t.Errorf(`legacy Claude matcher "Task" should still match Reasonix tool %q`, name)
1181 }
1182 }
1183 }
1184
1185 func TestClaudeFacingToolInputAdaptsMappedTools(t *testing.T) {
1186 cases := []struct {
1187 name string
1188 toolName string
1189 args string
1190 want string
1191 }{
1192 {"write_file", "write_file", `{"path":"a.txt","content":"hi"}`, `{"content":"hi","file_path":"a.txt"}`},
1193 {"edit_file", "edit_file", `{"path":"a.txt","old_string":"x","new_string":"y"}`, `{"file_path":"a.txt","new_string":"y","old_string":"x"}`},
1194 {"read_file", "read_file", `{"path":"a.txt"}`, `{"file_path":"a.txt"}`},
1195 {"multi_edit", "multi_edit", `{"path":"a.txt","edits":[]}`, `{"edits":[],"file_path":"a.txt"}`},
1196 {"notebook_edit", "notebook_edit", `{"path":"nb.ipynb","cell_id":"c1","new_source":"x"}`, `{"notebook_path":"nb.ipynb","cell_id":"c1","new_source":"x"}`},
1197 {"notebook-edit-delete-default-source", "notebook_edit", `{"path":"nb.ipynb","cell_number":2,"edit_mode":"delete"}`, `{"notebook_path":"nb.ipynb","cell_number":2,"edit_mode":"delete","new_source":""}`},
1198 {"notebook-edit-source-alias", "notebook_edit", `{"path":"nb.ipynb","cell_id":"c1","content":"x"}`, `{"notebook_path":"nb.ipynb","cell_id":"c1","content":"x","new_source":"x"}`},
1199 {"run_skill", "run_skill", `{"name":"deploy","arguments":"prod"}`, `{"skill":"deploy","args":"prod"}`},
1200 {"read_only_skill", "read_only_skill", `{"name":"explore","arguments":"map the auth flow"}`, `{"skill":"explore","args":"map the auth flow"}`},
1201 {"task-output", "bash_output", `{"job_id":"bash-1","filter":"err"}`, `{"task_id":"bash-1","filter":"err","block":false,"timeout":0}`},
1202 {"task-output-wait-one", "wait", `{"job_ids":["task-1"],"timeout_seconds":3}`, `{"job_ids":["task-1"],"timeout_seconds":3,"task_id":"task-1","block":true,"timeout":3000}`},
1203 {"task-output-wait-many", "wait", `{"job_ids":["task-1","task-2"]}`, `{"job_ids":["task-1","task-2"],"block":true}`},
1204 {"task-stop", "kill_shell", `{"job_id":"bash-1"}`, `{"task_id":"bash-1"}`},
1205 {"ask-defaults", "ask", `{"questions":[{"question":"Which?","header":"Choice","options":[{"label":"A"},{"label":"B","description":"Keep B"}]}]}`, `{"questions":[{"question":"Which?","header":"Choice","multiSelect":false,"options":[{"label":"A","description":""},{"label":"B","description":"Keep B"}]}]}`},
1206 {"todo-input-unchanged", "todo_write", `{"todos":[{"content":"Run tests","status":"pending"},{"content":"Ship it","status":"completed","activeForm":"Shipping it"}]}`, `{"todos":[{"content":"Run tests","status":"pending"},{"content":"Ship it","status":"completed","activeForm":"Shipping it"}]}`},
1207 {"task-default-description", "task", `{"prompt":"do it"}`, `{"prompt":"do it","description":"Run delegated subagent task"}`},
1208 {"task-explicit-description", "task", `{"prompt":"do it","description":"Inspect the auth flow"}`, `{"prompt":"do it","description":"Inspect the auth flow"}`},
1209 {"read-only-task-default-description", "read_only_task", `{"prompt":"inspect it"}`, `{"prompt":"inspect it","description":"Run read-only research task"}`},
1210 {"explore-wrapper", "explore", `{"task":"find all callers of X"}`, `{"prompt":"find all callers of X","description":"Explore the codebase"}`},
1211 {"research-wrapper", "research", `{"task":"compare the SDK"}`, `{"prompt":"compare the SDK","description":"Research external references"}`},
1212 {"review-wrapper", "review", `{"task":"review the diff"}`, `{"prompt":"review the diff","description":"Review the current changes"}`},
1213 {"security-review-wrapper", "security_review", `{"task":"audit the diff"}`, `{"prompt":"audit the diff","description":"Review security risks"}`},
1214 {"web_fetch-unchanged", "web_fetch", `{"url":"https://example.com"}`, `{"url":"https://example.com"}`},
1215 {"bash-unchanged", "bash", `{"command":"ls"}`, `{"command":"ls"}`},
1216 {"grep-unchanged", "grep", `{"pattern":"foo","path":"."}`, `{"pattern":"foo","path":"."}`},
1217 }
1218 for _, c := range cases {
1219 t.Run(c.name, func(t *testing.T) {
1220 got := claudeFacingToolInput(c.toolName, json.RawMessage(c.args), "")
1221 var gotObj, wantObj map[string]any
1222 if err := json.Unmarshal(got, &gotObj); err != nil {
1223 t.Fatalf("got invalid JSON %q: %v", got, err)
1224 }
1225 if err := json.Unmarshal([]byte(c.want), &wantObj); err != nil {
1226 t.Fatalf("bad test want: %v", err)
1227 }
1228 if !reflect.DeepEqual(gotObj, wantObj) {
1229 t.Fatalf("got = %s, want %s", got, c.want)
1230 }
1231 })
1232 }
1233 }
1234
1235 // TestClaudeFacingToolInputResolvesAbsolutePaths checks the Claude file-tool
1236 // contract ("file_path must be absolute"): a relative Reasonix path resolves
1237 // against the payload cwd — the same root the tool itself resolves against —
1238 // so a prefix-matching guard sees the path the tool actually accesses.
1239 func TestClaudeFacingToolInputResolvesAbsolutePaths(t *testing.T) {
1240 cwd := t.TempDir()
1241 got := claudeFacingToolInput("write_file", json.RawMessage(`{"path":"secrets/.env","content":"KEY=1"}`), cwd)
1242 var obj map[string]any
1243 if err := json.Unmarshal(got, &obj); err != nil {
1244 t.Fatalf("got invalid JSON %q: %v", got, err)
1245 }
1246 if want := filepath.Join(cwd, "secrets", ".env"); obj["file_path"] != want {
1247 t.Errorf("file_path = %v, want absolute %q", obj["file_path"], want)
1248 }
1249
1250 got = claudeFacingToolInput("notebook_edit", json.RawMessage(`{"path":"nb.ipynb","cell_id":"c1"}`), cwd)
1251 if err := json.Unmarshal(got, &obj); err != nil {
1252 t.Fatalf("got invalid JSON %q: %v", got, err)
1253 }
1254 if want := filepath.Join(cwd, "nb.ipynb"); obj["notebook_path"] != want {
1255 t.Errorf("notebook_path = %v, want absolute %q", obj["notebook_path"], want)
1256 }
1257
1258 // An already-absolute path is honored verbatim, mirroring resolveIn.
1259 abs := filepath.Join(cwd, "direct.txt")
1260 body, _ := json.Marshal(map[string]string{"path": abs})
1261 got = claudeFacingToolInput("read_file", body, cwd)
1262 if err := json.Unmarshal(got, &obj); err != nil {
1263 t.Fatalf("got invalid JSON %q: %v", got, err)
1264 }
1265 if obj["file_path"] != abs {
1266 t.Errorf("file_path = %v, want untouched absolute %q", obj["file_path"], abs)
1267 }
1268
1269 // With no cwd to resolve against, the relative path passes through.
1270 got = claudeFacingToolInput("read_file", json.RawMessage(`{"path":"a.txt"}`), "")
1271 if err := json.Unmarshal(got, &obj); err != nil {
1272 t.Fatalf("got invalid JSON %q: %v", got, err)
1273 }
1274 if obj["file_path"] != "a.txt" {
1275 t.Errorf("file_path = %v, want relative passthrough with empty cwd", obj["file_path"])
1276 }
1277 }
1278
1279 // TestClaudeFacingToolInputParallelTasksSynthesizesPrompt checks the
1280 // structural adapter: parallel_tasks maps to Claude's Agent tool, so an
1281 // Agent-scoped guard reading .tool_input.prompt must see every sub-task's
1282 // prompt instead of failing open on a missing field.
1283 func TestClaudeFacingToolInputParallelTasksSynthesizesPrompt(t *testing.T) {
1284 args := json.RawMessage(`{"tasks":[{"prompt":"scan auth","description":"a"},{"prompt":"scan crypto"}]}`)
1285 got := claudeFacingToolInput("parallel_tasks", args, "")
1286 var obj map[string]any
1287 if err := json.Unmarshal(got, &obj); err != nil {
1288 t.Fatalf("got invalid JSON %q: %v", got, err)
1289 }
1290 if obj["prompt"] != "scan auth\n\nscan crypto" {
1291 t.Errorf("prompt = %q, want the joined sub-task prompts", obj["prompt"])
1292 }
1293 if obj["description"] != "Run parallel subagent tasks" {
1294 t.Errorf("description = %q, want a stable Claude Agent description", obj["description"])
1295 }
1296 if _, kept := obj["tasks"]; !kept {
1297 t.Error("original tasks array should stay alongside the synthesized prompt")
1298 }
1299
1300 // Malformed or empty tasks stay untouched rather than fabricating input.
1301 if got := claudeFacingToolInput("parallel_tasks", json.RawMessage(`{"tasks":[]}`), ""); string(got) != `{"tasks":[]}` {
1302 t.Errorf("empty tasks = %s, want passthrough", got)
1303 }
1304 }
1305
1306 func TestClaudeFacingToolInputPassthroughEdgeCases(t *testing.T) {
1307 if got := claudeFacingToolInput("write_file", json.RawMessage(""), ""); string(got) != "" {
1308 t.Errorf("empty args = %q, want empty passthrough", got)
1309 }
1310 if got := claudeFacingToolInput("write_file", json.RawMessage("not json"), ""); string(got) != "not json" {
1311 t.Errorf("malformed args = %q, want unchanged passthrough", got)
1312 }
1313 }
1314
1315 func TestDecideOutcome(t *testing.T) {
1316 cases := []struct {
1317 name string
1318 event Event
1319 format string
1320 r SpawnResult
1321 want Decision
1322 }{
1323 {"pass", PreToolUse, "", SpawnResult{ExitCode: 0}, DecisionPass},
1324 {"block-exit2", PreToolUse, "", SpawnResult{ExitCode: 2}, DecisionBlock},
1325 {"exit2-nonblocking-warns", PostToolUse, "", SpawnResult{ExitCode: 2}, DecisionWarn},
1326 {"permission-exit2-warns", PermissionRequest, "", SpawnResult{ExitCode: 2}, DecisionWarn},
1327 {"other-nonzero-warns", PreToolUse, "", SpawnResult{ExitCode: 1}, DecisionWarn},
1328 {"timeout-blocking", UserPromptSubmit, "", SpawnResult{TimedOut: true}, DecisionBlock},
1329 {"permission-timeout-warns", PermissionRequest, "", SpawnResult{TimedOut: true}, DecisionWarn},
1330 {"timeout-nonblocking", Stop, "", SpawnResult{TimedOut: true}, DecisionWarn},
1331 {"spawn-error", PreToolUse, "", SpawnResult{SpawnErr: os.ErrNotExist}, DecisionError},
1332 // Claude's own PermissionRequest contract blocks on exit 2/timeout the
1333 // same way PreToolUse does; native Reasonix PermissionRequest hooks
1334 // (format == "") stay advisory-only, verified above.
1335 {"claude-permission-exit2-blocks", PermissionRequest, "claude", SpawnResult{ExitCode: 2}, DecisionBlock},
1336 {"claude-permission-timeout-blocks", PermissionRequest, "claude", SpawnResult{TimedOut: true}, DecisionBlock},
1337 }
1338 for _, c := range cases {
1339 h := ResolvedHook{Event: c.event, HookConfig: HookConfig{PayloadFormat: c.format}}
1340 if got := decideOutcome(h, c.r); got != c.want {
1341 t.Errorf("%s: decideOutcome = %s, want %s", c.name, got, c.want)
1342 }
1343 }
1344 }
1345
1346 func TestClaudeJSONDeny(t *testing.T) {
1347 cases := []struct {
1348 name string
1349 event Event
1350 stdout string
1351 wantDeny bool
1352 wantReason string
1353 }{
1354 {
1355 name: "pretooluse-permission-decision-deny",
1356 event: PreToolUse,
1357 stdout: `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"rm -rf blocked"}}`,
1358 wantDeny: true,
1359 wantReason: "rm -rf blocked",
1360 },
1361 {
1362 name: "pretooluse-permission-decision-allow",
1363 event: PreToolUse,
1364 stdout: `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}`,
1365 wantDeny: false,
1366 },
1367 {
1368 name: "permissionrequest-decision-behavior-deny",
1369 event: PermissionRequest,
1370 stdout: `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}`,
1371 wantDeny: true,
1372 wantReason: "",
1373 },
1374 {
1375 name: "non-json-stdout-never-denies",
1376 event: PreToolUse,
1377 stdout: "looks fine",
1378 wantDeny: false,
1379 },
1380 {
1381 name: "unsupported-event-never-denies",
1382 event: PostToolUse,
1383 stdout: `{"hookSpecificOutput":{"hookEventName":"PostToolUse","permissionDecision":"deny"}}`,
1384 wantDeny: false,
1385 },
1386 {
1387 name: "userpromptsubmit-top-level-decision-block",
1388 event: UserPromptSubmit,
1389 stdout: `{"decision":"block","reason":"prompt contains a secret"}`,
1390 wantDeny: true,
1391 wantReason: "prompt contains a secret",
1392 },
1393 {
1394 name: "userpromptsubmit-top-level-decision-approve",
1395 event: UserPromptSubmit,
1396 stdout: `{"decision":"approve"}`,
1397 wantDeny: false,
1398 },
1399 }
1400 for _, c := range cases {
1401 t.Run(c.name, func(t *testing.T) {
1402 deny, reason := claudeJSONDeny(c.event, c.stdout)
1403 if deny != c.wantDeny {
1404 t.Errorf("deny = %v, want %v", deny, c.wantDeny)
1405 }
1406 if reason != c.wantReason {
1407 t.Errorf("reason = %q, want %q", reason, c.wantReason)
1408 }
1409 })
1410 }
1411 }
1412
1413 func TestClaudeJSONAllow(t *testing.T) {
1414 if !claudeJSONAllow(PermissionRequest, `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`) {
1415 t.Error(`PermissionRequest decision.behavior "allow" should report allow`)
1416 }
1417 if claudeJSONAllow(PermissionRequest, `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}`) {
1418 t.Error(`decision.behavior "deny" must not report allow`)
1419 }
1420 if claudeJSONAllow(PreToolUse, `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}`) {
1421 t.Error("only PermissionRequest carries an auto-allow decision")
1422 }
1423 }
1424
1425 func TestParseOutputSessionStartJSONAdditionalContext(t *testing.T) {
1426 out, warnings := ParseOutput(SessionStart, `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Load conventions."}}`)
1427 if len(warnings) != 0 {
1428 t.Fatalf("warnings = %v, want none", warnings)
1429 }
1430 if out.AdditionalContext != "Load conventions." {
1431 t.Fatalf("AdditionalContext = %q, want context", out.AdditionalContext)
1432 }
1433 }
1434
1435 func TestParseOutputSessionStartPlainText(t *testing.T) {
1436 out, warnings := ParseOutput(SessionStart, " Load workspace notes. ")
1437 if len(warnings) != 0 {
1438 t.Fatalf("warnings = %v, want none", warnings)
1439 }
1440 if out.AdditionalContext != "Load workspace notes." {
1441 t.Fatalf("AdditionalContext = %q, want plain text", out.AdditionalContext)
1442 }
1443 }
1444
1445 func TestParseOutputRejectsMismatchedEvent(t *testing.T) {
1446 out, warnings := ParseOutput(SessionStart, `{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"wrong"}}`)
1447 if out.AdditionalContext != "" {
1448 t.Fatalf("AdditionalContext = %q, want empty", out.AdditionalContext)
1449 }
1450 if len(warnings) != 1 {
1451 t.Fatalf("warnings = %v, want one warning", warnings)
1452 }
1453 }
1454
1455 func TestParseOutputInvalidJSONWarns(t *testing.T) {
1456 out, warnings := ParseOutput(SessionStart, `{"hookSpecificOutput":`)
1457 if out.AdditionalContext != "" {
1458 t.Fatalf("AdditionalContext = %q, want empty", out.AdditionalContext)
1459 }
1460 if len(warnings) != 1 {
1461 t.Fatalf("warnings = %v, want one warning", warnings)
1462 }
1463 }
1464
1465 func TestRunStopsAtFirstBlock(t *testing.T) {
1466 hooks := []ResolvedHook{
1467 {HookConfig: HookConfig{Command: "first"}, Event: PreToolUse, Scope: ScopeProject},
1468 {HookConfig: HookConfig{Command: "second"}, Event: PreToolUse, Scope: ScopeProject},
1469 }
1470 var ran []string
1471 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
1472 ran = append(ran, in.Command)
1473 return SpawnResult{ExitCode: 2} // first blocks
1474 }
1475 rep := Run(context.Background(), Payload{Event: PreToolUse, ToolName: "bash"}, hooks, spawner)
1476 if !rep.Blocked {
1477 t.Error("report should be blocked")
1478 }
1479 if len(ran) != 1 || ran[0] != "first" {
1480 t.Errorf("should stop after the first block, ran %v", ran)
1481 }
1482 }
1483
1484 func TestRunHonorsClaudeJSONDenyOnExitZero(t *testing.T) {
1485 hooks := []ResolvedHook{
1486 {HookConfig: HookConfig{Command: "guard", PayloadFormat: "claude"}, Event: PreToolUse},
1487 }
1488 denyJSON := `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"rm -rf blocked"}}`
1489 rep := Run(context.Background(), Payload{Event: PreToolUse, ToolName: "bash"}, hooks,
1490 func(_ context.Context, in SpawnInput) SpawnResult { return SpawnResult{ExitCode: 0, Stdout: denyJSON} })
1491 if !rep.Blocked {
1492 t.Fatal("exit-0 hook with a Claude JSON deny decision should block")
1493 }
1494 if rep.Outcomes[0].Decision != DecisionBlock {
1495 t.Errorf("Decision = %s, want block", rep.Outcomes[0].Decision)
1496 }
1497 }
1498
1499 func TestRunNativeHookIgnoresPermissionDecisionField(t *testing.T) {
1500 // A native (non-Claude) hook's stdout happening to contain a field named
1501 // "permissionDecision" must not gain new blocking power — only imported
1502 // Claude hooks (PayloadFormat "claude") opt into that contract.
1503 hooks := []ResolvedHook{
1504 {HookConfig: HookConfig{Command: "guard"}, Event: PreToolUse},
1505 }
1506 denyJSON := `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny"}}`
1507 rep := Run(context.Background(), Payload{Event: PreToolUse, ToolName: "bash"}, hooks,
1508 func(_ context.Context, in SpawnInput) SpawnResult { return SpawnResult{ExitCode: 0, Stdout: denyJSON} })
1509 if rep.Blocked {
1510 t.Fatal("native hook JSON output should not be interpreted as a Claude deny decision")
1511 }
1512 }
1513
1514 func TestRunClaudePermissionRequestExit2Blocks(t *testing.T) {
1515 hooks := []ResolvedHook{
1516 {HookConfig: HookConfig{Command: "guard", PayloadFormat: "claude"}, Event: PermissionRequest},
1517 }
1518 rep := Run(context.Background(), Payload{Event: PermissionRequest, ToolName: "bash"}, hooks,
1519 func(_ context.Context, in SpawnInput) SpawnResult { return SpawnResult{ExitCode: 2} })
1520 if !rep.Blocked {
1521 t.Fatal("Claude-imported PermissionRequest hook exiting 2 should block")
1522 }
1523 }
1524
1525 func TestRunClaudePermissionRequestJSONAllow(t *testing.T) {
1526 hooks := []ResolvedHook{
1527 {HookConfig: HookConfig{Command: "guard", PayloadFormat: "claude"}, Event: PermissionRequest},
1528 }
1529 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
1530 rep := Run(context.Background(), Payload{Event: PermissionRequest, ToolName: "bash"}, hooks,
1531 func(_ context.Context, in SpawnInput) SpawnResult { return SpawnResult{ExitCode: 0, Stdout: allowJSON} })
1532 if rep.Blocked {
1533 t.Fatal("an allow decision must not block")
1534 }
1535 if !rep.Allowed {
1536 t.Fatal("exit-0 hook with a Claude JSON allow decision should set Report.Allowed")
1537 }
1538 }
1539
1540 func TestRunHonorsUserPromptSubmitTopLevelDeny(t *testing.T) {
1541 hooks := []ResolvedHook{
1542 {HookConfig: HookConfig{Command: "guard", PayloadFormat: "claude"}, Event: UserPromptSubmit},
1543 }
1544 denyJSON := `{"decision":"block","reason":"prompt contains a secret"}`
1545 rep := Run(context.Background(), Payload{Event: UserPromptSubmit}, hooks,
1546 func(_ context.Context, in SpawnInput) SpawnResult { return SpawnResult{ExitCode: 0, Stdout: denyJSON} })
1547 if !rep.Blocked {
1548 t.Fatal("exit-0 UserPromptSubmit hook with a top-level decision:block should block")
1549 }
1550 }
1551
1552 func TestRunFiltersByEventAndTool(t *testing.T) {
1553 hooks := []ResolvedHook{
1554 {HookConfig: HookConfig{Command: "a", Match: "bash"}, Event: PreToolUse},
1555 {HookConfig: HookConfig{Command: "b", Match: "read_file"}, Event: PreToolUse},
1556 {HookConfig: HookConfig{Command: "c"}, Event: PostToolUse},
1557 }
1558 var ran []string
1559 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
1560 ran = append(ran, in.Command)
1561 return SpawnResult{ExitCode: 0}
1562 }
1563 Run(context.Background(), Payload{Event: PreToolUse, ToolName: "bash"}, hooks, spawner)
1564 if len(ran) != 1 || ran[0] != "a" {
1565 t.Errorf("only the matching PreToolUse hook should run, got %v", ran)
1566 }
1567 }
1568
1569 func TestRunClaudePayloadAndDirectArgs(t *testing.T) {
1570 hooks := []ResolvedHook{{
1571 HookConfig: HookConfig{
1572 Command: "/tmp/agent-critter",
1573 Argv: []string{"--hook"},
1574 ExecutionMode: ExecutionExec,
1575 PayloadFormat: "claude",
1576 },
1577 Event: PostToolUseFailure,
1578 }}
1579 var input SpawnInput
1580 Run(context.Background(), Payload{
1581 Event: PostToolUseFailure, SessionID: "session-1", Cwd: "/workspace",
1582 ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"false"}`),
1583 ToolResult: "remote: denied", Error: "exit 1",
1584 }, hooks, func(_ context.Context, in SpawnInput) SpawnResult { input = in; return SpawnResult{ExitCode: 0} })
1585 if input.Command != "/tmp/agent-critter" || input.Mode != ExecutionExec || len(input.Args) != 1 || input.Args[0] != "--hook" {
1586 t.Fatalf("direct hook input = %+v", input)
1587 }
1588 var payload map[string]any
1589 if err := json.Unmarshal([]byte(input.Stdin), &payload); err != nil {
1590 t.Fatal(err)
1591 }
1592 if payload["hook_event_name"] != string(PostToolUseFailure) || payload["session_id"] != "session-1" || payload["error"] != "exit 1" {
1593 t.Fatalf("Claude payload = %#v", payload)
1594 }
1595 if payload["tool_name"] != "Bash" {
1596 t.Fatalf("Claude payload tool_name = %v, want the Claude vocabulary name Bash for Reasonix tool bash", payload["tool_name"])
1597 }
1598 response, ok := payload["tool_response"].(map[string]any)
1599 if !ok || response["stdout"] != "remote: denied" || response["stderr"] != "exit 1" || response["interrupted"] != false {
1600 t.Fatalf("Claude tool_response = %#v, want Claude's Bash shape {stdout, stderr, interrupted}", payload["tool_response"])
1601 }
1602 if _, exists := payload["event"]; exists {
1603 t.Fatalf("native payload field leaked into Claude payload: %#v", payload)
1604 }
1605 }
1606
1607 // TestRunClaudeWriteFileGuardFiresAndSeesFilePath is an end-to-end check that
1608 // a Claude plugin's "block writes to secrets" style PreToolUse guard —
1609 // matcher "Write", reading .tool_input.file_path — actually fires against a
1610 // Reasonix write_file call and sees the absolute target path Claude's
1611 // file-tool contract specifies.
1612 func TestRunClaudeWriteFileGuardFiresAndSeesFilePath(t *testing.T) {
1613 cwd := t.TempDir()
1614 hooks := []ResolvedHook{{
1615 HookConfig: HookConfig{Command: "guard", Match: "Write", PayloadFormat: "claude"},
1616 Event: PreToolUse,
1617 }}
1618 var input SpawnInput
1619 Run(context.Background(), Payload{
1620 Event: PreToolUse, Cwd: cwd, ToolName: "write_file",
1621 ToolArgs: json.RawMessage(`{"path":"secrets/.env","content":"KEY=1"}`),
1622 }, hooks, func(_ context.Context, in SpawnInput) SpawnResult { input = in; return SpawnResult{ExitCode: 0} })
1623 if input.Command == "" {
1624 t.Fatal(`matcher "Write" did not fire for Reasonix tool "write_file"`)
1625 }
1626 var payload map[string]any
1627 if err := json.Unmarshal([]byte(input.Stdin), &payload); err != nil {
1628 t.Fatal(err)
1629 }
1630 toolInput, ok := payload["tool_input"].(map[string]any)
1631 if !ok {
1632 t.Fatalf("tool_input = %#v, want an object", payload["tool_input"])
1633 }
1634 if want := filepath.Join(cwd, "secrets", ".env"); toolInput["file_path"] != want {
1635 t.Fatalf(`tool_input.file_path = %v, want absolute %q (a prefix-matching guard must see the path the tool accesses)`, toolInput["file_path"], want)
1636 }
1637 if _, hasPath := toolInput["path"]; hasPath {
1638 t.Fatalf("tool_input still has Reasonix's \"path\" key: %#v", toolInput)
1639 }
1640 }
1641
1642 // TestRunClaudeAgentGuardFiresAndSeesRequiredFields covers the full matcher to
1643 // stdin path for a dedicated Reasonix subagent wrapper. Claude Agent requires
1644 // both prompt and description even though the wrapper only accepts task.
1645 func TestRunClaudeAgentGuardFiresAndSeesRequiredFields(t *testing.T) {
1646 hooks := []ResolvedHook{{
1647 HookConfig: HookConfig{Command: "guard", Match: "Agent", PayloadFormat: "claude"},
1648 Event: PreToolUse,
1649 }}
1650 var input SpawnInput
1651 Run(context.Background(), Payload{
1652 Event: PreToolUse, ToolName: "security_review",
1653 ToolArgs: json.RawMessage(`{"task":"audit the auth changes"}`),
1654 }, hooks, func(_ context.Context, in SpawnInput) SpawnResult { input = in; return SpawnResult{ExitCode: 0} })
1655 if input.Command == "" {
1656 t.Fatal(`matcher "Agent" did not fire for Reasonix tool "security_review"`)
1657 }
1658 var payload map[string]any
1659 if err := json.Unmarshal([]byte(input.Stdin), &payload); err != nil {
1660 t.Fatal(err)
1661 }
1662 if payload["tool_name"] != "Agent" {
1663 t.Fatalf("tool_name = %v, want Agent", payload["tool_name"])
1664 }
1665 toolInput, ok := payload["tool_input"].(map[string]any)
1666 if !ok || toolInput["prompt"] != "audit the auth changes" || toolInput["description"] != "Review security risks" {
1667 t.Fatalf("tool_input = %#v, want Claude Agent prompt and description", payload["tool_input"])
1668 }
1669 }
1670
1671 func TestClaudeToolResponsePreservesPlainText(t *testing.T) {
1672 stdin := marshalPayload(Payload{Event: PostToolUse, ToolName: "read_file", ToolResult: "plain output"}, "claude")
1673 var payload map[string]any
1674 if err := json.Unmarshal([]byte(stdin), &payload); err != nil {
1675 t.Fatal(err)
1676 }
1677 if payload["tool_response"] != "plain output" {
1678 t.Fatalf("tool_response = %#v, want plain output", payload["tool_response"])
1679 }
1680 }
1681
1682 // TestClaudeToolResponseBashShape checks that a Bash tool_response is the
1683 // object Claude's contract (and the official security-guidance plugin's
1684 // commit/push checks) expect — {stdout, stderr, interrupted} — never a bare
1685 // string, and never raw JSON even when the command's output happens to be a
1686 // valid JSON document.
1687 func TestClaudeToolResponseBashShape(t *testing.T) {
1688 stdin := marshalPayload(Payload{Event: PostToolUse, ToolName: "bash", ToolResult: `{"looks":"like json"}`}, "claude")
1689 var payload map[string]any
1690 if err := json.Unmarshal([]byte(stdin), &payload); err != nil {
1691 t.Fatal(err)
1692 }
1693 response, ok := payload["tool_response"].(map[string]any)
1694 if !ok {
1695 t.Fatalf("tool_response = %#v, want an object", payload["tool_response"])
1696 }
1697 if response["stdout"] != `{"looks":"like json"}` || response["stderr"] != "" || response["interrupted"] != false {
1698 t.Fatalf("tool_response = %#v, want {stdout: <combined output>, stderr: \"\", interrupted: false}", response)
1699 }
1700
1701 // An interrupted failure carries the error and the interrupt flag.
1702 stdin = marshalPayload(Payload{
1703 Event: PostToolUseFailure, ToolName: "bash",
1704 ToolResult: "partial", Error: "context canceled", IsInterrupt: true,
1705 }, "claude")
1706 if err := json.Unmarshal([]byte(stdin), &payload); err != nil {
1707 t.Fatal(err)
1708 }
1709 response, ok = payload["tool_response"].(map[string]any)
1710 if !ok || response["stdout"] != "partial" || response["stderr"] != "context canceled" || response["interrupted"] != true {
1711 t.Fatalf("failure tool_response = %#v, want {stdout, stderr, interrupted:true}", payload["tool_response"])
1712 }
1713
1714 // PreToolUse has no result yet: no fabricated Bash response object.
1715 stdin = marshalPayload(Payload{Event: PreToolUse, ToolName: "bash", ToolArgs: json.RawMessage(`{"command":"ls"}`)}, "claude")
1716 if err := json.Unmarshal([]byte(stdin), &payload); err != nil {
1717 t.Fatal(err)
1718 }
1719 if payload["tool_response"] != "" {
1720 t.Fatalf("PreToolUse tool_response = %#v, want the empty passthrough", payload["tool_response"])
1721 }
1722 }
1723
1724 func TestRunAsyncHookReturnsBeforeSpawnerFinishes(t *testing.T) {
1725 started := make(chan struct{})
1726 release := make(chan struct{})
1727 hooks := []ResolvedHook{{HookConfig: HookConfig{Command: "critter", Async: true}, Event: Stop}}
1728 rep := Run(context.Background(), Payload{Event: Stop}, hooks, func(context.Context, SpawnInput) SpawnResult {
1729 close(started)
1730 <-release
1731 return SpawnResult{ExitCode: 0}
1732 })
1733 if len(rep.Outcomes) != 1 || rep.Outcomes[0].Decision != DecisionPass {
1734 t.Fatalf("report = %+v", rep)
1735 }
1736 select {
1737 case <-started:
1738 case <-time.After(5 * time.Second):
1739 t.Fatal("async hook did not start")
1740 }
1741 close(release)
1742 }
1743
1744 func TestRunFiltersPermissionRequestByTool(t *testing.T) {
1745 hooks := []ResolvedHook{
1746 {HookConfig: HookConfig{Command: "a", Match: "bash"}, Event: PermissionRequest},
1747 {HookConfig: HookConfig{Command: "b", Match: "read_file"}, Event: PermissionRequest},
1748 {HookConfig: HookConfig{Command: "c"}, Event: Notification},
1749 }
1750 var ran []string
1751 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
1752 ran = append(ran, in.Command)
1753 return SpawnResult{ExitCode: 0}
1754 }
1755 Run(context.Background(), Payload{Event: PermissionRequest, ToolName: "bash"}, hooks, spawner)
1756 if len(ran) != 1 || ran[0] != "a" {
1757 t.Errorf("only the matching PermissionRequest hook should run, got %v", ran)
1758 }
1759 }
1760
1761 func TestDefaultSpawner(t *testing.T) {
1762 if runtime.GOOS == "windows" {
1763 t.Skip("uses a POSIX shell")
1764 }
1765 ctx := context.Background()
1766 // exit 0 with stdout
1767 r := DefaultSpawner(ctx, SpawnInput{Command: "printf hi", Timeout: realSpawnTimeout})
1768 if r.ExitCode != 0 || r.Stdout != "hi" {
1769 t.Errorf("expected exit 0 / hi, got code=%d out=%q err=%v", r.ExitCode, r.Stdout, r.SpawnErr)
1770 }
1771 // exit 2 (block verdict on a gating event)
1772 r = DefaultSpawner(ctx, SpawnInput{Command: "exit 2", Timeout: realSpawnTimeout})
1773 if r.ExitCode != 2 {
1774 t.Errorf("expected exit 2, got %d", r.ExitCode)
1775 }
1776 // stdin is delivered as the payload
1777 r = DefaultSpawner(ctx, SpawnInput{Command: "cat", Stdin: "payload-here", Timeout: realSpawnTimeout})
1778 if r.Stdout != "payload-here" {
1779 t.Errorf("stdin not delivered: %q", r.Stdout)
1780 }
1781 // timeout kills the command
1782 r = DefaultSpawner(ctx, SpawnInput{Command: "sleep 5", Timeout: 100 * time.Millisecond})
1783 if !r.TimedOut {
1784 t.Errorf("expected timeout, got %+v", r)
1785 }
1786 }
1787
1788 func TestDefaultSpawnerExplicitShellPreservesCompoundScript(t *testing.T) {
1789 if runtime.GOOS == "windows" {
1790 t.Skip("Windows shell selection is covered by windows_batch_test.go")
1791 }
1792 r := DefaultSpawner(context.Background(), SpawnInput{
1793 Command: `printf '%s' "$HOOK_TEST_MARKER" && printf '%s' '|done'`,
1794 Mode: ExecutionShell,
1795 Shell: "bash",
1796 Env: map[string]string{"HOOK_TEST_MARKER": "shell"},
1797 Timeout: realSpawnTimeout,
1798 })
1799 if r.ExitCode != 0 || r.Stdout != "shell|done" {
1800 t.Fatalf("explicit shell-form hook failed: %+v", r)
1801 }
1802 }
1803
1804 func TestPowerShellCommandEncodesScriptWithoutQuoteReparsing(t *testing.T) {
1805 command := `Write-Output "a && 'b'"; $value = "C:\Program Files\hook"`
1806 cmd := powerShellCommand(context.Background(), "powershell", command)
1807 if got, want := cmd.Args[:4], []string{"powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand"}; !reflect.DeepEqual(got, want) {
1808 t.Fatalf("PowerShell argv prefix = %#v, want %#v", got, want)
1809 }
1810 decoded, err := decodePowerShellCommandForTest(cmd.Args[4])
1811 if err != nil {
1812 t.Fatal(err)
1813 }
1814 if got, want := decoded, sandbox.PowerShellUTF8Script(command); got != want {
1815 t.Fatalf("decoded command = %q, want %q", got, want)
1816 }
1817 if strings.Contains(cmd.Args[4], command) {
1818 t.Fatalf("raw script leaked into Windows command-line quoting: %#v", cmd.Args)
1819 }
1820 }
1821
1822 func TestDefaultSpawnerOutputCap(t *testing.T) {
1823 if runtime.GOOS == "windows" {
1824 t.Skip("uses a POSIX shell")
1825 }
1826 // Emit more than the cap; expect truncation flagged and bounded capture.
1827 r := DefaultSpawner(context.Background(), SpawnInput{
1828 Command: "yes x | head -c 400000",
1829 Timeout: realSpawnTimeout,
1830 })
1831 if !r.Truncated {
1832 t.Error("oversized output should be flagged truncated")
1833 }
1834 if len(r.Stdout) > outputCapBytes {
1835 t.Errorf("captured output %d exceeds cap %d", len(r.Stdout), outputCapBytes)
1836 }
1837 }
1838
1839 // TestWellFormedNodeEvalKeepsShellSemantics pins the execution contract for
1840 // commands that never needed repair: hook commands are documented to run
1841 // through the shell, and existing user hooks may rely on shell expansion.
1842 // A well-formed node -e stdin-hook command must therefore keep $VAR expansion
1843 // on POSIX — only repaired commands (whose broken quoting means they never
1844 // worked through a shell) may take the direct-exec path.
1845 func TestWellFormedNodeEvalKeepsShellSemantics(t *testing.T) {
1846 if runtime.GOOS == "windows" {
1847 t.Skip("cmd does not perform POSIX $ expansion; Windows intentionally direct-execs recognized node evals")
1848 }
1849 requireNode(t)
1850 command := `node -e "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log('$HOOK_TEST_MARKER' + payload.toolName)"`
1851 if got := NormalizeCommand(command); got != command {
1852 t.Fatalf("well-formed command was rewritten: %q", got)
1853 }
1854 r := DefaultSpawner(context.Background(), SpawnInput{
1855 Command: command,
1856 Stdin: `{"toolName":"bash"}`,
1857 Timeout: realSpawnTimeout,
1858 Env: map[string]string{"HOOK_TEST_MARKER": "expanded-"},
1859 })
1860 if r.ExitCode != 0 {
1861 t.Fatalf("spawn failed: %+v", r)
1862 }
1863 if r.Stdout != "expanded-bash" {
1864 t.Fatalf("stdout = %q, want %q — $VAR expansion was lost (command bypassed the shell)", r.Stdout, "expanded-bash")
1865 }
1866 }
1867
1867 lines GO