mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
368 lines
12 KiB
Go
368 lines
12 KiB
Go
package hooks_test
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
)
|
|
|
|
// baseValidCommandHook returns a HookConfig that passes Validate on the Lite
|
|
// edition. Tests override fields to trigger specific failure modes.
|
|
func baseValidCommandHook() hooks.HookConfig {
|
|
return hooks.HookConfig{
|
|
ID: uuid.New(),
|
|
TenantID: hooks.SentinelTenantID,
|
|
Event: hooks.EventPreToolUse,
|
|
HandlerType: hooks.HandlerCommand,
|
|
Scope: hooks.ScopeGlobal,
|
|
Config: map[string]any{"command": "/bin/true"},
|
|
Matcher: "^Write$",
|
|
IfExpr: `tool_name == "Write"`,
|
|
TimeoutMS: 5000,
|
|
OnTimeout: hooks.DecisionBlock,
|
|
Priority: 100,
|
|
Enabled: true,
|
|
Version: 1,
|
|
Source: "ui",
|
|
}
|
|
}
|
|
|
|
func TestValidate_AcceptsValidCommandHook(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
if err := h.Validate(edition.Lite, false); err != nil {
|
|
t.Fatalf("expected nil error; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_AcceptsValidHTTPHook(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.HandlerType = hooks.HandlerHTTP
|
|
h.Config = map[string]any{"url": "https://example.com/hook"}
|
|
h.Matcher = "" // http hook without matcher is allowed (matches all tools)
|
|
h.IfExpr = ""
|
|
if err := h.Validate(edition.Standard, false); err != nil {
|
|
t.Fatalf("expected nil error; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_AcceptsValidPromptHook(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.HandlerType = hooks.HandlerPrompt
|
|
h.Config = map[string]any{"prompt_template": "Review this action"}
|
|
h.Matcher = "^Write$"
|
|
h.IfExpr = ""
|
|
if err := h.Validate(edition.Standard, false); err != nil {
|
|
t.Fatalf("expected nil error; got %v", err)
|
|
}
|
|
}
|
|
|
|
// Prompt handler requires at least ONE filter (matcher OR if_expr) to avoid
|
|
// firing the LLM on every event — runaway-cost mitigation (L-risk in plan).
|
|
func TestValidate_RejectsPromptWithoutFilter(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.HandlerType = hooks.HandlerPrompt
|
|
h.Config = map[string]any{"template": "x"}
|
|
h.Matcher = ""
|
|
h.IfExpr = ""
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for prompt without matcher/if_expr")
|
|
}
|
|
if !strings.Contains(err.Error(), "prompt") || !strings.Contains(strings.ToLower(err.Error()), "matcher") {
|
|
t.Errorf("error should mention prompt+matcher; got %q", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsInvalidRegex(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Matcher = "[unclosed"
|
|
err := h.Validate(edition.Lite, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed regex")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "matcher") {
|
|
t.Errorf("error should mention matcher; got %q", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsMalformedCEL(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.IfExpr = `tool_name ===== "bad"` // CEL syntax error
|
|
err := h.Validate(edition.Lite, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed CEL")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "if_expr") && !strings.Contains(strings.ToLower(err.Error()), "cel") {
|
|
t.Errorf("error should mention if_expr/cel; got %q", err)
|
|
}
|
|
}
|
|
|
|
// Command on Standard + tenant scope is blocked via edition gate. Plan C2 drop:
|
|
// command handler is Lite-only (multi-tenant host can't run arbitrary shell).
|
|
func TestValidate_RejectsCommandOnStandardTenant(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeTenant
|
|
h.TenantID = uuid.New()
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for command on Standard+tenant")
|
|
}
|
|
if !strings.Contains(strings.ToLower(err.Error()), "command") {
|
|
t.Errorf("error should mention command; got %q", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsCommandOnStandardAgent(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeAgent
|
|
h.TenantID = uuid.New()
|
|
agentID := uuid.New()
|
|
h.AgentID = &agentID
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for command on Standard+agent")
|
|
}
|
|
}
|
|
|
|
func TestValidate_AcceptsCommandOnLiteAnyScope(t *testing.T) {
|
|
for _, scope := range []hooks.Scope{hooks.ScopeGlobal, hooks.ScopeTenant, hooks.ScopeAgent} {
|
|
h := baseValidCommandHook()
|
|
h.Scope = scope
|
|
if scope != hooks.ScopeGlobal {
|
|
h.TenantID = uuid.New()
|
|
}
|
|
if scope == hooks.ScopeAgent {
|
|
agentID := uuid.New()
|
|
h.AgentID = &agentID
|
|
}
|
|
if err := h.Validate(edition.Lite, false); err != nil {
|
|
t.Errorf("Lite+command+%s should be valid; got %v", scope, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TimeoutMS = 0 is replaced with a sane default so callers don't accidentally
|
|
// ship zero-timeout hooks that return immediately.
|
|
func TestValidate_AppliesTimeoutDefault(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.TimeoutMS = 0
|
|
if err := h.Validate(edition.Lite, false); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if h.TimeoutMS != hooks.DefaultTimeoutMS {
|
|
t.Errorf("TimeoutMS default = %d, want %d", h.TimeoutMS, hooks.DefaultTimeoutMS)
|
|
}
|
|
}
|
|
|
|
// OnTimeout defaults to "block" for blocking events (fail-closed) and to
|
|
// "allow" for non-blocking ones.
|
|
func TestValidate_AppliesOnTimeoutDefault(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.OnTimeout = ""
|
|
h.Event = hooks.EventPreToolUse // blocking
|
|
if err := h.Validate(edition.Lite, false); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if h.OnTimeout != hooks.DecisionBlock {
|
|
t.Errorf("OnTimeout default for blocking event = %q, want %q", h.OnTimeout, hooks.DecisionBlock)
|
|
}
|
|
|
|
h2 := baseValidCommandHook()
|
|
h2.OnTimeout = ""
|
|
h2.Event = hooks.EventPostToolUse // non-blocking
|
|
if err := h2.Validate(edition.Lite, false); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if h2.OnTimeout != hooks.DecisionAllow {
|
|
t.Errorf("OnTimeout default for non-blocking event = %q, want %q", h2.OnTimeout, hooks.DecisionAllow)
|
|
}
|
|
}
|
|
|
|
// Version defaults to 1 when zero — initial Create path.
|
|
func TestValidate_AppliesVersionDefault(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Version = 0
|
|
if err := h.Validate(edition.Lite, false); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if h.Version != 1 {
|
|
t.Errorf("Version default = %d, want 1", h.Version)
|
|
}
|
|
}
|
|
|
|
// Source defaults to "ui" when empty — distinguishes UI writes from agent-seeded
|
|
// hooks (M1 isolation in plan).
|
|
func TestValidate_AppliesSourceDefault(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Source = ""
|
|
if err := h.Validate(edition.Lite, false); err != nil {
|
|
t.Fatalf("Validate: %v", err)
|
|
}
|
|
if h.Source != "ui" {
|
|
t.Errorf("Source default = %q, want %q", h.Source, "ui")
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsUnknownEvent(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Event = hooks.HookEvent("webhook_magic")
|
|
err := h.Validate(edition.Lite, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown event")
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsGlobalScopeWithNonSentinelTenant(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeGlobal
|
|
h.TenantID = uuid.New() // must be sentinel for global
|
|
err := h.Validate(edition.Lite, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for global scope with non-sentinel tenant")
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsAgentScopeWithoutAgentID(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeAgent
|
|
h.TenantID = uuid.New()
|
|
h.AgentID = nil
|
|
err := h.Validate(edition.Lite, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for agent scope without agent_id")
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsNegativeTimeout(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.TimeoutMS = -1
|
|
err := h.Validate(edition.Lite, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for negative timeout")
|
|
}
|
|
}
|
|
|
|
func TestValidate_CapsTimeoutToMax(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.TimeoutMS = 60 * 1000 // 60s — exceeds chain wall-time budget
|
|
err := h.Validate(edition.Lite, false)
|
|
if err == nil {
|
|
t.Fatal("expected error for timeout > MaxTimeoutMS")
|
|
}
|
|
}
|
|
|
|
// ─── Script handler validation (Phase 03) ────────────────────────────────────
|
|
|
|
func baseValidScriptHook() hooks.HookConfig {
|
|
h := baseValidCommandHook()
|
|
h.HandlerType = hooks.HandlerScript
|
|
h.Config = map[string]any{"source": `function handle(e){return {decision:"allow"}}`}
|
|
return h
|
|
}
|
|
|
|
func TestValidate_AcceptsValidScriptHook(t *testing.T) {
|
|
h := baseValidScriptHook()
|
|
if err := h.Validate(edition.Standard, false); err != nil {
|
|
t.Fatalf("expected nil error; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsEmptyScriptSource(t *testing.T) {
|
|
h := baseValidScriptHook()
|
|
h.Config = map[string]any{"source": ""}
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil || !strings.Contains(err.Error(), "non-empty") {
|
|
t.Fatalf("expected non-empty source error; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsOversizedScriptSource(t *testing.T) {
|
|
h := baseValidScriptHook()
|
|
big := strings.Repeat("// pad\n", 6000) // >32 KiB
|
|
h.Config = map[string]any{"source": big}
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil || !strings.Contains(err.Error(), "exceeds") {
|
|
t.Fatalf("expected exceeds-bytes error; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsScriptCompileError(t *testing.T) {
|
|
h := baseValidScriptHook()
|
|
// Missing closing brace → parser error with line:col.
|
|
h.Config = map[string]any{"source": `function handle(e) { return {`}
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil || !strings.Contains(err.Error(), "compile error") {
|
|
t.Fatalf("expected compile error; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsOnTimeoutAsk(t *testing.T) {
|
|
h := baseValidScriptHook()
|
|
h.OnTimeout = hooks.DecisionAsk
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil || !strings.Contains(err.Error(), "on_timeout") {
|
|
t.Fatalf("expected on_timeout rejection; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidate_RejectsOnTimeoutDefer(t *testing.T) {
|
|
h := baseValidScriptHook()
|
|
h.OnTimeout = hooks.DecisionDefer
|
|
err := h.Validate(edition.Standard, false)
|
|
if err == nil || !strings.Contains(err.Error(), "on_timeout") {
|
|
t.Fatalf("expected on_timeout rejection; got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestValidate_MasterScopeAllowsSystemTenantHooks verifies that master/owner
|
|
// callers can create ScopeTenant and ScopeAgent hooks whose TenantID equals
|
|
// MasterTenantID (the real system tenant row). Before this fix, validateScope
|
|
// rejected such hooks because SentinelTenantID == MasterTenantID.
|
|
func TestValidate_MasterScopeAllowsSystemTenantHooks(t *testing.T) {
|
|
systemTenantID := hooks.SentinelTenantID // same UUID as MasterTenantID
|
|
|
|
t.Run("ScopeTenant with MasterTenantID as master", func(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeTenant
|
|
h.TenantID = systemTenantID
|
|
if err := h.Validate(edition.Lite, true); err != nil {
|
|
t.Errorf("master scope should accept ScopeTenant+MasterTenantID; got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("ScopeAgent with MasterTenantID as master", func(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeAgent
|
|
h.TenantID = systemTenantID
|
|
agentID := uuid.New()
|
|
h.AgentID = &agentID
|
|
if err := h.Validate(edition.Lite, true); err != nil {
|
|
t.Errorf("master scope should accept ScopeAgent+MasterTenantID; got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("ScopeTenant with MasterTenantID as non-master is rejected", func(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeTenant
|
|
h.TenantID = systemTenantID
|
|
if err := h.Validate(edition.Lite, false); err == nil {
|
|
t.Error("non-master scope should reject ScopeTenant+MasterTenantID")
|
|
}
|
|
})
|
|
|
|
t.Run("ScopeAgent with uuid.Nil is always rejected", func(t *testing.T) {
|
|
h := baseValidCommandHook()
|
|
h.Scope = hooks.ScopeAgent
|
|
h.TenantID = uuid.Nil
|
|
agentID := uuid.New()
|
|
h.AgentID = &agentID
|
|
if err := h.Validate(edition.Lite, true); err == nil {
|
|
t.Error("uuid.Nil tenant_id should always be rejected for agent scope")
|
|
}
|
|
})
|
|
}
|