mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
The capabilities, role grants and pairing that a connector binding
stores now decide what a sender can make the agent do.
Authorize is one pure function: the capability must be in the owner's
ceiling for the binding and the sender must be on the allowlist. A
sensitive capability then needs a paired sender; roles never count for
it. Any other capability needs a pairing or a role that the owner
granted and that the kind lists. The default is no.
Each message gets a grant for its sender. A message that joins a
running turn narrows it to what both senders may do. The tool builder
offers a gated tool only for a held capability, and the tool checks
again when the model calls it, so a call that gets through is refused
with the reason. Today this covers propose_change (memory and profile
kinds), revert_change, the trigger tools, and files sent to the bot. A
chat worker gets the grant with the start request.
The capability gate that the trigger features ask is now answered by
Authorize, and the application sets it. Other features can add a
global switch with AddCapabilitySwitch.
Every use records an allow or deny row (time, binding, sender IDs,
roles, capability, result; no text or codes). GET
/api/chats/connectors/{id}/access returns the effective permissions
per kind of sender and the latest rows, for an add-on settings page.
A binding saved before this change has no capabilities, so its senders
lose these features until the owner enables them.
Assisted-by: Claude:claude-sonnet-5-5 go swag
417 lines
16 KiB
Go
417 lines
16 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
|
"github.com/mudler/LocalAI/core/services/studio/hostexec"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/core/services/studio/store"
|
|
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
|
)
|
|
|
|
// The built-in bash runs commands directly on this machine, with no isolation
|
|
// and with the network of the server (see hostexec). It serves when no Sandbox
|
|
// add-on does: none is installed, it is switched off for the chat, stopped, or
|
|
// cannot run. An administrator chooses with chats_host_exec who gets it: off,
|
|
// ask (administrators, and each command waits for their yes in the chat) or
|
|
// on (everyone, no card).
|
|
|
|
// HostExec runs the built-in bash. *hostexec.Runner implements it.
|
|
type HostExec interface {
|
|
// Unavailable says why this server cannot run commands, or "".
|
|
Unavailable() string
|
|
// Tool is the tool definition of a chat.
|
|
Tool(a workspace.Actor) session.Tool
|
|
}
|
|
|
|
var _ HostExec = (*hostexec.Runner)(nil)
|
|
|
|
// HostExecCardKind is the card of a call of the built-in bash. Its ID says
|
|
// what happened: ran (no card was needed), once (the person allowed it),
|
|
// denied, timeout (nobody answered), admin (only an administrator can allow
|
|
// it). The interface shows it on the tool line, so a reload still says that
|
|
// the command ran on this machine and who allowed it.
|
|
const HostExecCardKind = "host_exec"
|
|
|
|
// The answers of the consent card.
|
|
const (
|
|
HostExecOnce = "once"
|
|
HostExecDeny = "deny"
|
|
)
|
|
|
|
// HostExecDecision is the answer of the consent card, sent on the chat bus to
|
|
// the frontend that runs the turn.
|
|
type HostExecDecision struct {
|
|
ID string `json:"id"`
|
|
Decision string `json:"decision"`
|
|
}
|
|
|
|
// ErrHostExecDecision means the person who sent the answer may not allow
|
|
// commands on this machine.
|
|
var ErrHostExecDecision = errors.New("studio: only an administrator can allow commands on this machine")
|
|
|
|
// ErrHostExecBadDecision means the answer names no card or is not one the card
|
|
// offers. It is a bad request, not a refusal: an administrator who sends it
|
|
// is not denied anything.
|
|
var ErrHostExecBadDecision = errors.New("studio: the answer must name the card and be once or deny")
|
|
|
|
// ErrHostExecExpired means the card has nobody waiting on it: the turn that
|
|
// asked ended (or its frontend died), or a newer card replaced it. The
|
|
// command did not run, and no answer can run it now.
|
|
var ErrHostExecExpired = errors.New("studio: the card expired; the command did not run")
|
|
|
|
// ErrHostExecAnswered means the card was answered already with the other
|
|
// answer. The same answer again is not an error.
|
|
var ErrHostExecAnswered = errors.New("studio: the card was answered already")
|
|
|
|
// hostExecWait is how long a command waits for the card. The card is kept with
|
|
// the chat, so a reload shows it again; the call ends with "no answer" when
|
|
// nobody answers in time.
|
|
var hostExecWait = 5 * time.Minute
|
|
|
|
func (s *Service) hostExecMode() string {
|
|
if s.d.HostExecMode == nil {
|
|
return hostexec.ModeOff
|
|
}
|
|
switch m := s.d.HostExecMode(); m {
|
|
case hostexec.ModeAsk, hostexec.ModeOn:
|
|
return m
|
|
}
|
|
return hostexec.ModeOff
|
|
}
|
|
|
|
func (s *Service) isAdmin(ctx context.Context, user string) bool {
|
|
if s.d.IsAdmin == nil {
|
|
return true // no accounts: the one person is the administrator
|
|
}
|
|
return s.d.IsAdmin(ctx, user)
|
|
}
|
|
|
|
// registerHostBash adds bash to the registry of built-in tools, in the
|
|
// switch group GroupShell. The group's switch hides bash whoever serves it, so
|
|
// the tool is registered also on a server that cannot run commands itself:
|
|
// the Sandbox add-on may serve it. Settings > Tools lists it only where the
|
|
// built-in can run.
|
|
func (s *Service) registerHostBash() error {
|
|
host := s.d.HostExec != nil
|
|
return s.builtins.Register(builtins.Tool{
|
|
ID: commandTool,
|
|
Group: builtins.GroupShell,
|
|
Label: "Commands (bash)",
|
|
Description: hostBashDescription,
|
|
Setting: builtins.SettingHostExec,
|
|
Unlisted: !host,
|
|
Replaceable: true,
|
|
Covers: true,
|
|
Enabled: func() bool { return host && s.hostExecMode() != hostexec.ModeOff },
|
|
Build: s.buildHostBash,
|
|
})
|
|
}
|
|
|
|
// hostBashDescription is the line of Settings > Tools for the built-in bash.
|
|
// It says that an add-on can replace it and names none.
|
|
const hostBashDescription = "Runs commands and code for the chats directly on this machine, with no isolation: commands can read what LocalAI can read " +
|
|
"and reach the internet and your network. An add-on that runs commands in isolation can replace it."
|
|
|
|
// scopedHostBashReason is why a chat whose agent works in one folder has no
|
|
// built-in bash (D10 of the agent folder design): a command on this machine
|
|
// reads what the server can read, and no workspace rule confines it.
|
|
const scopedHostBashReason = "this agent works in one folder, and commands on this machine could read files outside it; it runs commands only in an add-on that isolates them"
|
|
|
|
// buildHostBash gives a chat the built-in bash when the setting allows it.
|
|
// A chat whose actor is fenced (its agent, or its parent's, has a folder)
|
|
// never gets it.
|
|
func (s *Service) buildHostBash(_ context.Context, c builtins.Chat) (session.Tool, bool, string) {
|
|
if s.d.HostExec == nil {
|
|
return session.Tool{}, false, "this server cannot run commands by itself"
|
|
}
|
|
if workspace.HasFence(c.Actor) {
|
|
return session.Tool{}, false, scopedHostBashReason
|
|
}
|
|
if r := s.d.HostExec.Unavailable(); r != "" {
|
|
return session.Tool{}, false, r
|
|
}
|
|
if !s.d.Files.Available() {
|
|
return session.Tool{}, false, "the files are off"
|
|
}
|
|
mode := s.hostExecMode()
|
|
if c.Origin != "" && !c.Agent {
|
|
// An unattended run without an agent never gets a tool that acts at
|
|
// once, as with the add-ons' action tools.
|
|
return session.Tool{}, false, "an unattended run without an agent"
|
|
}
|
|
if mode == hostexec.ModeAsk {
|
|
switch {
|
|
case c.SubAgent || c.Origin != "":
|
|
return session.Tool{}, false, "each command needs a person's yes, and nobody watches this chat"
|
|
case !c.Admin:
|
|
return session.Tool{}, false, "only an administrator can allow commands on this machine"
|
|
}
|
|
}
|
|
tool := s.d.HostExec.Tool(c.Actor)
|
|
a := c.Actor
|
|
run := s.withCommandFiles(a, tool.Run)
|
|
tool.Run = func(ctx context.Context, args json.RawMessage) (string, error) {
|
|
return s.runHostBash(ctx, a, run, args)
|
|
}
|
|
return tool, true, ""
|
|
}
|
|
|
|
// hostExecCardLimit is the longest command the approval card shows in full.
|
|
const hostExecCardLimit = 2000
|
|
|
|
// runHostBash runs one call, after the card when the mode asks for one.
|
|
func (s *Service) runHostBash(ctx context.Context, a workspace.Actor, run func(context.Context, json.RawMessage) (string, error), args json.RawMessage) (string, error) {
|
|
outcome := "ran"
|
|
switch s.hostExecMode() {
|
|
case hostexec.ModeOff:
|
|
return "Commands on this machine were switched off by an administrator. Do not try again.", nil
|
|
case hostexec.ModeAsk:
|
|
if !s.isAdmin(ctx, a.Principal) {
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "admin"})
|
|
return "Not run: only an administrator can allow commands on this machine. Tell the user to ask one, or give them the command to run.", nil
|
|
}
|
|
var in struct {
|
|
Command string `json:"command"`
|
|
}
|
|
_ = json.Unmarshal(args, &in)
|
|
// The card shows the whole command. A longer one is refused, so the
|
|
// person never approves text they could not read.
|
|
if len(in.Command) > hostExecCardLimit {
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "denied"})
|
|
return fmt.Sprintf("Not run: the command is longer than %d bytes, too long for the user to read before they allow it. Do not try again as it is; split it into shorter commands, or put the script in a file first.", hostExecCardLimit), nil
|
|
}
|
|
switch d := s.askHostExec(ctx, a, in.Command); d {
|
|
case HostExecOnce:
|
|
outcome = "once"
|
|
case "stopped":
|
|
return "", ctx.Err()
|
|
case "timeout":
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "timeout"})
|
|
return "Not run: the user did not answer the card that asks to run this command on this machine. Do not try again; ask them.", nil
|
|
default:
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: "denied"})
|
|
return "Not run: the user did not allow this command on this machine. Do not try again; give them the command, or ask what to do.", nil
|
|
}
|
|
}
|
|
out, err := run(ctx, args)
|
|
if err == nil {
|
|
session.SetToolCard(ctx, session.ToolCard{Kind: HostExecCardKind, ID: outcome})
|
|
// What the command wrote is in the user's files now.
|
|
s.bus.Publish(key(a.Principal, a.ChatID), session.Event{Kind: session.KindFiles})
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// askHostExec shows the card and waits for its answer: once, deny, timeout,
|
|
// or stopped when the turn ends first. The answer is stored with the card
|
|
// before it goes on the chat bus; the call wakes on the message, or finds the
|
|
// answer when it reads the card again every two seconds, as the network card
|
|
// does, so a lost message does not cost the person's click.
|
|
func (s *Service) askHostExec(ctx context.Context, a workspace.Actor, command string) string {
|
|
id := newID()
|
|
k := key(a.Principal, a.ChatID)
|
|
wake, cancel := s.listenConsent(k, func(m ControlMsg) bool { return m.HostExec != nil && m.HostExec.ID == id })
|
|
defer cancel()
|
|
// The card is kept with the chat while it waits, so a reload, or another
|
|
// frontend, shows it again and can answer it. It names the turn that
|
|
// asked, so a card that a dead frontend leaves behind is not taken for a
|
|
// waiting one.
|
|
turn := consentTurn(s.chatTurn(ctx, a.Principal, a.ChatID))
|
|
s.setHostConsent(ctx, a, &session.HostConsent{ID: id, Command: command, Turn: turn, AskedAt: s.now().UTC()})
|
|
defer s.clearHostConsent(context.WithoutCancel(ctx), a.Principal, a.ChatID, id)
|
|
s.bus.Publish(k, session.Event{Kind: session.KindHostExecConsent, IDs: []string{id}, Text: command})
|
|
decision := ""
|
|
if s.waitConsent(ctx, wake, hostExecWait, func() bool {
|
|
rec, err := s.d.Store.GetChat(ctx, a.Principal, a.ChatID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
c := rec.HostConsent
|
|
switch {
|
|
case c == nil || c.ID != id:
|
|
// Cleared by the next turn or the reaper: this turn is over.
|
|
decision = "timeout"
|
|
case c.Decision != "":
|
|
decision = c.Decision
|
|
default:
|
|
return false
|
|
}
|
|
return true
|
|
}) {
|
|
return decision
|
|
}
|
|
if ctx.Err() != nil {
|
|
return "stopped"
|
|
}
|
|
return "timeout"
|
|
}
|
|
|
|
// clearHostConsent clears the card id, and leaves a newer card alone.
|
|
func (s *Service) clearHostConsent(ctx context.Context, p, conv, id string) {
|
|
err := s.d.Store.UpdateHostConsent(ctx, p, conv, func(cur *session.HostConsent) (*session.HostConsent, error) {
|
|
if cur == nil || cur.ID != id {
|
|
return cur, nil
|
|
}
|
|
return nil, nil
|
|
})
|
|
if err != nil {
|
|
slog.Warn("Chats: could not clear the consent card of the chat", "chat", conv, "error", err)
|
|
}
|
|
}
|
|
|
|
// liveHostConsent returns the card of rec when it still has a waiter, by the
|
|
// rule the network cards follow; nil otherwise.
|
|
func (s *Service) liveHostConsent(rec session.ChatRecord, info store.TurnInfo) *session.HostConsent {
|
|
c := rec.HostConsent
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
asked := c.AskedAt
|
|
if asked.IsZero() {
|
|
// A card stored before cards named their turn: trust the turn alone.
|
|
asked = s.now()
|
|
if !info.Running {
|
|
return nil
|
|
}
|
|
}
|
|
if !consentLive(c.Turn, asked, hostExecWait, info, s.now()) {
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
// HostConsent returns the consent card of the chat that waits for an answer,
|
|
// or nil: a card whose turn is over, or that a frontend that died left, is
|
|
// not shown. The chat of another user is session.ErrNotFound.
|
|
func (s *Service) HostConsent(ctx context.Context, user string, rec session.ChatRecord, info store.TurnInfo) *session.HostConsent {
|
|
if rec.Principal != principal(user) {
|
|
return nil
|
|
}
|
|
c := s.liveHostConsent(rec, info)
|
|
if c == nil || c.Decision != "" {
|
|
// An answered card is about to go: the call has its answer.
|
|
return nil
|
|
}
|
|
return c
|
|
}
|
|
|
|
// setHostConsent stores or clears the card that waits. A failure only costs
|
|
// the card after a reload; the live card still works.
|
|
func (s *Service) setHostConsent(ctx context.Context, a workspace.Actor, c *session.HostConsent) {
|
|
if err := s.d.Store.SetHostConsent(ctx, a.Principal, a.ChatID, c); err != nil {
|
|
slog.Warn("Chats: could not keep the consent card with the chat", "chat", a.ChatID, "error", err)
|
|
}
|
|
}
|
|
|
|
// HostExecDecide answers the consent card of a chat. Only an administrator
|
|
// may allow a command; anyone who owns the chat may refuse it. The chat of
|
|
// another user is session.ErrNotFound.
|
|
func (s *Service) HostExecDecide(ctx context.Context, user, conv, id, decision string) error {
|
|
p := principal(user)
|
|
rec, err := s.d.Store.GetChat(ctx, p, conv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id == "" || len(id) > 64 {
|
|
return ErrHostExecBadDecision
|
|
}
|
|
switch decision {
|
|
case HostExecDeny:
|
|
case HostExecOnce:
|
|
if !s.isAdmin(ctx, p) {
|
|
return ErrHostExecDecision
|
|
}
|
|
default:
|
|
return ErrHostExecBadDecision
|
|
}
|
|
// An answer to a card nobody waits on reaches no one: say so, rather
|
|
// than a success the command never follows.
|
|
if c := s.liveHostConsent(rec, s.chatTurn(ctx, p, conv)); c == nil || c.ID != id {
|
|
return ErrHostExecExpired
|
|
}
|
|
// The answer goes to the record first, then on the bus: the waiting call
|
|
// finds it on its next read even when the message is lost.
|
|
again := false
|
|
err = s.d.Store.UpdateHostConsent(ctx, p, conv, func(cur *session.HostConsent) (*session.HostConsent, error) {
|
|
switch {
|
|
case cur == nil || cur.ID != id:
|
|
return cur, ErrHostExecExpired
|
|
case cur.Decision == decision:
|
|
again = true // a double click: it is applied, say so again
|
|
return cur, nil
|
|
case cur.Decision != "":
|
|
return cur, ErrHostExecAnswered
|
|
}
|
|
cur.Decision, cur.AnsweredAt = decision, s.now().UTC()
|
|
return cur, nil
|
|
})
|
|
if err != nil || again {
|
|
return err
|
|
}
|
|
s.bus.Control(key(p, conv), ControlMsg{HostExec: &HostExecDecision{ID: id, Decision: decision}})
|
|
return nil
|
|
}
|
|
|
|
// Builtins returns the registry of the built-in tools (bash, web_search,
|
|
// web_fetch), for Settings > Tools and for other features to register theirs.
|
|
func (s *Service) Builtins() *builtins.Registry { return s.builtins }
|
|
|
|
// builtinsFor builds the built-in tools of a chat.
|
|
func (s *Service) builtinsFor(ctx context.Context, a workspace.Actor, rec session.ChatRecord, subAgent bool) []builtins.Built {
|
|
off, on := s.switchesOf(ctx, rec)
|
|
chat := builtins.Chat{Actor: a, Origin: rec.Origin, OriginID: rec.OriginID, Agent: rec.AgentID != "", AgentID: rec.AgentID, Model: rec.Model, SubAgent: subAgent,
|
|
Admin: s.isAdmin(ctx, a.Principal), GroupsOff: off, GroupsOn: on, GoalActive: goalActive(rec) && !subAgent}
|
|
if rec.Origin == session.OriginConnector {
|
|
// A connector chat acts for the sender of the turn, not for its owner:
|
|
// the tools that need a right ask what that sender holds.
|
|
chat.Allow, chat.Audit = s.turnAllow(a.ChatID)
|
|
}
|
|
return s.builtins.Build(ctx, chat)
|
|
}
|
|
|
|
// applyBuiltins adds the built-in tools that no add-on serves. When the
|
|
// built-in bash is added, the stand-in of an add-on that runs commands but
|
|
// cannot now is left out: bash serves instead, and the Tools menu says so.
|
|
func (s *Service) applyBuiltins(tools []session.Tool, built []builtins.Built) []session.Tool {
|
|
var add []session.Tool
|
|
for _, b := range built {
|
|
if b.OK {
|
|
add = append(add, b.Tool)
|
|
}
|
|
}
|
|
tools = withBuiltins(tools, add...)
|
|
if providerOf(tools, "bash") != BuiltinProvider {
|
|
return tools
|
|
}
|
|
out := tools[:0:0]
|
|
for _, t := range tools {
|
|
if standInRunner([]session.Tool{t}) != "" {
|
|
continue
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// runsCommands reports whether a tool list has a tool that runs commands: an
|
|
// add-on's or the built-in bash.
|
|
func (s *Service) runsCommands(tools []session.Tool) bool {
|
|
if addonRunner(tools) != "" {
|
|
return true
|
|
}
|
|
return providerOf(tools, "bash") == BuiltinProvider
|
|
}
|
|
|
|
// SharedRoots lists the shared folders a user may use, for the scratch
|
|
// folder of the built-in bash.
|
|
func (s *Service) SharedRoots(ctx context.Context, a workspace.Actor) ([]session.SharedRoot, error) {
|
|
return s.shared.Roots(ctx, a)
|
|
}
|