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