package tool import ( "context" "strings" "testing" llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm" ) // fakeTool is a minimal Tool used to exercise the registry's gating. type fakeTool struct { name string desc string perm Permission calledWith *Invocation returnText string returnError error } func (f *fakeTool) Name() string { return f.name } func (f *fakeTool) Description() string { return f.desc } func (f *fakeTool) Permission() Permission { return f.perm } func (f *fakeTool) BuildLLM(inv Invocation) llm.Tool { type emptyParams struct{} return llm.DefineTool( f.name, f.desc, func(ctx context.Context, _ emptyParams) (any, error) { if err := CheckGate(inv); err != nil { EmitAudit(inv, "{}", "", err) return "", err } f.calledWith = &inv EmitAudit(inv, "{}", f.returnText, f.returnError) return f.returnText, f.returnError }, ) } func TestRegister_DuplicateRejected(t *testing.T) { r := NewRegistry() a := &fakeTool{name: "x", perm: Permission{AuthoringRequirement: RequirementAnyone, SafeForShare: true}} b := &fakeTool{name: "x", perm: Permission{AuthoringRequirement: RequirementAnyone, SafeForShare: true}} if err := r.Register(a); err != nil { t.Fatal(err) } err := r.Register(b) if err == nil || !strings.Contains(err.Error(), "duplicate") { t.Fatalf("expected duplicate-name error, got %v", err) } } func TestRegister_RejectsEmpty(t *testing.T) { r := NewRegistry() if err := r.Register(&fakeTool{name: ""}); err == nil { t.Fatal("expected empty-name rejection") } if err := r.Register(nil); err == nil { t.Fatal("expected nil-tool rejection") } } func TestBuild_UnknownTool(t *testing.T) { r := NewRegistry() _, err := r.Build([]string{"nope"}, Invocation{}, VisibilityPrivate, nil) if err == nil || !strings.Contains(err.Error(), "unknown tool") { t.Fatalf("expected unknown-tool error, got %v", err) } } func TestBuild_SharedRejectsUnsafeTool(t *testing.T) { r := NewRegistry() _ = r.Register(&fakeTool{name: "balance", perm: Permission{SafeForShare: false}}) _, err := r.Build([]string{"balance"}, Invocation{}, VisibilityShared, nil) if err == nil || !strings.Contains(err.Error(), "not safe for share") { t.Fatalf("expected share-safety error, got %v", err) } } // TestBuild_TrustedBuiltinBypassesShareSafety verifies the // trusted-flag escape hatch: a builtin (skill-wizard, mortventure) // legitimately ships with public visibility AND not-safe-for-share // tools. Build with trusted=true must not reject those. // // Why: pre-fix, invocation of skill-wizard (visibility=public, tools // include wizard_* with SafeForShare=false) was rejected at runtime // even though the loader had already bypassed save-time gates. The // trusted flag aligns the invocation-time gate with the loader's // trust model. func TestBuild_TrustedBuiltinBypassesShareSafety(t *testing.T) { r := NewRegistry() _ = r.Register(&fakeTool{ name: "wizard_list", perm: Permission{SafeForShare: false}, returnText: "ok", }) box, err := r.Build([]string{"wizard_list"}, Invocation{SkillName: "skill-wizard"}, VisibilityPublic, nil, true) if err != nil { t.Fatalf("trusted=true should bypass share-safety, got %v", err) } if box == nil { t.Fatal("trusted=true should produce a toolbox, got nil") } } // TestBuild_NonTrustedSharedStillRejects confirms the bypass is // strictly opt-in: a non-builtin caller with the same shape (public // visibility + unsafe tool) still hits the rejection path. func TestBuild_NonTrustedSharedStillRejects(t *testing.T) { r := NewRegistry() _ = r.Register(&fakeTool{name: "balance", perm: Permission{SafeForShare: false}}) _, err := r.Build([]string{"balance"}, Invocation{}, VisibilityPublic, nil, false) if err == nil || !strings.Contains(err.Error(), "not safe for share") { t.Fatalf("trusted=false (non-builtin) must still reject unsafe tool at public visibility, got %v", err) } // Omitted variadic = trusted defaults to false → same rejection. _, err = r.Build([]string{"balance"}, Invocation{}, VisibilityPublic, nil) if err == nil || !strings.Contains(err.Error(), "not safe for share") { t.Fatalf("omitted variadic must default to trusted=false, got %v", err) } } func TestBuild_PublicAcceptsSafeTool(t *testing.T) { r := NewRegistry() _ = r.Register(&fakeTool{name: "search", perm: Permission{SafeForShare: true}, returnText: "hits"}) box, err := r.Build([]string{"search"}, Invocation{SkillName: "echo"}, VisibilityPublic, nil) if err != nil { t.Fatal(err) } out, err := execBox(box, toolCall{Name: "search", Arguments: "{}"}) if err != nil || out != "hits" { t.Fatalf("unexpected: %q %v", out, err) } } func TestBuild_GateBlocksMismatchedSkill(t *testing.T) { r := NewRegistry() tt := &fakeTool{ name: "wizard_save", perm: Permission{SafeForShare: true, SkillNameGate: "skill-wizard"}, returnText: "saved", } _ = r.Register(tt) box, err := r.Build([]string{"wizard_save"}, Invocation{SkillName: "echo"}, VisibilityPrivate, nil) if err != nil { t.Fatalf("build: %v", err) } out, err := execBox(box, toolCall{Name: "wizard_save", Arguments: "{}"}) if err == nil || !strings.Contains(err.Error(), "restricted to") { t.Fatalf("expected gate rejection, got out=%q err=%v", out, err) } } func TestBuild_GateAllowsMatchingSkill(t *testing.T) { r := NewRegistry() tt := &fakeTool{ name: "wizard_save", perm: Permission{SafeForShare: true, SkillNameGate: "skill-wizard"}, returnText: "saved", } _ = r.Register(tt) box, _ := r.Build([]string{"wizard_save"}, Invocation{SkillName: "skill-wizard"}, VisibilityPrivate, nil) out, err := execBox(box, toolCall{Name: "wizard_save", Arguments: "{}"}) if err != nil || out != "saved" { t.Fatalf("unexpected: %q %v", out, err) } } func TestBuild_EmitsAudit(t *testing.T) { r := NewRegistry() tt := &fakeTool{name: "search", perm: Permission{SafeForShare: true}, returnText: "hits"} _ = r.Register(tt) var calls []AuditCall hook := func(c AuditCall) { calls = append(calls, c) } box, _ := r.Build([]string{"search"}, Invocation{SkillName: "echo"}, VisibilityPrivate, hook) _, _ = execBox(box, toolCall{Name: "search", Arguments: "{}"}) if len(calls) != 1 || calls[0].Tool != "search" || calls[0].Result != "hits" || calls[0].Err != nil { t.Fatalf("unexpected audit: %+v", calls) } }