diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index c235596d8a..75ca949c22 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -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" diff --git a/core/services/studio/connectoraccess.go b/core/services/studio/connectoraccess.go index b176394867..4af8a02785 100644 --- a/core/services/studio/connectoraccess.go +++ b/core/services/studio/connectoraccess.go @@ -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:". + // Via says how the sender got the capability: "paired", "allowlist" (a + // sender on the allowlist, for a capability that is not sensitive) or + // "role:". 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 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 diff --git a/core/services/studio/connectoraccess_internal_test.go b/core/services/studio/connectoraccess_internal_test.go index bc7104b5e8..71a6ee0ef1 100644 --- a/core/services/studio/connectoraccess_internal_test.go +++ b/core/services/studio/connectoraccess_internal_test.go @@ -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"})) + }) +}) diff --git a/core/services/studio/connectorcaps.go b/core/services/studio/connectorcaps.go index 1a67476d49..b90c1add4e 100644 --- a/core/services/studio/connectorcaps.go +++ b/core/services/studio/connectorcaps.go @@ -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 diff --git a/core/services/studio/connectorconfirm.go b/core/services/studio/connectorconfirm.go index 874ed64678..6031642e6e 100644 --- a/core/services/studio/connectorconfirm.go +++ b/core/services/studio/connectorconfirm.go @@ -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) } diff --git a/core/services/studio/connectordenied.go b/core/services/studio/connectordenied.go new file mode 100644 index 0000000000..294783e58b --- /dev/null +++ b/core/services/studio/connectordenied.go @@ -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 +} diff --git a/core/services/studio/connectordenied_internal_test.go b/core/services/studio/connectordenied_internal_test.go new file mode 100644 index 0000000000..382c62645f --- /dev/null +++ b/core/services/studio/connectordenied_internal_test.go @@ -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)) + }) +}) diff --git a/core/services/studio/connectorgrants_test.go b/core/services/studio/connectorgrants_test.go index 655e5deaaf..ad1d75687a 100644 --- a/core/services/studio/connectorgrants_test.go +++ b/core/services/studio/connectorgrants_test.go @@ -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()) diff --git a/core/services/studio/connectors_enforce_test.go b/core/services/studio/connectors_enforce_test.go index 61fb6ce350..822ddaeb50 100644 --- a/core/services/studio/connectors_enforce_test.go +++ b/core/services/studio/connectors_enforce_test.go @@ -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()) diff --git a/core/services/studio/connectortalkback_test.go b/core/services/studio/connectortalkback_test.go index 352c56c117..404ad04bd3 100644 --- a/core/services/studio/connectortalkback_test.go +++ b/core/services/studio/connectortalkback_test.go @@ -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() { diff --git a/core/services/studio/studiotest/tools.go b/core/services/studio/studiotest/tools.go index fb5034c896..2e0f6aea11 100644 --- a/core/services/studio/studiotest/tools.go +++ b/core/services/studio/studiotest/tools.go @@ -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) +} diff --git a/core/services/studio/subagent.go b/core/services/studio/subagent.go index ed0a684a35..7b88333dfc 100644 --- a/core/services/studio/subagent.go +++ b/core/services/studio/subagent.go @@ -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 { diff --git a/core/services/studio/triggerroute.go b/core/services/studio/triggerroute.go index 78515a8b6d..ee43dd665c 100644 --- a/core/services/studio/triggerroute.go +++ b/core/services/studio/triggerroute.go @@ -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 diff --git a/core/services/studio/triggertools.go b/core/services/studio/triggertools.go index f700d82a17..e55dae3704 100644 --- a/core/services/studio/triggertools.go +++ b/core/services/studio/triggertools.go @@ -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 diff --git a/core/services/workerctl/chat.go b/core/services/workerctl/chat.go index d540cef342..a60fed516d 100644 --- a/core/services/workerctl/chat.go +++ b/core/services/workerctl/chat.go @@ -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 diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index de44354320..6e8821f539 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -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//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:`. 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//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 diff --git a/swagger/docs.go b/swagger/docs.go index 9d802f1178..cdbb9d5ada 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -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" ], diff --git a/swagger/swagger.json b/swagger/swagger.json index f9ff524bcb..2a49b09a8b 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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" ], diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 1c8147e8fa..732fa0ad3c 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -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