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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 09:27:04 +00:00
1 parent b1df05a800
commit b988cae89d
3 files changed
+276

No files matched your search

+50
View File
@@ -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
})
+146
View File
@@ -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
},
}
@@ -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))
})
})