Files
LocalAI/core/services/studio/actgate.go
T
Ettore Di Giacinto d8bd3879f2 feat(studio): let a trigger proposal carry its acts, shown before the yes
A trigger run with nobody present only reads unless its trigger holds
act.* grants, and until now only the REST API could set them. An agent
that proposes a trigger can now ask for acts too:

- propose_trigger takes an optional acts list. The names must be
  act.* grants the agent of the chat holds; anything else is refused
  and nothing is stored. A read_only proposal cannot carry acts.
- The proposal stores the list. Confirming it, in Studio, by a plain
  yes or by confirm CODE, grants exactly that list. No tool changes
  the acts of a trigger afterwards. Two proposals that differ only in
  their acts are kept apart.
- The text of a proposal in a messaging app now has a Permissions
  line, in plain words with sensitive classes marked: read only, or
  what a run may do. The confirmation repeats it. A proposal that asks
  for acts is never set up without a yes, whatever the approval mode.
- The trigger REST object gains acts_held, read_only and
  read_only_why, computed with the same rules as the run, so a client
  can show why a run only reads.

Assisted-by: Claude:claude-sonnet-5-5 go ginkgo
2026-11-03 20:53:02 +00:00

177 lines
6.6 KiB
Go

package studio
import (
"context"
"encoding/json"
"slices"
"strings"
"github.com/mudler/LocalAI/core/services/studio/builtins"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/triggers"
)
// The action policy of an unattended run (a trigger or an agent job). The
// rules are in builtins.ActPolicy. This file finds the rights of a run, wraps
// the acting tools of the chat with the check, and writes the audit line.
//
// A tool that the policy refuses stays in the tool list. Its call returns a
// plain sentence instead of running, so the model can tell the person what it
// wanted to do. The refusal is a result, not an error: an error would make
// the model retry.
// unattended says whether a chat started by itself: no person watches it and
// nobody can approve a step.
func unattended(origin string) bool {
return origin == session.OriginTrigger || origin == session.OriginJob
}
// actPolicy gives the rights of an unattended run, or false for a chat that a
// person watches. It fails closed: a trigger or binding that cannot be read
// gives a read only policy.
//
// - Trigger run: the act.* names of the trigger (Acts), within the grants of
// its agent. A trigger without an agent has no ceiling but its own list. A
// trigger whose plan names a file it writes (Writes) holds act.files, as
// the owner saw it on the proposal card.
// - Read only: the trigger declared that its runs only read, or it was made
// without asking by a binding in the auto_readonly mode.
// - Job run: jobs have no list of their own, so the run holds the act.*
// grants of its agent, and nothing without one.
func (s *Service) actPolicy(ctx context.Context, rec session.ChatRecord) (builtins.ActPolicy, bool) {
if !unattended(rec.Origin) {
return builtins.ActPolicy{}, false
}
ceiling := actNames(s.chatGrants(ctx, rec))
switch rec.Origin {
case session.OriginJob:
return builtins.ActPolicy{Held: ceiling}, true
}
if s.triggers == nil {
return builtins.ActPolicy{ReadOnly: true}, true
}
t, err := s.triggers.o.Store.GetTrigger(ctx, rec.OriginID)
if err != nil || t.Owner != rec.Principal {
return builtins.ActPolicy{ReadOnly: true}, true
}
return s.triggerPolicy(ctx, t, ceiling, rec.AgentID != ""), true
}
// triggerPolicy is the rights of the runs of trigger t: its acts within the
// ceiling (the grants of its agent), when it has an agent. The proposal card,
// the REST view and the run itself all read it, so they cannot disagree.
func (s *Service) triggerPolicy(ctx context.Context, t triggers.Trigger, ceiling []string, hasAgent bool) builtins.ActPolicy {
want := slices.Clone(t.Acts)
if t.Writes != "" && !slices.Contains(want, builtins.ClassFiles) {
want = append(want, builtins.ClassFiles)
}
pol := builtins.ActPolicy{ReadOnly: t.Route.ReadOnly}
if t.Route.Auto && s.bindingMode(ctx, t.Route.Binding) == connectors.ApprovalAutoReadonly {
pol.ReadOnly = true
}
for _, n := range want {
if !hasAgent || withinCeiling(n, ceiling) {
pol.Held = append(pol.Held, n)
}
}
return pol
}
// bindingMode is the approval mode of a binding. A binding that cannot be read
// is treated as auto_readonly: the host does not widen a run on a doubt.
func (s *Service) bindingMode(ctx context.Context, id string) string {
if id == "" || s.connectors == nil {
return connectors.ApprovalAutoReadonly
}
b, err := s.connectors.o.Store.GetBinding(ctx, id)
if err != nil {
return connectors.ApprovalAutoReadonly
}
return b.Approval.ModeOrDefault()
}
// actNames keeps the act.* names of a list of grants.
func actNames(grants []string) []string {
var out []string
for _, g := range grants {
if slices.Contains(builtins.ActGrants(), g) {
out = append(out, g)
}
}
return out
}
// withinCeiling says whether the agent's grants cover a name of a trigger: the
// name itself, or act.addons for act.addons:<id>.
func withinCeiling(name string, ceiling []string) bool {
if slices.Contains(ceiling, name) {
return true
}
return strings.HasPrefix(name, builtins.ClassAddons+":") && slices.Contains(ceiling, builtins.ClassAddons)
}
// gateAct wraps a tool with the policy. The policy is checked at each call, so
// a prompt cannot change the answer.
func (s *Service) gateAct(rec session.ChatRecord, pol builtins.ActPolicy, t session.Tool, req builtins.ActTool) session.Tool {
if req.Name == "" {
req.Name = t.Name
}
run := t.Run
t.Run = func(ctx context.Context, args json.RawMessage) (string, error) {
v := pol.Decide(req)
if !req.Read {
s.log.Info("studio: unattended action", "chat", rec.ID, "origin", rec.Origin, "origin_id", rec.OriginID,
"tool", req.Name, "allowed", v.Allowed, "reason", v.Reason, "missing", v.Missing)
}
if !v.Allowed {
return v.Sentence, nil
}
return run(ctx, args)
}
return t
}
// gateAddonAct applies the policy to the tool of an add-on. The kind that the
// add-on declared decides read or action, and a permission means the tool
// acts outside LocalAI with a credential.
func (s *Service) gateAddonAct(rec session.ChatRecord, pol builtins.ActPolicy, t session.Tool, d AddonTool) session.Tool {
return s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Read: !d.Action, Class: builtins.ClassAddons,
AddonID: d.AddonID, External: d.Permission != ""})
}
// gateExternalAct applies the policy to tools that come from outside, such as
// MCP servers: they are paid or external, so they need act.spend.
func (s *Service) gateExternalAct(ctx context.Context, rec session.ChatRecord, tools []session.Tool) []session.Tool {
pol, ok := s.actPolicy(ctx, rec)
if !ok {
return tools
}
out := make([]session.Tool, len(tools))
for i, t := range tools {
out[i] = s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Class: builtins.ClassSpend})
}
return out
}
// gateBuiltinActs applies the policy to the built-in tools that change files
// or run commands. The other built-in tools read, or have their own grant.
func (s *Service) gateBuiltinActs(ctx context.Context, rec session.ChatRecord, tools []session.Tool) []session.Tool {
pol, ok := s.actPolicy(ctx, rec)
if !ok {
return tools
}
out := make([]session.Tool, len(tools))
for i, t := range tools {
out[i] = t
switch {
case fileWriteTools[t.Name]:
out[i] = s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Class: builtins.ClassFiles})
case t.Name == commandTool && t.Provider == BuiltinProvider:
// Commands on this machine reach the files and the network.
out[i] = s.gateAct(rec, pol, t, builtins.ActTool{Name: t.Name, Class: builtins.ClassFiles, External: true})
}
}
return out
}