Files
LocalAI/core/services/studio/hostbash.go
T
Ettore Di Giacinto 24c837e453 feat(studio): grant built-in capabilities to a saved agent
A saved agent now has a Builtins list: the capabilities its owner gave
it (memory.write, profile.write, skills.write, pages.write,
dashboard.write, triggers.propose, triggers.run, talkback.channels,
talkback.new_channel, files.upload, act.files, act.addons, act.send,
act.spend and the opt-in group saved_agents). The list is empty by
default, and nothing turns a grant on for a template, an import or a
write that omits the field.

A chat of the agent holds the grants the user may use at the turn
(agent scope). A tool exists when the grant is held, the chat did not
switch its group off and the run can hold rights: a sub-agent or a run
that started by itself never does. propose_change and revert_change
now exist only for a chat that holds the grant of at least one kind,
and the schema lists only those kinds.

propose_change gains the kinds skill, page and dashboard. A skill is a
SKILL.md for the personal or the agent layer, and a skill with
locked: true is refused, also as a shadow of a locked personal skill.
A page is a set of files checked against the Pages rules and written
as one change set. The dashboard is a home.json checked against its
schema. All three are re-checked when the owner confirms or edits.

GET /api/chats/agents/builtin-grants lists each grant with its title,
description and sensitive flag. The agent record, the write body and
the export file carry builtins; an old file imports as before and an
unknown name is dropped in an import and refused in a write.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt swag
2026-11-03 14:56:42 +00:00

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, Grants: s.chatGrants(ctx, rec), 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)
}