feat(studio): explain the first blocker of each connector capability

The access view said only that a capability was refused, in free text.
It now reports, for every capability of every kind of sender, the first
link of the permission chain that fails: a closed code, who holds the
link (server, tools, agent, connection, sender), one plain sentence, one
fix as a kind and a stable target, and the links not yet checked. The
chain order is server setting, Tools switch, agent permission, the
permission of the connection, then sender trust. The code is produced
where Authorize and the capability switches decide, so the English
reason and the decision cannot drift apart. The Tools switch of the
agent and the server triggers setting are now switches in that chain.

POST /api/chats/connectors/{id}/access/preview runs the same chain on a
form that is not saved (id "new" works with a kind), optionally for one
kind of sender. It writes nothing, records nothing and never takes or
returns the token.

The held-back note for the model, the tool errors and the trigger tool
reason take their sentence from the same table of codes, in plain words
with no setting names.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-04 13:50:05 +00:00
1 parent 2b9f62d055
commit 80e4c61610
25 files changed
+1518 -110

No files matched your search

+3
View File
@@ -144,6 +144,9 @@ func (a *Application) startStudioConnectors(svc *studio.Service) {
xlog.Info("Connector talk-back is turned off (LOCALAI_DISABLE_CONNECTOR_TALKBACK)")
c.AddCapabilitySwitch(studio.TalkbackSwitch("turned off on this server (LOCALAI_DISABLE_CONNECTOR_TALKBACK)"))
}
if a.applicationConfig.DisableTriggers {
c.AddCapabilitySwitch(studio.TriggersSwitch("triggers are turned off on this server (LOCALAI_DISABLE_TRIGGERS)"))
}
a.studioConnectorsHook.Store(c)
if a.applicationConfig.SkipBindingCapsMigration {
xlog.Info("Connectors: the grant of the usual permissions to old bindings is skipped (LOCALAI_SKIP_BINDING_CAPS_MIGRATION)")
+1
View File
@@ -149,6 +149,7 @@ var RouteFeatureRegistry = []RouteFeature{
{"GET", "/api/chats/connectors/:id/senders", FeatureConnectors},
{"POST", "/api/chats/connectors/:id/senders/:sender/allow", FeatureConnectors},
{"GET", "/api/chats/connectors/:id/access", FeatureConnectors},
{"POST", "/api/chats/connectors/:id/access/preview", FeatureConnectors},
{"POST", "/api/chats/connectors/:id/pairing", FeatureConnectors},
{"DELETE", "/api/chats/connectors/:id/paired/:sender", FeatureConnectors},
{"GET", "/api/chats/mcp-connectors", FeatureChats},
+1
View File
@@ -126,6 +126,7 @@ var _ = Describe("Chats feature registration", func() {
"GET /api/chats/connectors/:id/senders",
"POST /api/chats/connectors/:id/senders/:sender/allow",
"GET /api/chats/connectors/:id/access",
"POST /api/chats/connectors/:id/access/preview",
"POST /api/chats/connectors/:id/pairing",
"DELETE /api/chats/connectors/:id/paired/:sender",
))
+40 -4
View File
@@ -423,18 +423,54 @@ func (h *Handlers) ConnectorAccess(c echo.Context) error {
if err != nil {
return failConnector(c, err)
}
out := schema.ChatsConnectorAccess{Binding: v.Binding, Subjects: []schema.ChatsConnectorAccessSubject{}, Recent: []schema.ChatsConnectorAccessEvent{}}
return c.JSON(http.StatusOK, accessOut(v))
}
func accessOut(v studiosvc.AccessView) schema.ChatsConnectorAccess {
out := schema.ChatsConnectorAccess{Binding: v.Binding, Subjects: []schema.ChatsConnectorAccessSubject{}, Recent: []schema.ChatsConnectorAccessEvent{}, Preview: v.Preview}
for _, s := range v.Subjects {
o := schema.ChatsConnectorAccessSubject{ID: s.ID, Title: s.Title, Decisions: []schema.ChatsConnectorAccessDecision{}}
for _, d := range s.Decisions {
o.Decisions = append(o.Decisions, schema.ChatsConnectorAccessDecision{Capability: d.Capability, Allowed: d.Allowed, Via: d.Via, Reason: d.Reason})
od := schema.ChatsConnectorAccessDecision{Capability: d.Capability, Allowed: d.Allowed, Via: d.Via, Reason: d.Reason,
Code: d.Code, BlockedBy: d.BlockedBy, Message: d.Message, ThenCheck: d.ThenCheck}
if d.Fix != nil {
od.Fix = &schema.ChatsConnectorAccessFix{Kind: d.Fix.Kind, Target: d.Fix.Target, Field: d.Fix.Field}
}
o.Decisions = append(o.Decisions, od)
}
out.Subjects = append(out.Subjects, o)
}
for _, e := range v.Recent {
out.Recent = append(out.Recent, schema.ChatsConnectorAccessEvent{At: e.At, Senders: e.Senders, Roles: e.Roles, Capability: e.Capability, Allowed: e.Allowed, Via: e.Via, Reason: e.Reason})
out.Recent = append(out.Recent, schema.ChatsConnectorAccessEvent{At: e.At, Senders: e.Senders, Roles: e.Roles, Capability: e.Capability, Allowed: e.Allowed, Via: e.Via, Reason: e.Reason, Code: e.Code})
}
return c.JSON(http.StatusOK, out)
return out
}
// ConnectorAccessPreview explains what each kind of sender would be allowed
// to do on a connection form that is not saved.
// @Summary Preview what each kind of sender may do on a connector binding, before saving.
// @Description Runs the same permission chain as the access view on the state in the request: the server setting, the Tools switch of the agent, the agent permission, the permission of the connection and the trust of the sender, in that order, and reports the first problem of each capability with a code, a plain message, one fix and the links not yet checked. Fields that are absent keep the saved value. Use the id "new" with a kind for a connection that is not saved yet. It writes nothing, records no decision, and neither takes nor returns the token. Same sign-in and Connectors feature as the other connector routes.
// @Tags chats
// @Param id path string true "the binding's id, or new"
// @Param request body schema.ChatsConnectorAccessPreviewRequest true "the form state to check"
// @Success 200 {object} schema.ChatsConnectorAccess "The effective permissions of that state"
// @Failure 400 {object} schema.ErrorResponse "The request is not valid"
// @Router /api/chats/connectors/{id}/access/preview [post]
func (h *Handlers) ConnectorAccessPreview(c echo.Context) error {
host, ok := h.connectorHost(c)
if !ok {
return nil
}
req, ok := bind[schema.ChatsConnectorAccessPreviewRequest](c, msgNotJSON)
if !ok {
return nil
}
v, err := host.AccessPreview(c.Request().Context(), userID(c), c.Param("id"), studiosvc.AccessDraft{
Kind: req.Kind, AgentID: req.AgentID, AcceptAny: req.AcceptAny, Allow: req.Allow, Caps: req.Caps, Grants: grantsIn(req.Grants), Sender: req.Sender})
if err != nil {
return failConnector(c, err)
}
return c.JSON(http.StatusOK, accessOut(v))
}
// RemoveConnectorPaired ends the pairing of a sender.
+1
View File
@@ -223,6 +223,7 @@ func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioend
e.GET(base+"/connectors/:id/senders", h.ConnectorSenders)
e.POST(base+"/connectors/:id/senders/:sender/allow", h.AllowConnectorSender)
e.GET(base+"/connectors/:id/access", h.ConnectorAccess)
e.POST(base+"/connectors/:id/access/preview", h.ConnectorAccessPreview)
e.POST(base+"/connectors/:id/pairing", h.StartConnectorPairing)
e.DELETE(base+"/connectors/:id/paired/:sender", h.RemoveConnectorPaired)
e.GET(base+"/mcp-connectors", h.ListMCPConnectors)
@@ -56,6 +56,7 @@ var _ = Describe("Chats connectors endpoints", func() {
Entry("senders", http.MethodGet, "/api/chats/connectors/c1/senders"),
Entry("allow", http.MethodPost, "/api/chats/connectors/c1/senders/42/allow"),
Entry("access", http.MethodGet, "/api/chats/connectors/c1/access"),
Entry("access preview", http.MethodPost, "/api/chats/connectors/c1/access/preview"),
Entry("pairing", http.MethodPost, "/api/chats/connectors/c1/pairing"),
Entry("unpair", http.MethodDelete, "/api/chats/connectors/c1/paired/42"),
)
@@ -351,5 +352,51 @@ var _ = Describe("Chats connectors endpoints", func() {
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
Expect(decodeJSON(rec)).To(And(HaveKeyWithValue("binding", id), HaveKeyWithValue("subjects", Not(BeEmpty())), HaveKey("recent")))
Expect(s.do("bob", http.MethodGet, "/api/chats/connectors/"+id+"/access", nil).Code).To(Equal(http.StatusNotFound))
// The preview answers for a form that is not saved, with the first
// blocker as data, and never carries the token.
subject := func(v map[string]any, subj string) map[string]any {
for _, x := range v["subjects"].([]any) {
if m := x.(map[string]any); m["id"] == subj {
return m
}
}
Fail("no subject " + subj)
return nil
}
decision := func(v map[string]any, subj, capability string) map[string]any {
for _, x := range subject(v, subj)["decisions"].([]any) {
if m := x.(map[string]any); m["capability"] == capability {
return m
}
}
Fail("no capability " + capability)
return nil
}
preview := "/api/chats/connectors/" + id + "/access/preview"
rec = s.do("alice", http.MethodGet, "/api/chats/connectors/"+id+"/access", nil)
blocked := decision(decodeJSON(rec), "listed", "triggers.propose")
Expect(blocked).To(And(HaveKeyWithValue("allowed", false), HaveKeyWithValue("code", "connection_permission_off"),
HaveKeyWithValue("blocked_by", "connection"), HaveKey("message"), HaveKey("reason"),
HaveKeyWithValue("fix", HaveKeyWithValue("kind", "open_connection")), HaveKeyWithValue("then_check", ContainElement("sender"))))
rec = s.do("alice", http.MethodPost, preview, map[string]any{"caps": []string{"triggers.propose"}, "sender": "listed"})
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
v := decodeJSON(rec)
Expect(v).To(HaveKeyWithValue("preview", true))
Expect(v["subjects"]).To(HaveLen(1))
allowed := decision(v, "listed", "triggers.propose")
Expect(allowed).To(And(HaveKeyWithValue("allowed", true), HaveKeyWithValue("via", "allowlist")))
Expect(allowed).NotTo(HaveKey("code"))
Expect(rec.Body.String()).NotTo(ContainSubstring(specToken))
// Nothing was saved.
rec = s.do("alice", http.MethodGet, "/api/chats/connectors/"+id, nil)
Expect(decodeJSON(rec)).To(HaveKeyWithValue("caps", BeEmpty()))
Expect(s.do("alice", http.MethodPost, preview, map[string]any{"sender": "root"}).Code).To(Equal(http.StatusBadRequest))
Expect(s.do("alice", http.MethodPost, preview, "not json").Code).To(Equal(http.StatusBadRequest))
Expect(s.do("bob", http.MethodPost, preview, map[string]any{}).Code).To(Equal(http.StatusNotFound))
Expect(s.do("carol", http.MethodPost, preview, map[string]any{}).Code).To(Equal(http.StatusForbidden))
rec = s.do("alice", http.MethodPost, "/api/chats/connectors/new/access/preview", map[string]any{"kind": "telegram", "agent_id": ag, "caps": []string{"memory.write"}})
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
Expect(decision(decodeJSON(rec), "listed", "memory.write")).To(HaveKeyWithValue("allowed", true))
})
})
+52
View File
@@ -1391,6 +1391,37 @@ type ChatsConnectorAccessDecision struct {
// Via is how the capability is reached: paired, or role:<id>.
Via string `json:"via,omitempty"`
Reason string `json:"reason,omitempty"`
// Code says which link of the chain refused first. It is one of
// server_setting_off, tools_switch_off, agent_permission_off,
// agent_unavailable, connection_permission_off, no_agent,
// sender_not_listed, sender_needs_pairing, channel_not_trusted,
// role_not_granted, kind_not_running, context_rule or
// unknown_capability. Only a refused capability has it.
Code string `json:"code,omitempty"`
// BlockedBy is the link that refused: server, tools, agent, connection
// or sender.
BlockedBy string `json:"blocked_by,omitempty"`
// Message is one plain sentence for the owner. It names no setting.
Message string `json:"message,omitempty"`
// Fix points at the one place that solves it. The server sends no URL.
Fix *ChatsConnectorAccessFix `json:"fix,omitempty"`
// ThenCheck lists the links after the first problem that were not
// looked at, in order: tools, agent, connection, sender and, for
// triggers.propose, approval. More problems may follow.
ThenCheck []string `json:"then_check,omitempty"`
}
// ChatsConnectorAccessFix is the one place that solves a refusal.
type ChatsConnectorAccessFix struct {
// Kind is open_server_setting, open_agent, open_connection, pair_sender
// or add_person.
Kind string `json:"kind"`
// Target is a stable ID: the key of a server setting, an agent ID, a field
// of the connection form (caps, grants, agent_id, allow) or pairing.
Target string `json:"target"`
// Field narrows the target: the capability to tick on the connection or
// to grant on the agent, or tools:<group> for a Tools switch.
Field string `json:"field,omitempty"`
}
// ChatsConnectorAccessSubject is what one kind of sender may do: a sender on
@@ -1411,6 +1442,8 @@ type ChatsConnectorAccessEvent struct {
Allowed bool `json:"allowed"`
Via string `json:"via,omitempty"`
Reason string `json:"reason,omitempty"`
// Code is the code of a refusal, as in the decisions.
Code string `json:"code,omitempty"`
}
// ChatsConnectorAccess is the effective permissions of a binding, as the host
@@ -1419,6 +1452,25 @@ type ChatsConnectorAccess struct {
Binding string `json:"binding"`
Subjects []ChatsConnectorAccessSubject `json:"subjects"`
Recent []ChatsConnectorAccessEvent `json:"recent"`
// Preview is true when the view is for a form that is not saved.
Preview bool `json:"preview,omitempty"`
}
// ChatsConnectorAccessPreviewRequest is the state of a connection form that
// the permission chain reads, saved or not. A field that is absent keeps the
// saved value. It has no token: a preview never takes or returns one.
type ChatsConnectorAccessPreviewRequest struct {
// Kind is read only for the id "new" (a connection that is not saved).
Kind string `json:"kind,omitempty"`
AgentID *string `json:"agent_id,omitempty"`
AcceptAny *bool `json:"accept_any,omitempty"`
Allow []string `json:"allow,omitempty"`
// Caps and Grants are the form's permissions; an empty list clears.
Caps []string `json:"caps,omitempty"`
Grants []ChatsConnectorRoleGrant `json:"grants,omitempty"`
// Sender limits the answer to one kind of sender: listed, paired,
// channel_member, anyone or role:<id>. Absent means every kind.
Sender string `json:"sender,omitempty"`
}
// ChatsConnectorPairing is a pairing code. Send it from the account to pair, as
+236 -46
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"maps"
"slices"
"strings"
"sync"
"time"
@@ -48,13 +49,30 @@ type Decision struct {
// Reason says why the capability was refused. It is safe to show to the
// sender and to the model.
Reason string `json:"reason,omitempty"`
// The fields below describe the first link of the chain that refused (see
// connectorreasons.go). They are empty for an allowed capability.
//
// Code is one of the Code constants; BlockedBy is the link (server,
// tools, agent, connection or sender); Message is one plain sentence for
// the owner; Fix points at the one place that solves it; ThenCheck lists
// the links after it that were not looked at.
Code string `json:"code,omitempty"`
BlockedBy string `json:"blocked_by,omitempty"`
Message string `json:"message,omitempty"`
Fix *Fix `json:"fix,omitempty"`
ThenCheck []string `json:"then_check,omitempty"`
}
// 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 allowed to talk to the bot, and
// then:
// default is no. The links are checked in this order, and the first that
// fails is the answer:
//
// - the binding must have an agent and the capability must be on the closed
// list;
// - off: a switch that is off now (the server, the Tools switch of the
// agent, the grants of the agent);
// - the capability must be in the ceiling 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; neither a
// role nor the allowlist counts;
// - any other capability is held by a pairing, by a sender who is on the
@@ -64,30 +82,36 @@ type Decision struct {
// (Permissions).
//
// kind is nil when the kind is not running: a role then counts for nothing.
// off is the reason of a switch that is off now (a global setting or the
// grants of the agent), or empty. Authorize reads nothing else and writes
// nothing, so the whole matrix can be tested.
func Authorize(b connectors.Binding, kind *contract.ConnectorKind, s AccessSubject, capability string, now time.Time, off string) Decision {
deny := func(format string, a ...any) Decision {
return Decision{Capability: capability, Reason: fmt.Sprintf(format, a...)}
// off is the verdict of the switches (see Connectors.offReason); the zero
// value means none is off. Authorize reads nothing else and writes nothing,
// so the whole matrix can be tested.
func Authorize(b connectors.Binding, kind *contract.ConnectorKind, s AccessSubject, capability string, now time.Time, off Block) Decision {
deny := func(code, format string, a ...any) Decision {
return refusal(b, capability, code, fmt.Sprintf(format, a...), nil)
}
switch {
case b.CredentialsOnly():
return deny("this connection has no agent: it holds credentials only")
return deny(CodeNoAgent, "this connection has no agent: it holds credentials only")
case !contract.KnownCapability(capability):
return deny("%q is not a capability", capability)
case !s.Allowlisted:
return deny("the sender is not on the allowlist")
d := deny(CodeUnknownCapability, "%q is not a capability", capability)
d.ThenCheck = nil
return d
case off.Off():
code := off.Code
if code == "" {
code = CodeServerSettingOff
}
return refusal(b, capability, code, off.Reason, off.Fix)
case !slices.Contains(b.Caps, capability):
return deny("the owner has not enabled %s for this bot", capability)
case off != "":
return deny("%s", off)
return deny(CodeConnectionPermissionOff, "the owner has not enabled %s for this bot", capability)
case !s.Allowlisted:
return deny(CodeSenderNotListed, "the sender is not on the allowlist")
}
if s.SenderID != "" && b.IsPaired(s.SenderID, now) {
return Decision{Capability: capability, Allowed: true, Via: "paired"}
}
if contract.SensitiveCapability(capability) {
return deny("%s needs a sender that the owner paired with this bot", capability)
return deny(CodeSenderNeedsPairing, "%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"}
@@ -101,14 +125,57 @@ func Authorize(b connectors.Binding, kind *contract.ConnectorKind, s AccessSubje
}
}
}
return deny("%s needs a sender on this bot's allowlist, or one that the owner paired with this bot", capability)
reason := fmt.Sprintf("%s needs a sender on this bot's allowlist, or one that the owner paired with this bot", capability)
switch {
case len(s.Roles) > 0 && kind == nil:
return refusal(b, capability, CodeKindNotRunning, reason, nil)
case len(s.Roles) > 0:
return refusal(b, capability, CodeRoleNotGranted, reason, nil)
}
return refusal(b, capability, CodeChannelNotTrusted, reason, nil)
}
// refusal builds the Decision of a refused capability from its code: who holds
// the link, the sentence for the owner, the fix and the links not looked at.
// reason is the English sentence of the log. fix, when nil, is the usual one
// for the code.
func refusal(b connectors.Binding, capability, code, reason string, fix *Fix) Decision {
row := blockTable[code]
if fix == nil {
fix = defaultFix(b, capability, code)
}
return Decision{Capability: capability, Reason: reason, Code: code, BlockedBy: row.By,
Message: blockMessage(code, capability), Fix: fix, ThenCheck: thenCheck(row.By, capability)}
}
// defaultFix is the fix of the links that Authorize itself checks. The
// switches name their own.
func defaultFix(b connectors.Binding, capability, code string) *Fix {
switch code {
case CodeNoAgent:
return &Fix{Kind: FixOpenConnection, Target: "agent_id"}
case CodeConnectionPermissionOff:
return &Fix{Kind: FixOpenConnection, Target: "caps", Field: capability}
case CodeSenderNotListed, CodeChannelNotTrusted:
return &Fix{Kind: FixAddPerson, Target: "allow"}
case CodeSenderNeedsPairing:
return &Fix{Kind: FixPairSender, Target: "pairing"}
case CodeRoleNotGranted:
return &Fix{Kind: FixOpenConnection, Target: "grants", Field: capability}
case CodeAgentUnavailable, CodeAgentPermissionOff, CodeToolsSwitchOff:
if b.AgentID != "" {
return &Fix{Kind: FixOpenAgent, Target: b.AgentID, Field: capability}
}
}
return nil
}
// CapabilitySwitch is an extra switch in the chain of a capability: a global
// setting, or the grants of the agent. It returns the reason when the
// capability is off for the binding, or "" when it is on. It runs for every
// check, so it must be cheap and must not block.
type CapabilitySwitch func(ctx context.Context, b connectors.Binding, capability string) string
// setting, the Tools switch of the agent, or the grants of the agent. It
// returns the verdict when the capability is off for the binding, or the zero
// Block when it is on. It runs for every check, so it must be cheap and must
// not block.
type CapabilitySwitch func(ctx context.Context, b connectors.Binding, capability string) Block
// AddCapabilitySwitch adds a switch. The capability is off when any gate says so.
func (c *Connectors) AddCapabilitySwitch(g CapabilitySwitch) {
@@ -117,16 +184,20 @@ func (c *Connectors) AddCapabilitySwitch(g CapabilitySwitch) {
c.gates = append(c.gates, g)
}
func (c *Connectors) offReason(ctx context.Context, b connectors.Binding, capability string) string {
// offReason runs every switch and returns the refusal that stands first in the
// chain (server, then Tools, then agent), so the answer does not depend on the
// order in which the switches were added.
func (c *Connectors) offReason(ctx context.Context, b connectors.Binding, capability string) Block {
c.gmu.Lock()
gates := slices.Clone(c.gates)
c.gmu.Unlock()
var first Block
for _, g := range gates {
if why := g(ctx, b, capability); why != "" {
return why
if why := g(ctx, b, capability); why.Off() && (!first.Off() || chainRank(why.Code) < chainRank(first.Code)) {
first = why
}
}
return ""
return first
}
// kindOf finds the running kind that serves a binding.
@@ -164,7 +235,7 @@ func (c *Connectors) Authorize(ctx context.Context, b connectors.Binding, m cont
if d.Allowed {
for _, f := range check {
if why := f(); why != "" {
d = Decision{Capability: capability, Reason: why}
d = refusal(b, capability, CodeContextRule, why, nil)
break
}
}
@@ -180,12 +251,16 @@ func (c *Connectors) Authorize(ctx context.Context, b connectors.Binding, m cont
func (c *Connectors) CeilingAllows(ctx context.Context, b connectors.Binding, capability string) Decision {
switch {
case !contract.KnownCapability(capability):
return Decision{Capability: capability, Reason: "not a capability"}
return refusal(b, capability, CodeUnknownCapability, "not a capability", nil)
case !slices.Contains(b.Caps, capability):
return Decision{Capability: capability, Reason: "not enabled for this bot"}
return refusal(b, capability, CodeConnectionPermissionOff, "not enabled for this bot", nil)
}
if why := c.offReason(ctx, b, capability); why != "" {
return Decision{Capability: capability, Reason: why}
if off := c.offReason(ctx, b, capability); off.Off() {
code := off.Code
if code == "" {
code = CodeServerSettingOff
}
return refusal(b, capability, code, off.Reason, off.Fix)
}
return Decision{Capability: capability, Allowed: true, Via: "binding"}
}
@@ -259,7 +334,9 @@ func (c *Connectors) grantFor(ctx context.Context, b connectors.Binding, m contr
if g.Denied == nil {
g.Denied = map[string]string{}
}
g.Denied[name] = d.Reason
// The model and the person read this, so it is the plain
// sentence of the code, not the English line of the log.
g.Denied[name] = d.Say()
}
}
return g
@@ -301,6 +378,8 @@ type AccessEvent struct {
Allowed bool `json:"allowed"`
Via string `json:"via,omitempty"`
Reason string `json:"reason,omitempty"`
// Code is the code of the refusal (see connectorreasons.go).
Code string `json:"code,omitempty"`
// The fields below are set by a message the host wrote on its own (see
// Connectors.Send). They hold where, how long and a hash of the text, and
// never the text.
@@ -360,7 +439,7 @@ func (a *accessState) get(chat string) (turnGrant, bool) {
func (a *accessState) record(now time.Time, b connectors.Binding, senders, roles []string, d Decision) {
e := AccessEvent{At: now.UTC(), Binding: b.ID, Kind: b.Kind, Senders: senders, Roles: roles,
Capability: d.Capability, Allowed: d.Allowed, Via: d.Via, Reason: d.Reason}
Capability: d.Capability, Allowed: d.Allowed, Via: d.Via, Reason: d.Reason, Code: d.Code}
a.append(e)
}
@@ -403,7 +482,7 @@ func (s *Service) turnAllow(chat string) (allow func(string) (bool, string), aud
if why := g.Denied[capability]; why != "" {
return false, why
}
return false, "the owner has not allowed " + capability + " for the sender of this message"
return false, BlockSay(CodeConnectionPermissionOff, capability)
}
audit = func(capability string, ok bool, reason string) {
g, _ := s.access.get(chat)
@@ -433,17 +512,115 @@ type AccessView struct {
Binding string `json:"binding"`
Subjects []AccessSubjectView `json:"subjects"`
Recent []AccessEvent `json:"recent"`
// Preview is true when the view describes a draft that is not saved.
Preview bool `json:"preview,omitempty"`
}
// Access explains what each kind of sender may do on a binding of the user,
// by running Authorize for a sender with no role, a paired sender and a sender
// of each role the kind declares. Nothing in it is a guess: it is the same
// function the turns use.
// Access explains what each kind of sender may do on a binding, by running
// Authorize for a sender with no role, a paired sender and a sender of each
// role the kind declares. Nothing in it is a guess: it is the same function
// the turns use.
func (c *Connectors) Access(ctx context.Context, user, id string) (AccessView, error) {
b, err := c.owned(ctx, user, id)
if err != nil {
return AccessView{}, err
}
out := c.accessOf(ctx, b, "")
out.Recent = c.svc.access.recent(b.ID, 50)
return out, nil
}
// The kinds of sender that a preview can ask for. SenderChannelMember and
// SenderAnyone both mean a sender let in without being on the list by name;
// the answer lists it as "anyone".
const (
SenderListed = "listed"
SenderPaired = "paired"
SenderChannelMember = "channel_member"
SenderAnyone = "anyone"
SenderRolePrefix = "role:"
)
// AccessDraft is the state of a connection as the form holds it, before it is
// saved. A field that is nil (or an empty Kind) keeps what is saved. Only the
// fields that the permission chain reads are here. There is no token: a
// preview never takes one, and never returns one.
type AccessDraft struct {
// Kind is read only for a connection that is not saved yet (the id "new").
Kind string
AgentID *string
AcceptAny *bool
Allow []string
Caps []string
Grants []connectors.RoleGrant
// Sender, when set, limits the answer to one kind of sender: "listed",
// "paired", "channel_member", "anyone" or "role:<id>".
Sender string
}
// ErrBadSender means a preview asked for a kind of sender that does not exist.
var ErrBadSender = fmt.Errorf("%w: sender must be listed, paired, channel_member, anyone or role:<id>", connectors.ErrInvalid)
// NewConnectionID is the id under which a preview describes a connection that
// is not saved yet.
const NewConnectionID = "new"
// AccessPreview answers like Access for the state in the draft, which is not
// saved. It writes nothing, records no decision and returns nothing from the
// token: the binding's stored secrets are not read. The chain is the one the
// turns use, run on the draft.
func (c *Connectors) AccessPreview(ctx context.Context, user, id string, d AccessDraft) (AccessView, error) {
var b connectors.Binding
if id == NewConnectionID {
if d.Kind == "" {
return AccessView{}, connectorBad("a connection that is not saved needs a kind")
}
addon, err := c.resolveAddon(ctx, d.Kind)
if err != nil {
return AccessView{}, err
}
b = connectors.Binding{ID: NewConnectionID, Owner: principal(user), Kind: d.Kind, AddonID: addon}
} else {
var err error
if b, err = c.owned(ctx, user, id); err != nil {
return AccessView{}, err
}
}
b.Token = nil // the preview never needs it, so it never holds it
if d.AgentID != nil {
b.AgentID = *d.AgentID
}
if d.AcceptAny != nil {
b.AcceptAny = *d.AcceptAny
}
if d.Allow != nil {
b.Allow = slices.Clone(d.Allow)
}
if d.Caps != nil {
b.Caps = slices.Clone(d.Caps)
}
if d.Grants != nil {
b.Grants = slices.Clone(d.Grants)
}
switch s := d.Sender; {
case s == "", s == SenderListed, s == SenderPaired, s == SenderChannelMember, s == SenderAnyone:
case strings.HasPrefix(s, SenderRolePrefix):
kind := c.kindOf(ctx, b)
if kind == nil || !slices.ContainsFunc(kind.Roles, func(r contract.ConnectorRole) bool { return SenderRolePrefix+r.ID == s }) {
return AccessView{}, ErrBadSender
}
default:
return AccessView{}, ErrBadSender
}
out := c.accessOf(ctx, b, d.Sender)
out.Preview = true
return out, nil
}
// accessOf runs the chain for each kind of sender of a binding, or for the one
// kind asked for. It is the one computation behind the saved view and the
// preview.
func (c *Connectors) accessOf(ctx context.Context, b connectors.Binding, only string) AccessView {
kind := c.kindOf(ctx, b)
now := c.o.Clock()
view := func(id, title string, bind connectors.Binding, s AccessSubject) AccessSubjectView {
@@ -453,19 +630,32 @@ func (c *Connectors) Access(ctx context.Context, user, id string) (AccessView, e
}
return v
}
want := func(id string) bool {
if only == SenderChannelMember {
only = SenderAnyone
}
return only == "" || only == id
}
out := AccessView{Binding: b.ID}
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}))
if want("listed") {
out.Subjects = append(out.Subjects, view("listed", "A sender on the allowlist", b, AccessSubject{SenderID: "x", Allowlisted: true, Listed: true}))
}
if want("anyone") {
out.Subjects = append(out.Subjects, view("anyone", "A sender let in through a listed channel", b, AccessSubject{SenderID: "x", Allowlisted: true}))
}
if want("paired") {
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}))
}
if kind != nil {
for _, r := range kind.Roles {
out.Subjects = append(out.Subjects, view("role:"+r.ID, r.Title, b, AccessSubject{SenderID: "x", Allowlisted: true, Roles: []string{r.ID}}))
if want("role:" + r.ID) {
out.Subjects = append(out.Subjects, view("role:"+r.ID, r.Title, b, AccessSubject{SenderID: "x", Allowlisted: true, Roles: []string{r.ID}}))
}
}
}
out.Recent = c.svc.access.recent(b.ID, 50)
return out, nil
return out
}
// CapabilityGate returns the gate that answers the questions of the features
@@ -75,7 +75,7 @@ var _ = Describe("Authorize", func() {
}
var got []string
for _, name := range all {
d := Authorize(binding, k, r.subject, name, now, r.off)
d := Authorize(binding, k, r.subject, name, now, Block{Reason: r.off})
Expect(d.Capability).To(Equal(name))
if d.Allowed {
got = append(got, name)
@@ -96,23 +96,23 @@ var _ = Describe("Authorize", func() {
if !contract.SensitiveCapability(name) {
continue
}
d := Authorize(binding, &kind, AccessSubject{SenderID: "U", Allowlisted: true, Roles: []string{"admin"}}, name, now, "")
d := Authorize(binding, &kind, AccessSubject{SenderID: "U", Allowlisted: true, Roles: []string{"admin"}}, name, now, Block{})
Expect(d.Allowed).To(BeFalse(), name)
}
})
It("denies a name that is not in the closed list", func() {
d := Authorize(binding, &kind, AccessSubject{SenderID: "P", Allowlisted: true}, "shell.run", now, "")
d := Authorize(binding, &kind, AccessSubject{SenderID: "P", Allowlisted: true}, "shell.run", now, Block{})
Expect(d.Allowed).To(BeFalse())
})
It("names the way a sender got the capability", func() {
Expect(Authorize(binding, &kind, AccessSubject{SenderID: "U", Allowlisted: true, Roles: []string{"admin"}}, contract.CapMemoryWrite, now, "").Via).To(Equal("role:admin"))
Expect(Authorize(binding, &kind, AccessSubject{SenderID: "P", Allowlisted: true}, contract.CapMemoryWrite, now, "").Via).To(Equal("paired"))
Expect(Authorize(binding, &kind, AccessSubject{SenderID: "U", Allowlisted: true, Roles: []string{"admin"}}, contract.CapMemoryWrite, now, Block{}).Via).To(Equal("role:admin"))
Expect(Authorize(binding, &kind, AccessSubject{SenderID: "P", Allowlisted: true}, contract.CapMemoryWrite, now, Block{}).Via).To(Equal("paired"))
})
It("denies everything to a binding with an empty ceiling", func() {
empty := binding
empty.Caps = nil
for _, name := range all {
Expect(Authorize(empty, &kind, AccessSubject{SenderID: "P", Allowlisted: true}, name, now, "").Allowed).To(BeFalse(), name)
Expect(Authorize(empty, &kind, AccessSubject{SenderID: "P", Allowlisted: true}, name, now, Block{}).Allowed).To(BeFalse(), name)
}
})
})
@@ -158,14 +158,14 @@ var _ = Describe("Authorize for a listed sender", func() {
It("trusts a listed sender with every capability that is not sensitive", func() {
for _, name := range plainCaps {
d := Authorize(binding, &kind, listed, name, now, "")
d := Authorize(binding, &kind, listed, name, now, Block{})
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, "")
d := Authorize(binding, &kind, listed, name, now, Block{})
Expect(d.Allowed).To(BeFalse(), name)
Expect(d.Reason).To(ContainSubstring("paired"), name)
}
@@ -173,7 +173,7 @@ var _ = Describe("Authorize for a listed sender", func() {
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, "")
d := Authorize(binding, &kind, s, name, now, Block{})
Expect(d.Allowed).To(BeTrue(), name)
Expect(d.Via).To(Equal("paired"), name)
}
@@ -181,7 +181,7 @@ var _ = Describe("Authorize for a listed sender", func() {
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, "")
d := Authorize(binding, &kind, s, name, now, Block{})
Expect(d.Allowed).To(BeFalse(), name)
Expect(d.Reason).NotTo(BeEmpty(), name)
}
@@ -191,40 +191,40 @@ var _ = Describe("Authorize for a listed sender", func() {
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, "")
d := Authorize(b, &k, AccessSubject{SenderID: "U9", Allowlisted: true, Roles: []string{"admin"}}, contract.CapMemoryWrite, now, Block{})
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)
Expect(Authorize(binding, &kind, AccessSubject{SenderID: "U1", Listed: true}, name, now, Block{}).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())
Expect(Authorize(binding, &kind, AccessSubject{Allowlisted: true, Listed: true}, contract.CapMemoryWrite, now, Block{}).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(Authorize(b, &kind, listed, contract.CapMemoryWrite, now, Block{}).Allowed).To(BeTrue())
d := Authorize(b, &kind, listed, contract.CapTriggersPropose, now, Block{})
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")
d := Authorize(binding, &kind, listed, contract.CapTriggersPropose, now, Block{Reason: "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())
Expect(Authorize(binding, nil, listed, contract.CapTriggersPropose, now, Block{}).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())
Expect(Authorize(b, &kind, listed, contract.CapMemoryWrite, now, Block{}).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, "")
d := Authorize(binding, &kind, AccessSubject{SenderID: "U9", Allowlisted: true}, contract.CapTriggersPropose, now, Block{})
Expect(d.Reason).To(ContainSubstring("allowlist"))
Expect(d.Reason).To(ContainSubstring("paired"))
})
-19
View File
@@ -6,27 +6,8 @@ import (
"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 (
+45 -6
View File
@@ -71,20 +71,59 @@ func grantVerdict(capability string, listed, held []string, hasAgent bool) strin
// of a binding. It fails closed: a binding whose agent cannot be read gets
// nothing.
func agentGrantSwitch(s *Service) CapabilitySwitch {
return func(ctx context.Context, b connectors.Binding, capability string) string {
return func(ctx context.Context, b connectors.Binding, capability string) Block {
if !GrantGatedCapability(capability) {
return ""
return Block{}
}
if b.AgentID == "" {
return "this connection has no agent"
return Block{Code: CodeNoAgent, Reason: "this connection has no agent"}
}
unreadable := Block{Code: CodeAgentUnavailable, Reason: "the agent of this bot cannot be read",
Fix: &Fix{Kind: FixOpenAgent, Target: b.AgentID}}
if s.d.Agents == nil {
return "the agent of this bot cannot be read"
return unreadable
}
ag, err := s.d.Agents.Get(ctx, principal(b.Owner), b.AgentID)
if err != nil {
return "the agent of this bot cannot be read"
return unreadable
}
return grantVerdict(capability, ag.Builtins, s.usableGrants(ctx, principal(b.Owner), ag.Builtins), true)
if why := grantVerdict(capability, ag.Builtins, s.usableGrants(ctx, principal(b.Owner), ag.Builtins), true); why != "" {
return Block{Code: CodeAgentPermissionOff, Reason: why,
Fix: &Fix{Kind: FixOpenAgent, Target: b.AgentID, Field: capability}}
}
return Block{}
}
}
// toolGroupOf names the Tools switch group that serves a capability in a
// connector chat: the tool that uses the capability is not built while its
// group is off, so the capability is not usable either.
var toolGroupOf = map[string]string{
contract.CapTriggersPropose: builtins.GroupTriggers,
contract.CapMemoryWrite: builtins.GroupChanges,
contract.CapProfileWrite: builtins.GroupChanges,
contract.CapSkillsWrite: builtins.GroupChanges,
contract.CapPagesWrite: builtins.GroupChanges,
contract.CapDashboardWrite: builtins.GroupChanges,
}
// toolsSwitch is the CapabilitySwitch of the Tools switch: a capability whose
// tool group the agent of the binding turns off is off. The grant of the agent
// is a separate link (agentGrantSwitch).
func toolsSwitch(s *Service) CapabilitySwitch {
return func(ctx context.Context, b connectors.Binding, capability string) Block {
group := toolGroupOf[capability]
if group == "" || b.AgentID == "" || s.d.Agents == nil {
return Block{}
}
ag, err := s.d.Agents.Get(ctx, principal(b.Owner), b.AgentID)
if err != nil {
return Block{} // the grants switch reports an agent it cannot read
}
if slices.Contains(ag.BuiltinsOff, group) || (s.builtins.IsOptIn(group) && !slices.Contains(ag.BuiltinsOn, group)) {
return Block{Code: CodeToolsSwitchOff, Reason: "the " + group + " tools are switched off for the agent of this bot",
Fix: &Fix{Kind: FixOpenAgent, Target: b.AgentID, Field: "tools:" + group}}
}
return Block{}
}
}
@@ -106,7 +106,7 @@ var _ = Describe("the agent grants at the connector gate", func() {
for _, r := range rows {
It(r.who, func() {
off := grantVerdict(r.cap, r.listed, r.held, true)
d := Authorize(binding(r.caps...), kind, r.s, r.cap, now, off)
d := Authorize(binding(r.caps...), kind, r.s, r.cap, now, Block{Reason: off})
Expect(d.Allowed).To(Equal(r.allowed), "%s: %s", r.cap, d.Reason)
if !r.allowed {
Expect(d.Reason).NotTo(BeEmpty())
+223
View File
@@ -0,0 +1,223 @@
package studio
import (
"fmt"
"slices"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// This file is the one table of the reasons a connector capability is refused.
// Authorize and the capability switches produce a code where they decide; the
// owner's settings page, the note the model reads and the tool errors all say
// the reason from this table. Nothing here decides: adding a code does not
// change who may do what.
// The codes of a refusal. The set is closed (see TestBlockCodesAreClosed): a
// client maps each code to its own copy and may rely on the list.
const (
// CodeNoAgent: the connection has no agent, it holds credentials only.
CodeNoAgent = "no_agent"
// CodeUnknownCapability: the name is not on the closed list.
CodeUnknownCapability = "unknown_capability"
// CodeServerSettingOff: an administrator turned the feature off for the
// whole server.
CodeServerSettingOff = "server_setting_off"
// CodeToolsSwitchOff: the tools group that serves the capability is
// switched off for the agent.
CodeToolsSwitchOff = "tools_switch_off"
// CodeAgentPermissionOff: the agent was not granted the permission.
CodeAgentPermissionOff = "agent_permission_off"
// CodeAgentUnavailable: the agent of the connection cannot be read.
CodeAgentUnavailable = "agent_unavailable"
// CodeConnectionPermissionOff: the owner did not tick the capability on
// the connection.
CodeConnectionPermissionOff = "connection_permission_off"
// CodeSenderNotListed: the sender is not let in at all.
CodeSenderNotListed = "sender_not_listed"
// CodeSenderNeedsPairing: the capability is sensitive and the sender is
// not paired.
CodeSenderNeedsPairing = "sender_needs_pairing"
// CodeChannelNotTrusted: the sender is let in through a channel or because
// the connection accepts anyone, so they are not trusted by name.
CodeChannelNotTrusted = "channel_not_trusted"
// CodeRoleNotGranted: the sender has a role that holds no grant for it.
CodeRoleNotGranted = "role_not_granted"
// CodeKindNotRunning: the add-on of the connection does not run, so roles
// cannot be known.
CodeKindNotRunning = "kind_not_running"
// CodeContextRule: the capability is held but this place or thread is not
// one the feature allows.
CodeContextRule = "context_rule"
)
// BlockCodes lists every code, in the order of the chain.
func BlockCodes() []string {
return []string{CodeNoAgent, CodeUnknownCapability, CodeServerSettingOff, CodeToolsSwitchOff, CodeAgentPermissionOff,
CodeAgentUnavailable, CodeConnectionPermissionOff, CodeSenderNotListed, CodeSenderNeedsPairing,
CodeChannelNotTrusted, CodeRoleNotGranted, CodeKindNotRunning, CodeContextRule}
}
// Who holds the link of the chain that refused.
const (
ByServer = "server"
ByTools = "tools"
ByAgent = "agent"
ByConnection = "connection"
BySender = "sender"
// ByApproval is not a blocker. It names the last link of the chain of a
// trigger, which shapes the result (ask, or at once) and so is listed in
// ThenCheck for the capability that proposes one.
ByApproval = "approval"
)
// chain is the order in which the links are checked: the first one that fails
// is the one reported.
var chain = []string{ByServer, ByTools, ByAgent, ByConnection, BySender}
// The kinds of fix. A client turns each into a link of its own; the server
// never puts a URL in the answer.
const (
FixOpenServerSetting = "open_server_setting"
FixOpenAgent = "open_agent"
FixOpenConnection = "open_connection"
FixPairSender = "pair_sender"
FixAddPerson = "add_person"
)
// Fix points at the one place that can solve a refusal.
type Fix struct {
Kind string `json:"kind"`
// Target is a stable ID: a server setting key, an agent ID, a field of
// the connection form ("caps", "grants", "agent_id", "allow") or
// "pairing".
Target string `json:"target"`
// Field narrows the target where it helps: the capability to tick on the
// connection, or the permission to grant on the agent.
Field string `json:"field,omitempty"`
}
// Block is the verdict of a capability switch: zero when the capability is
// on. Reason is the English sentence for logs and tests; Code says which link
// refused.
type Block struct {
Code string
Reason string
Fix *Fix
}
// Off says whether the switch refuses.
func (b Block) Off() bool { return b.Code != "" || b.Reason != "" }
// blockInfo is a row of the table: who holds the link, what the owner reads,
// and what the person who asked (and the model) is told.
type blockInfo struct {
By string
// Message is for the owner. %s is the capability in words.
Message string
// Say is for the sender and the model. It names no setting, host or
// error code, and goes to the person who asked, never to a channel.
Say string
}
var blockTable = map[string]blockInfo{
CodeNoAgent: {ByConnection, "This connection has no agent.", "this bot has no agent"},
CodeUnknownCapability: {ByServer, "That is not a permission this server knows.", "that is not something this bot can be allowed to do"},
CodeServerSettingOff: {ByServer, "An administrator turned %s off for this whole server.", "%s is turned off on this server; an administrator can change that"},
CodeToolsSwitchOff: {ByTools, "The tools for %s are switched off for the agent.", "the owner has switched off the tools for %s"},
CodeAgentPermissionOff: {ByAgent, "The agent has not been given permission for %s.", "the owner has not given the agent permission for %s"},
CodeAgentUnavailable: {ByAgent, "The agent of this connection cannot be read.", "the agent of this bot cannot be read"},
CodeConnectionPermissionOff: {ByConnection, "This connection does not allow %s.", "the owner has not allowed %s for this bot"},
CodeSenderNotListed: {BySender, "This person is not on the list of people the bot answers.", "you are not on the list of people this bot answers"},
CodeSenderNeedsPairing: {BySender, "This person has to be paired with the bot by the owner before %s.", "%s needs you to be paired with this bot by the owner"},
CodeChannelNotTrusted: {BySender, "This person is let in through a channel, or because the bot accepts anyone, so they are not trusted for %s. Add them by name or pair them.", "%s is only for people the owner added by name or paired"},
CodeRoleNotGranted: {BySender, "The role of this person was not given %s.", "the owner has not allowed %s for your role"},
CodeKindNotRunning: {BySender, "The app for this connection is not running, so roles cannot be checked.", "the app for this bot is not running"},
CodeContextRule: {ByConnection, "%s is not allowed in this place.", "%s is not allowed in this place"},
}
// capabilityWords says a capability in the words a person uses. The reasons of
// Authorize name the capability; the model and the owner's page 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",
}
func wordsOf(capability string) string {
if w, ok := capabilityWords[capability]; ok {
return w
}
return "this"
}
// blockMessage is what the owner reads for a code.
func blockMessage(code, capability string) string {
row, ok := blockTable[code]
if !ok {
return ""
}
return sprintfOnce(row.Message, wordsOf(capability))
}
// BlockSay is what the person who asked, and the model, is told for a code:
// one short plain sentence with no setting names.
func BlockSay(code, capability string) string {
row, ok := blockTable[code]
if !ok {
return ""
}
return sprintfOnce(row.Say, wordsOf(capability))
}
func sprintfOnce(format, arg string) string {
for i := 0; i+1 < len(format); i++ {
if format[i] == '%' && format[i+1] == 's' {
return fmt.Sprintf(format, arg)
}
}
return format
}
// Say is the sentence for the person who asked and for the model. A refusal
// with no code (a rule of the feature, written by it) keeps its own reason.
func (d Decision) Say() string {
if s := BlockSay(d.Code, d.Capability); s != "" {
return s
}
return d.Reason
}
// thenCheck lists the links of the chain after the one that refused, so the
// owner knows more may follow. The approval mode follows for a trigger.
func thenCheck(by, capability string) []string {
i := slices.Index(chain, by)
if i < 0 {
return nil
}
out := slices.Clone(chain[i+1:])
if capability == contract.CapTriggersPropose {
out = append(out, ByApproval)
}
return out
}
// chainRank orders the links so that a refusal from a switch is compared with
// the others by where it stands in the chain, not by when it was registered.
func chainRank(code string) int {
row, ok := blockTable[code]
if !ok {
return slices.Index(chain, ByServer)
}
return slices.Index(chain, row.By)
}
@@ -0,0 +1,137 @@
package studio
import (
"context"
"regexp"
"slices"
"strings"
"time"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The codes of a refusal come from the place that decides. These specs write
// out which code each branch of Authorize gives, and the order of the chain.
var _ = Describe("the codes of a refusal", func() {
now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
kind := contract.ConnectorKind{ID: "chat", Roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}},
Permissions: []string{contract.CapMemoryWrite, contract.CapTriggersPropose}}
binding := connectors.Binding{ID: "b1", Kind: "chat", AgentID: "a1",
Caps: []string{contract.CapMemoryWrite, contract.CapTriggersPropose, contract.CapTalkbackNewChan},
Paired: []connectors.PairedSender{{SenderID: "P", At: now}}}
listed := AccessSubject{SenderID: "U", Allowlisted: true, Listed: true}
serverOff := Block{Code: CodeServerSettingOff, Reason: "off here", Fix: &Fix{Kind: FixOpenServerSetting, Target: SettingDisableTriggers}}
toolsOff := Block{Code: CodeToolsSwitchOff, Reason: "tools off"}
agentOff := Block{Code: CodeAgentPermissionOff, Reason: "agent off"}
type row struct {
who string
b connectors.Binding
kind *contract.ConnectorKind
s AccessSubject
cap string
off Block
code string
by string
fix string
after []string
}
noAgent := binding
noAgent.AgentID = ""
rows := []row{
{"no agent", noAgent, &kind, listed, contract.CapMemoryWrite, Block{}, CodeNoAgent, ByConnection, FixOpenConnection, []string{BySender}},
{"unknown capability", binding, &kind, listed, "root.everything", Block{}, CodeUnknownCapability, ByServer, "", nil},
{"server setting", binding, &kind, listed, contract.CapTriggersPropose, serverOff, CodeServerSettingOff, ByServer, FixOpenServerSetting,
[]string{ByTools, ByAgent, ByConnection, BySender, ByApproval}},
{"tools switch", binding, &kind, listed, contract.CapTriggersPropose, toolsOff, CodeToolsSwitchOff, ByTools, FixOpenAgent, []string{ByAgent, ByConnection, BySender, ByApproval}},
{"agent permission", binding, &kind, listed, contract.CapMemoryWrite, agentOff, CodeAgentPermissionOff, ByAgent, FixOpenAgent, []string{ByConnection, BySender}},
{"connection permission", binding, &kind, listed, contract.CapProfileWrite, Block{}, CodeConnectionPermissionOff, ByConnection, FixOpenConnection, []string{BySender}},
{"not on the allowlist", binding, &kind, AccessSubject{SenderID: "U"}, contract.CapMemoryWrite, Block{}, CodeSenderNotListed, BySender, FixAddPerson, []string{}},
{"sensitive, not paired", binding, &kind, listed, contract.CapTalkbackNewChan, Block{}, CodeSenderNeedsPairing, BySender, FixPairSender, []string{}},
{"let in by a channel", binding, &kind, AccessSubject{SenderID: "U", Allowlisted: true}, contract.CapMemoryWrite, Block{}, CodeChannelNotTrusted, BySender, FixAddPerson, []string{}},
{"a role without a grant", binding, &kind, AccessSubject{SenderID: "U", Allowlisted: true, Roles: []string{"admin"}}, contract.CapMemoryWrite, Block{}, CodeRoleNotGranted, BySender, FixOpenConnection, []string{}},
{"a role while the kind is not running", binding, nil, AccessSubject{SenderID: "U", Allowlisted: true, Roles: []string{"admin"}}, contract.CapMemoryWrite, Block{}, CodeKindNotRunning, BySender, "", []string{}},
}
for _, r := range rows {
It("gives "+r.code+" for "+r.who, func() {
d := Authorize(r.b, r.kind, r.s, r.cap, now, r.off)
Expect(d.Allowed).To(BeFalse())
Expect(d.Code).To(Equal(r.code))
Expect(d.BlockedBy).To(Equal(blockTable[r.code].By))
Expect(d.BlockedBy).To(Equal(r.by))
Expect(d.Reason).NotTo(BeEmpty(), "the English line stays")
Expect(d.Message).NotTo(BeEmpty())
if r.fix == "" {
Expect(d.Fix).To(BeNil())
} else {
Expect(d.Fix).NotTo(BeNil())
Expect(d.Fix.Kind).To(Equal(r.fix))
}
if len(r.after) == 0 {
Expect(d.ThenCheck).To(BeEmpty())
} else {
Expect(d.ThenCheck).To(Equal(r.after), "the links not looked at")
}
})
}
It("allows with no code, block or fix", func() {
d := Authorize(binding, &kind, listed, contract.CapMemoryWrite, now, Block{})
Expect(d).To(Equal(Decision{Capability: contract.CapMemoryWrite, Allowed: true, Via: "allowlist"}))
})
It("reports the first problem when several stand: the order of the chain", func() {
noCap := binding
noCap.Caps = nil
// Server, tools, agent and connection permission all fail; the sender is not listed too.
chain := []struct {
off Block
code string
}{{serverOff, CodeServerSettingOff}, {toolsOff, CodeToolsSwitchOff}, {agentOff, CodeAgentPermissionOff}, {Block{}, CodeConnectionPermissionOff}}
for _, c := range chain {
d := Authorize(noCap, &kind, AccessSubject{SenderID: "U"}, contract.CapTriggersPropose, now, c.off)
Expect(d.Code).To(Equal(c.code))
}
// With the connection fixed, the sender is the next problem.
d := Authorize(binding, &kind, AccessSubject{SenderID: "U"}, contract.CapMemoryWrite, now, Block{})
Expect(d.Code).To(Equal(CodeSenderNotListed))
})
It("picks the earliest link among switches whatever the order they were added in", func() {
c := &Connectors{}
for _, b := range []Block{agentOff, toolsOff, serverOff} {
c.AddCapabilitySwitch(func(_ context.Context, _ connectors.Binding, _ string) Block { return b })
}
Expect(c.offReason(context.Background(), binding, contract.CapMemoryWrite).Code).To(Equal(CodeServerSettingOff))
})
It("keeps the set of codes closed, each with a row, and a sentence free of internal names", func() {
Expect(BlockCodes()).To(HaveLen(len(blockTable)))
keyish := regexp.MustCompile(`[a-z]+\.[a-z_]+|LOCALAI|_|https?:|\d+\.\d+\.\d+`)
for _, code := range BlockCodes() {
row, ok := blockTable[code]
Expect(ok).To(BeTrue(), code)
Expect(slices.Contains(chain, row.By)).To(BeTrue(), code)
for _, name := range append(contract.ConnectorCapabilityNames(), "") {
for _, text := range []string{blockMessage(code, name), BlockSay(code, name)} {
Expect(text).NotTo(BeEmpty(), code)
Expect(keyish.MatchString(text)).To(BeFalse(), "%s: %q", code, text)
Expect(strings.Contains(text, "%")).To(BeFalse(), text)
}
}
}
// Every code that Authorize can give is on the list.
for _, r := range rows {
Expect(BlockCodes()).To(ContainElement(r.code))
}
})
It("says a refusal for the person who asked from the code, and keeps the reason of a rule", func() {
d := Authorize(binding, &kind, listed, contract.CapProfileWrite, now, Block{})
Expect(d.Say()).To(Equal("the owner has not allowed changing the agent's rules for this bot"))
Expect(Decision{Reason: "not in this thread"}.Say()).To(Equal("not in this thread"))
})
})
+1 -1
View File
@@ -205,7 +205,7 @@ func NewConnectors(svc *Service, o ConnectorOptions) (*Connectors, error) {
o.Logger = slog.Default()
}
return &Connectors{svc: svc, o: o, log: o.Logger, loops: map[string]context.CancelFunc{}, senders: map[string]*senderState{}, nextEdit: map[string]time.Time{},
wake: make(chan struct{}, 1), done: make(chan struct{}), gates: []CapabilitySwitch{agentGrantSwitch(svc)}}, nil
wake: make(chan struct{}, 1), done: make(chan struct{}), gates: []CapabilitySwitch{agentGrantSwitch(svc), toolsSwitch(svc)}}, nil
}
// Notify asks Run to look at the bindings now.
@@ -120,7 +120,7 @@ var _ = Describe("a credentials-only connector binding", func() {
b.Caps = contract.ConnectorCapabilityNames()
b.Paired = []connectors.PairedSender{{SenderID: "11", At: b.Created}}
for _, name := range contract.ConnectorCapabilityNames() {
d := studio.Authorize(b, nil, studio.AccessSubject{SenderID: "11", Allowlisted: true}, name, b.Updated, "")
d := studio.Authorize(b, nil, studio.AccessSubject{SenderID: "11", Allowlisted: true}, name, b.Updated, studio.Block{})
Expect(d.Allowed).To(BeFalse(), name)
Expect(d.Reason).To(ContainSubstring("no agent"))
}
@@ -116,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: profile.write needs a sender that the owner paired with this bot")))
Expect(res[1].Err).To(MatchError(ContainSubstring("not permitted: changing the agent's rules needs you to be paired with this bot by the owner")))
list, _ := r.svc.Changes().List(ctx, alice)
Expect(list).To(HaveLen(1), "the refused change made no proposal")
})
@@ -207,11 +207,11 @@ var _ = Describe("permissions in the turns of a connector chat", func() {
It("lets an extra switch turn a capability off for everyone", func() {
setup([]string{contract.CapMemoryWrite}, nil, "11")
off := true
r.conns.AddCapabilitySwitch(func(_ context.Context, _ connectors.Binding, c string) string {
r.conns.AddCapabilitySwitch(func(_ context.Context, _ connectors.Binding, c string) studio.Block {
if off && c == contract.CapMemoryWrite {
return "memory is switched off on this server"
return studio.Block{Code: studio.CodeServerSettingOff, Reason: "memory is switched off on this server"}
}
return ""
return studio.Block{}
})
ask("11", nil, "remember")
Expect(tools()).NotTo(ContainElement("propose_change"))
@@ -0,0 +1,219 @@
package studio_test
import (
"context"
"encoding/json"
"slices"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/agents"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/tests/studiobot"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The access view explains the first problem of the chain for each kind of
// sender, and the preview answers the same for a form that is not saved. Both
// run the one chain the turns use, here with the real switches of the manager.
var _ = Describe("why a capability is blocked on a connection", func() {
ctx := context.Background()
const secretToken = "tok-very-secret-1234"
var (
r *connRig
b connectors.Binding
)
script := func(_ workspace.Actor, _ string) []studiotest.ToolStep { return []studiotest.ToolStep{{Say: "ok"}} }
setup := func(caps ...string) {
tr := studiobot.NewTransport(studiobot.Addon{ID: "chat-addon", Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPull,
Roles: []contract.ConnectorRole{{ID: "writer", Title: "Writer"}},
Permissions: []string{contract.CapMemoryWrite, contract.CapTriggersPropose, contract.CapTalkbackNewChan}}, Runtime: &postAddon{}})
r = newConnRigOpts(newShared(), connStore(), tr, script, nil, nil)
r.untrusted = true
b = r.bind(studio.ConnectorInput{Kind: "chatty", Token: secretToken, Allow: []string{"11"}, Caps: caps})
}
saveAgent := func(f func(a *agents.Agent)) {
ag := r.agent
f(&ag)
saved, err := r.svc.SaveAgent(ctx, alice, ag)
Expect(err).NotTo(HaveOccurred())
r.agent = saved
}
decision := func(v studio.AccessView, subject, capability string) studio.Decision {
i := slices.IndexFunc(v.Subjects, func(s studio.AccessSubjectView) bool { return s.ID == subject })
Expect(i).To(BeNumerically(">=", 0), subject)
j := slices.IndexFunc(v.Subjects[i].Decisions, func(d studio.Decision) bool { return d.Capability == capability })
Expect(j).To(BeNumerically(">=", 0), capability)
return v.Subjects[i].Decisions[j]
}
saved := func() studio.AccessView {
v, err := r.conns.Access(ctx, alice, b.ID)
Expect(err).NotTo(HaveOccurred())
return v
}
preview := func(d studio.AccessDraft) studio.AccessView {
v, err := r.conns.AccessPreview(ctx, alice, b.ID, d)
Expect(err).NotTo(HaveOccurred())
return v
}
const propose = contract.CapTriggersPropose
It("names the connection when the owner did not tick the permission", func() {
setup(contract.CapMemoryWrite)
d := decision(saved(), "listed", propose)
Expect(d.Allowed).To(BeFalse())
Expect(d.Code).To(Equal(studio.CodeConnectionPermissionOff))
Expect(d.BlockedBy).To(Equal(studio.ByConnection))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenConnection, Target: "caps", Field: propose}))
Expect(d.ThenCheck).To(Equal([]string{studio.BySender, studio.ByApproval}))
Expect(d.Message).To(Equal("This connection does not allow reminders and scheduled tasks."))
Expect(d.Reason).NotTo(BeEmpty())
Expect(decision(saved(), "listed", contract.CapMemoryWrite).Allowed).To(BeTrue())
})
It("names the agent when its list of permissions leaves the capability out", func() {
setup(contract.CapMemoryWrite, propose)
saveAgent(func(a *agents.Agent) { a.Builtins = []string{contract.CapMemoryWrite} })
d := decision(saved(), "listed", propose)
Expect(d.Code).To(Equal(studio.CodeAgentPermissionOff))
Expect(d.BlockedBy).To(Equal(studio.ByAgent))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenAgent, Target: r.agent.ID, Field: propose}))
Expect(d.ThenCheck).To(Equal([]string{studio.ByConnection, studio.BySender, studio.ByApproval}))
})
It("names the Tools switch before the agent permission, and the server before both", func() {
setup(contract.CapMemoryWrite, propose)
saveAgent(func(a *agents.Agent) {
a.Builtins = []string{contract.CapMemoryWrite}
a.BuiltinsOff = []string{"triggers"}
})
d := decision(saved(), "listed", propose)
Expect(d.Code).To(Equal(studio.CodeToolsSwitchOff), "two problems stand; the first is reported")
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenAgent, Target: r.agent.ID, Field: "tools:triggers"}))
Expect(d.ThenCheck).To(Equal([]string{studio.ByAgent, studio.ByConnection, studio.BySender, studio.ByApproval}))
// Memory goes through another group, which is on.
Expect(decision(saved(), "listed", contract.CapMemoryWrite).Allowed).To(BeTrue())
r.conns.AddCapabilitySwitch(studio.TriggersSwitch("triggers are off"))
d = decision(saved(), "listed", propose)
Expect(d.Code).To(Equal(studio.CodeServerSettingOff))
Expect(d.BlockedBy).To(Equal(studio.ByServer))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenServerSetting, Target: studio.SettingDisableTriggers}))
Expect(d.ThenCheck).To(Equal([]string{studio.ByTools, studio.ByAgent, studio.ByConnection, studio.BySender, studio.ByApproval}))
})
It("names the sender: a pairing for a sensitive capability, the allowlist for a person let in by a channel, a grant for a role", func() {
setup(contract.CapMemoryWrite, contract.CapTalkbackNewChan)
saveAgent(func(a *agents.Agent) { a.Builtins = []string{contract.CapMemoryWrite, contract.CapTalkbackNewChan} })
v := saved()
d := decision(v, "listed", contract.CapTalkbackNewChan)
Expect(d.Code).To(Equal(studio.CodeSenderNeedsPairing))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixPairSender, Target: "pairing"}))
Expect(decision(v, "paired", contract.CapTalkbackNewChan).Allowed).To(BeTrue())
d = decision(v, "anyone", contract.CapMemoryWrite)
Expect(d.Code).To(Equal(studio.CodeChannelNotTrusted))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixAddPerson, Target: "allow"}))
d = decision(v, "role:writer", contract.CapMemoryWrite)
Expect(d.Code).To(Equal(studio.CodeRoleNotGranted))
Expect(d.Fix).To(Equal(&studio.Fix{Kind: studio.FixOpenConnection, Target: "grants", Field: contract.CapMemoryWrite}))
})
It("gives every refusal of the view a code from the closed list, and every allowed capability none", func() {
setup(contract.CapMemoryWrite)
for _, s := range saved().Subjects {
for _, d := range s.Decisions {
if d.Allowed {
Expect([]any{d.Code, d.BlockedBy, d.Message, d.Fix, d.ThenCheck}).To(Equal([]any{"", "", "", (*studio.Fix)(nil), []string(nil)}))
continue
}
Expect(studio.BlockCodes()).To(ContainElement(d.Code), "%s %s: %s", s.ID, d.Capability, d.Reason)
Expect(d.BlockedBy).NotTo(BeEmpty())
Expect(d.Message).NotTo(BeEmpty())
}
}
})
Describe("the preview", func() {
It("equals the saved view for the saved state", func() {
setup(contract.CapMemoryWrite)
saveAgent(func(a *agents.Agent) { a.BuiltinsOff = []string{"triggers"} })
want, got := saved(), preview(studio.AccessDraft{})
Expect(got.Preview).To(BeTrue())
want.Recent, got.Preview = nil, false
Expect(got).To(Equal(want))
// Sending the saved values changes nothing either.
again := preview(studio.AccessDraft{Caps: b.Caps, Allow: b.Allow, AgentID: &r.agent.ID})
again.Preview = false
Expect(again).To(Equal(want))
})
It("answers for a change that is not saved, and saves nothing", func() {
setup(contract.CapMemoryWrite)
before, err := r.store.GetBinding(ctx, b.ID)
Expect(err).NotTo(HaveOccurred())
Expect(decision(saved(), "listed", propose).Code).To(Equal(studio.CodeConnectionPermissionOff))
d := decision(preview(studio.AccessDraft{Caps: []string{contract.CapMemoryWrite, propose}}), "listed", propose)
Expect(d.Allowed).To(BeTrue(), "ticking triggers.propose changes the answer")
d = decision(preview(studio.AccessDraft{Caps: []string{contract.CapMemoryWrite, propose}, Sender: "paired"}), "paired", propose)
Expect(d.Allowed).To(BeTrue())
other := "no-such-agent"
d = decision(preview(studio.AccessDraft{Caps: []string{contract.CapMemoryWrite, propose}, AgentID: &other}), "listed", propose)
Expect(d.Code).To(Equal(studio.CodeAgentUnavailable), "an agent in the form that cannot be read")
none := ""
d = decision(preview(studio.AccessDraft{Caps: []string{propose}, AgentID: &none}), "listed", propose)
Expect(d.Code).To(Equal(studio.CodeNoAgent))
after, err := r.store.GetBinding(ctx, b.ID)
Expect(err).NotTo(HaveOccurred())
Expect(after).To(Equal(before), "the binding is as it was, token included")
Expect(saved().Recent).To(BeEmpty(), "no decision is recorded")
})
It("limits the answer to one kind of sender, and refuses one that does not exist", func() {
setup(contract.CapMemoryWrite)
for sender, id := range map[string]string{"listed": "listed", "paired": "paired", "anyone": "anyone", "channel_member": "anyone", "role:writer": "role:writer"} {
v := preview(studio.AccessDraft{Sender: sender})
Expect(v.Subjects).To(HaveLen(1), sender)
Expect(v.Subjects[0].ID).To(Equal(id))
}
for _, bad := range []string{"role:nobody", "root", "role:"} {
_, err := r.conns.AccessPreview(ctx, alice, b.ID, studio.AccessDraft{Sender: bad})
Expect(err).To(MatchError(studio.ErrBadSender), bad)
}
})
It("works for a connection that is not saved yet", func() {
setup()
v, err := r.conns.AccessPreview(ctx, alice, studio.NewConnectionID, studio.AccessDraft{Kind: "chatty", Caps: []string{contract.CapMemoryWrite}})
Expect(err).NotTo(HaveOccurred())
Expect(v.Binding).To(Equal("new"))
Expect(decision(v, "listed", contract.CapMemoryWrite).Code).To(Equal(studio.CodeNoAgent), "no agent chosen yet")
ag := r.agent.ID
v, err = r.conns.AccessPreview(ctx, alice, studio.NewConnectionID, studio.AccessDraft{Kind: "chatty", AgentID: &ag, Caps: []string{contract.CapMemoryWrite}})
Expect(err).NotTo(HaveOccurred())
Expect(decision(v, "listed", contract.CapMemoryWrite).Allowed).To(BeTrue())
_, err = r.conns.AccessPreview(ctx, alice, studio.NewConnectionID, studio.AccessDraft{})
Expect(err).To(HaveOccurred(), "a kind is needed")
_, err = r.conns.AccessPreview(ctx, alice, studio.NewConnectionID, studio.AccessDraft{Kind: "no-such-kind"})
Expect(err).To(HaveOccurred())
list, _ := r.conns.ListConnectors(ctx, alice)
Expect(list).To(HaveLen(1), "nothing was created")
})
It("is closed to another user, and never carries the token", func() {
setup(contract.CapMemoryWrite)
_, err := r.conns.AccessPreview(ctx, "bob", b.ID, studio.AccessDraft{})
Expect(err).To(MatchError(connectors.ErrNotFound))
for _, v := range []studio.AccessView{saved(), preview(studio.AccessDraft{Caps: []string{propose}})} {
raw, err := json.Marshal(v)
Expect(err).NotTo(HaveOccurred())
Expect(string(raw)).NotTo(ContainSubstring(secretToken))
}
})
})
})
+26 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"strings"
"unicode/utf8"
@@ -116,13 +117,32 @@ func listedChannels(b connectors.Binding) string {
return "; the channels are " + strings.Join(parts, ", ")
}
// ServerSwitch is a CapabilitySwitch for a feature that an administrator
// turned off for the whole server. setting is the stable key of that setting
// (the fix of the answer points at it, a client names the page); reason is the
// English line for the log.
func ServerSwitch(setting, reason string, capabilities ...string) CapabilitySwitch {
return func(_ context.Context, _ connectors.Binding, capability string) Block {
if slices.Contains(capabilities, capability) {
return Block{Code: CodeServerSettingOff, Reason: reason, Fix: &Fix{Kind: FixOpenServerSetting, Target: setting}}
}
return Block{}
}
}
// TalkbackSwitch is a CapabilitySwitch that turns off both talk-back
// capabilities, with the reason shown to the owner. The operator switch uses it.
func TalkbackSwitch(reason string) CapabilitySwitch {
return func(_ context.Context, _ connectors.Binding, capability string) string {
if capability == contract.CapTalkbackChannels || capability == contract.CapTalkbackNewChan {
return reason
}
return ""
}
return ServerSwitch(SettingDisableConnectorTalkback, reason, contract.CapTalkbackChannels, contract.CapTalkbackNewChan)
}
// The keys of the server settings that the chain can point at.
const (
SettingDisableTriggers = "disable_triggers"
SettingDisableConnectorTalkback = "disable_connector_talkback"
)
// TriggersSwitch turns off the capabilities of triggers for the whole server.
func TriggersSwitch(reason string) CapabilitySwitch {
return ServerSwitch(SettingDisableTriggers, reason, contract.CapTriggersPropose, contract.CapTriggersRun)
}
@@ -141,7 +141,7 @@ var _ = Describe("talk-back from a connector chat", func() {
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("writing to other channels is only for people the owner added by name or paired"), "the model is told why")
Expect(prompt).To(ContainSubstring("never say it does not exist"))
})
+1 -1
View File
@@ -197,7 +197,7 @@ 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, builtins.ReasonNotPermitted + ": the owner has not enabled " + CapTriggersPropose + " for this bot"
return session.Tool{}, false, builtins.ReasonNotPermitted + ": " + BlockSay(CodeConnectionPermissionOff, CapTriggersPropose)
}
// The binding allows it; the sender of this turn must hold it too.
if held, why := c.Holds(CapTriggersPropose); !held {
+78 -1
View File
@@ -655,9 +655,86 @@ To see the result, call `GET /api/chats/connectors/<id>/access`. It runs the sam
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
capability and result, never message text). An add-on settings page can show
it.
### Why a capability is blocked
Every capability in the access view says `allowed`. A refused one also says why, as data
a page can turn into a sentence and a link, in the first place the check failed:
```json
{
"capability": "triggers.propose",
"allowed": false,
"reason": "the owner has not enabled triggers.propose for this bot",
"code": "connection_permission_off",
"blocked_by": "connection",
"message": "This connection does not allow reminders and scheduled tasks.",
"fix": {"kind": "open_connection", "target": "caps", "field": "triggers.propose"},
"then_check": ["sender", "approval"]
}
```
An allowed capability has `allowed: true` and `via` (`paired`, `allowlist` or
`role:<id>`) and none of the other fields. `reason` stays the English line for logs.
The check goes through these links in order and reports the first that fails:
1. `server`: an administrator turned the feature off for the whole server.
2. `tools`: the Tools switch of the agent has the tool group off.
3. `agent`: the agent has not been given the permission.
4. `connection`: the permission is not ticked on this connection (or it has no agent).
5. `sender`: the person is not trusted for it (not on the list, needs a pairing, got in
through a channel, or has a role without the grant).
`then_check` lists the links after the failed one that were not looked at, so more
problems may follow. For `triggers.propose` it ends with `approval`: the approval mode
shapes the result and never blocks.
| `code` | `blocked_by` | Meaning | `fix.kind` |
|---|---|---|---|
| `server_setting_off` | `server` | The feature is off for the server. `fix.target` is the setting key, `disable_triggers` or `disable_connector_talkback`. | `open_server_setting` |
| `tools_switch_off` | `tools` | The Tools switch of the agent is off. `fix.field` is `tools:<group>`. | `open_agent` |
| `agent_permission_off` | `agent` | The agent lacks the permission. `fix.field` is the permission. | `open_agent` |
| `agent_unavailable` | `agent` | The agent cannot be read. | `open_agent` |
| `no_agent` | `connection` | The connection has no agent. `fix.target` is `agent_id`. | `open_connection` |
| `connection_permission_off` | `connection` | The permission is not ticked. `fix.target` is `caps`. | `open_connection` |
| `sender_not_listed` | `sender` | The sender is not let in. | `add_person` |
| `sender_needs_pairing` | `sender` | A sensitive permission needs a pairing. | `pair_sender` |
| `channel_not_trusted` | `sender` | The sender got in through a channel or because the bot accepts anyone. | `add_person` |
| `role_not_granted` | `sender` | The role of the sender has no grant. `fix.target` is `grants`. | `open_connection` |
| `kind_not_running` | `sender` | The app of the connection is not running, so roles are unknown. | none |
| `context_rule` | `connection` | Held, but not in this place or thread. | none |
| `unknown_capability` | `server` | Not a capability. | none |
The server sends no URL. A page maps `fix.kind` and `fix.target` to its own links. The
set of codes is closed, and the recent decisions carry `code` too.
The same codes feed the words a sender and the model get when a tool is held back. The
note for the model and the tool error use one plain sentence per code ("the owner has
not allowed reminders and scheduled tasks for this bot"), with no setting names, host
names or error codes. The host never posts it to a channel on its own.
### Preview before saving
`POST /api/chats/connectors/<id>/access/preview` answers like the access view for a form
that is not saved. The body has the fields that the check reads, with the names of the
update call: `agent_id`, `accept_any`, `allow`, `caps` and `grants`. A field that is
absent keeps the saved value; an empty list clears it. `sender` limits the answer to one
kind of sender: `listed`, `paired`, `channel_member` (or `anyone`), or `role:<id>`;
an unknown value is a 400. Use the id `new` with a `kind` for a connection that is not
saved yet. The reply has the shape of the access view, with `preview: true` and no recent
decisions.
```json
{"caps": ["memory.write", "triggers.propose"], "sender": "listed"}
```
The preview writes nothing and records no decision. It does not take a token and the
reply never has one. It uses the same sign-in, Connectors feature and routing as the
other connector routes, and the connection of another user is a 404.
## Triggers from a connector chat
When the binding holds the capability `triggers.propose`, the agent can propose a
+131
View File
@@ -5878,6 +5878,47 @@ const docTemplate = `{
}
}
},
"/api/chats/connectors/{id}/access/preview": {
"post": {
"description": "Runs the same permission chain as the access view on the state in the request: the server setting, the Tools switch of the agent, the agent permission, the permission of the connection and the trust of the sender, in that order, and reports the first problem of each capability with a code, a plain message, one fix and the links not yet checked. Fields that are absent keep the saved value. Use the id \"new\" with a kind for a connection that is not saved yet. It writes nothing, records no decision, and neither takes nor returns the token. Same sign-in and Connectors feature as the other connector routes.",
"tags": [
"chats"
],
"summary": "Preview what each kind of sender may do on a connector binding, before saving.",
"parameters": [
{
"type": "string",
"description": "the binding's id, or new",
"name": "id",
"in": "path",
"required": true
},
{
"description": "the form state to check",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.ChatsConnectorAccessPreviewRequest"
}
}
],
"responses": {
"200": {
"description": "The effective permissions of that state",
"schema": {
"$ref": "#/definitions/schema.ChatsConnectorAccess"
}
},
"400": {
"description": "The request is not valid",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/api/chats/connectors/{id}/disable": {
"post": {
"tags": [
@@ -21441,6 +21482,10 @@ const docTemplate = `{
"binding": {
"type": "string"
},
"preview": {
"description": "Preview is true when the view is for a form that is not saved.",
"type": "boolean"
},
"recent": {
"type": "array",
"items": {
@@ -21461,12 +21506,39 @@ const docTemplate = `{
"allowed": {
"type": "boolean"
},
"blocked_by": {
"description": "BlockedBy is the link that refused: server, tools, agent, connection\nor sender.",
"type": "string"
},
"capability": {
"type": "string"
},
"code": {
"description": "Code says which link of the chain refused first. It is one of\nserver_setting_off, tools_switch_off, agent_permission_off,\nagent_unavailable, connection_permission_off, no_agent,\nsender_not_listed, sender_needs_pairing, channel_not_trusted,\nrole_not_granted, kind_not_running, context_rule or\nunknown_capability. Only a refused capability has it.",
"type": "string"
},
"fix": {
"description": "Fix points at the one place that solves it. The server sends no URL.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorAccessFix"
}
]
},
"message": {
"description": "Message is one plain sentence for the owner. It names no setting.",
"type": "string"
},
"reason": {
"type": "string"
},
"then_check": {
"description": "ThenCheck lists the links after the first problem that were not\nlooked at, in order: tools, agent, connection, sender and, for\ntriggers.propose, approval. More problems may follow.",
"type": "array",
"items": {
"type": "string"
}
},
"via": {
"description": "Via is how the capability is reached: paired, or role:\u003cid\u003e.",
"type": "string"
@@ -21485,6 +21557,10 @@ const docTemplate = `{
"capability": {
"type": "string"
},
"code": {
"description": "Code is the code of a refusal, as in the decisions.",
"type": "string"
},
"reason": {
"type": "string"
},
@@ -21505,6 +21581,61 @@ const docTemplate = `{
}
}
},
"schema.ChatsConnectorAccessFix": {
"type": "object",
"properties": {
"field": {
"description": "Field narrows the target: the capability to tick on the connection or\nto grant on the agent, or tools:\u003cgroup\u003e for a Tools switch.",
"type": "string"
},
"kind": {
"description": "Kind is open_server_setting, open_agent, open_connection, pair_sender\nor add_person.",
"type": "string"
},
"target": {
"description": "Target is a stable ID: the key of a server setting, an agent ID, a field\nof the connection form (caps, grants, agent_id, allow) or pairing.",
"type": "string"
}
}
},
"schema.ChatsConnectorAccessPreviewRequest": {
"type": "object",
"properties": {
"accept_any": {
"type": "boolean"
},
"agent_id": {
"type": "string"
},
"allow": {
"type": "array",
"items": {
"type": "string"
}
},
"caps": {
"description": "Caps and Grants are the form's permissions; an empty list clears.",
"type": "array",
"items": {
"type": "string"
}
},
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"kind": {
"description": "Kind is read only for the id \"new\" (a connection that is not saved).",
"type": "string"
},
"sender": {
"description": "Sender limits the answer to one kind of sender: listed, paired,\nchannel_member, anyone or role:\u003cid\u003e. Absent means every kind.",
"type": "string"
}
}
},
"schema.ChatsConnectorAccessSubject": {
"type": "object",
"properties": {
+131
View File
@@ -5875,6 +5875,47 @@
}
}
},
"/api/chats/connectors/{id}/access/preview": {
"post": {
"description": "Runs the same permission chain as the access view on the state in the request: the server setting, the Tools switch of the agent, the agent permission, the permission of the connection and the trust of the sender, in that order, and reports the first problem of each capability with a code, a plain message, one fix and the links not yet checked. Fields that are absent keep the saved value. Use the id \"new\" with a kind for a connection that is not saved yet. It writes nothing, records no decision, and neither takes nor returns the token. Same sign-in and Connectors feature as the other connector routes.",
"tags": [
"chats"
],
"summary": "Preview what each kind of sender may do on a connector binding, before saving.",
"parameters": [
{
"type": "string",
"description": "the binding's id, or new",
"name": "id",
"in": "path",
"required": true
},
{
"description": "the form state to check",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/schema.ChatsConnectorAccessPreviewRequest"
}
}
],
"responses": {
"200": {
"description": "The effective permissions of that state",
"schema": {
"$ref": "#/definitions/schema.ChatsConnectorAccess"
}
},
"400": {
"description": "The request is not valid",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/api/chats/connectors/{id}/disable": {
"post": {
"tags": [
@@ -21438,6 +21479,10 @@
"binding": {
"type": "string"
},
"preview": {
"description": "Preview is true when the view is for a form that is not saved.",
"type": "boolean"
},
"recent": {
"type": "array",
"items": {
@@ -21458,12 +21503,39 @@
"allowed": {
"type": "boolean"
},
"blocked_by": {
"description": "BlockedBy is the link that refused: server, tools, agent, connection\nor sender.",
"type": "string"
},
"capability": {
"type": "string"
},
"code": {
"description": "Code says which link of the chain refused first. It is one of\nserver_setting_off, tools_switch_off, agent_permission_off,\nagent_unavailable, connection_permission_off, no_agent,\nsender_not_listed, sender_needs_pairing, channel_not_trusted,\nrole_not_granted, kind_not_running, context_rule or\nunknown_capability. Only a refused capability has it.",
"type": "string"
},
"fix": {
"description": "Fix points at the one place that solves it. The server sends no URL.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorAccessFix"
}
]
},
"message": {
"description": "Message is one plain sentence for the owner. It names no setting.",
"type": "string"
},
"reason": {
"type": "string"
},
"then_check": {
"description": "ThenCheck lists the links after the first problem that were not\nlooked at, in order: tools, agent, connection, sender and, for\ntriggers.propose, approval. More problems may follow.",
"type": "array",
"items": {
"type": "string"
}
},
"via": {
"description": "Via is how the capability is reached: paired, or role:\u003cid\u003e.",
"type": "string"
@@ -21482,6 +21554,10 @@
"capability": {
"type": "string"
},
"code": {
"description": "Code is the code of a refusal, as in the decisions.",
"type": "string"
},
"reason": {
"type": "string"
},
@@ -21502,6 +21578,61 @@
}
}
},
"schema.ChatsConnectorAccessFix": {
"type": "object",
"properties": {
"field": {
"description": "Field narrows the target: the capability to tick on the connection or\nto grant on the agent, or tools:\u003cgroup\u003e for a Tools switch.",
"type": "string"
},
"kind": {
"description": "Kind is open_server_setting, open_agent, open_connection, pair_sender\nor add_person.",
"type": "string"
},
"target": {
"description": "Target is a stable ID: the key of a server setting, an agent ID, a field\nof the connection form (caps, grants, agent_id, allow) or pairing.",
"type": "string"
}
}
},
"schema.ChatsConnectorAccessPreviewRequest": {
"type": "object",
"properties": {
"accept_any": {
"type": "boolean"
},
"agent_id": {
"type": "string"
},
"allow": {
"type": "array",
"items": {
"type": "string"
}
},
"caps": {
"description": "Caps and Grants are the form's permissions; an empty list clears.",
"type": "array",
"items": {
"type": "string"
}
},
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"kind": {
"description": "Kind is read only for the id \"new\" (a connection that is not saved).",
"type": "string"
},
"sender": {
"description": "Sender limits the answer to one kind of sender: listed, paired,\nchannel_member, anyone or role:\u003cid\u003e. Absent means every kind.",
"type": "string"
}
}
},
"schema.ChatsConnectorAccessSubject": {
"type": "object",
"properties": {
+119
View File
@@ -4622,6 +4622,9 @@ definitions:
properties:
binding:
type: string
preview:
description: Preview is true when the view is for a form that is not saved.
type: boolean
recent:
items:
$ref: '#/definitions/schema.ChatsConnectorAccessEvent'
@@ -4635,10 +4638,40 @@ definitions:
properties:
allowed:
type: boolean
blocked_by:
description: |-
BlockedBy is the link that refused: server, tools, agent, connection
or sender.
type: string
capability:
type: string
code:
description: |-
Code says which link of the chain refused first. It is one of
server_setting_off, tools_switch_off, agent_permission_off,
agent_unavailable, connection_permission_off, no_agent,
sender_not_listed, sender_needs_pairing, channel_not_trusted,
role_not_granted, kind_not_running, context_rule or
unknown_capability. Only a refused capability has it.
type: string
fix:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorAccessFix'
description: Fix points at the one place that solves it. The server sends
no URL.
message:
description: Message is one plain sentence for the owner. It names no setting.
type: string
reason:
type: string
then_check:
description: |-
ThenCheck lists the links after the first problem that were not
looked at, in order: tools, agent, connection, sender and, for
triggers.propose, approval. More problems may follow.
items:
type: string
type: array
via:
description: 'Via is how the capability is reached: paired, or role:<id>.'
type: string
@@ -4651,6 +4684,9 @@ definitions:
type: string
capability:
type: string
code:
description: Code is the code of a refusal, as in the decisions.
type: string
reason:
type: string
roles:
@@ -4664,6 +4700,53 @@ definitions:
via:
type: string
type: object
schema.ChatsConnectorAccessFix:
properties:
field:
description: |-
Field narrows the target: the capability to tick on the connection or
to grant on the agent, or tools:<group> for a Tools switch.
type: string
kind:
description: |-
Kind is open_server_setting, open_agent, open_connection, pair_sender
or add_person.
type: string
target:
description: |-
Target is a stable ID: the key of a server setting, an agent ID, a field
of the connection form (caps, grants, agent_id, allow) or pairing.
type: string
type: object
schema.ChatsConnectorAccessPreviewRequest:
properties:
accept_any:
type: boolean
agent_id:
type: string
allow:
items:
type: string
type: array
caps:
description: Caps and Grants are the form's permissions; an empty list clears.
items:
type: string
type: array
grants:
items:
$ref: '#/definitions/schema.ChatsConnectorRoleGrant'
type: array
kind:
description: Kind is read only for the id "new" (a connection that is not
saved).
type: string
sender:
description: |-
Sender limits the answer to one kind of sender: listed, paired,
channel_member, anyone or role:<id>. Absent means every kind.
type: string
type: object
schema.ChatsConnectorAccessSubject:
properties:
decisions:
@@ -16835,6 +16918,42 @@ paths:
summary: Show what each kind of sender may do on a connector binding.
tags:
- chats
/api/chats/connectors/{id}/access/preview:
post:
description: 'Runs the same permission chain as the access view on the state
in the request: the server setting, the Tools switch of the agent, the agent
permission, the permission of the connection and the trust of the sender,
in that order, and reports the first problem of each capability with a code,
a plain message, one fix and the links not yet checked. Fields that are absent
keep the saved value. Use the id "new" with a kind for a connection that is
not saved yet. It writes nothing, records no decision, and neither takes nor
returns the token. Same sign-in and Connectors feature as the other connector
routes.'
parameters:
- description: the binding's id, or new
in: path
name: id
required: true
type: string
- description: the form state to check
in: body
name: request
required: true
schema:
$ref: '#/definitions/schema.ChatsConnectorAccessPreviewRequest'
responses:
"200":
description: The effective permissions of that state
schema:
$ref: '#/definitions/schema.ChatsConnectorAccess'
"400":
description: The request is not valid
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Preview what each kind of sender may do on a connector binding, before
saving.
tags:
- chats
/api/chats/connectors/{id}/disable:
post:
parameters: