mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
A write tool of an add-on that holds a credential was protected only by the write mode of the connection and the confirmation of the proposal. In a connector chat it now also needs the capability tools.write for the sender of the turn, through the same Authorize path as the other capabilities: the tool is not offered without it, a call is checked again and every decision is audited. The default is no. The rule is by shape, not by name. The manifest says an add-on holds a credential, and contract.ToolPermission maps its non-read tools to tools.write. The chat reads the permission from the tool definition, so the core names no add-on. Also close a hole in local-owner mode, where a chat turn and the browser share one credential: a proposal that waits for a person could be confirmed from a chat turn. The call of a chat tool now carries a mark, and the owner refuses to confirm such a proposal under it. A remote owner does not see the mark; local-owner mode has no remote owner. Assisted-by: Claude:claude-sonnet-5-5 go test
210 lines
8.2 KiB
Go
210 lines
8.2 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package contract
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
)
|
|
|
|
// MinPermissionsAPI is the first manifest API version that knows the roles and
|
|
// permissions of a connector kind.
|
|
const MinPermissionsAPI uint64 = 2
|
|
|
|
// Limits of the permissions of a connector kind.
|
|
const (
|
|
MaxConnectorRoles = 8
|
|
MaxConnectorPermissions = 12
|
|
// MaxMessageRoles is how many role IDs one ConnectorMessage carries.
|
|
MaxMessageRoles = 8
|
|
)
|
|
|
|
// Capability names. The host owns this closed list. The same names are used
|
|
// for what an owner enables on a binding, what an owner grants to a role of a
|
|
// kind, and what a kind declares that it can grant.
|
|
const (
|
|
CapProposalsConfirm = "proposals.confirm"
|
|
CapMemoryWrite = "memory.write"
|
|
CapFilesUpload = "files.upload"
|
|
CapTriggersPropose = "triggers.propose"
|
|
CapTriggersRun = "triggers.run"
|
|
CapTalkbackChannels = "talkback.channels"
|
|
CapTalkbackNewChan = "talkback.new_channel"
|
|
CapProfileWrite = "profile.write"
|
|
CapSkillsWrite = "skills.write"
|
|
CapPagesWrite = "pages.write"
|
|
CapDashboardWrite = "dashboard.write"
|
|
// CapToolsWrite lets a sender make an add-on tool change something outside
|
|
// LocalAI, such as a comment on an issue. It is for the tools of a kind
|
|
// whose binding holds a credential.
|
|
CapToolsWrite = "tools.write"
|
|
)
|
|
|
|
// capabilities maps each name to whether it is sensitive. A sensitive
|
|
// capability is never granted to a role: only a paired sender can use it.
|
|
var capabilities = map[string]bool{
|
|
CapProposalsConfirm: false,
|
|
CapMemoryWrite: false,
|
|
CapFilesUpload: false,
|
|
CapTriggersPropose: false,
|
|
CapTriggersRun: false,
|
|
CapTalkbackChannels: false,
|
|
CapTalkbackNewChan: true,
|
|
CapProfileWrite: true,
|
|
CapSkillsWrite: true,
|
|
CapPagesWrite: true,
|
|
CapDashboardWrite: true,
|
|
CapToolsWrite: false,
|
|
}
|
|
|
|
// KnownCapability reports whether name is in the closed list of capabilities.
|
|
func KnownCapability(name string) bool { _, ok := capabilities[name]; return ok }
|
|
|
|
// SensitiveCapability reports whether name is a sensitive capability.
|
|
func SensitiveCapability(name string) bool { return capabilities[name] }
|
|
|
|
// ConnectorCapabilityNames returns the closed list, sorted.
|
|
func ConnectorCapabilityNames() []string {
|
|
out := make([]string, 0, len(capabilities))
|
|
for n := range capabilities {
|
|
out = append(out, n)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// CapabilityInfo describes one capability for a settings page: a plain title
|
|
// and a sentence that says what the owner allows. It adds no new rules.
|
|
type CapabilityInfo struct {
|
|
Name string `json:"name"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Sensitive bool `json:"sensitive"`
|
|
// Usual marks the capabilities that a settings page turns on with its
|
|
// one-click preset. A sensitive capability is never usual.
|
|
Usual bool `json:"usual"`
|
|
}
|
|
|
|
// capabilityText is the title and description of each capability. Titles are
|
|
// short verbs; descriptions say what changes and who can cause it.
|
|
var capabilityText = map[string][2]string{
|
|
CapProposalsConfirm: {"Confirm changes from the chat", "A sender can confirm a change that the agent proposed, such as a memory or a trigger, by answering in the same chat."},
|
|
CapMemoryWrite: {"Save to memory", "The agent may remember what a sender asks it to remember."},
|
|
CapFilesUpload: {"Receive files", "The agent may read files that senders attach to a message."},
|
|
CapTriggersPropose: {"Propose triggers", "The agent may propose a timer or an event trigger when a sender asks for one."},
|
|
CapTriggersRun: {"Fire event triggers", "A message from a sender may start a run of an event trigger."},
|
|
CapTalkbackChannels: {"Post to approved channels", "The agent may write to the approved channels below when a sender asks, besides the chat it answers in."},
|
|
CapTalkbackNewChan: {"Post to a new channel", "The agent may write to a channel that is not approved yet."},
|
|
CapProfileWrite: {"Change the agent profile", "The agent may change its own profile."},
|
|
CapSkillsWrite: {"Write skills", "The agent may write or change skills."},
|
|
CapPagesWrite: {"Write pages", "The agent may write or change pages."},
|
|
CapDashboardWrite: {"Write the dashboard", "The agent may change the dashboard."},
|
|
CapToolsWrite: {"Change things outside LocalAI", "An add-on tool may change something with the credential of the binding, such as a comment on an issue."},
|
|
}
|
|
|
|
// usualCapabilities are what an owner normally allows on a new binding: the
|
|
// everyday ones that stay inside LocalAI and the approved channels.
|
|
var usualCapabilities = map[string]bool{
|
|
CapProposalsConfirm: true, CapMemoryWrite: true, CapFilesUpload: true,
|
|
CapTriggersPropose: true, CapTriggersRun: true, CapTalkbackChannels: true,
|
|
}
|
|
|
|
// ConnectorCapabilities returns the closed list with its texts, in the order of
|
|
// ConnectorCapabilityNames.
|
|
func ConnectorCapabilities() []CapabilityInfo {
|
|
names := ConnectorCapabilityNames()
|
|
out := make([]CapabilityInfo, 0, len(names))
|
|
for _, n := range names {
|
|
text := capabilityText[n]
|
|
out = append(out, CapabilityInfo{Name: n, Title: text[0], Description: text[1], Sensitive: capabilities[n], Usual: usualCapabilities[n] && !capabilities[n]})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ToolPermission is the capability that a sender needs to use a tool of an
|
|
// add-on in a connector chat, or "" when the tool needs none beyond the
|
|
// binding. The rule is by shape, so the host names no add-on: a tool that
|
|
// writes, in an add-on that holds a credential for the person's binding,
|
|
// changes something outside LocalAI with that person's token, and needs
|
|
// CapToolsWrite. credentialed says that the manifest declares credentials.
|
|
func ToolPermission(credentialed bool, d ToolDefinition) string {
|
|
if credentialed && d.Kind != ToolKindRead {
|
|
return CapToolsWrite
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ConnectorRole is a role that a kind can give to a sender, such as the admin
|
|
// of a workspace. The add-on decides who has which role and sends the role IDs
|
|
// with each message; the owner decides what each role may do.
|
|
type ConnectorRole struct {
|
|
// ID names the role in ConnectorMessage.Roles. It has the shape of an
|
|
// add-on namespace ID.
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
// Role returns the role of the kind with the given ID.
|
|
func (k ConnectorKind) Role(id string) (ConnectorRole, bool) {
|
|
for _, r := range k.Roles {
|
|
if r.ID == id {
|
|
return r, true
|
|
}
|
|
}
|
|
return ConnectorRole{}, false
|
|
}
|
|
|
|
// Grantable reports whether the kind lists the capability and the capability
|
|
// may go to a role: it is known and not sensitive.
|
|
func (k ConnectorKind) Grantable(capability string) bool {
|
|
return KnownCapability(capability) && !SensitiveCapability(capability) && slices.Contains(k.Permissions, capability)
|
|
}
|
|
|
|
// FilterRoles keeps the role IDs of a message that the kind declares, without
|
|
// repeats and at most MaxMessageRoles. A kind that declares no roles keeps none.
|
|
func (k ConnectorKind) FilterRoles(ids []string) []string {
|
|
var out []string
|
|
for _, id := range ids {
|
|
if len(out) == MaxMessageRoles {
|
|
break
|
|
}
|
|
if _, ok := k.Role(id); ok && !slices.Contains(out, id) {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validatePermissions(k ConnectorKind, api VersionRange) error {
|
|
if len(k.Roles) == 0 && len(k.Permissions) == 0 {
|
|
return nil
|
|
}
|
|
if api.Max < MinPermissionsAPI {
|
|
return fmt.Errorf("connector roles and permissions need api %d", MinPermissionsAPI)
|
|
}
|
|
if len(k.Roles) > MaxConnectorRoles {
|
|
return fmt.Errorf("a connector kind has at most %d roles", MaxConnectorRoles)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, r := range k.Roles {
|
|
if !namespace.MatchString(r.ID) || seen[r.ID] || !plainText(r.Title, MaxTitleChars) || (r.Description != "" && !plainText(r.Description, MaxDescriptionChars)) {
|
|
return fmt.Errorf("invalid or repeated connector role")
|
|
}
|
|
seen[r.ID] = true
|
|
}
|
|
if len(k.Permissions) > MaxConnectorPermissions {
|
|
return fmt.Errorf("a connector kind lists at most %d permissions", MaxConnectorPermissions)
|
|
}
|
|
listed := map[string]bool{}
|
|
for _, p := range k.Permissions {
|
|
if !KnownCapability(p) {
|
|
return fmt.Errorf("unknown connector permission %q", p)
|
|
}
|
|
if listed[p] {
|
|
return fmt.Errorf("repeated connector permission %q", p)
|
|
}
|
|
listed[p] = true
|
|
}
|
|
return nil
|
|
}
|