Files
executus/tool/registry_test.go
T
steve dc28b63ad8
executus CI / test (push) Successful in 36s
P1 (part 1): move skilltools core -> tool/ (clean, verbatim)
The tool registry core (registry, permission model, Invocation, gated-tool
wrapper, ssrf guard, hmac, encryption, argcoerce, helpers, rootrun,
session_tools, webhook_rate_limit) had zero mort coupling — it imports only
majordomo/llm + x/crypto/hkdf — so it moves verbatim with a package rename
(skilltools -> tool). All same-package tests came along and pass; the SSRF,
gated-wrapper, encryption and output-pattern invariants are re-anchored here.

majordomo re-enters the module graph (now pinned to the latest, incl. the
front-loaded-output fix). model/ + llmmeta + structured follow next.

Docs: CLAUDE.md now requires README/examples to stay in sync with changes in
the same commit; CI skips docs/example-only pushes via paths-ignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:31:47 -04:00

185 lines
6.3 KiB
Go

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