mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 18:00:54 -10:00
When executing a security-critical beforeTool hook, if the hook command fails to launch (e.g. binary not found, missing execute permission), it previously failed open and let tool execution proceed. Update classifyResult to return AuditBlocked, true when a launch error occurs on a beforeTool hook, enforcing a fail-closed policy. Observational afterTool hooks still fail open.
242 lines
10 KiB
Go
242 lines
10 KiB
Go
package hooks
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func beforeToolConfig(hooks ...Definition) Config {
|
|
return Config{Enabled: true, Hooks: hooks}
|
|
}
|
|
|
|
func TestDispatchRunsMatchingHooksAndRecordsAudit(t *testing.T) {
|
|
var calls []string
|
|
runner := func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
|
calls = append(calls, command)
|
|
return commandResult{ExitCode: 0, Stdout: "ok"}
|
|
}
|
|
audit, err := NewAuditStore(AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
|
if err != nil {
|
|
t.Fatalf("NewAuditStore: %v", err)
|
|
}
|
|
config := beforeToolConfig(
|
|
Definition{ID: "h1", Event: EventBeforeTool, Matcher: "bash", Command: "guard", Enabled: true},
|
|
Definition{ID: "h2", Event: EventBeforeTool, Command: "log", Enabled: true}, // no matcher = always
|
|
Definition{ID: "h3", Event: EventBeforeTool, Matcher: "read_file", Command: "skip", Enabled: true},
|
|
)
|
|
dispatcher := NewDispatcher(DispatcherOptions{Config: config, Audit: audit, run: runner})
|
|
|
|
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash", ToolCallID: "call_1"})
|
|
if outcome.Blocked {
|
|
t.Fatalf("unexpected block: %#v", outcome)
|
|
}
|
|
if outcome.Ran != 2 {
|
|
t.Fatalf("Ran = %d, want 2 (h1 matcher + h2 unmatched), calls=%v", outcome.Ran, calls)
|
|
}
|
|
events, err := audit.ReadEvents()
|
|
if err != nil {
|
|
t.Fatalf("ReadEvents: %v", err)
|
|
}
|
|
started, completed := 0, 0
|
|
for _, event := range events {
|
|
switch event.Type {
|
|
case "hook_execution_started":
|
|
started++
|
|
case "hook_execution_completed":
|
|
completed++
|
|
if event.Status != AuditCompleted {
|
|
t.Fatalf("status = %q, want completed", event.Status)
|
|
}
|
|
}
|
|
}
|
|
if started != 2 || completed != 2 {
|
|
t.Fatalf("audit events: started=%d completed=%d, want 2/2", started, completed)
|
|
}
|
|
}
|
|
|
|
func TestDispatchBeforeToolBlocksOnNonZeroExitAndStops(t *testing.T) {
|
|
ran := 0
|
|
runner := func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
|
ran++
|
|
if command == "deny" {
|
|
return commandResult{ExitCode: 2, Stderr: "policy violation"}
|
|
}
|
|
return commandResult{ExitCode: 0}
|
|
}
|
|
config := beforeToolConfig(
|
|
Definition{ID: "deny", Event: EventBeforeTool, Command: "deny", Enabled: true},
|
|
Definition{ID: "after-deny", Event: EventBeforeTool, Command: "second", Enabled: true},
|
|
)
|
|
dispatcher := NewDispatcher(DispatcherOptions{Config: config, run: runner})
|
|
|
|
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
|
|
if !outcome.Blocked || outcome.BlockedBy != "deny" {
|
|
t.Fatalf("outcome = %#v, want blocked by deny", outcome)
|
|
}
|
|
if outcome.Reason != "policy violation" {
|
|
t.Fatalf("reason = %q, want hook stderr", outcome.Reason)
|
|
}
|
|
if ran != 1 {
|
|
t.Fatalf("ran %d hooks, want 1 (must stop after the first veto)", ran)
|
|
}
|
|
}
|
|
|
|
func TestDispatchNonBlockingEventDoesNotVetoOnNonZero(t *testing.T) {
|
|
runner := func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
|
return commandResult{ExitCode: 1, Stderr: "noisy"}
|
|
}
|
|
config := Config{Enabled: true, Hooks: []Definition{
|
|
{ID: "notify", Event: EventAfterTool, Command: "notify", Enabled: true},
|
|
}}
|
|
dispatcher := NewDispatcher(DispatcherOptions{Config: config, run: runner})
|
|
|
|
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventAfterTool, ToolName: "bash"})
|
|
if outcome.Blocked {
|
|
t.Fatalf("afterTool must not block: %#v", outcome)
|
|
}
|
|
if outcome.Ran != 1 {
|
|
t.Fatalf("Ran = %d, want 1", outcome.Ran)
|
|
}
|
|
}
|
|
|
|
func TestDispatchCollectsHookOutputMessages(t *testing.T) {
|
|
runner := func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
|
switch command {
|
|
case "fmt":
|
|
return commandResult{ExitCode: 0, Stdout: " reformatted main.go "}
|
|
case "vet":
|
|
return commandResult{ExitCode: 1, Stderr: "vet: suspicious construct"} // stdout empty → stderr surfaces
|
|
case "quiet":
|
|
return commandResult{ExitCode: 0} // no output → omitted
|
|
}
|
|
return commandResult{}
|
|
}
|
|
config := Config{Enabled: true, Hooks: []Definition{
|
|
{ID: "fmt", Event: EventAfterTool, Command: "fmt", Enabled: true},
|
|
{ID: "vet", Event: EventAfterTool, Command: "vet", Enabled: true},
|
|
{ID: "quiet", Event: EventAfterTool, Command: "quiet", Enabled: true},
|
|
}}
|
|
dispatcher := NewDispatcher(DispatcherOptions{Config: config, run: runner})
|
|
|
|
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventAfterTool, ToolName: "write_file"})
|
|
if outcome.Blocked {
|
|
t.Fatalf("afterTool must not block: %#v", outcome)
|
|
}
|
|
if outcome.Ran != 3 {
|
|
t.Fatalf("Ran = %d, want 3", outcome.Ran)
|
|
}
|
|
want := []string{"reformatted main.go", "vet: suspicious construct"}
|
|
if strings.Join(outcome.Messages, "|") != strings.Join(want, "|") {
|
|
t.Fatalf("Messages = %#v, want trimmed stdout then stderr-fallback, quiet omitted: %#v", outcome.Messages, want)
|
|
}
|
|
}
|
|
|
|
func TestDispatchSkipsWhenDisabledOrUnmatched(t *testing.T) {
|
|
runner := func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
|
t.Fatal("runner must not be called")
|
|
return commandResult{}
|
|
}
|
|
disabled := Config{Enabled: false, Hooks: []Definition{{ID: "h", Event: EventBeforeTool, Command: "x", Enabled: true}}}
|
|
if outcome := NewDispatcher(DispatcherOptions{Config: disabled, run: runner}).Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"}); outcome.Ran != 0 {
|
|
t.Fatalf("disabled config ran hooks: %#v", outcome)
|
|
}
|
|
unmatched := beforeToolConfig(Definition{ID: "h", Event: EventBeforeTool, Matcher: "read_file", Command: "x", Enabled: true})
|
|
if outcome := NewDispatcher(DispatcherOptions{Config: unmatched, run: runner}).Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"}); outcome.Ran != 0 {
|
|
t.Fatalf("unmatched matcher ran hooks: %#v", outcome)
|
|
}
|
|
}
|
|
|
|
func TestDispatchDeliversJSONPayloadOnStdin(t *testing.T) {
|
|
var gotStdin string
|
|
runner := func(ctx context.Context, command string, args []string, stdin []byte, cwd string, env []string) commandResult {
|
|
gotStdin = string(stdin)
|
|
return commandResult{ExitCode: 0}
|
|
}
|
|
config := beforeToolConfig(Definition{ID: "h", Event: EventBeforeTool, Command: "x", Enabled: true})
|
|
dispatcher := NewDispatcher(DispatcherOptions{Config: config, run: runner})
|
|
|
|
dispatcher.Dispatch(context.Background(), DispatchInput{
|
|
Event: EventBeforeTool,
|
|
ToolName: "bash",
|
|
Payload: map[string]any{"tool": "bash", "args": map[string]any{"command": "ls"}},
|
|
})
|
|
if !strings.Contains(gotStdin, `"tool":"bash"`) || !strings.Contains(gotStdin, `"command":"ls"`) {
|
|
t.Fatalf("stdin payload = %q, want serialized tool call", gotStdin)
|
|
}
|
|
}
|
|
|
|
func TestExecCommandRunnerCapturesExitAndStdin(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses /bin/sh")
|
|
}
|
|
// Echoes stdin to stderr and exits non-zero so we exercise both paths.
|
|
result := execCommandRunner(context.Background(), "/bin/sh", []string{"-c", "cat 1>&2; exit 4"}, []byte("payload-123"), t.TempDir(), nil)
|
|
if result.Err != nil {
|
|
t.Fatalf("unexpected launch error: %v", result.Err)
|
|
}
|
|
if result.ExitCode != 4 {
|
|
t.Fatalf("exit code = %d, want 4", result.ExitCode)
|
|
}
|
|
if !strings.Contains(result.Stderr, "payload-123") {
|
|
t.Fatalf("stderr = %q, want stdin echoed", result.Stderr)
|
|
}
|
|
}
|
|
|
|
func TestExecCommandRunnerReportsLaunchFailureFailsClosedForBeforeTool(t *testing.T) {
|
|
result := execCommandRunner(context.Background(), "definitely-not-a-real-binary-zzz", nil, nil, t.TempDir(), nil)
|
|
if result.Err == nil {
|
|
t.Fatal("expected launch error for a missing binary")
|
|
}
|
|
// A launch failure for beforeTool fails closed (vetoes/blocks).
|
|
if status, blocked := classifyResult(EventBeforeTool, result); !blocked || status != AuditBlocked {
|
|
t.Fatalf("beforeTool classify = (%q, %v), want (blocked, true) for a launch failure", status, blocked)
|
|
}
|
|
// An observational afterTool hook still fails open (does not block).
|
|
if status, blocked := classifyResult(EventAfterTool, result); blocked || status != AuditError {
|
|
t.Fatalf("afterTool classify = (%q, %v), want (error, false) for a launch failure", status, blocked)
|
|
}
|
|
}
|
|
|
|
func TestClassifyResultTimedOutFailsClosedForBeforeTool(t *testing.T) {
|
|
// Unlike a launch failure, a hook that STARTED but was killed by its deadline
|
|
// gave no verdict — a beforeTool policy hook must fail CLOSED (veto), or a hung
|
|
// hook would silently wave the tool through.
|
|
timedOut := commandResult{TimedOut: true, Err: context.DeadlineExceeded}
|
|
if status, blocked := classifyResult(EventBeforeTool, timedOut); !blocked || status != AuditBlocked {
|
|
t.Fatalf("beforeTool timeout classify = (%q, %v), want (blocked, true)", status, blocked)
|
|
}
|
|
// Observational events still never veto, even on timeout.
|
|
if status, blocked := classifyResult(EventAfterTool, timedOut); blocked || status != AuditError {
|
|
t.Fatalf("afterTool timeout classify = (%q, %v), want (error, false)", status, blocked)
|
|
}
|
|
if reason := blockReason(timedOut); !strings.Contains(reason, "timed out") {
|
|
t.Fatalf("blockReason = %q, want a timeout message", reason)
|
|
}
|
|
}
|
|
|
|
func TestDispatchBeforeToolFailsClosedWhenHookTimesOut(t *testing.T) {
|
|
// End-to-end: a beforeTool hook that hangs past the dispatcher timeout vetoes
|
|
// the tool. The injected runner blocks until its context is cancelled.
|
|
runner := func(ctx context.Context, _ string, _ []string, _ []byte, _ string, _ []string) commandResult {
|
|
<-ctx.Done()
|
|
return commandResult{Err: ctx.Err()}
|
|
}
|
|
config := beforeToolConfig(Definition{ID: "slow-guard", Event: EventBeforeTool, Command: "hang", Enabled: true})
|
|
dispatcher := NewDispatcher(DispatcherOptions{Config: config, run: runner, Timeout: 20 * time.Millisecond})
|
|
|
|
outcome := dispatcher.Dispatch(context.Background(), DispatchInput{Event: EventBeforeTool, ToolName: "bash"})
|
|
if !outcome.Blocked {
|
|
t.Fatal("a timed-out beforeTool hook must fail closed (block the tool)")
|
|
}
|
|
if outcome.BlockedBy != "slow-guard" {
|
|
t.Fatalf("BlockedBy = %q, want slow-guard", outcome.BlockedBy)
|
|
}
|
|
if !strings.Contains(outcome.Reason, "timed out") {
|
|
t.Fatalf("Reason = %q, want a timeout reason", outcome.Reason)
|
|
}
|
|
}
|