Files
2026-07-13 13:00:08 +08:00

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")
}
}