Files
LocalAI/core/services/studio/knowledge.go
T
Ettore Di Giacinto 75c21675fc feat(studio): scan knowledge proposals for secrets
A text that a model proposes for a collection is checked with the secret
patterns that already keep a memory note from being saved at once. A
flagged text is still stored and shown, with a warning on the card, and
the owner can confirm it in the web interface. A messaging chat cannot
confirm it: the host does not write it in the chat, and a yes does not
apply it. A no still drops it.

The proposal carries the result in a new warning field, worked out at
each read.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-05 09:15:21 +00:00

212 lines
7.7 KiB
Go

package studio
import (
"context"
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/proposals"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Knowledge writes. A model can propose a text for a collection of the
// person's Knowledge. It goes through the change proposals like a memory note,
// with two differences that are on purpose: the host never applies it unasked,
// whatever the save level says (a wrong text would feed later answers of every
// chat that searches the collection, and indexing costs compute), and applying
// it adds an entry to a collection instead of writing a file of the home.
// ErrNoKnowledgeWriter means the server cannot add to collections.
var ErrNoKnowledgeWriter = errors.New("studio: this server cannot add to Knowledge")
// CollectionWriter is the part of the collections that adds and removes a text.
// A Collections value may implement it; without it the knowledge kind is not
// offered. Every call runs as the user and sees only the user's collections.
type CollectionWriter interface {
// EmbeddingReady returns nil when the model that indexes the collections
// can be used, or an error that says what is missing.
EmbeddingReady(ctx context.Context) error
// Add stores text as one entry of the collection under the file name.
Add(ctx context.Context, user, collection, name, text string) error
// Remove deletes the entry that Add stored under the file name.
Remove(ctx context.Context, user, collection, name string) error
}
// Reasons a knowledge proposal is refused, in the words the model passes on.
var (
errKnowledgeEmbedding = errors.New("the embedding model is not installed, so nothing can be added to Knowledge until an administrator installs it")
errKnowledgeNoList = errors.New("no collection is on this agent's list, so it cannot add to Knowledge")
)
const maxCollectionName = 128
// knowledgeWriter returns the writer of the server, or nil.
func (s *Service) knowledgeWriter() CollectionWriter {
w, _ := s.d.Collections.(CollectionWriter)
return w
}
// knowledgeAvailable says whether the user can add to Knowledge on this
// server: the collections run, the account may use them and the embedding
// model is there.
func (s *Service) knowledgeAvailable(ctx context.Context, user string) bool {
w := s.knowledgeWriter()
return w != nil && s.d.Collections.Allowed(ctx, principal(user)) && w.EmbeddingReady(ctx) == nil
}
// knowledgeBlock says why the capability to add to Knowledge is off for the
// agent of a connection, or "" with the code when it is on. The server link
// comes first: the embedding model is missing for everybody.
func (s *Service) knowledgeBlock(ctx context.Context, user, agentID string) (code, reason string) {
w := s.knowledgeWriter()
if w == nil || !s.d.Collections.Allowed(ctx, principal(user)) {
return CodeServerSettingOff, "Knowledge is not available on this server for this person"
}
if w.EmbeddingReady(ctx) != nil {
return CodeKnowledgeNoEmbedding, "the embedding model is not installed"
}
if agentID != "" && s.d.Agents != nil {
ag, err := s.d.Agents.Get(ctx, principal(user), agentID)
if err == nil && len(ag.Collections) == 0 {
return CodeKnowledgeNone, "no collection is on the list of the agent"
}
}
return "", ""
}
// knowledgeWarning is the threat scan of a knowledge proposal. A text that
// reaches a collection is read back by every chat that searches it, so a secret
// in it would leak and a wrong text would mislead. It uses the same secret
// patterns as the check that holds a memory note back from "Save it and tell
// me" (secretLike). It returns a code of ChangeProposal.Warning, or "".
func knowledgeWarning(p contract.ChangeProposal) string {
if p.Kind != contract.ChangeKnowledge {
return ""
}
if secretLike.MatchString(p.Summary) || secretLike.MatchString(p.Content) {
return contract.WarningSecret
}
return ""
}
func validCollectionName(n string) bool {
if n == "" || len(n) > maxCollectionName || !utf8.ValidString(n) {
return false
}
return !strings.ContainsFunc(n, unicode.IsControl)
}
// knowledgeEntryName is the file name a proposal's text gets in the collection:
// a short slug of the summary and the start of the proposal ID, so the entry
// can be found again to undo it.
func knowledgeEntryName(p contract.ChangeProposal) string {
var b strings.Builder
for _, r := range strings.ToLower(p.Summary) {
switch {
case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)):
b.WriteRune(r)
case b.Len() > 0 && !strings.HasSuffix(b.String(), "-"):
b.WriteByte('-')
}
if b.Len() >= 40 {
break
}
}
slug := strings.Trim(b.String(), "-")
if slug == "" {
slug = "note"
}
id := strings.ReplaceAll(p.ID, "-", "")
return slug + "-" + id[:min(8, len(id))] + ".md"
}
// knowledgeEntryText is what is stored: the summary as a title, then the text.
func knowledgeEntryText(p contract.ChangeProposal) string {
return "# " + p.Summary + "\n\n" + strings.TrimSpace(p.Content) + "\n"
}
// knowledgeKind builds the knowledge kind on the collections of the service.
func (c *Changes) knowledgeKind() proposals.Kind {
s := c.s
return proposals.Kind{
Name: contract.ChangeKnowledge,
Grant: contract.CapKnowledgeWrite,
Sensitive: true,
MaxBytes: contract.MaxKnowledgeBytes,
Path: func(a proposals.Aim) (string, error) {
switch {
case a.Scope != contract.ScopeUser:
return "", fmt.Errorf("%w: Knowledge belongs to the user: use scope user", proposals.ErrBadTarget)
case !validCollectionName(a.Target):
return "", fmt.Errorf("%w: the target of a knowledge change is the name of a collection (list_collections shows them)", proposals.ErrBadTarget)
}
return a.Target, nil
},
Check: func(in proposals.CheckInput) error {
if strings.TrimSpace(in.Content) == "" {
return errors.New("the text to add is empty")
}
return nil
},
Dest: &proposals.Destination{
Prepare: func(ctx context.Context, in proposals.ApplyInput) error {
w := s.knowledgeWriter()
if w == nil {
return ErrNoKnowledgeWriter
}
if !s.d.Collections.Allowed(ctx, in.Owner) {
return ErrNoKnowledgeWriter
}
if err := w.EmbeddingReady(ctx); err != nil {
return errKnowledgeEmbedding
}
mine, err := s.d.Collections.List(ctx, in.Owner)
if err != nil {
return fmt.Errorf("cannot list the collections: %v", err)
}
name := in.Proposal.Target
if !set(mine)[name] {
return fmt.Errorf("there is no collection %q", name)
}
if in.Proposal.Agent == "" {
return nil
}
// A saved agent adds only to the collections on its own list.
if s.d.Agents == nil {
return errors.New("the agent cannot be read")
}
ag, err := s.d.Agents.Get(ctx, in.Owner, in.Proposal.Agent)
switch {
case errors.Is(err, agents.ErrNotFound):
return errors.New("the agent no longer exists")
case err != nil:
return fmt.Errorf("cannot read the agent: %v", err)
case len(ag.Collections) == 0:
return errKnowledgeNoList
case !set(ag.Collections)[name]:
return fmt.Errorf("the collection %q is not on this agent's list", name)
}
return nil
},
Apply: func(ctx context.Context, in proposals.ApplyInput) error {
w := s.knowledgeWriter()
if w == nil {
return ErrNoKnowledgeWriter
}
return w.Add(ctx, in.Owner, in.Proposal.Target, knowledgeEntryName(in.Proposal), knowledgeEntryText(in.Proposal))
},
Undo: func(ctx context.Context, in proposals.ApplyInput) error {
w := s.knowledgeWriter()
if w == nil {
return ErrNoKnowledgeWriter
}
return w.Remove(ctx, in.Owner, in.Proposal.Target, knowledgeEntryName(in.Proposal))
},
},
}
}