mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
fix(studio): trust a sender who is on the allowlist for capabilities that are not sensitive
A binding with its capabilities ticked still gave nothing to the owner's own allowlisted account: Authorize wanted a pairing or a role even for capabilities that are not sensitive, so the agent had no reminder, memory or talk-back tool. A sender whose ID or @name is an entry of the allowlist now holds every capability that is not sensitive, within the ceiling of the binding and while no switch is off. The decision says Via "allowlist". A sensitive capability still needs a pairing, and a role never grants it. The entry of a channel or group lets its members talk to the bot but does not trust them: the trust is matched against the sender only, and a binding that accepts anyone lists nobody. Such a sender still gets capabilities from a pairing or a role. The capability gate takes the handle of the sender for the same match, and a sensitive request ignores the allowlist as it ignores roles. When a tool is held back for a missing right, the turn now carries a short note with the reason in plain words and tells the model to relay it, instead of saying that it has no such tool. The reasons are kept in the grant of the turn, so a worker reports the same words. The access view lists a "listed" sender apart from one let in through a channel. Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
19 files changed
+483
-50
No files matched your search
@@ -408,7 +408,7 @@ func (h *Handlers) StartConnectorPairing(c echo.Context) error {
|
||||
|
||||
// ConnectorAccess explains what each kind of sender may do on a binding.
|
||||
// @Summary Show what each kind of sender may do on a connector binding.
|
||||
// @Description Runs the permission check the host uses for each turn, for a sender on the allowlist, a paired sender and a sender of each role the kind declares, and lists the latest decisions. A settings page of the add-on can show it.
|
||||
// @Description Runs the permission check the host uses for each turn, for a sender on the allowlist by ID or name (trusted with every capability that is not sensitive), a sender let in only through a listed channel, a paired sender and a sender of each role the kind declares, and lists the latest decisions. A settings page of the add-on can show it.
|
||||
// @Tags chats
|
||||
// @Param id path string true "the binding's id"
|
||||
// @Success 200 {object} schema.ChatsConnectorAccess "The effective permissions"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -24,16 +25,25 @@ import (
|
||||
// the IDs that the kind declares (Ingest dropped the others); nothing here is
|
||||
// a name or text that the sender wrote.
|
||||
type AccessSubject struct {
|
||||
SenderID string
|
||||
Roles []string
|
||||
SenderID string
|
||||
Roles []string
|
||||
// Allowlisted says the message was let in: the sender is listed, or the
|
||||
// chat is (a listed group), or the binding accepts anyone.
|
||||
Allowlisted bool
|
||||
// Listed says the sender itself is on the allowlist, by ID or by handle.
|
||||
// A match through a channel or group entry, or a binding that accepts
|
||||
// anyone, does not set it: those let a whole room talk to the bot, and the
|
||||
// trust of a listed sender must not reach the rest of the room.
|
||||
Listed bool
|
||||
}
|
||||
|
||||
// Decision is the outcome of Authorize for one capability.
|
||||
type Decision struct {
|
||||
Capability string `json:"capability"`
|
||||
Allowed bool `json:"allowed"`
|
||||
// Via says how the sender got the capability: "paired" or "role:<id>".
|
||||
// Via says how the sender got the capability: "paired", "allowlist" (a
|
||||
// sender on the allowlist, for a capability that is not sensitive) or
|
||||
// "role:<id>".
|
||||
Via string `json:"via,omitempty"`
|
||||
// Reason says why the capability was refused. It is safe to show to the
|
||||
// sender and to the model.
|
||||
@@ -42,12 +52,15 @@ type Decision struct {
|
||||
|
||||
// Authorize decides whether the sender may use a capability on a binding. The
|
||||
// default is no. The capability must be in the closed list and in the ceiling
|
||||
// of the binding (Caps), the sender must be on the allowlist, and then:
|
||||
// of the binding (Caps), the sender must be allowed to talk to the bot, and
|
||||
// then:
|
||||
//
|
||||
// - a sensitive capability needs a pairing that has not ended; a role never
|
||||
// counts;
|
||||
// - any other capability needs a pairing, or a role of the sender that the
|
||||
// owner granted the capability (Grants) and that the kind can grant
|
||||
// - a sensitive capability needs a pairing that has not ended; neither a
|
||||
// role nor the allowlist counts;
|
||||
// - any other capability is held by a pairing, by a sender who is on the
|
||||
// allowlist themselves (Listed: their ID or handle is an entry; an entry
|
||||
// for a channel or group does not count), or by a role of the sender that
|
||||
// the owner granted the capability (Grants) and that the kind can grant
|
||||
// (Permissions).
|
||||
//
|
||||
// kind is nil when the kind is not running: a role then counts for nothing.
|
||||
@@ -76,6 +89,9 @@ func Authorize(b connectors.Binding, kind *contract.ConnectorKind, s AccessSubje
|
||||
if contract.SensitiveCapability(capability) {
|
||||
return deny("%s needs a sender that the owner paired with this bot", capability)
|
||||
}
|
||||
if s.Listed && s.SenderID != "" {
|
||||
return Decision{Capability: capability, Allowed: true, Via: "allowlist"}
|
||||
}
|
||||
if kind != nil && kind.Grantable(capability) {
|
||||
for _, id := range kind.FilterRoles(s.Roles) {
|
||||
for _, g := range b.Grants {
|
||||
@@ -85,7 +101,7 @@ func Authorize(b connectors.Binding, kind *contract.ConnectorKind, s AccessSubje
|
||||
}
|
||||
}
|
||||
}
|
||||
return deny("%s is not granted to this sender", capability)
|
||||
return deny("%s needs a sender on this bot's allowlist, or one that the owner paired with this bot", capability)
|
||||
}
|
||||
|
||||
// CapabilitySwitch is an extra switch in the chain of a capability: a global
|
||||
@@ -126,8 +142,14 @@ func (c *Connectors) kindOf(ctx context.Context, b connectors.Binding) *contract
|
||||
return nil
|
||||
}
|
||||
|
||||
// subjectOf describes the sender of a message that Ingest let in. Listed is
|
||||
// true only when the sender itself is an entry of the allowlist (ID or
|
||||
// handle). The entry of a channel or group is matched against the chat, never
|
||||
// against the sender, and AcceptAny lists nobody: neither makes a sender
|
||||
// Listed.
|
||||
func (c *Connectors) subjectOf(b connectors.Binding, m contract.ConnectorMessage, allowlisted bool) AccessSubject {
|
||||
return AccessSubject{SenderID: m.SenderID, Roles: m.Roles, Allowlisted: allowlisted}
|
||||
return AccessSubject{SenderID: m.SenderID, Roles: m.Roles, Allowlisted: allowlisted,
|
||||
Listed: m.SenderID != "" && b.Allows(m.SenderID, m.SenderUsername)}
|
||||
}
|
||||
|
||||
// Authorize decides whether the sender of a message may use a capability, and
|
||||
@@ -175,12 +197,27 @@ type turnGrant struct {
|
||||
Senders []string
|
||||
Roles []string
|
||||
Caps []string
|
||||
// Denied says why a capability of the closed list is not held, for the
|
||||
// ones that are in the ceiling of the binding. The tools use it to tell
|
||||
// the person what the owner can change.
|
||||
Denied map[string]string
|
||||
}
|
||||
|
||||
func (g turnGrant) merge(o turnGrant) turnGrant {
|
||||
out := turnGrant{Binding: g.Binding, Kind: g.Kind}
|
||||
out.Senders = unionOf(g.Senders, o.Senders)
|
||||
out.Roles = unionOf(g.Roles, o.Roles)
|
||||
if len(g.Denied)+len(o.Denied) > 0 {
|
||||
out.Denied = maps.Clone(g.Denied)
|
||||
if out.Denied == nil {
|
||||
out.Denied = map[string]string{}
|
||||
}
|
||||
for k, v := range o.Denied {
|
||||
if _, ok := out.Denied[k]; !ok {
|
||||
out.Denied[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range g.Caps {
|
||||
if slices.Contains(o.Caps, c) {
|
||||
out.Caps = append(out.Caps, c)
|
||||
@@ -200,11 +237,11 @@ func unionOf(a, b []string) []string {
|
||||
}
|
||||
|
||||
func grantOf(w *workerctl.ChatGrant) turnGrant {
|
||||
return turnGrant{Binding: w.Binding, Kind: w.Kind, Senders: w.Senders, Roles: w.Roles, Caps: w.Caps}
|
||||
return turnGrant{Binding: w.Binding, Kind: w.Kind, Senders: w.Senders, Roles: w.Roles, Caps: w.Caps, Denied: w.Denied}
|
||||
}
|
||||
|
||||
func (g turnGrant) wire() *workerctl.ChatGrant {
|
||||
return &workerctl.ChatGrant{Binding: g.Binding, Kind: g.Kind, Senders: g.Senders, Roles: g.Roles, Caps: g.Caps}
|
||||
return &workerctl.ChatGrant{Binding: g.Binding, Kind: g.Kind, Senders: g.Senders, Roles: g.Roles, Caps: g.Caps, Denied: g.Denied}
|
||||
}
|
||||
|
||||
// grantFor computes what the sender of a message may do, for the whole closed
|
||||
@@ -214,8 +251,15 @@ func (c *Connectors) grantFor(ctx context.Context, b connectors.Binding, m contr
|
||||
g := turnGrant{Binding: b.ID, Kind: b.Kind, Senders: []string{m.SenderID}, Roles: slices.Clone(m.Roles)}
|
||||
now := c.o.Clock()
|
||||
for _, name := range contract.ConnectorCapabilityNames() {
|
||||
if Authorize(b, kind, c.subjectOf(b, m, true), name, now, c.offReason(ctx, b, name)).Allowed {
|
||||
d := Authorize(b, kind, c.subjectOf(b, m, true), name, now, c.offReason(ctx, b, name))
|
||||
switch {
|
||||
case d.Allowed:
|
||||
g.Caps = append(g.Caps, name)
|
||||
case d.Reason != "":
|
||||
if g.Denied == nil {
|
||||
g.Denied = map[string]string{}
|
||||
}
|
||||
g.Denied[name] = d.Reason
|
||||
}
|
||||
}
|
||||
return g
|
||||
@@ -356,6 +400,9 @@ func (s *Service) turnAllow(chat string) (allow func(string) (bool, string), aud
|
||||
case slices.Contains(g.Caps, capability):
|
||||
return true, ""
|
||||
}
|
||||
if why := g.Denied[capability]; why != "" {
|
||||
return false, why
|
||||
}
|
||||
return false, "the owner has not allowed " + capability + " for the sender of this message"
|
||||
}
|
||||
audit = func(capability string, ok bool, reason string) {
|
||||
@@ -371,7 +418,8 @@ func (s *Service) turnAllow(chat string) (allow func(string) (bool, string), aud
|
||||
|
||||
// AccessSubjectView is what one kind of sender may do on a binding.
|
||||
type AccessSubjectView struct {
|
||||
// ID is "anyone" (an allowlisted sender with no role), "paired", or
|
||||
// ID is "listed" (a sender on the allowlist by ID or name), "anyone" (a
|
||||
// sender let in by a listed channel, with no role), "paired", or
|
||||
// "role:<id>".
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -406,7 +454,8 @@ func (c *Connectors) Access(ctx context.Context, user, id string) (AccessView, e
|
||||
return v
|
||||
}
|
||||
out := AccessView{Binding: b.ID}
|
||||
out.Subjects = append(out.Subjects, view("anyone", "A sender on the allowlist", b, AccessSubject{SenderID: "x", Allowlisted: true}))
|
||||
out.Subjects = append(out.Subjects, view("listed", "A sender on the allowlist", b, AccessSubject{SenderID: "x", Allowlisted: true, Listed: true}))
|
||||
out.Subjects = append(out.Subjects, view("anyone", "A sender let in through a listed channel", b, AccessSubject{SenderID: "x", Allowlisted: true}))
|
||||
paired := b
|
||||
paired.Paired = []connectors.PairedSender{{SenderID: "paired", At: now}}
|
||||
out.Subjects = append(out.Subjects, view("paired", "A paired sender", paired, AccessSubject{SenderID: "paired", Allowlisted: true}))
|
||||
@@ -436,10 +485,11 @@ func (c *Connectors) CapabilityGate() CapabilityGate {
|
||||
return c.CeilingAllows(ctx, b, r.Capability).Allowed
|
||||
}
|
||||
roles := r.Roles
|
||||
listed := b.Allows(r.Sender, r.Username)
|
||||
if r.Sensitive {
|
||||
roles = nil
|
||||
roles, listed = nil, false
|
||||
}
|
||||
d := Authorize(b, c.kindOf(ctx, b), AccessSubject{SenderID: r.Sender, Roles: roles, Allowlisted: true},
|
||||
d := Authorize(b, c.kindOf(ctx, b), AccessSubject{SenderID: r.Sender, Roles: roles, Allowlisted: true, Listed: listed},
|
||||
r.Capability, c.o.Clock(), c.offReason(ctx, b, r.Capability))
|
||||
c.svc.access.record(c.o.Clock(), b, []string{r.Sender}, roles, d)
|
||||
return d.Allowed
|
||||
|
||||
@@ -133,3 +133,137 @@ var _ = Describe("the grant of a turn", func() {
|
||||
Expect(g("A", nil, "memory.write").merge(g("B", nil)).Caps).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
// The trust of a sender who is on the allowlist by name: every capability that
|
||||
// is not sensitive, within the ceiling of the binding and while no switch is
|
||||
// off. A sender who got in through a channel entry is not trusted this way.
|
||||
var _ = Describe("Authorize for a listed sender", func() {
|
||||
now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||
all := contract.ConnectorCapabilityNames()
|
||||
var plainCaps, sensitiveCaps []string
|
||||
for _, n := range all {
|
||||
if contract.SensitiveCapability(n) {
|
||||
sensitiveCaps = append(sensitiveCaps, n)
|
||||
} else {
|
||||
plainCaps = append(plainCaps, n)
|
||||
}
|
||||
}
|
||||
kind := contract.ConnectorKind{ID: "chat", Roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}}
|
||||
binding := connectors.Binding{
|
||||
ID: "b1", Kind: "chat", AgentID: "a1", Caps: slices.Clone(all),
|
||||
Allow: []string{"U1", "@owner", "C123"},
|
||||
Paired: []connectors.PairedSender{{SenderID: "P", At: now.Add(-time.Hour)}},
|
||||
}
|
||||
listed := AccessSubject{SenderID: "U1", Allowlisted: true, Listed: true}
|
||||
|
||||
It("trusts a listed sender with every capability that is not sensitive", func() {
|
||||
for _, name := range plainCaps {
|
||||
d := Authorize(binding, &kind, listed, name, now, "")
|
||||
Expect(d.Allowed).To(BeTrue(), name)
|
||||
Expect(d.Via).To(Equal("allowlist"), name)
|
||||
}
|
||||
})
|
||||
It("does not trust a listed sender with a sensitive capability, and says what to do", func() {
|
||||
for _, name := range sensitiveCaps {
|
||||
d := Authorize(binding, &kind, listed, name, now, "")
|
||||
Expect(d.Allowed).To(BeFalse(), name)
|
||||
Expect(d.Reason).To(ContainSubstring("paired"), name)
|
||||
}
|
||||
})
|
||||
It("lets a pairing hold a sensitive capability, and names the pairing", func() {
|
||||
s := AccessSubject{SenderID: "P", Allowlisted: true, Listed: true}
|
||||
for _, name := range sensitiveCaps {
|
||||
d := Authorize(binding, &kind, s, name, now, "")
|
||||
Expect(d.Allowed).To(BeTrue(), name)
|
||||
Expect(d.Via).To(Equal("paired"), name)
|
||||
}
|
||||
})
|
||||
It("gives nothing to a sender who is allowed only through a channel entry", func() {
|
||||
s := AccessSubject{SenderID: "U9", Allowlisted: true}
|
||||
for _, name := range all {
|
||||
d := Authorize(binding, &kind, s, name, now, "")
|
||||
Expect(d.Allowed).To(BeFalse(), name)
|
||||
Expect(d.Reason).NotTo(BeEmpty(), name)
|
||||
}
|
||||
})
|
||||
It("still lets a role grant a channel-only sender a capability that is not sensitive", func() {
|
||||
k := kind
|
||||
k.Permissions = []string{contract.CapMemoryWrite}
|
||||
b := binding
|
||||
b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapMemoryWrite}}}
|
||||
d := Authorize(b, &k, AccessSubject{SenderID: "U9", Allowlisted: true, Roles: []string{"admin"}}, contract.CapMemoryWrite, now, "")
|
||||
Expect(d.Via).To(Equal("role:admin"))
|
||||
})
|
||||
It("gives nothing to a sender who is not allowed", func() {
|
||||
for _, name := range all {
|
||||
Expect(Authorize(binding, &kind, AccessSubject{SenderID: "U1", Listed: true}, name, now, "").Allowed).To(BeFalse(), name)
|
||||
}
|
||||
})
|
||||
It("gives nothing to a listed sender with no ID", func() {
|
||||
Expect(Authorize(binding, &kind, AccessSubject{Allowlisted: true, Listed: true}, contract.CapMemoryWrite, now, "").Allowed).To(BeFalse())
|
||||
})
|
||||
It("keeps the ceiling: a capability the owner did not tick is denied", func() {
|
||||
b := binding
|
||||
b.Caps = []string{contract.CapMemoryWrite}
|
||||
Expect(Authorize(b, &kind, listed, contract.CapMemoryWrite, now, "").Allowed).To(BeTrue())
|
||||
d := Authorize(b, &kind, listed, contract.CapTriggersPropose, now, "")
|
||||
Expect(d.Allowed).To(BeFalse())
|
||||
Expect(d.Reason).To(Equal("the owner has not enabled triggers.propose for this bot"))
|
||||
})
|
||||
It("keeps the switches: an off switch denies with its reason", func() {
|
||||
d := Authorize(binding, &kind, listed, contract.CapTriggersPropose, now, "triggers are off for this agent")
|
||||
Expect(d.Allowed).To(BeFalse())
|
||||
Expect(d.Reason).To(Equal("triggers are off for this agent"))
|
||||
})
|
||||
It("does not need the kind to be running", func() {
|
||||
Expect(Authorize(binding, nil, listed, contract.CapTriggersPropose, now, "").Allowed).To(BeTrue())
|
||||
})
|
||||
It("gives nothing to a binding that holds credentials only", func() {
|
||||
b := binding
|
||||
b.AgentID = ""
|
||||
Expect(Authorize(b, &kind, listed, contract.CapMemoryWrite, now, "").Allowed).To(BeFalse())
|
||||
})
|
||||
It("tells the owner what to do when a plain capability is not trusted", func() {
|
||||
d := Authorize(binding, &kind, AccessSubject{SenderID: "U9", Allowlisted: true}, contract.CapTriggersPropose, now, "")
|
||||
Expect(d.Reason).To(ContainSubstring("allowlist"))
|
||||
Expect(d.Reason).To(ContainSubstring("paired"))
|
||||
})
|
||||
|
||||
Describe("subjectOf", func() {
|
||||
c := &Connectors{}
|
||||
msg := func(id, user, chat string, direct bool) contract.ConnectorMessage {
|
||||
return contract.ConnectorMessage{SenderID: id, SenderUsername: user, Chat: chat, Direct: direct, Mention: true}
|
||||
}
|
||||
It("lists a sender by ID", func() {
|
||||
Expect(c.subjectOf(binding, msg("U1", "", "D1", true), true).Listed).To(BeTrue())
|
||||
})
|
||||
It("lists a sender by handle, ignoring case", func() {
|
||||
Expect(c.subjectOf(binding, msg("U7", "Owner", "D7", true), true).Listed).To(BeTrue())
|
||||
})
|
||||
It("does not list a sender whose channel is on the list", func() {
|
||||
s := c.subjectOf(binding, msg("U9", "someone", "C123", false), true)
|
||||
Expect(s.Listed).To(BeFalse())
|
||||
Expect(s.Allowlisted).To(BeTrue())
|
||||
})
|
||||
It("does not list a sender when the binding accepts anyone", func() {
|
||||
open := binding
|
||||
open.Allow = nil
|
||||
open.AcceptAny = true
|
||||
Expect(c.subjectOf(open, msg("U9", "someone", "D9", true), true).Listed).To(BeFalse())
|
||||
})
|
||||
It("does not list an empty sender ID", func() {
|
||||
b := binding
|
||||
b.Allow = []string{""}
|
||||
Expect(c.subjectOf(b, msg("", "", "D", true), true).Listed).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("the grant of a turn, denied reasons", func() {
|
||||
It("keeps the reasons of both senders", func() {
|
||||
a := turnGrant{Binding: "b", Senders: []string{"A"}, Caps: []string{"memory.write"}, Denied: map[string]string{"x": "ra"}}
|
||||
b := turnGrant{Binding: "b", Senders: []string{"B"}, Denied: map[string]string{"memory.write": "rb"}}
|
||||
m := a.merge(b)
|
||||
Expect(m.Denied).To(Equal(map[string]string{"x": "ra", "memory.write": "rb"}))
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,9 @@ type CapabilityRequest struct {
|
||||
Owner string
|
||||
Agent string
|
||||
Sender string
|
||||
// Username is the handle of the sender on the platform, when the message
|
||||
// had one. It lets the gate match an @handle entry of the allowlist.
|
||||
Username string
|
||||
// Roles are the role IDs, declared by the kind, that the add-on reported
|
||||
// for the sender of this message. Empty when Sender is.
|
||||
Roles []string
|
||||
|
||||
@@ -24,7 +24,7 @@ func (c *Connectors) answerPhrase(ctx context.Context, b connectors.Binding, m c
|
||||
return false
|
||||
}
|
||||
reply, handled := h.AnswerByPhrase(ctx, PhraseFrom{Binding: b.ID, Owner: b.Owner, Agent: b.AgentID,
|
||||
Chat: m.Chat, Thread: m.Thread, Sender: m.SenderID, Roles: m.Roles}, verb, code)
|
||||
Chat: m.Chat, Thread: m.Thread, Sender: m.SenderID, Username: m.SenderUsername, Roles: m.Roles}, verb, code)
|
||||
if handled {
|
||||
say(reply)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
)
|
||||
|
||||
// capabilityWords says a capability in the words a person uses, for the note
|
||||
// that the model reads. The reasons of Authorize name the capability; the model
|
||||
// should not repeat a key that the person never saw.
|
||||
var capabilityWords = map[string]string{
|
||||
contract.CapProposalsConfirm: "confirming proposals",
|
||||
contract.CapMemoryWrite: "saving to memory",
|
||||
contract.CapFilesUpload: "receiving files",
|
||||
contract.CapTriggersPropose: "reminders and scheduled tasks",
|
||||
contract.CapTriggersRun: "running scheduled tasks",
|
||||
contract.CapTalkbackChannels: "writing to other channels",
|
||||
contract.CapTalkbackNewChan: "starting conversations in new channels",
|
||||
contract.CapProfileWrite: "changing the agent's rules",
|
||||
contract.CapSkillsWrite: "saving skills",
|
||||
contract.CapPagesWrite: "publishing pages",
|
||||
contract.CapDashboardWrite: "changing the dashboard",
|
||||
contract.CapToolsWrite: "changes made by add-on tools",
|
||||
}
|
||||
|
||||
// The note is cut at 400 characters by the session, so it names the reasons
|
||||
// and not every tool, stays short, and goes first in the note of the turn.
|
||||
const (
|
||||
deniedNoteStart = "Held back from this sender: "
|
||||
deniedNoteEnd = " If asked for one of these, say why; never say it does not exist."
|
||||
deniedNoteBudget = 400 - len(deniedNoteStart) - len(deniedNoteEnd) - 1
|
||||
)
|
||||
|
||||
// deniedToolsNote tells the model which tools of this turn the sender may not
|
||||
// use and why, so that a request for one gets an answer the person can act on
|
||||
// ("the owner has not enabled reminders for this bot") and not "I have no such
|
||||
// tool". It is empty for a chat that no connector started, and when nothing was
|
||||
// held back for a missing right.
|
||||
func deniedToolsNote(rec session.ChatRecord, built []builtins.Built) string {
|
||||
if rec.Origin != session.OriginConnector {
|
||||
return ""
|
||||
}
|
||||
prefix := builtins.ReasonNotPermitted + ": "
|
||||
var words []string
|
||||
for name, w := range capabilityWords {
|
||||
words = append(words, name, w)
|
||||
}
|
||||
say := strings.NewReplacer(words...)
|
||||
var reasons []string
|
||||
for _, b := range built {
|
||||
why, ok := strings.CutPrefix(b.Reason, prefix)
|
||||
if b.OK || !ok || why == "" {
|
||||
continue
|
||||
}
|
||||
if why = say.Replace(why); !slices.Contains(reasons, why) {
|
||||
reasons = append(reasons, why)
|
||||
}
|
||||
}
|
||||
slices.Sort(reasons)
|
||||
var kept []string
|
||||
used := 0
|
||||
for _, why := range reasons {
|
||||
if used+len(why)+2 > deniedNoteBudget {
|
||||
break
|
||||
}
|
||||
kept = append(kept, why)
|
||||
used += len(why) + 2
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return ""
|
||||
}
|
||||
return deniedNoteStart + strings.Join(kept, "; ") + "." + deniedNoteEnd
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("the note about tools held back from a sender", func() {
|
||||
conn := session.ChatRecord{Origin: session.OriginConnector}
|
||||
It("is empty for a chat that no connector started", func() {
|
||||
Expect(deniedToolsNote(session.ChatRecord{}, []builtins.Built{{ID: "propose_trigger", Reason: "not permitted: x"}})).To(BeEmpty())
|
||||
})
|
||||
It("is empty when no tool was held back for a missing right", func() {
|
||||
Expect(deniedToolsNote(conn, []builtins.Built{{ID: "bash", Reason: builtins.ReasonOff}, {ID: "web_search", OK: true}})).To(BeEmpty())
|
||||
})
|
||||
It("names the reasons in plain words, and tells the model to relay them", func() {
|
||||
note := deniedToolsNote(conn, []builtins.Built{
|
||||
{ID: "propose_trigger", Reason: "not permitted: the owner has not enabled triggers.propose for this bot"},
|
||||
{ID: "list_triggers", Reason: "not permitted: the owner has not enabled triggers.propose for this bot"},
|
||||
{ID: "send_message", Reason: "not permitted: talkback.new_channel needs a sender that the owner paired with this bot"},
|
||||
{ID: "bash", Reason: builtins.ReasonOff},
|
||||
})
|
||||
Expect(note).To(HavePrefix("Held back from this sender: "))
|
||||
Expect(note).To(ContainSubstring("the owner has not enabled reminders and scheduled tasks for this bot"), "said once for both tools")
|
||||
Expect(strings.Count(note, "reminders")).To(Equal(1))
|
||||
Expect(note).To(ContainSubstring("starting conversations in new channels needs a sender that the owner paired with this bot"))
|
||||
Expect(note).To(ContainSubstring("never say it does not exist"))
|
||||
Expect(note).NotTo(ContainSubstring("triggers.propose"))
|
||||
Expect(note).NotTo(ContainSubstring("bash"))
|
||||
})
|
||||
It("fits what the session keeps of a note, whatever is held back", func() {
|
||||
var built []builtins.Built
|
||||
for name := range capabilityWords {
|
||||
built = append(built, builtins.Built{ID: name, Reason: "not permitted: " + name + " needs a sender that the owner paired with this bot"})
|
||||
}
|
||||
Expect(len(deniedToolsNote(conn, built))).To(BeNumerically("<=", 400))
|
||||
})
|
||||
})
|
||||
@@ -213,12 +213,23 @@ var _ = Describe("builtin grants in the turns of a connector chat", func() {
|
||||
Expect(lastResult("addon__office__comment").Err).To(MatchError(ContainSubstring("no tool named")))
|
||||
})
|
||||
|
||||
It("are not offered to a sender who is not paired and holds no role with the capability", func() {
|
||||
It("are offered to a sender on the allowlist who is not paired and holds no role", func() {
|
||||
setup()
|
||||
bb, _ := r.store.GetBinding(ctx, b.ID)
|
||||
bb.Paired = slices.DeleteFunc(bb.Paired, func(p connectors.PairedSender) bool { return p.SenderID == "12" })
|
||||
Expect(r.store.PutBinding(ctx, bb)).To(Succeed())
|
||||
say("12", "comment")
|
||||
waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) == 1 })
|
||||
Expect(tools()).To(ContainElement("addon__office__comment"))
|
||||
})
|
||||
|
||||
It("are not offered to a sender who only came in through a listed channel, and is not paired and holds no role", func() {
|
||||
setup()
|
||||
say("12", "hello")
|
||||
waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) == 1 })
|
||||
bb, _ := r.store.GetBinding(ctx, b.ID)
|
||||
bb.Paired = slices.DeleteFunc(bb.Paired, func(p connectors.PairedSender) bool { return p.SenderID == "12" })
|
||||
bb.Allow = []string{chatID}
|
||||
Expect(r.store.PutBinding(ctx, bb)).To(Succeed())
|
||||
say("12", "comment")
|
||||
waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) == 2 })
|
||||
@@ -245,6 +256,7 @@ var _ = Describe("builtin grants in the turns of a connector chat", func() {
|
||||
setup()
|
||||
bb, _ := r.store.GetBinding(ctx, b.ID)
|
||||
bb.Paired, bb.Grants = nil, nil
|
||||
bb.Allow = []string{chatID}
|
||||
Expect(r.store.PutBinding(ctx, bb)).To(Succeed())
|
||||
Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{
|
||||
Chat: chatID, Thread: thread, Mention: true, MessageID: "mr", SenderID: "11", SenderName: "S", Text: "comment", Roles: []string{"writer"}}})).To(Succeed())
|
||||
|
||||
@@ -45,6 +45,14 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
|
||||
Chat: sender, Direct: true, SenderID: sender, SenderName: "S", Text: text, Roles: roles}})).To(Succeed())
|
||||
waitFor("the answer", func() bool { return len(r.bot.answers()) >= n })
|
||||
}
|
||||
// askMember sends a message from a person who is not on the allowlist
|
||||
// themselves: they write in a group that is, and name the bot.
|
||||
askMember := func(roles []string, text string) {
|
||||
n++
|
||||
Expect(r.conns.Ingest(ctx, "telegram", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{
|
||||
Chat: "-100", Mention: true, SenderID: "21", SenderName: "M", Text: text, Roles: roles}})).To(Succeed())
|
||||
waitFor("the answer", func() bool { return len(r.bot.answers()) >= n })
|
||||
}
|
||||
tools := func() []string {
|
||||
chats, err := r.svc.List(ctx, alice, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
@@ -61,7 +69,7 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
|
||||
r = newConnRigWith(sh, connStore(), studiobot.NewTransport(addon), script, nil)
|
||||
r.bot, r.untrusted = bot, true
|
||||
r.grantBuiltins(contract.CapMemoryWrite, contract.CapProfileWrite, contract.CapTriggersPropose)
|
||||
b = r.bind(studio.ConnectorInput{Allow: []string{"11", "12"}, Caps: caps, Grants: grants})
|
||||
b = r.bind(studio.ConnectorInput{Allow: []string{"11", "12", "-100", "@boss"}, Caps: caps, Grants: grants})
|
||||
got, err := r.store.GetBinding(ctx, b.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
for _, p := range paired {
|
||||
@@ -72,9 +80,18 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
|
||||
}
|
||||
admin := []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapMemoryWrite}}}
|
||||
|
||||
It("offers no gated tool to a sender who holds no capability", func() {
|
||||
setup([]string{contract.CapMemoryWrite}, admin)
|
||||
It("offers a gated tool to a sender who is on the allowlist, without a pairing or a role", func() {
|
||||
setup([]string{contract.CapMemoryWrite}, nil)
|
||||
ask("11", nil, "remember")
|
||||
Expect(tools()).To(ContainElement("propose_change"))
|
||||
res := r.h.ResultsFor("propose_change")
|
||||
Expect(res).To(HaveLen(1))
|
||||
Expect(res[0].Err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("offers no gated tool to a sender who only came in through a listed group", func() {
|
||||
setup([]string{contract.CapMemoryWrite}, admin)
|
||||
askMember(nil, "remember")
|
||||
Expect(tools()).NotTo(ContainElement("propose_change"))
|
||||
res := r.h.ResultsFor("propose_change")
|
||||
Expect(res).To(HaveLen(1))
|
||||
@@ -99,7 +116,7 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
|
||||
ask("11", []string{"admin"}, "profile")
|
||||
res = r.h.ResultsFor("propose_change")
|
||||
Expect(res).To(HaveLen(2))
|
||||
Expect(res[1].Err).To(MatchError(ContainSubstring("not permitted: the owner has not allowed profile.write")))
|
||||
Expect(res[1].Err).To(MatchError(ContainSubstring("not permitted: profile.write needs a sender that the owner paired with this bot")))
|
||||
list, _ := r.svc.Changes().List(ctx, alice)
|
||||
Expect(list).To(HaveLen(1), "the refused change made no proposal")
|
||||
})
|
||||
@@ -114,7 +131,7 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
|
||||
|
||||
It("does not trust a role that the sender claims on a binding that did not grant it", func() {
|
||||
setup([]string{contract.CapMemoryWrite}, nil)
|
||||
ask("11", []string{"admin"}, "remember")
|
||||
askMember([]string{"admin"}, "remember")
|
||||
Expect(tools()).NotTo(ContainElement("propose_change"))
|
||||
})
|
||||
|
||||
@@ -143,6 +160,7 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
|
||||
}
|
||||
}
|
||||
Expect(allowed).To(Equal(map[string][]string{
|
||||
"listed": {contract.CapMemoryWrite},
|
||||
"paired": {contract.CapMemoryWrite, contract.CapProfileWrite},
|
||||
"role:admin": {contract.CapMemoryWrite},
|
||||
}))
|
||||
@@ -167,9 +185,14 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
|
||||
}
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapTriggersPropose})).To(BeTrue(), "the binding allows it")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapTriggersRun})).To(BeFalse(), "not in the ceiling")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "11"})).To(BeFalse())
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "11", Roles: []string{"admin"}})).To(BeTrue(), "a role the owner granted it")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "11", Roles: []string{"admin"}, Sensitive: true})).To(BeFalse(), "a role never counts for a sensitive request")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "11"})).To(BeTrue(), "a sender on the allowlist")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "7", Username: "Boss"})).To(BeTrue(), "a sender on the allowlist by handle")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "7", Username: "other"})).To(BeFalse(), "not on the allowlist")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "21"})).To(BeFalse(), "a member of a listed group is not on the allowlist")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "21", Roles: []string{"admin"}})).To(BeTrue(), "a role the owner granted it")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "21", Roles: []string{"admin"}, Sensitive: true})).To(BeFalse(), "a role never counts for a sensitive request")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "11", Sensitive: true})).To(BeFalse(), "neither does the allowlist")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "12", Sensitive: true})).To(BeTrue(), "a pairing does")
|
||||
Expect(req(studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Sender: "12"})).To(BeTrue(), "paired")
|
||||
Expect(gate.Allow(ctx, studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Binding: "nope", Sender: "12"})).To(BeFalse())
|
||||
Expect(gate.Allow(ctx, studio.CapabilityRequest{Capability: contract.CapProposalsConfirm, Binding: b.ID, Owner: "mallory", Sender: "12"})).To(BeFalse())
|
||||
|
||||
@@ -122,13 +122,27 @@ var _ = Describe("talk-back from a connector chat", func() {
|
||||
Expect(chatTools()).NotTo(ContainElement("send_message"))
|
||||
})
|
||||
|
||||
It("is absent when the sender is not paired and has no granted role", func() {
|
||||
It("is offered to a sender on the allowlist who is not paired", func() {
|
||||
setup(nil)
|
||||
bb, _ := r.store.GetBinding(ctx, b.ID)
|
||||
bb.Paired = nil
|
||||
Expect(r.store.PutBinding(ctx, bb)).To(Succeed())
|
||||
turn("hello")
|
||||
Expect(chatTools()).To(ContainElement("send_message"))
|
||||
})
|
||||
|
||||
It("is absent when the sender only came in through a listed channel and is not paired", func() {
|
||||
setup(nil)
|
||||
bb, _ := r.store.GetBinding(ctx, b.ID)
|
||||
bb.Paired = nil
|
||||
bb.Allow = []string{chatID}
|
||||
Expect(r.store.PutBinding(ctx, bb)).To(Succeed())
|
||||
turn("hello")
|
||||
Expect(chatTools()).NotTo(ContainElement("send_message"))
|
||||
l, _ := r.svc.List(ctx, alice, 10)
|
||||
prompt := strings.Join(r.h.SentRaw(l[0].ID), "\n")
|
||||
Expect(prompt).To(ContainSubstring("writing to other channels needs a sender"), "the model is told why")
|
||||
Expect(prompt).To(ContainSubstring("never say it does not exist"))
|
||||
})
|
||||
|
||||
It("is absent when the server switch is off", func() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/session"
|
||||
@@ -42,6 +43,7 @@ type ToolHarness struct {
|
||||
mu sync.Mutex
|
||||
results []ToolResult
|
||||
sent map[string][]string
|
||||
raw map[string][]string
|
||||
tools map[string][]string
|
||||
schemas map[string]map[string]string
|
||||
prompts map[string]string
|
||||
@@ -83,13 +85,21 @@ func (h *ToolHarness) ResultsFor(tool string) []ToolResult {
|
||||
return out
|
||||
}
|
||||
|
||||
// Sent returns the messages a chat's turns were given.
|
||||
// Sent returns the messages a chat's turns were given, without the sentence
|
||||
// about the tools held back from the sender (see SentRaw).
|
||||
func (h *ToolHarness) Sent(chat string) []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return append([]string(nil), h.sent[chat]...)
|
||||
}
|
||||
|
||||
// SentRaw is Sent with the sentence about the tools held back from the sender.
|
||||
func (h *ToolHarness) SentRaw(chat string) []string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return append([]string(nil), h.raw[chat]...)
|
||||
}
|
||||
|
||||
// ToolNames returns the names of the tools the chat's session was last started with.
|
||||
func (h *ToolHarness) ToolNames(chat string) []string {
|
||||
h.mu.Lock()
|
||||
@@ -184,9 +194,13 @@ func (s *toolSession) Send(ctx context.Context, text string) error {
|
||||
defer cancel()
|
||||
chat := s.env.Actor.ChatID
|
||||
s.h.mu.Lock()
|
||||
s.h.sent[chat] = append(s.h.sent[chat], text)
|
||||
s.h.sent[chat] = append(s.h.sent[chat], withoutDeniedNote(text))
|
||||
if s.h.raw == nil {
|
||||
s.h.raw = map[string][]string{}
|
||||
}
|
||||
s.h.raw[chat] = append(s.h.raw[chat], text)
|
||||
s.h.mu.Unlock()
|
||||
for _, step := range s.h.Script(s.env.Actor, text) {
|
||||
for _, step := range s.h.Script(s.env.Actor, withoutDeniedNote(text)) {
|
||||
switch {
|
||||
case step.Say != "":
|
||||
s.env.Emit(session.Event{Kind: session.KindText, Text: step.Say})
|
||||
@@ -243,3 +257,27 @@ func (s *toolSession) Interrupt() error {
|
||||
|
||||
func (s *toolSession) Export() ([]byte, error) { return []byte(`{}`), nil }
|
||||
func (s *toolSession) Close() error { return s.Interrupt() }
|
||||
|
||||
// DeniedNoteStart and DeniedNoteEnd bound the sentence that tells the model
|
||||
// which tools the sender of a connector message may not use. Sent leaves it
|
||||
// out, so a spec keys on the words of the person; SentRaw keeps it.
|
||||
const (
|
||||
DeniedNoteStart = "Held back from this sender: "
|
||||
DeniedNoteEnd = "never say it does not exist."
|
||||
)
|
||||
|
||||
// withoutDeniedNote removes that sentence, and the empty note around it.
|
||||
func withoutDeniedNote(text string) string {
|
||||
a := strings.Index(text, DeniedNoteStart)
|
||||
if a < 0 {
|
||||
return text
|
||||
}
|
||||
b := strings.Index(text[a:], DeniedNoteEnd)
|
||||
if b < 0 {
|
||||
return text
|
||||
}
|
||||
text = text[:a] + text[a+b+len(DeniedNoteEnd):]
|
||||
text = strings.Replace(text, "(System note: )\n\n", "", 1)
|
||||
text = strings.Replace(text, "(System note: )\n\n", "", 1)
|
||||
return strings.Replace(text, " )\n\n", ")\n\n", 1)
|
||||
}
|
||||
@@ -92,7 +92,8 @@ func (s *Service) chatTools(ctx context.Context, x session.Access) (session.Turn
|
||||
cmds := s.commandsOf(ctx, a.Principal, tools, rec.AddonsOff, sc)
|
||||
// The built-in tools no add-on serves: bash on this machine when the
|
||||
// Sandbox does not serve it and the setting allows it.
|
||||
tools = s.applyBuiltins(tools, s.builtinsFor(ctx, a, rec, sub))
|
||||
built := s.builtinsFor(ctx, a, rec, sub)
|
||||
tools = s.applyBuiltins(tools, built)
|
||||
cmds = shellSwitched(s.withHostBash(cmds, tools), hidden)
|
||||
// The file tools are the one file toolset of the chat. A sub-agent gets
|
||||
// them from subset, built for the parent chat; one from a saved agent gets
|
||||
@@ -179,6 +180,12 @@ func (s *Service) chatTools(ctx context.Context, x session.Access) (session.Turn
|
||||
if aNote != "" {
|
||||
note = strings.TrimSpace(note + " " + aNote)
|
||||
}
|
||||
// A tool the sender of a connector message may not use is not offered. The
|
||||
// model hears why, so it tells the person what the owner can change
|
||||
// instead of saying that it has no such tool.
|
||||
if line := deniedToolsNote(rec, built); line != "" {
|
||||
note = strings.TrimSpace(line + " " + note)
|
||||
}
|
||||
// What the user did with the triggers this chat proposed: the tool result
|
||||
// said they were waiting, so a later turn must hear how they ended.
|
||||
if s.triggers != nil {
|
||||
|
||||
@@ -175,6 +175,8 @@ func ParsePhrase(text string) (verb, code string, ok bool) {
|
||||
type PhraseFrom struct {
|
||||
Binding, Owner, Agent string
|
||||
Chat, Thread, Sender string
|
||||
// Username is the handle of the sender, when the message had one.
|
||||
Username string
|
||||
// Roles are the roles of the sender that the kind declares.
|
||||
Roles []string
|
||||
}
|
||||
@@ -220,7 +222,7 @@ func sameThread(t triggers.Trigger, from PhraseFrom) bool {
|
||||
// of the binding.
|
||||
func (h *Triggers) canAnswer(ctx context.Context, from PhraseFrom) bool {
|
||||
return h.svc.allowCap(ctx, CapabilityRequest{Capability: CapProposalsConfirm, Binding: from.Binding, Owner: from.Owner, Agent: from.Agent,
|
||||
Sender: from.Sender, Roles: from.Roles, Kind: "trigger"})
|
||||
Sender: from.Sender, Username: from.Username, Roles: from.Roles, Kind: "trigger"})
|
||||
}
|
||||
|
||||
// answerBare answers with a word that has no code. It acts only when the
|
||||
|
||||
@@ -196,11 +196,11 @@ func (s *Service) registerTriggerTools() error {
|
||||
case c.AgentID == "":
|
||||
return session.Tool{}, false, "a trigger from a messaging app needs the agent of the connection"
|
||||
case !s.allowCap(ctx, CapabilityRequest{Capability: CapTriggersPropose, Binding: c.OriginID, Owner: c.Actor.Principal, Agent: c.AgentID}):
|
||||
return session.Tool{}, false, "triggers are off for this connection"
|
||||
return session.Tool{}, false, builtins.ReasonNotPermitted + ": the owner has not enabled " + CapTriggersPropose + " for this bot"
|
||||
}
|
||||
// The binding allows it; the sender of this turn must hold it too.
|
||||
if held, why := c.Holds(CapTriggersPropose); !held {
|
||||
return session.Tool{}, false, why
|
||||
return session.Tool{}, false, builtins.ReasonNotPermitted + ": " + why
|
||||
}
|
||||
if d.id == proposeTriggerName {
|
||||
desc, schema = desc+connectorProposeNote, proposeTriggerConnectorSchema
|
||||
|
||||
@@ -25,6 +25,8 @@ type ChatGrant struct {
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
// Caps are the capabilities every sender of the turn holds.
|
||||
Caps []string `json:"caps,omitempty"`
|
||||
// Denied says why a capability in the ceiling is not held, by name.
|
||||
Denied map[string]string `json:"denied,omitempty"`
|
||||
}
|
||||
|
||||
// ChatStartReply is the worker's answer to a ChatStartRequest. With Error
|
||||
|
||||
@@ -500,9 +500,16 @@ grants nothing you would mind sharing.
|
||||
|
||||
### Capabilities, roles and pairing
|
||||
|
||||
Three things sit on top of the allowlist. They work the same for every connector
|
||||
Four things sit on top of the allowlist. They work the same for every connector
|
||||
add-on.
|
||||
|
||||
- **A listed sender.** A person who is on the allowlist themselves, by their ID or
|
||||
their `@name`, is trusted with every capability that is not sensitive, as far as
|
||||
`caps` and the switches of the agent and the server allow it. Nobody needs a
|
||||
pairing or a role for that. Someone who is let in only because their channel or group
|
||||
is listed, or because the binding accepts anyone, is not a listed sender and gets no
|
||||
capability this way.
|
||||
|
||||
- **Capabilities** (`caps`). The ceiling you enable for a binding, from a closed list
|
||||
(`proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`,
|
||||
`triggers.run`, `talkback.channels`, `tools.write`, and the sensitive `talkback.new_channel`,
|
||||
@@ -523,7 +530,7 @@ add-on.
|
||||
with `grants: [{"role", "caps"}]`. A grant is within `caps`, within what the kind
|
||||
lists as `permissions`, and never a sensitive capability. The kind list
|
||||
(`GET /api/chats/connectors/kinds`) shows each kind's `roles` and `permissions`.
|
||||
- **Pairing.** A sender you pair may use the sensitive capabilities. Ask for a code
|
||||
- **Pairing.** Only a sender you pair may use the sensitive capabilities. Ask for a code
|
||||
with `POST /api/chats/connectors/<id>/pairing`; send it as a direct message to the
|
||||
bot from an account on the allowlist. The code looks like `LA-` and eight characters,
|
||||
works once and lasts ten minutes. The host checks it before any model turn, so
|
||||
@@ -536,10 +543,24 @@ add-on.
|
||||
Pairing needs only a stable sender ID, so an add-on without roles supports it too.
|
||||
|
||||
The host checks these values for every message. A sender may use a capability when
|
||||
the capability is in `caps`, the sender is on the allowlist, and the sender is paired
|
||||
or has a role that you granted it. A sensitive capability needs a pairing; a role
|
||||
never counts. If several people share one turn, the turn gets only what all of them
|
||||
may do.
|
||||
the capability is in `caps`, no switch is off, and:
|
||||
|
||||
- for a capability that is not sensitive: the sender is on the allowlist by ID or
|
||||
`@name`, or is paired, or has a role that you granted it;
|
||||
- for a sensitive capability: the sender is paired. The allowlist and a role never
|
||||
count.
|
||||
|
||||
The allowlist can hold people and channels together. The host matches the sender of
|
||||
the message against the people entries (ID, and `@name` without regard to case). It
|
||||
matches the chat only to decide whether the bot answers, never to trust the sender.
|
||||
So a listed channel lets its members talk to the bot, but each of them gets
|
||||
capabilities only through a role, a pairing or an entry of their own. A binding with
|
||||
`accept_any` lists nobody. A sender who is let in but is not trusted still gets a
|
||||
role or a pairing as before.
|
||||
|
||||
The access view and the log name the way a sender got a capability: `allowlist`,
|
||||
`paired` or `role:<id>`. If several people share one turn, the turn gets only what
|
||||
all of them may do.
|
||||
|
||||
A tool of an add-on that holds your credential and changes something outside
|
||||
LocalAI (a comment on an issue, for example) needs `tools.write`. The host reads
|
||||
@@ -554,7 +575,12 @@ the capability and the agent must hold the grant.
|
||||
|
||||
The check decides which tools the model gets. A tool that the sender may not use is
|
||||
not offered, so the model cannot be talked into it. If the model calls one anyway, the
|
||||
call is refused with the reason, and the model tells the person. Today this gates the
|
||||
call is refused with the reason, and the model tells the person. When a tool is held
|
||||
back for a missing right, the turn also carries one short note for the model with the
|
||||
reason in plain words (for example "the owner has not enabled reminders and scheduled
|
||||
tasks for this bot", or "starting conversations in new channels needs a sender that the
|
||||
owner paired with this bot"). The model is told to relay it and not to say that it has
|
||||
no such tool. Today this gates the
|
||||
agent's change proposals (`memory.write` for a memory note, `profile.write` for a
|
||||
profile), files sent to the bot (`files.upload`), the trigger tools
|
||||
(`triggers.propose`), and the confirm phrase (`proposals.confirm`). Messages from a
|
||||
@@ -573,7 +599,8 @@ kind list also returns `capabilities`, the closed list with a title, a descripti
|
||||
and the `sensitive` and `usual` flags of each, so a page needs no list of its own.
|
||||
|
||||
To see the result, call `GET /api/chats/connectors/<id>/access`. It runs the same check
|
||||
for a sender on the allowlist, a paired sender and a sender of each role the kind
|
||||
for a sender on the allowlist by ID or name (`listed`), a sender let in through a
|
||||
listed channel (`anyone`), a paired sender and a sender of each role the kind
|
||||
declares, and lists the latest decisions this server made (sender IDs, roles,
|
||||
capability and result, never message text or codes). An add-on settings page can show
|
||||
it.
|
||||
@@ -594,8 +621,8 @@ trigger stays in Studio. Nothing runs before the owner confirms.
|
||||
LocalAI handles it before any model turn. When several proposals wait in one thread, a
|
||||
bare word is not enough: the bot lists the codes, and the person writes `yes CODE`,
|
||||
`confirm CODE` or `cancel CODE`. Any longer message goes to the agent as usual. It
|
||||
needs the capability `proposals.confirm` for that sender (a paired sender, or a role
|
||||
with the grant); a message from any other sender, or text the agent read, never answers
|
||||
needs the capability `proposals.confirm` for that sender (a sender on the allowlist by ID
|
||||
or name, a paired sender, or a role with the grant); a message from any other sender, or text the agent read, never answers
|
||||
a proposal. A code is used once, and a proposal lapses after 24 hours. Studio always
|
||||
works as well.
|
||||
- **Permissions are in the text.** The proposal says what a run may do with nobody
|
||||
|
||||
+1
-1
@@ -5854,7 +5854,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/chats/connectors/{id}/access": {
|
||||
"get": {
|
||||
"description": "Runs the permission check the host uses for each turn, for a sender on the allowlist, a paired sender and a sender of each role the kind declares, and lists the latest decisions. A settings page of the add-on can show it.",
|
||||
"description": "Runs the permission check the host uses for each turn, for a sender on the allowlist by ID or name (trusted with every capability that is not sensitive), a sender let in only through a listed channel, a paired sender and a sender of each role the kind declares, and lists the latest decisions. A settings page of the add-on can show it.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
|
||||
@@ -5851,7 +5851,7 @@
|
||||
},
|
||||
"/api/chats/connectors/{id}/access": {
|
||||
"get": {
|
||||
"description": "Runs the permission check the host uses for each turn, for a sender on the allowlist, a paired sender and a sender of each role the kind declares, and lists the latest decisions. A settings page of the add-on can show it.",
|
||||
"description": "Runs the permission check the host uses for each turn, for a sender on the allowlist by ID or name (trusted with every capability that is not sensitive), a sender let in only through a listed channel, a paired sender and a sender of each role the kind declares, and lists the latest decisions. A settings page of the add-on can show it.",
|
||||
"tags": [
|
||||
"chats"
|
||||
],
|
||||
|
||||
@@ -16796,8 +16796,10 @@ paths:
|
||||
/api/chats/connectors/{id}/access:
|
||||
get:
|
||||
description: Runs the permission check the host uses for each turn, for a sender
|
||||
on the allowlist, a paired sender and a sender of each role the kind declares,
|
||||
and lists the latest decisions. A settings page of the add-on can show it.
|
||||
on the allowlist by ID or name (trusted with every capability that is not
|
||||
sensitive), a sender let in only through a listed channel, a paired sender
|
||||
and a sender of each role the kind declares, and lists the latest decisions.
|
||||
A settings page of the add-on can show it.
|
||||
parameters:
|
||||
- description: the binding's id
|
||||
in: path
|
||||
|
||||
Reference in new issue
Block a user