763 lines
26 KiB
Go
763 lines
26 KiB
Go
package hook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/pluginpkg"
|
|
)
|
|
|
|
func writeSettings(t *testing.T, dir, json string) {
|
|
t.Helper()
|
|
d := filepath.Join(dir, SettingsDirname)
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(d, SettingsFilename), []byte(json), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func writeHookTestFile(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
writeHookTestBytes(t, path, []byte(body))
|
|
}
|
|
|
|
func writeHookTestBytes(t *testing.T, path string, body []byte) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func requireNode(t *testing.T) {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("node"); err != nil {
|
|
t.Skip("node not available")
|
|
}
|
|
}
|
|
|
|
const sampleSettings = `{"hooks":{"PreToolUse":[{"match":"bash","command":"echo pre"}],"Stop":[{"command":"echo stop"}]}}`
|
|
|
|
func hookSettingsWithCommand(t *testing.T, event Event, command string) string {
|
|
t.Helper()
|
|
body, err := json.Marshal(Settings{Hooks: map[Event][]HookConfig{
|
|
event: []HookConfig{{Match: "bash", Command: command}},
|
|
}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(body)
|
|
}
|
|
|
|
func TestLoadTrustGating(t *testing.T) {
|
|
home := t.TempDir()
|
|
proj := t.TempDir()
|
|
writeSettings(t, proj, sampleSettings)
|
|
writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo g"}]}}`)
|
|
|
|
// Untrusted: only the global hook loads.
|
|
got := Load(LoadOptions{ProjectRoot: proj, HomeDir: home, Trusted: false})
|
|
if len(got) != 1 || got[0].Scope != ScopeGlobal {
|
|
t.Fatalf("untrusted load should be global-only, got %d %+v", len(got), got)
|
|
}
|
|
// Trusted: project hooks (before global) load too.
|
|
got = Load(LoadOptions{ProjectRoot: proj, HomeDir: home, Trusted: true})
|
|
if len(got) != 3 {
|
|
t.Fatalf("trusted load should include project + global, got %d", len(got))
|
|
}
|
|
if got[0].Scope != ScopeProject {
|
|
t.Errorf("project hooks should sort first, got %s", got[0].Scope)
|
|
}
|
|
}
|
|
|
|
func TestLoadDecodesGB18030GlobalSettings(t *testing.T) {
|
|
home := t.TempDir()
|
|
body := `{"hooks":{"Stop":[{"command":"echo 中文","description":"全局"}]}}`
|
|
writeHookTestBytes(t, GlobalSettingsPath(home), fileencoding.Encode(body, fileencoding.GB18030))
|
|
|
|
got := Load(LoadOptions{HomeDir: home})
|
|
if len(got) != 1 {
|
|
t.Fatalf("Load hooks = %+v, want one decoded global hook", got)
|
|
}
|
|
if got[0].Scope != ScopeGlobal || got[0].Event != Stop || got[0].Command != "echo 中文" || got[0].Description != "全局" {
|
|
t.Fatalf("decoded global hook = %+v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestLoadDecodesUTF8BOMProjectSettings(t *testing.T) {
|
|
home := t.TempDir()
|
|
proj := t.TempDir()
|
|
body := `{"hooks":{"PreToolUse":[{"match":"bash","command":"echo pre"}]}}`
|
|
writeHookTestBytes(t, ProjectSettingsPath(proj), fileencoding.Encode(body, fileencoding.UTF8BOM))
|
|
|
|
got := Load(LoadOptions{HomeDir: home, ProjectRoot: proj, Trusted: true})
|
|
if len(got) != 1 {
|
|
t.Fatalf("Load hooks = %+v, want one decoded project hook", got)
|
|
}
|
|
if got[0].Scope != ScopeProject || got[0].Event != PreToolUse || got[0].Match != "bash" || got[0].Command != "echo pre" {
|
|
t.Fatalf("decoded project hook = %+v", got[0])
|
|
}
|
|
}
|
|
|
|
func TestLoadNormalizesQuotedNodeEvalHooksPerProject(t *testing.T) {
|
|
requireNode(t)
|
|
|
|
home := t.TempDir()
|
|
projA := t.TempDir()
|
|
projB := t.TempDir()
|
|
script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
|
|
bad := `node -e "\"` + script + `\""`
|
|
want := NormalizeCommand(bad)
|
|
if want == bad {
|
|
t.Fatal("test command did not normalize")
|
|
}
|
|
writeSettings(t, projA, hookSettingsWithCommand(t, PreToolUse, bad))
|
|
writeSettings(t, projB, hookSettingsWithCommand(t, PreToolUse, bad))
|
|
|
|
for _, project := range []string{projA, projB, projB} {
|
|
hooks := Load(LoadOptions{HomeDir: home, ProjectRoot: project, Trusted: true})
|
|
if len(hooks) != 1 {
|
|
t.Fatalf("Load(%q) hooks = %+v, want one", project, hooks)
|
|
}
|
|
if hooks[0].Command != want {
|
|
t.Fatalf("Load(%q) command = %q, want %q", project, hooks[0].Command, want)
|
|
}
|
|
rep := Run(context.Background(), Payload{Event: PreToolUse, Cwd: project, ToolName: "bash"}, hooks, nil)
|
|
if len(rep.Outcomes) != 1 || rep.Outcomes[0].Decision != DecisionPass || rep.Outcomes[0].Stdout != "bash" {
|
|
t.Fatalf("normalized hook outcome = %+v, want pass with bash stdout", rep)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCommandRepairsOnlyStdinNodeEvalQuoting(t *testing.T) {
|
|
script := "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log(payload.toolName)"
|
|
doubleQuoteScript := `const payload = JSON.parse(require(\"fs\").readFileSync(0, \"utf8\")); console.log(payload.toolName)`
|
|
tests := []struct {
|
|
name string
|
|
command string
|
|
repair bool
|
|
}{
|
|
{
|
|
name: "quoted script argument",
|
|
command: `node -e "\"` + script + `\""`,
|
|
repair: true,
|
|
},
|
|
{
|
|
name: "json escaped shell quotes",
|
|
command: `node -e \"` + script + `\"`,
|
|
repair: true,
|
|
},
|
|
{
|
|
name: "json escaped shell and script quotes",
|
|
command: `node -e \"` + doubleQuoteScript + `\"`,
|
|
repair: true,
|
|
},
|
|
{
|
|
name: "normal hook command",
|
|
command: `node -e "` + script + `"`,
|
|
},
|
|
{
|
|
name: "intentional string literal",
|
|
command: `node -e '"hello"'`,
|
|
},
|
|
{
|
|
name: "not stdin hook script",
|
|
command: `node -e "\"console.log(1)\""`,
|
|
},
|
|
{
|
|
name: "compound command",
|
|
command: `node -e "\"` + script + `\"" && echo done`,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := NormalizeCommand(tt.command)
|
|
if tt.repair {
|
|
if got == tt.command {
|
|
t.Fatalf("NormalizeCommand(%q) did not repair", tt.command)
|
|
}
|
|
if strings.Contains(got, `\""`) {
|
|
t.Fatalf("NormalizeCommand(%q) left accidental escaped quotes in %q", tt.command, got)
|
|
}
|
|
requireNode(t)
|
|
r := DefaultSpawner(context.Background(), SpawnInput{
|
|
Command: got,
|
|
Stdin: `{"toolName":"bash"}`,
|
|
Timeout: 2 * time.Second,
|
|
})
|
|
if r.ExitCode != 0 || r.Stdout != "bash" {
|
|
t.Fatalf("normalized command did not execute: command=%q result=%+v", got, r)
|
|
}
|
|
return
|
|
}
|
|
if got != tt.command {
|
|
t.Fatalf("NormalizeCommand(%q) = %q, want unchanged", tt.command, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeCommandRepairsOnlyPowerShellFileEscapedQuotes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
command string
|
|
want string
|
|
}{
|
|
{
|
|
name: "powershell file path copied with json escaped quotes",
|
|
command: `powershell -File \"C:\Users\Example\.reasonix\hooks\archive-attachments.ps1\"`,
|
|
want: `powershell -File "C:\Users\Example\.reasonix\hooks\archive-attachments.ps1"`,
|
|
},
|
|
{
|
|
name: "pwsh file path with spaces",
|
|
command: `pwsh.exe -NoProfile -NonInteractive -File \"C:\Program Files\Reasonix Hooks\archive attachments.ps1\"`,
|
|
want: `pwsh.exe -NoProfile -NonInteractive -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`,
|
|
},
|
|
{
|
|
name: "doubly escaped copied quotes",
|
|
command: `pwsh -File \\\"C:\Program Files\Reasonix Hooks\archive attachments.ps1\\\" \"arg with spaces\"`,
|
|
want: `pwsh -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1" "arg with spaces"`,
|
|
},
|
|
{
|
|
name: "powershell executable path copied with escaped quotes",
|
|
command: `\"C:\Program Files\PowerShell\7\pwsh.exe\" -File \"C:\hooks\archive.ps1\"`,
|
|
want: `"C:\Program Files\PowerShell\7\pwsh.exe" -File "C:\hooks\archive.ps1"`,
|
|
},
|
|
{
|
|
name: "well formed file command stays unchanged",
|
|
command: `powershell -NoProfile -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`,
|
|
want: `powershell -NoProfile -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`,
|
|
},
|
|
{
|
|
name: "command mode may intentionally contain escaped quotes",
|
|
command: `powershell -Command \"Write-Output hi\"`,
|
|
want: `powershell -Command \"Write-Output hi\"`,
|
|
},
|
|
{
|
|
name: "compound command is left alone",
|
|
command: `powershell -File \"C:\hooks\archive.ps1\" && echo done`,
|
|
want: `powershell -File \"C:\hooks\archive.ps1\" && echo done`,
|
|
},
|
|
{
|
|
name: "multiline command is left alone",
|
|
command: "powershell -File \\\"C:\\hooks\\archive.ps1\\\"\necho done",
|
|
want: "powershell -File \\\"C:\\hooks\\archive.ps1\\\"\necho done",
|
|
},
|
|
{
|
|
name: "well formed sibling argument keeps its escaped quotes",
|
|
command: `powershell -File \"C:\hooks\archive.ps1\" "say \"hi\""`,
|
|
want: `powershell -File "C:\hooks\archive.ps1" "say \"hi\""`,
|
|
},
|
|
{
|
|
name: "single quoted sibling argument stays literal",
|
|
command: `powershell -File \"C:\hooks\archive.ps1\" 'keep \" literal'`,
|
|
want: `powershell -File "C:\hooks\archive.ps1" 'keep \" literal'`,
|
|
},
|
|
{
|
|
name: "non powershell command is left alone",
|
|
command: `python \"C:\hooks\archive.py\"`,
|
|
want: `python \"C:\hooks\archive.py\"`,
|
|
},
|
|
{
|
|
name: "missing file argument is left alone",
|
|
command: `powershell -NoProfile -File`,
|
|
want: `powershell -NoProfile -File`,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := NormalizeCommand(tt.command); got != tt.want {
|
|
t.Fatalf("NormalizeCommand(%q) = %q, want %q", tt.command, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadNormalizesPowerShellFileEscapedQuotes(t *testing.T) {
|
|
home := t.TempDir()
|
|
bad := `powershell -File \"C:\Program Files\Reasonix Hooks\archive attachments.ps1\"`
|
|
want := `powershell -File "C:\Program Files\Reasonix Hooks\archive attachments.ps1"`
|
|
writeSettings(t, home, hookSettingsWithCommand(t, SessionStart, bad))
|
|
|
|
hooks := Load(LoadOptions{HomeDir: home})
|
|
if len(hooks) != 1 {
|
|
t.Fatalf("Load hooks = %+v, want one", hooks)
|
|
}
|
|
if hooks[0].Command != want {
|
|
t.Fatalf("loaded command = %q, want %q", hooks[0].Command, want)
|
|
}
|
|
}
|
|
|
|
func TestRepairablePowerShellFileArgs(t *testing.T) {
|
|
command := `powershell -NoProfile -NonInteractive -File \"C:\Program Files\Reasonix Hooks\archive attachments.ps1\" -Mode \"startup\"`
|
|
name, args, ok := repairablePowerShellFileArgs(command)
|
|
if !ok {
|
|
t.Fatalf("repairablePowerShellFileArgs(%q) ok = false, want true", command)
|
|
}
|
|
if name != "powershell" {
|
|
t.Fatalf("name = %q, want powershell", name)
|
|
}
|
|
wantArgs := []string{"-NoProfile", "-NonInteractive", "-File", `C:\Program Files\Reasonix Hooks\archive attachments.ps1`, "-Mode", "startup"}
|
|
if strings.Join(args, "\x00") != strings.Join(wantArgs, "\x00") {
|
|
t.Fatalf("args = %#v, want %#v", args, wantArgs)
|
|
}
|
|
if _, _, ok := repairablePowerShellFileArgs(`powershell -File "C:\hooks\archive.ps1"`); ok {
|
|
t.Fatal("well formed PowerShell command should keep shell execution")
|
|
}
|
|
if _, _, ok := repairablePowerShellFileArgs(`powershell -File \"C:\hooks\archive.ps1\" && echo done`); ok {
|
|
t.Fatal("compound PowerShell command should not be direct-exec repaired")
|
|
}
|
|
if _, _, ok := repairablePowerShellFileArgs("powershell -File \\\"C:\\hooks\\archive.ps1\\\"\necho done"); ok {
|
|
t.Fatal("multiline PowerShell command should not be direct-exec repaired")
|
|
}
|
|
}
|
|
|
|
func TestLoadPermissionRequestHook(t *testing.T) {
|
|
home := t.TempDir()
|
|
writeSettings(t, home, `{"hooks":{"PermissionRequest":[{"match":"bash","command":"notify"}]}}`)
|
|
|
|
got := Load(LoadOptions{HomeDir: home})
|
|
if len(got) != 1 {
|
|
t.Fatalf("hooks count = %d, want 1", len(got))
|
|
}
|
|
if got[0].Event != PermissionRequest || got[0].Match != "bash" || got[0].Command != "notify" {
|
|
t.Fatalf("loaded hook = %+v, want PermissionRequest/bash/notify", got[0])
|
|
}
|
|
}
|
|
|
|
func TestLoadIncludesPluginSessionStartHook(t *testing.T) {
|
|
home := t.TempDir()
|
|
reasonixHome := filepath.Join(home, ".reasonix")
|
|
root := filepath.Join(reasonixHome, "plugins", "superpowers")
|
|
writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo global"}]}}`)
|
|
writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
|
|
"name": "superpowers",
|
|
"version": "6.1.0",
|
|
"skills": "./skills/"
|
|
}`)
|
|
writeHookTestFile(t, filepath.Join(root, "hooks", "session-start-codex"), "#!/usr/bin/env bash\necho ok\n")
|
|
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
|
|
Name: "superpowers",
|
|
Root: "plugins/superpowers",
|
|
Version: "6.1.0",
|
|
ManifestKind: "codex",
|
|
Enabled: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := Load(LoadOptions{HomeDir: home, ProjectRoot: "/workspace", Trusted: true})
|
|
if len(got) != 2 {
|
|
t.Fatalf("hooks = %+v, want plugin + global", got)
|
|
}
|
|
if got[0].Scope != ScopePlugin || got[0].Event != SessionStart {
|
|
t.Fatalf("first hook = %+v, want plugin SessionStart", got[0])
|
|
}
|
|
if got[0].Env["REASONIX_PLUGIN_NAME"] != "superpowers" || got[0].Env["REASONIX_WORKSPACE_ROOT"] != "/workspace" {
|
|
t.Fatalf("plugin env = %#v", got[0].Env)
|
|
}
|
|
if got[1].Scope != ScopeGlobal {
|
|
t.Fatalf("second hook = %+v, want global", got[1])
|
|
}
|
|
}
|
|
|
|
func TestLoadIncludesPluginClaudeCompatibilityHooks(t *testing.T) {
|
|
home := t.TempDir()
|
|
reasonixHome := filepath.Join(home, ".reasonix")
|
|
root := filepath.Join(reasonixHome, "plugins", "claude-pack")
|
|
writeHookTestFile(t, filepath.Join(root, pluginpkg.CodexManifest), `{
|
|
"name": "claude-pack",
|
|
"version": "1.0.0",
|
|
"skills": "skills"
|
|
}`)
|
|
writeHookTestFile(t, filepath.Join(root, "CLAUDE.md"), "Use the bundled workflow.")
|
|
writeHookTestFile(t, filepath.Join(root, ".claude", "settings.json"), `{
|
|
"hooks": {
|
|
"PostToolUse": [
|
|
{
|
|
"matcher": "bash",
|
|
"hooks": [
|
|
{ "type": "command", "command": "node hooks/post-tool.js", "timeout": 2 }
|
|
]
|
|
}
|
|
],
|
|
"UserPromptSubmit": [
|
|
{
|
|
"hooks": [
|
|
{ "type": "command", "command": "node hooks/prompt.js" }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
|
|
Name: "claude-pack",
|
|
Root: "plugins/claude-pack",
|
|
Version: "1.0.0",
|
|
ManifestKind: "codex",
|
|
Enabled: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := Load(LoadOptions{HomeDir: home, ProjectRoot: "/workspace", Trusted: true})
|
|
if len(got) != 3 {
|
|
t.Fatalf("hooks = %+v, want three plugin hooks", got)
|
|
}
|
|
byEvent := map[Event]ResolvedHook{}
|
|
for _, h := range got {
|
|
if h.Scope != ScopePlugin {
|
|
t.Fatalf("hook scope = %s, want plugin: %+v", h.Scope, h)
|
|
}
|
|
byEvent[h.Event] = h
|
|
}
|
|
if h := byEvent[SessionStart]; h.ContextFile != filepath.Join(root, "CLAUDE.md") || h.Command != "" {
|
|
t.Fatalf("SessionStart hook = %+v, want CLAUDE.md context file", h)
|
|
}
|
|
if h := byEvent[PostToolUse]; h.Match != "bash" || h.Command != "node hooks/post-tool.js" || h.Timeout != 2000 || h.Cwd != root {
|
|
t.Fatalf("PostToolUse hook = %+v", h)
|
|
}
|
|
if h := byEvent[UserPromptSubmit]; h.Command != "node hooks/prompt.js" || h.Cwd != root {
|
|
t.Fatalf("UserPromptSubmit hook = %+v", h)
|
|
}
|
|
if h := byEvent[PostToolUse]; h.Env["CLAUDE_PROJECT_DIR"] != "/workspace" || h.Env["REASONIX_PLUGIN_NAME"] != "claude-pack" {
|
|
t.Fatalf("plugin env = %#v", h.Env)
|
|
}
|
|
}
|
|
|
|
func TestReasonixHomeOverridesGlobalHookPaths(t *testing.T) {
|
|
home := t.TempDir()
|
|
reasonixHome := filepath.Join(t.TempDir(), "rx-home")
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("REASONIX_HOME", reasonixHome)
|
|
if err := os.MkdirAll(reasonixHome, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(reasonixHome, SettingsFilename), []byte(`{"hooks":{"PostToolUse":[{"command":"echo rx"}]}}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo old"}]}}`)
|
|
|
|
if got := GlobalSettingsPath(""); got != filepath.Join(reasonixHome, SettingsFilename) {
|
|
t.Fatalf("GlobalSettingsPath = %q, want Reasonix home", got)
|
|
}
|
|
if got := TrustPath(""); got != filepath.Join(reasonixHome, TrustFilename) {
|
|
t.Fatalf("TrustPath = %q, want Reasonix home", got)
|
|
}
|
|
hooks := Load(LoadOptions{})
|
|
if len(hooks) != 1 || hooks[0].Command != "echo rx" {
|
|
t.Fatalf("Load hooks = %+v, want Reasonix home hook only", hooks)
|
|
}
|
|
}
|
|
|
|
func TestReasonixHomeDoesNotFallBackToLegacyWhenIsolated(t *testing.T) {
|
|
home := t.TempDir()
|
|
reasonixHome := filepath.Join(t.TempDir(), "rx-home")
|
|
proj := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("REASONIX_HOME", reasonixHome)
|
|
writeSettings(t, home, `{"hooks":{"PostToolUse":[{"command":"echo old"}]}}`)
|
|
|
|
hooks := Load(LoadOptions{})
|
|
if len(hooks) != 0 {
|
|
t.Fatalf("Load hooks = %+v, want empty (isolated REASONIX_HOME must not load legacy hooks)", hooks)
|
|
}
|
|
|
|
absProj, err := filepath.Abs(proj)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := json.Marshal(trustFile{Projects: map[string]bool{absProj: true}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacyTrust := filepath.Join(home, SettingsDirname, TrustFilename)
|
|
if err := os.WriteFile(legacyTrust, body, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if IsTrusted(proj, "") {
|
|
t.Fatal("legacy trust must not be honored when REASONIX_HOME is set and trust.json is absent")
|
|
}
|
|
if err := Trust(proj, ""); err != nil {
|
|
t.Fatalf("Trust: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(reasonixHome, TrustFilename)); err != nil {
|
|
t.Fatalf("Trust should write current Reasonix home trust file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProjectDefinesHooks(t *testing.T) {
|
|
proj := t.TempDir()
|
|
if ProjectDefinesHooks(proj) {
|
|
t.Error("empty project should define no hooks")
|
|
}
|
|
writeSettings(t, proj, sampleSettings)
|
|
if !ProjectDefinesHooks(proj) {
|
|
t.Error("project with settings.json should define hooks")
|
|
}
|
|
}
|
|
|
|
func TestMalformedSettingsIgnored(t *testing.T) {
|
|
home := t.TempDir()
|
|
writeSettings(t, home, `{not valid json`)
|
|
if got := Load(LoadOptions{HomeDir: home}); len(got) != 0 {
|
|
t.Errorf("malformed settings should yield no hooks, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestMatchesTool(t *testing.T) {
|
|
pre := func(match string) ResolvedHook {
|
|
return ResolvedHook{HookConfig: HookConfig{Match: match}, Event: PreToolUse}
|
|
}
|
|
if MatchesTool(pre("file"), "read_file") {
|
|
t.Error(`anchored "file" must not match "read_file"`)
|
|
}
|
|
if !MatchesTool(pre(".*file"), "read_file") {
|
|
t.Error(`".*file" should match "read_file"`)
|
|
}
|
|
if !MatchesTool(pre("bash"), "bash") {
|
|
t.Error(`"bash" should match "bash"`)
|
|
}
|
|
if !MatchesTool(pre("*"), "anything") || !MatchesTool(pre(""), "anything") {
|
|
t.Error(`"*"/"" should match every tool`)
|
|
}
|
|
if MatchesTool(pre("["), "bash") {
|
|
t.Error("malformed regex should not fire")
|
|
}
|
|
perm := func(match string) ResolvedHook {
|
|
return ResolvedHook{HookConfig: HookConfig{Match: match}, Event: PermissionRequest}
|
|
}
|
|
if !MatchesTool(perm("bash"), "bash") {
|
|
t.Error(`PermissionRequest "bash" should match "bash"`)
|
|
}
|
|
if MatchesTool(perm("bash"), "read_file") {
|
|
t.Error(`PermissionRequest "bash" must not match "read_file"`)
|
|
}
|
|
if MatchesTool(perm("["), "bash") {
|
|
t.Error("malformed PermissionRequest regex should not fire")
|
|
}
|
|
// Non-tool events always match regardless of the match field.
|
|
prompt := ResolvedHook{HookConfig: HookConfig{Match: "bash"}, Event: UserPromptSubmit}
|
|
if !MatchesTool(prompt, "") {
|
|
t.Error("non-tool events should always match")
|
|
}
|
|
}
|
|
|
|
func TestDecideOutcome(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
event Event
|
|
r SpawnResult
|
|
want Decision
|
|
}{
|
|
{"pass", PreToolUse, SpawnResult{ExitCode: 0}, DecisionPass},
|
|
{"block-exit2", PreToolUse, SpawnResult{ExitCode: 2}, DecisionBlock},
|
|
{"exit2-nonblocking-warns", PostToolUse, SpawnResult{ExitCode: 2}, DecisionWarn},
|
|
{"permission-exit2-warns", PermissionRequest, SpawnResult{ExitCode: 2}, DecisionWarn},
|
|
{"other-nonzero-warns", PreToolUse, SpawnResult{ExitCode: 1}, DecisionWarn},
|
|
{"timeout-blocking", UserPromptSubmit, SpawnResult{TimedOut: true}, DecisionBlock},
|
|
{"permission-timeout-warns", PermissionRequest, SpawnResult{TimedOut: true}, DecisionWarn},
|
|
{"timeout-nonblocking", Stop, SpawnResult{TimedOut: true}, DecisionWarn},
|
|
{"spawn-error", PreToolUse, SpawnResult{SpawnErr: os.ErrNotExist}, DecisionError},
|
|
}
|
|
for _, c := range cases {
|
|
if got := decideOutcome(c.event, c.r); got != c.want {
|
|
t.Errorf("%s: decideOutcome = %s, want %s", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseOutputSessionStartJSONAdditionalContext(t *testing.T) {
|
|
out, warnings := ParseOutput(SessionStart, `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Load conventions."}}`)
|
|
if len(warnings) != 0 {
|
|
t.Fatalf("warnings = %v, want none", warnings)
|
|
}
|
|
if out.AdditionalContext != "Load conventions." {
|
|
t.Fatalf("AdditionalContext = %q, want context", out.AdditionalContext)
|
|
}
|
|
}
|
|
|
|
func TestParseOutputSessionStartPlainText(t *testing.T) {
|
|
out, warnings := ParseOutput(SessionStart, " Load workspace notes. ")
|
|
if len(warnings) != 0 {
|
|
t.Fatalf("warnings = %v, want none", warnings)
|
|
}
|
|
if out.AdditionalContext != "Load workspace notes." {
|
|
t.Fatalf("AdditionalContext = %q, want plain text", out.AdditionalContext)
|
|
}
|
|
}
|
|
|
|
func TestParseOutputRejectsMismatchedEvent(t *testing.T) {
|
|
out, warnings := ParseOutput(SessionStart, `{"hookSpecificOutput":{"hookEventName":"Stop","additionalContext":"wrong"}}`)
|
|
if out.AdditionalContext != "" {
|
|
t.Fatalf("AdditionalContext = %q, want empty", out.AdditionalContext)
|
|
}
|
|
if len(warnings) != 1 {
|
|
t.Fatalf("warnings = %v, want one warning", warnings)
|
|
}
|
|
}
|
|
|
|
func TestParseOutputInvalidJSONWarns(t *testing.T) {
|
|
out, warnings := ParseOutput(SessionStart, `{"hookSpecificOutput":`)
|
|
if out.AdditionalContext != "" {
|
|
t.Fatalf("AdditionalContext = %q, want empty", out.AdditionalContext)
|
|
}
|
|
if len(warnings) != 1 {
|
|
t.Fatalf("warnings = %v, want one warning", warnings)
|
|
}
|
|
}
|
|
|
|
func TestRunStopsAtFirstBlock(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "first"}, Event: PreToolUse, Scope: ScopeProject},
|
|
{HookConfig: HookConfig{Command: "second"}, Event: PreToolUse, Scope: ScopeProject},
|
|
}
|
|
var ran []string
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
ran = append(ran, in.Command)
|
|
return SpawnResult{ExitCode: 2} // first blocks
|
|
}
|
|
rep := Run(context.Background(), Payload{Event: PreToolUse, ToolName: "bash"}, hooks, spawner)
|
|
if !rep.Blocked {
|
|
t.Error("report should be blocked")
|
|
}
|
|
if len(ran) != 1 || ran[0] != "first" {
|
|
t.Errorf("should stop after the first block, ran %v", ran)
|
|
}
|
|
}
|
|
|
|
func TestRunFiltersByEventAndTool(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "a", Match: "bash"}, Event: PreToolUse},
|
|
{HookConfig: HookConfig{Command: "b", Match: "read_file"}, Event: PreToolUse},
|
|
{HookConfig: HookConfig{Command: "c"}, Event: PostToolUse},
|
|
}
|
|
var ran []string
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
ran = append(ran, in.Command)
|
|
return SpawnResult{ExitCode: 0}
|
|
}
|
|
Run(context.Background(), Payload{Event: PreToolUse, ToolName: "bash"}, hooks, spawner)
|
|
if len(ran) != 1 || ran[0] != "a" {
|
|
t.Errorf("only the matching PreToolUse hook should run, got %v", ran)
|
|
}
|
|
}
|
|
|
|
func TestRunFiltersPermissionRequestByTool(t *testing.T) {
|
|
hooks := []ResolvedHook{
|
|
{HookConfig: HookConfig{Command: "a", Match: "bash"}, Event: PermissionRequest},
|
|
{HookConfig: HookConfig{Command: "b", Match: "read_file"}, Event: PermissionRequest},
|
|
{HookConfig: HookConfig{Command: "c"}, Event: Notification},
|
|
}
|
|
var ran []string
|
|
spawner := func(_ context.Context, in SpawnInput) SpawnResult {
|
|
ran = append(ran, in.Command)
|
|
return SpawnResult{ExitCode: 0}
|
|
}
|
|
Run(context.Background(), Payload{Event: PermissionRequest, ToolName: "bash"}, hooks, spawner)
|
|
if len(ran) != 1 || ran[0] != "a" {
|
|
t.Errorf("only the matching PermissionRequest hook should run, got %v", ran)
|
|
}
|
|
}
|
|
|
|
func TestTrustStore(t *testing.T) {
|
|
home := t.TempDir()
|
|
proj := t.TempDir()
|
|
if IsTrusted(proj, home) {
|
|
t.Error("project should start untrusted")
|
|
}
|
|
if err := Trust(proj, home); err != nil {
|
|
t.Fatalf("trust: %v", err)
|
|
}
|
|
if !IsTrusted(proj, home) {
|
|
t.Error("project should be trusted after Trust")
|
|
}
|
|
}
|
|
|
|
func TestDefaultSpawner(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a POSIX shell")
|
|
}
|
|
ctx := context.Background()
|
|
// exit 0 with stdout
|
|
r := DefaultSpawner(ctx, SpawnInput{Command: "printf hi", Timeout: 2 * time.Second})
|
|
if r.ExitCode != 0 || r.Stdout != "hi" {
|
|
t.Errorf("expected exit 0 / hi, got code=%d out=%q err=%v", r.ExitCode, r.Stdout, r.SpawnErr)
|
|
}
|
|
// exit 2 (block verdict on a gating event)
|
|
r = DefaultSpawner(ctx, SpawnInput{Command: "exit 2", Timeout: 2 * time.Second})
|
|
if r.ExitCode != 2 {
|
|
t.Errorf("expected exit 2, got %d", r.ExitCode)
|
|
}
|
|
// stdin is delivered as the payload
|
|
r = DefaultSpawner(ctx, SpawnInput{Command: "cat", Stdin: "payload-here", Timeout: 2 * time.Second})
|
|
if r.Stdout != "payload-here" {
|
|
t.Errorf("stdin not delivered: %q", r.Stdout)
|
|
}
|
|
// timeout kills the command
|
|
r = DefaultSpawner(ctx, SpawnInput{Command: "sleep 5", Timeout: 100 * time.Millisecond})
|
|
if !r.TimedOut {
|
|
t.Errorf("expected timeout, got %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestDefaultSpawnerOutputCap(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a POSIX shell")
|
|
}
|
|
// Emit more than the cap; expect truncation flagged and bounded capture.
|
|
r := DefaultSpawner(context.Background(), SpawnInput{
|
|
Command: "yes x | head -c 400000",
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if !r.Truncated {
|
|
t.Error("oversized output should be flagged truncated")
|
|
}
|
|
if len(r.Stdout) > outputCapBytes {
|
|
t.Errorf("captured output %d exceeds cap %d", len(r.Stdout), outputCapBytes)
|
|
}
|
|
}
|
|
|
|
// TestWellFormedNodeEvalKeepsShellSemantics pins the execution contract for
|
|
// commands that never needed repair: hook commands are documented to run
|
|
// through the shell, and existing user hooks may rely on shell expansion.
|
|
// A well-formed node -e stdin-hook command must therefore keep $VAR expansion
|
|
// on POSIX — only repaired commands (whose broken quoting means they never
|
|
// worked through a shell) may take the direct-exec path.
|
|
func TestWellFormedNodeEvalKeepsShellSemantics(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("cmd does not perform POSIX $ expansion; Windows intentionally direct-execs recognized node evals")
|
|
}
|
|
requireNode(t)
|
|
command := `node -e "const payload = JSON.parse(require('fs').readFileSync(0, 'utf8')); console.log('$HOOK_TEST_MARKER' + payload.toolName)"`
|
|
if got := NormalizeCommand(command); got != command {
|
|
t.Fatalf("well-formed command was rewritten: %q", got)
|
|
}
|
|
r := DefaultSpawner(context.Background(), SpawnInput{
|
|
Command: command,
|
|
Stdin: `{"toolName":"bash"}`,
|
|
Timeout: 2 * time.Second,
|
|
Env: map[string]string{"HOOK_TEST_MARKER": "expanded-"},
|
|
})
|
|
if r.ExitCode != 0 {
|
|
t.Fatalf("spawn failed: %+v", r)
|
|
}
|
|
if r.Stdout != "expanded-bash" {
|
|
t.Fatalf("stdout = %q, want %q — $VAR expansion was lost (command bypassed the shell)", r.Stdout, "expanded-bash")
|
|
}
|
|
}
|