feat(connectors): read the builtin grants of the agent at the connector gate

The grants of an agent were stored and listed, but only the proposal kinds
read them. The connector gate now reads them too, so a tool exists when
the sender holds the capability on the binding, the agent holds the grant
and the chat did not switch the tool off.

It covers triggers.propose, triggers.run, talkback.channels,
talkback.new_channel and files.upload. Every question that goes through
the gate asks the grants: the capabilities of a turn, the check of a
feature that has a sender, and a run without a sender, so a trigger that
fires from a message pauses when the grant goes.

An agent with no grant list keeps the capabilities that are not
sensitive, as before. A sensitive one (talkback.new_channel) needs the
explicit grant. Once any grant is on the list, the list is the whole
answer. The act.* grants are not enforced: the action policy of unattended
runs that uses them does not exist yet.

Assisted-by: Claude:claude-sonnet-5-5 go test
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 15:41:45 +00:00
1 parent 2b588a7350
commit 164d51f263
7 files changed
+495 -3

No files matched your search

+90
View File
@@ -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)
}
}
@@ -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())
}
})
}
})
})
@@ -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)
}
})
})
+1 -1
View File
@@ -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.
@@ -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())
@@ -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 })
+14
View File
@@ -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
```