diff --git a/core/services/studio/connectorgrants.go b/core/services/studio/connectorgrants.go new file mode 100644 index 0000000000..5775509523 --- /dev/null +++ b/core/services/studio/connectorgrants.go @@ -0,0 +1,90 @@ +package studio + +import ( + "context" + "slices" + + "github.com/mudler/LocalAI/core/services/studio/builtins" + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// The builtin grants of the agent that a binding serves also govern what a +// connector chat may do. The rule for one capability of the list below: +// +// a tool exists = the sender's right on the binding (Authorize) +// AND the grant of the agent (this file) +// AND the chat switch of the tool's group +// +// This file is the middle term. It is a CapabilitySwitch, so every question +// that goes through the connector gate asks it too: the capabilities a turn +// gets (grantFor), the check of a feature that has a sender (Authorize), the +// question of a run without one (CeilingAllows: a trigger that fires from a +// message pauses when the grant goes) and the gate that the trigger tools use. +// +// What an empty list means. An agent saved before grants existed has none, and +// a connection of it keeps working: an agent with no grant list at all (the +// Builtins list is empty) holds every non-sensitive capability below, as it +// did. A sensitive one is never implied; it needs the explicit grant. As soon +// as the owner grants any builtin, the list is the whole answer: a capability +// that is not on it is off. This is the one place where adding a grant (say +// memory.write) narrows an agent that had no list, and the editor shows the +// checklist so the owner sees it. + +// grantGated are the capabilities that an agent grant governs at the gate. +// The grants of the proposal kinds (memory.write and the others) have their +// own enforcement point in the proposal tools and are not repeated here. +// act.* are not listed on purpose: they are the action policy of unattended +// runs, which has no enforcement point yet (see the customization spec). +var grantGated = []string{ + contract.CapTriggersPropose, + contract.CapTriggersRun, + contract.CapTalkbackChannels, + contract.CapTalkbackNewChan, + contract.CapFilesUpload, +} + +// GrantGatedCapability reports whether the agent's grants govern the +// capability at the connector gate. +func GrantGatedCapability(capability string) bool { return slices.Contains(grantGated, capability) } + +// grantVerdict is the pure rule of the switch. listed is the grants the owner +// wrote on the agent (empty: the agent has no list), held is that list after +// the intersection with what the user may use. It returns the reason the +// capability is off, or "". +func grantVerdict(capability string, listed, held []string, hasAgent bool) string { + if !GrantGatedCapability(capability) { + return "" + } + switch { + case slices.Contains(held, capability): + return "" + case builtins.SensitiveGrant(capability): + return "the agent of this bot has not been granted " + capability + case !hasAgent || len(listed) == 0: + return "" // no grant list: the capabilities that are not sensitive keep working + } + return "the agent of this bot has not been granted " + capability +} + +// agentGrantSwitch is the CapabilitySwitch that reads the grants of the agent +// 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 { + if !GrantGatedCapability(capability) { + return "" + } + if b.AgentID == "" { + return grantVerdict(capability, nil, nil, false) + } + if s.d.Agents == nil { + return "the agent of this bot cannot be read" + } + 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 grantVerdict(capability, ag.Builtins, s.usableGrants(ctx, principal(b.Owner), ag.Builtins), true) + } +} diff --git a/core/services/studio/connectorgrants_internal_test.go b/core/services/studio/connectorgrants_internal_test.go new file mode 100644 index 0000000000..8f5cd6e535 --- /dev/null +++ b/core/services/studio/connectorgrants_internal_test.go @@ -0,0 +1,117 @@ +package studio + +import ( + "slices" + "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 rule for the agent grants at the connector gate, written out for every +// combination. A tool exists when the sender's right on the binding, the grant +// of the agent and the chat switch all allow it. +var _ = Describe("the agent grants at the connector gate", func() { + gated := []string{ + contract.CapTriggersPropose, contract.CapTriggersRun, contract.CapTalkbackChannels, + contract.CapTalkbackNewChan, contract.CapFilesUpload, + } + + Describe("the verdict", func() { + type row struct { + who string + listed []string + held []string + hasAgent bool + // allowed are the gated capabilities that stay on. + allowed []string + } + nonSensitive := []string{contract.CapTriggersPropose, contract.CapTriggersRun, contract.CapTalkbackChannels, contract.CapFilesUpload} + rows := []row{ + {"an agent with no grant list", nil, nil, true, nonSensitive}, + {"an agent with an empty grant list", []string{}, nil, true, nonSensitive}, + {"a binding with no agent", nil, nil, false, nonSensitive}, + {"a list that holds one capability", []string{contract.CapTriggersPropose, contract.CapMemoryWrite}, []string{contract.CapTriggersPropose}, true, + []string{contract.CapTriggersPropose}}, + {"a list of other grants only", []string{contract.CapMemoryWrite}, []string{contract.CapMemoryWrite}, true, nil}, + {"the sensitive grant alone", []string{contract.CapTalkbackNewChan}, []string{contract.CapTalkbackNewChan}, true, []string{contract.CapTalkbackNewChan}}, + {"a grant the user may not use now", gated, nil, true, nil}, + {"every grant", gated, gated, true, gated}, + } + for _, r := range rows { + It(r.who, func() { + for _, name := range gated { + why := grantVerdict(name, r.listed, r.held, r.hasAgent) + Expect(why == "").To(Equal(slices.Contains(r.allowed, name)), "%s: %q", name, why) + } + }) + } + + It("never decides a capability that no agent grant governs", func() { + for _, name := range contract.ConnectorCapabilityNames() { + if slices.Contains(gated, name) { + continue + } + Expect(grantVerdict(name, []string{contract.CapMemoryWrite}, nil, true)).To(BeEmpty(), name) + } + }) + + It("keeps the act classes outside the gate, because their enforcement point is the action policy of unattended runs", func() { + for _, name := range []string{"act.files", "act.addons", "act.send", "act.spend"} { + Expect(GrantGatedCapability(name)).To(BeFalse(), name) + } + }) + }) + + // The whole rule: the binding right (paired sender, ceiling) combined with + // the grant verdict. Each row is a sender and the switch of the agent. + Describe("together with Authorize", func() { + now := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + paired := []connectors.PairedSender{{SenderID: "P", At: now.Add(-time.Hour)}} + kind := &contract.ConnectorKind{ID: "chat", Roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}, + Permissions: []string{contract.CapTriggersPropose, contract.CapTalkbackChannels}} + binding := func(caps ...string) connectors.Binding { + return connectors.Binding{ID: "b", Kind: "chat", Caps: caps, Paired: paired, + Grants: []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapTriggersPropose, contract.CapTalkbackChannels}}}} + } + type row struct { + who string + caps []string + s AccessSubject + listed []string + held []string + cap string + allowed bool + } + pair := AccessSubject{SenderID: "P", Allowlisted: true} + admin := AccessSubject{SenderID: "U", Allowlisted: true, Roles: []string{"admin"}} + plain := AccessSubject{SenderID: "U", Allowlisted: true} + rows := []row{ + {"paired, ceiling, no list", gated, pair, nil, nil, contract.CapTriggersPropose, true}, + {"paired, ceiling, grant held", gated, pair, []string{contract.CapTriggersPropose}, []string{contract.CapTriggersPropose}, contract.CapTriggersPropose, true}, + {"paired, ceiling, list without it", gated, pair, []string{contract.CapMemoryWrite}, nil, contract.CapTriggersPropose, false}, + {"paired, no ceiling, grant held", nil, pair, gated, gated, contract.CapTriggersPropose, false}, + {"role, ceiling, no list", gated, admin, nil, nil, contract.CapTalkbackChannels, true}, + {"role, ceiling, list without it", gated, admin, []string{contract.CapTriggersPropose}, []string{contract.CapTriggersPropose}, contract.CapTalkbackChannels, false}, + {"role, no ceiling, grant held", nil, admin, gated, gated, contract.CapTalkbackChannels, false}, + {"plain sender, ceiling, grant held", gated, plain, gated, gated, contract.CapTalkbackChannels, false}, + {"paired, sensitive, no list", gated, pair, nil, nil, contract.CapTalkbackNewChan, false}, + {"paired, sensitive, list without it", gated, pair, []string{contract.CapTalkbackChannels}, []string{contract.CapTalkbackChannels}, contract.CapTalkbackNewChan, false}, + {"paired, sensitive, granted", gated, pair, []string{contract.CapTalkbackNewChan}, []string{contract.CapTalkbackNewChan}, contract.CapTalkbackNewChan, true}, + {"role, sensitive, granted", gated, admin, gated, gated, contract.CapTalkbackNewChan, false}, + {"paired, sensitive, granted, no ceiling", nil, pair, gated, gated, contract.CapTalkbackNewChan, false}, + } + 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) + Expect(d.Allowed).To(Equal(r.allowed), "%s: %s", r.cap, d.Reason) + if !r.allowed { + Expect(d.Reason).NotTo(BeEmpty()) + } + }) + } + }) +}) diff --git a/core/services/studio/connectorgrants_test.go b/core/services/studio/connectorgrants_test.go new file mode 100644 index 0000000000..655e5deaaf --- /dev/null +++ b/core/services/studio/connectorgrants_test.go @@ -0,0 +1,265 @@ +package studio_test + +import ( + "context" + "slices" + "time" + + "github.com/mudler/LocalAI/core/services/studio" + "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" +) + +// A tool exists in a connector chat when the right of the sender on the binding, +// the builtin grants of the agent and the chat switch all allow it. These specs +// run whole turns: they look at the tools the model is offered, and at what +// happens when it calls a tool that it should not have. The platform in them is +// made up; nothing depends on which add-on serves it. +var _ = Describe("builtin grants in the turns of a connector chat", func() { + ctx := context.Background() + const ( + chatID = "C1" + thread = "T1" + general = "G1" + ops = "G2" + ) + var ( + fake *postAddon + r *connRig + b connectors.Binding + args string + seq int + ) + script := func(_ workspace.Actor, text string) []studiotest.ToolStep { + switch text { + case "send": + return []studiotest.ToolStep{{Tool: "send_message", Args: args}, {Say: "done"}} + case "comment": + return []studiotest.ToolStep{{Tool: "addon__office__comment", Args: `{}`}, {Say: "done"}} + } + return []studiotest.ToolStep{{Say: "ok"}} + } + setup := func() { + fake = &postAddon{} + sh := newShared() + // One read tool and one write tool of an add-on that holds a credential: + // the write tool declares the capability that it needs. + write := addonTool("office", "comment") + write.Action, write.Permission = true, contract.CapToolsWrite + sh.addons.tools[alice] = append(sh.addons.tools[alice], write) + 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.CapToolsWrite}}, Runtime: fake}) + r = newConnRigOpts(sh, connStore(), tr, script, nil, func(o *studio.ConnectorOptions) { o.OutboxPoll = 10 * time.Millisecond }) + // The real gate, as the application sets it. + r.svc.SetCapabilityGate(r.conns.CapabilityGate()) + b = r.bind(studio.ConnectorInput{Kind: "chatty", Token: "tok", Allow: []string{"11", "12"}, + Channels: []connectors.Channel{{ID: general, Label: "General", Approved: true}, {ID: ops, Label: "Ops"}}}) + seq = 0 + } + // grants sets the builtin grants of the agent of the binding. No argument + // clears the list. + grants := func(names ...string) { + ag := r.agent + ag.Builtins = append([]string{}, names...) + saved, err := r.svc.SaveAgent(ctx, alice, ag) + Expect(err).NotTo(HaveOccurred()) + r.agent = saved + } + say := func(sender, text string, media ...string) { + seq++ + Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{ + Chat: chatID, Thread: thread, Mention: true, MessageID: "m" + string(rune('a'+seq)), SenderID: sender, SenderName: "S", Text: text, Media: media}})).To(Succeed()) + } + turn := func(text string) { + before := len(fake.texts(chatID, thread)) + say("11", text) + waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) > before }) + } + tools := func() []string { + l, err := r.svc.List(ctx, alice, 10) + Expect(err).NotTo(HaveOccurred()) + Expect(l).NotTo(BeEmpty()) + return r.h.ToolNames(l[0].ID) + } + lastResult := func(tool string) studiotest.ToolResult { + res := r.h.ResultsFor(tool) + Expect(res).NotTo(BeEmpty()) + return res[len(res)-1] + } + denied := func(capability string) bool { + v, err := r.conns.Access(ctx, alice, b.ID) + Expect(err).NotTo(HaveOccurred()) + return slices.ContainsFunc(v.Recent, func(e studio.AccessEvent) bool { return e.Capability == capability && !e.Allowed }) + } + triggerTools := []string{"propose_trigger", "list_triggers"} + + Describe("an agent with no grant list", func() { + It("keeps the tools that are not sensitive", func() { + setup() + turn("hello") + Expect(tools()).To(ContainElement("send_message")) + Expect(tools()).To(ContainElement("propose_trigger")) + }) + + It("does not start a conversation on a channel the owner did not approve, which is sensitive", func() { + setup() + args = `{"text":"first","channel":"` + ops + `"}` + turn("send") + Expect(lastResult("send_message").Err).To(MatchError(ContainSubstring("not permitted"))) + Expect(fake.texts(ops, "")).To(BeEmpty()) + Expect(denied(contract.CapTalkbackNewChan)).To(BeTrue(), "the refusal is audited") + }) + }) + + Describe("an agent with a grant list", func() { + It("offers a tool only for the grants on the list", func() { + setup() + grants(contract.CapMemoryWrite) + turn("hello") + Expect(tools()).NotTo(ContainElement("send_message")) + for _, n := range triggerTools { + Expect(tools()).NotTo(ContainElement(n), n) + } + grants(contract.CapTalkbackChannels) + turn("hello") + Expect(tools()).To(ContainElement("send_message")) + Expect(tools()).NotTo(ContainElement("propose_trigger")) + grants(contract.CapTriggersPropose) + turn("hello") + Expect(tools()).To(ContainElement("propose_trigger")) + Expect(tools()).NotTo(ContainElement("send_message")) + }) + + It("refuses a call to a tool that is not offered", func() { + setup() + grants(contract.CapTriggersPropose) + args = `{"text":"hi"}` + turn("send") + Expect(lastResult("send_message").Err).To(MatchError(ContainSubstring("no tool named"))) + Expect(fake.texts(chatID, thread)).NotTo(ContainElement("hi")) + }) + + It("keeps a new channel closed until the sensitive grant is on the list", func() { + setup() + grants(contract.CapTalkbackChannels) + args = `{"text":"first","channel":"` + ops + `"}` + turn("send") + Expect(lastResult("send_message").Err).To(MatchError(ContainSubstring("not permitted"))) + Expect(fake.texts(ops, "")).To(BeEmpty()) + // The approved channel is still open to the same agent. + args = `{"text":"fine","channel":"` + general + `"}` + turn("send") + waitFor("the message", func() bool { return len(fake.texts(general, "")) == 1 }) + grants(contract.CapTalkbackChannels, contract.CapTalkbackNewChan) + args = `{"text":"first","channel":"` + ops + `"}` + turn("send") + waitFor("the message", func() bool { return len(fake.texts(ops, "")) == 1 }) + }) + + It("takes the tool away on the next turn when the owner removes the grant", func() { + setup() + grants(contract.CapTalkbackChannels) + turn("hello") + Expect(tools()).To(ContainElement("send_message")) + grants(contract.CapMemoryWrite) + turn("hello") + Expect(tools()).NotTo(ContainElement("send_message")) + }) + + It("refuses a file from a sender when files.upload is not on the list", func() { + setup() + grants(contract.CapTalkbackChannels) + say("11", "here", "file-1") + waitFor("the refusal", func() bool { return slices.Contains(fake.texts(chatID, thread), "You may not send files to this bot.") }) + Expect(denied(contract.CapFilesUpload)).To(BeTrue()) + }) + + It("does not widen what the binding gives the sender", func() { + setup() + grants(contract.CapTalkbackChannels, contract.CapTalkbackNewChan, contract.CapTriggersPropose) + bb, err := r.store.GetBinding(ctx, b.ID) + Expect(err).NotTo(HaveOccurred()) + bb.Caps = []string{contract.CapMemoryWrite} + Expect(r.store.PutBinding(ctx, bb)).To(Succeed()) + turn("hello") + Expect(tools()).NotTo(ContainElement("send_message")) + Expect(tools()).NotTo(ContainElement("propose_trigger")) + }) + }) + + Describe("the write tools of an add-on that holds a credential", func() { + It("are offered to a paired sender and run", func() { + setup() + turn("comment") + Expect(tools()).To(ContainElement("addon__office__comment")) + Expect(tools()).To(ContainElement("addon__office__read-doc")) + Expect(lastResult("addon__office__comment").Err).NotTo(HaveOccurred()) + }) + + It("are not offered when the owner did not enable tools.write for the bot, and the read tool stays", func() { + setup() + bb, err := r.store.GetBinding(ctx, b.ID) + Expect(err).NotTo(HaveOccurred()) + bb.Caps = slices.DeleteFunc(slices.Clone(bb.Caps), func(c string) bool { return c == contract.CapToolsWrite }) + Expect(r.store.PutBinding(ctx, bb)).To(Succeed()) + turn("comment") + Expect(tools()).NotTo(ContainElement("addon__office__comment")) + Expect(tools()).To(ContainElement("addon__office__read-doc")) + Expect(lastResult("addon__office__comment").Err).To(MatchError(ContainSubstring("no tool named"))) + }) + + It("are not offered to a sender who is not paired and holds no role with the capability", func() { + setup() + say("12", "hello") + waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) == 1 }) + bb, _ := r.store.GetBinding(ctx, b.ID) + bb.Paired = slices.DeleteFunc(bb.Paired, func(p connectors.PairedSender) bool { return p.SenderID == "12" }) + Expect(r.store.PutBinding(ctx, bb)).To(Succeed()) + say("12", "comment") + waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) == 2 }) + l, _ := r.svc.List(ctx, alice, 10) + for _, c := range l { + Expect(r.h.ToolNames(c.ID)).NotTo(ContainElement("addon__office__comment")) + } + }) + + It("are offered to a role that the owner granted tools.write, because the kind lists it", func() { + setup() + bb, _ := r.store.GetBinding(ctx, b.ID) + bb.Paired = nil + bb.Grants = []connectors.RoleGrant{{Role: "writer", Caps: []string{contract.CapToolsWrite}}} + Expect(r.store.PutBinding(ctx, bb)).To(Succeed()) + seq++ + Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{ + Chat: chatID, Thread: thread, Mention: true, MessageID: "mr", SenderID: "11", SenderName: "S", Text: "comment", Roles: []string{"writer"}}})).To(Succeed()) + waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) == 1 }) + Expect(tools()).To(ContainElement("addon__office__comment")) + }) + + It("are not offered to a role when the binding did not grant it", func() { + setup() + bb, _ := r.store.GetBinding(ctx, b.ID) + bb.Paired, bb.Grants = nil, nil + Expect(r.store.PutBinding(ctx, bb)).To(Succeed()) + Expect(r.conns.Ingest(ctx, "chat-addon", contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{ + Chat: chatID, Thread: thread, Mention: true, MessageID: "mr", SenderID: "11", SenderName: "S", Text: "comment", Roles: []string{"writer"}}})).To(Succeed()) + waitFor("the answer", func() bool { return len(fake.texts(chatID, thread)) == 1 }) + Expect(tools()).NotTo(ContainElement("addon__office__comment")) + }) + }) + + It("keeps the act classes outside the gate, so an agent that holds them gets no new tool", func() { + setup() + grants("act.files", "act.addons", "act.send", "act.spend") + turn("hello") + Expect(tools()).NotTo(ContainElement("send_message")) + for _, a := range []string{"act.files", "act.addons", "act.send", "act.spend"} { + Expect(studio.GrantGatedCapability(a)).To(BeFalse(), a) + } + }) +}) diff --git a/core/services/studio/connectors.go b/core/services/studio/connectors.go index 3c202ebb75..b45d46ce56 100644 --- a/core/services/studio/connectors.go +++ b/core/services/studio/connectors.go @@ -202,7 +202,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{})}, nil + wake: make(chan struct{}, 1), done: make(chan struct{}), gates: []CapabilitySwitch{agentGrantSwitch(svc)}}, nil } // Notify asks Run to look at the bindings now. diff --git a/core/services/studio/connectors_enforce_test.go b/core/services/studio/connectors_enforce_test.go index 12cb5ed426..61fb6ce350 100644 --- a/core/services/studio/connectors_enforce_test.go +++ b/core/services/studio/connectors_enforce_test.go @@ -60,7 +60,7 @@ var _ = Describe("permissions in the turns of a connector chat", func() { sh := changeShared(grantAll) r = newConnRigWith(sh, connStore(), studiobot.NewTransport(addon), script, nil) r.bot, r.untrusted = bot, true - r.grantBuiltins(contract.CapMemoryWrite, contract.CapProfileWrite) + r.grantBuiltins(contract.CapMemoryWrite, contract.CapProfileWrite, contract.CapTriggersPropose) b = r.bind(studio.ConnectorInput{Allow: []string{"11", "12"}, Caps: caps, Grants: grants}) got, err := r.store.GetBinding(ctx, b.ID) Expect(err).NotTo(HaveOccurred()) diff --git a/core/services/studio/connectortalkback_test.go b/core/services/studio/connectortalkback_test.go index 4cfb7cf236..d762c974d5 100644 --- a/core/services/studio/connectortalkback_test.go +++ b/core/services/studio/connectortalkback_test.go @@ -180,8 +180,14 @@ var _ = Describe("talk-back from a connector chat", func() { Expect(fake.texts("SECRET", "")).To(BeEmpty()) }) - It("to a listed channel the owner did not approve needs a paired sender", func() { + It("to a listed channel the owner did not approve needs a paired sender and the grant of the agent", func() { setup(nil) + // The sensitive grant is never implied: the agent must hold it. + ag := r.agent + ag.Builtins = []string{contract.CapTalkbackChannels, contract.CapTalkbackNewChan} + saved, err := r.svc.SaveAgent(ctx, alice, ag) + Expect(err).NotTo(HaveOccurred()) + r.agent = saved args = `{"text":"first","channel":"` + ops + `"}` turn("send") waitFor("the message", func() bool { return len(fake.texts(ops, "")) == 1 }) diff --git a/docs/content/features/chats-agents.md b/docs/content/features/chats-agents.md index f588a97ee0..35288492bb 100644 --- a/docs/content/features/chats-agents.md +++ b/docs/content/features/chats-agents.md @@ -262,6 +262,20 @@ confirm every proposal, and you can undo it. A write to the agent without the `builtins` field keeps them, an empty list clears them and an unknown name is refused (an import drops it). +In a chat that a messaging connection started, the grants also decide which tools +the model gets. A tool exists when the sender holds the capability on the +connection, the agent holds the grant, and the chat did not switch the tool off. +Four grants work this way: `triggers.propose`, `triggers.run`, `talkback.channels` +and `files.upload` (the sender's files going to the agent), plus the sensitive +`talkback.new_channel`. An agent that has no `builtins` list at all keeps the +non-sensitive ones, as before. A sensitive grant is never implied: it needs to be +on the list. The moment you grant any built-in, the list is the whole answer, so +add the connector grants you want to keep (granting only `memory.write` to an agent +that proposed triggers from a messaging app turns those off; nothing is deleted +and they work again when you grant `triggers.propose`). The `act.*` grants are +recorded but not enforced yet: the action policy of unattended runs that uses them +is still to come. + ### Export and import ```