Files
LocalAI/pkg/addons/contract/usersettings.go
T
Ettore Di Giacinto 31aa996272 feat(addons): let the owner relax the confirmation of a write tool
A write tool with approval "always" always waits for a person. A tool can
now also declare approvalOverridable, and the owner can then choose not to
be asked for that one tool, with a user setting approval-<tool> (ask or
skip, default ask). It is the only way to relax the ceiling.

The host honors the setting only for the owner's own chat and for a
messaging chat whose senders are all paired with the bot. Allowlisted
but unpaired senders, anyone-mode senders, roles, triggers, jobs, pages,
sub-agents and calls without a chat still wait for a person. The setting
is read again at the apply. Every run without a card is logged with the
tool, the connection and the outcome, never the arguments, and is added
to the latest decisions of the connection. The access view and the
access preview list the relaxable tools and who they run for.

Assisted-by: Claude:claude-sonnet-5-5
2026-11-05 10:01:46 +00:00

172 lines
6.0 KiB
Go

// SPDX-License-Identifier: MIT
package contract
import (
"fmt"
"regexp"
"slices"
)
// Limits of the per-user settings an add-on declares.
const (
// MaxUserSettings is the most settings one add-on declares.
MaxUserSettings = 8
// MaxUserSettingValues is the most values one setting allows.
MaxUserSettingValues = 8
)
var (
userSettingKey = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
userSettingValue = regexp.MustCompile(`^[a-z][a-z0-9-]{0,39}$`)
)
// ValidUserSettingKey reports whether s can be the key of a user setting.
func ValidUserSettingKey(s string) bool { return userSettingKey.MatchString(s) }
// UserSetting declares one choice that each person makes for an add-on, such
// as how the add-on behaves for them. The add-on only names the key and the
// values it understands. The host keeps the person's answer (it is not a file
// of the add-on), checks every write against this declaration and reads it
// for its own rules. A person who has not chosen has the Default.
//
// A setting is for the person who set it: it never applies to another person,
// and a connector chat or an unattended run does not read it as a widening of
// their authority; a host rule that reads a setting says how it uses it.
type UserSetting struct {
// Key names the setting inside the add-on: lowercase letters, digits and
// dashes, at most 40.
Key string `json:"key"`
// Values are the allowed answers, in the order the add-on lists them. Each
// one has the same form as the key.
Values []string `json:"values"`
// Default is the answer of a person who has not chosen. It is one of Values.
Default string `json:"default"`
// Role names what the host does with the answer, when the host reads it
// itself. Empty: the setting is only for the add-on's own page. A known
// role fixes the values and the default (see roleRules), so the manifest
// cannot make the host more permissive than its own default.
Role string `json:"role,omitempty"`
}
// The roles a user setting can play for the host. The host defines the values
// and the default of each role.
const (
// UserSettingRoleMemorySave is how a memory note that a model proposes is
// saved: ask (the person confirms), auto (saved at once, with an undo) or
// never. The host reads it for the memory kind of propose_change.
UserSettingRoleMemorySave = "memory.save-mode"
)
// roleRules are the values and the default that each role needs.
var roleRules = map[string]struct {
values []string
deflt string
}{
UserSettingRoleMemorySave: {values: []string{"ask", "auto", "never"}, deflt: "ask"},
}
// Allows reports whether v is one of the allowed values.
func (s UserSetting) Allows(v string) bool { return slices.Contains(s.Values, v) }
func (s UserSetting) validate() error {
bad := func(format string, a ...any) error {
return fmt.Errorf("user setting %q: %s", s.Key, fmt.Sprintf(format, a...))
}
if !userSettingKey.MatchString(s.Key) {
return bad("the key is lowercase letters, digits and dashes, at most 40, and starts with a letter")
}
if len(s.Values) < 2 || len(s.Values) > MaxUserSettingValues {
return bad("it needs 2 to %d values", MaxUserSettingValues)
}
seen := map[string]bool{}
for _, v := range s.Values {
if !userSettingValue.MatchString(v) {
return bad("the value %q is lowercase letters, digits and dashes, at most 40, and starts with a letter", v)
}
if seen[v] {
return bad("the value %q is listed twice", v)
}
seen[v] = true
}
if !seen[s.Default] {
return bad("the default must be one of the values")
}
if s.Role != "" {
rule, ok := roleRules[s.Role]
if !ok {
return bad("the role %q is not one the host knows", s.Role)
}
if s.Default != rule.deflt || len(s.Values) != len(rule.values) {
return bad("the role %q takes the values %v and the default %q", s.Role, rule.values, rule.deflt)
}
for _, v := range rule.values {
if !seen[v] {
return bad("the role %q takes the values %v and the default %q", s.Role, rule.values, rule.deflt)
}
}
}
return nil
}
// UserSettingOf returns the declaration of key in the list.
func UserSettingOf(list []UserSetting, key string) (UserSetting, bool) {
for _, s := range list {
if s.Key == key {
return s, true
}
}
return UserSetting{}, false
}
// validateUserSettings checks the declaration against the manifest.
func validateUserSettings(list []UserSetting) error {
if len(list) == 0 {
return nil
}
if len(list) > MaxUserSettings {
return fmt.Errorf("too many user settings: at most %d", MaxUserSettings)
}
seen, roles := map[string]bool{}, map[string]bool{}
for _, s := range list {
if err := s.validate(); err != nil {
return err
}
if seen[s.Key] {
return fmt.Errorf("user setting %q is declared twice", s.Key)
}
seen[s.Key] = true
if s.Role != "" {
if roles[s.Role] {
return fmt.Errorf("user setting %q: the role %q is taken by another setting of this add-on", s.Key, s.Role)
}
roles[s.Role] = true
}
}
return nil
}
// validateApprovalSettings checks that every tool which the person may relax
// has its setting, with the values and the default the host expects: the
// default must be to ask, so a manifest cannot start with the confirmation off.
func validateApprovalSettings(defs []ToolDefinition, list []UserSetting, credentialed bool) error {
for _, d := range defs {
if !d.ApprovalOverridable {
continue
}
if !credentialed {
// Only a call under a connection of the person has an owner whose
// setting the host can read for it.
return fmt.Errorf("tool %s: approvalOverridable is for an add-on that declares a credential", d.Name)
}
key := ApprovalSettingKey(d.Name)
s, ok := UserSettingOf(list, key)
if !ok {
return fmt.Errorf("tool %s: approvalOverridable needs the user setting %q", d.Name, key)
}
if s.Default != ApprovalAsk || len(s.Values) != 2 || !s.Allows(ApprovalAsk) || !s.Allows(ApprovalSkip) || s.Role != "" {
return fmt.Errorf("tool %s: the user setting %q takes the values %q and %q, the default %q and no role", d.Name, key, ApprovalAsk, ApprovalSkip, ApprovalAsk)
}
}
return nil
}