From b988cae89dfc921116e5f7ecc778003fa38e8cf3 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:27:04 +0000 Subject: [PATCH] feat(studio): map change kinds to files and add the grant gate Memory and profile changes write to fixed places in the owner's home; the target a model names is checked against a small pattern so it cannot leave Memory/. Other features register their kinds. The gate is the hook the agent grants fill; by default it refuses a chat that runs a saved agent. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto --- core/services/studio/proposals/gate.go | 50 +++++++ core/services/studio/proposals/kinds.go | 146 +++++++++++++++++++ core/services/studio/proposals/kinds_test.go | 80 ++++++++++ 3 files changed, 276 insertions(+) create mode 100644 core/services/studio/proposals/gate.go create mode 100644 core/services/studio/proposals/kinds.go create mode 100644 core/services/studio/proposals/kinds_test.go diff --git a/core/services/studio/proposals/gate.go b/core/services/studio/proposals/gate.go new file mode 100644 index 0000000000..564268bdd8 --- /dev/null +++ b/core/services/studio/proposals/gate.go @@ -0,0 +1,50 @@ +package proposals + +import ( + "context" + "errors" + + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// ErrNotGranted means the chat may not change this kind of state. +var ErrNotGranted = errors.New("proposals: this agent was not given the right to make this change") + +// What a chat asks the gate for. +const ( + GatePropose = "propose" + GateRevert = "revert" +) + +// Request is one question to the gate. Agent is the saved agent of the chat, +// empty for a plain chat. Source says which surface the chat came from, by +// the host's own names, never a platform's. +type Request struct { + Action string + User string + Agent string + Kind contract.ChangeKind + Scope contract.ChangeScope + Source contract.ChangeSource +} + +// Gate decides whether a model may propose or revert in a chat. The grants of +// a saved agent plug in here; until they exist the default applies. +type Gate interface { + Allow(ctx context.Context, r Request) error +} + +// GateFunc makes a function a Gate. +type GateFunc func(ctx context.Context, r Request) error + +// Allow calls f. +func (f GateFunc) Allow(ctx context.Context, r Request) error { return f(ctx, r) } + +// DefaultGate allows a plain chat and refuses a chat that runs a saved agent: +// an agent has no right until the owner grants it. +var DefaultGate Gate = GateFunc(func(_ context.Context, r Request) error { + if r.Agent != "" { + return ErrNotGranted + } + return nil +}) diff --git a/core/services/studio/proposals/kinds.go b/core/services/studio/proposals/kinds.go new file mode 100644 index 0000000000..f2cc7c7d08 --- /dev/null +++ b/core/services/studio/proposals/kinds.go @@ -0,0 +1,146 @@ +package proposals + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "sync" + + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// ErrBadTarget means a proposal names a place its kind does not allow. +var ErrBadTarget = errors.New("proposals: this target is not allowed") + +// Aim is where a proposal wants to write, as the model and the chat gave it. +type Aim struct { + Scope contract.ChangeScope + Agent string // the saved agent of the chat, "" for a plain chat + Target string // the name the model gave + Source contract.ChangeSource +} + +// Kind says how one kind of change maps to a file in the home of the owner +// and how big it may be. +type Kind struct { + Name contract.ChangeKind + // Sensitive kinds are never confirmed from a chat that is not Studio; the + // gate and the confirm lanes read it. + Sensitive bool + // MaxBytes bounds the content; zero means contract.MaxChangeBytes. + MaxBytes int + // Path returns the file inside the home that the change writes. + Path func(Aim) (string, error) +} + +// Limit is the content bound of the kind. +func (k Kind) Limit() int { + if k.MaxBytes <= 0 || k.MaxBytes > contract.MaxChangeBytes { + return contract.MaxChangeBytes + } + return k.MaxBytes +} + +// Kinds is the set of kinds a host offers. Later features register theirs. +type Kinds struct { + mu sync.RWMutex + m map[contract.ChangeKind]Kind +} + +// NewKinds returns the kinds this package defines: memory and profile. +func NewKinds() *Kinds { + k := &Kinds{m: map[contract.ChangeKind]Kind{}} + for _, d := range []Kind{memoryKind, profileKind} { + k.m[d.Name] = d + } + return k +} + +// Register adds a kind. It refuses a name the contract does not know, a kind +// without Path and a kind that is registered already. +func (k *Kinds) Register(d Kind) error { + switch { + case !contract.ValidChangeKind(string(d.Name)): + return fmt.Errorf("proposals: %q is not a change kind", d.Name) + case d.Path == nil: + return fmt.Errorf("proposals: %s has no Path", d.Name) + } + k.mu.Lock() + defer k.mu.Unlock() + if _, ok := k.m[d.Name]; ok { + return fmt.Errorf("proposals: %s is registered already", d.Name) + } + k.m[d.Name] = d + return nil +} + +// Lookup returns the kind of a name. +func (k *Kinds) Lookup(name string) (Kind, bool) { + k.mu.RLock() + defer k.mu.RUnlock() + d, ok := k.m[contract.ChangeKind(name)] + return d, ok +} + +// Names lists the registered kinds in contract order. +func (k *Kinds) Names() []contract.ChangeKind { + k.mu.RLock() + defer k.mu.RUnlock() + var out []contract.ChangeKind + for _, n := range contract.ChangeKinds() { + if _, ok := k.m[n]; ok { + out = append(out, n) + } + } + return out +} + +var ( + topicFile = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}\.md$`) + idSafe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) +) + +// ChannelDir names the memory folder of one connector conversation: the +// binding and a short hash of the host chat. It uses no platform name. +func ChannelDir(s contract.ChangeSource) string { + sum := sha256.Sum256([]byte(s.Chat)) + return s.ID + "-" + hex.EncodeToString(sum[:4]) +} + +var memoryKind = Kind{ + Name: contract.ChangeMemory, + Path: func(a Aim) (string, error) { + if !topicFile.MatchString(a.Target) { + return "", fmt.Errorf("%w: a memory target is a file name such as index.md or food.md", ErrBadTarget) + } + switch a.Scope { + case contract.ScopeUser: + return "Memory/" + a.Target, nil + case contract.ScopeAgent: + if !idSafe.MatchString(a.Agent) { + return "", fmt.Errorf("%w: agent memory needs a chat that runs a saved agent", ErrBadTarget) + } + return "Memory/agents/" + a.Agent + "/" + a.Target, nil + case contract.ScopeChannel: + if !idSafe.MatchString(a.Source.ID) || a.Source.Chat == "" { + return "", fmt.Errorf("%w: channel memory needs a chat that came from a connector", ErrBadTarget) + } + return "Memory/channels/" + ChannelDir(a.Source) + "/" + a.Target, nil + } + return "", fmt.Errorf("%w: unknown scope %q", ErrBadTarget, a.Scope) + }, +} + +var profileKind = Kind{ + Name: contract.ChangeProfile, + Sensitive: true, + MaxBytes: contract.MaxProfileBytes, + Path: func(a Aim) (string, error) { + if a.Scope != contract.ScopeAgent || !idSafe.MatchString(a.Agent) { + return "", fmt.Errorf("%w: a profile belongs to a saved agent: use scope agent in a chat that runs one", ErrBadTarget) + } + return "LocalAI/agents/" + a.Agent + "/profile.md", nil + }, +} diff --git a/core/services/studio/proposals/kinds_test.go b/core/services/studio/proposals/kinds_test.go new file mode 100644 index 0000000000..e3f9bb54eb --- /dev/null +++ b/core/services/studio/proposals/kinds_test.go @@ -0,0 +1,80 @@ +package proposals_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio/proposals" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +var _ = Describe("kinds", func() { + kinds := proposals.NewKinds() + path := func(kind contract.ChangeKind, a proposals.Aim) (string, error) { + k, ok := kinds.Lookup(string(kind)) + Expect(ok).To(BeTrue()) + return k.Path(a) + } + + It("maps memory to files under Memory/ only", func() { + p, err := path(contract.ChangeMemory, proposals.Aim{Scope: contract.ScopeUser, Target: "index.md"}) + Expect(err).NotTo(HaveOccurred()) + Expect(p).To(Equal("Memory/index.md")) + p, err = path(contract.ChangeMemory, proposals.Aim{Scope: contract.ScopeAgent, Agent: "ag1", Target: "food.md"}) + Expect(err).NotTo(HaveOccurred()) + Expect(p).To(Equal("Memory/agents/ag1/food.md")) + src := contract.ChangeSource{Kind: "connector", ID: "b1", Chat: "c1"} + p, err = path(contract.ChangeMemory, proposals.Aim{Scope: contract.ScopeChannel, Source: src, Target: "index.md"}) + Expect(err).NotTo(HaveOccurred()) + Expect(p).To(Equal("Memory/channels/" + proposals.ChannelDir(src) + "/index.md")) + Expect(proposals.ChannelDir(src)).To(HavePrefix("b1-")) + }) + + DescribeTable("refuses a memory target that could leave Memory/", + func(a proposals.Aim) { + _, err := path(contract.ChangeMemory, a) + Expect(err).To(MatchError(proposals.ErrBadTarget)) + }, + Entry("a parent", proposals.Aim{Scope: contract.ScopeUser, Target: "../x.md"}), + Entry("a nested path", proposals.Aim{Scope: contract.ScopeUser, Target: "a/b.md"}), + Entry("no md", proposals.Aim{Scope: contract.ScopeUser, Target: "notes.txt"}), + Entry("an upper case name", proposals.Aim{Scope: contract.ScopeUser, Target: "Food.md"}), + Entry("agent scope without an agent", proposals.Aim{Scope: contract.ScopeAgent, Target: "a.md"}), + Entry("an agent id with a slash", proposals.Aim{Scope: contract.ScopeAgent, Agent: "a/../b", Target: "a.md"}), + Entry("channel scope without a connector chat", proposals.Aim{Scope: contract.ScopeChannel, Target: "a.md"}), + Entry("an unknown scope", proposals.Aim{Scope: "team", Target: "a.md"}), + ) + + It("maps a profile to the agent's file and caps it at 4 KiB", func() { + k, _ := kinds.Lookup("profile") + Expect(k.Sensitive).To(BeTrue()) + Expect(k.Limit()).To(Equal(4096)) + p, err := k.Path(proposals.Aim{Scope: contract.ScopeAgent, Agent: "ag1", Target: "ignored"}) + Expect(err).NotTo(HaveOccurred()) + Expect(p).To(Equal("LocalAI/agents/ag1/profile.md")) + _, err = k.Path(proposals.Aim{Scope: contract.ScopeUser}) + Expect(err).To(MatchError(proposals.ErrBadTarget)) + m, _ := kinds.Lookup("memory") + Expect(m.Limit()).To(Equal(contract.MaxChangeBytes)) + }) + + It("lets later features register a kind once", func() { + k := proposals.NewKinds() + Expect(k.Names()).To(Equal([]contract.ChangeKind{contract.ChangeMemory, contract.ChangeProfile})) + skill := proposals.Kind{Name: contract.ChangeSkill, Path: func(proposals.Aim) (string, error) { return "x", nil }} + Expect(k.Register(skill)).To(Succeed()) + Expect(k.Register(skill)).NotTo(Succeed()) + Expect(k.Register(proposals.Kind{Name: "nope", Path: skill.Path})).NotTo(Succeed()) + Expect(k.Register(proposals.Kind{Name: contract.ChangePage})).NotTo(Succeed()) + }) +}) + +var _ = Describe("the default gate", func() { + It("allows a plain chat and refuses a chat that runs an agent", func() { + Expect(proposals.DefaultGate.Allow(context.Background(), proposals.Request{Action: proposals.GatePropose})).To(Succeed()) + err := proposals.DefaultGate.Allow(context.Background(), proposals.Request{Action: proposals.GatePropose, Agent: "ag1"}) + Expect(err).To(MatchError(proposals.ErrNotGranted)) + }) +})