dc28b63ad8
executus CI / test (push) Successful in 36s
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>
185 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|