mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
feat(studio): enforce the action policy of unattended runs
A trigger run or an agent job has nobody to approve a step, and so far the approval mode auto_readonly and the act.* grants were only recorded. The prompt was the only thing that kept such a run from acting. Every tool call of an unattended run now goes through one check at the call. A tool that only reads runs. Any other tool needs the grant of its class, and the check fails closed: - act.files for write_file, edit_file, move_file, delete_file and the built-in bash; - act.addons, or act.addons:<id>, for add-on tools whose declared kind is not read, plus act.spend when the add-on gates the tool with a permission; - act.spend for MCP tools. A trigger holds its new acts list within the grants of its agent. A trigger that declared read only runs, or that a binding in auto_readonly mode made without asking, is refused every non-read tool whatever it holds. A binding that cannot be read counts as auto_readonly. A job holds the act grants of its agent. Sub-agents follow their parent, and a saved agent cannot be started from an unattended run. A refused call answers with a plain sentence the model can pass on, not an error, and writes one audit log line. Triggers set acts through the REST API; a model never can. A trigger whose plan names a file it writes keeps act.files. Assisted-by: Claude:claude-sonnet-5-5 go ginkgo swag
This commit is contained in:
22 files changed
+760
-9
No files matched your search
@@ -41,7 +41,8 @@ func triggerOut(ctx context.Context, host *studiosvc.Triggers, t triggers.Trigge
|
||||
Folder: host.RelFolder(t), QuietSeconds: int(t.Quiet / time.Second), DailyCap: t.CapOrDefault(), Enabled: t.Enabled,
|
||||
AgentID: t.AgentID, Model: t.Model, SourceChatID: t.SourceChatID, Summary: host.Summary(t),
|
||||
Plan: schema.ChatsTriggerPlan{Reads: t.Reads, Writes: t.Writes, Widget: t.Widget},
|
||||
Cost: schema.ChatsTriggerCost{EstimatedTokens: t.EstTokens, MaxRunsPerDay: host.MaxRunsPerDay(t), Remote: host.RemoteModel(t.Model)}}
|
||||
Cost: schema.ChatsTriggerCost{EstimatedTokens: t.EstTokens, MaxRunsPerDay: host.MaxRunsPerDay(t), Remote: host.RemoteModel(t.Model)},
|
||||
Acts: t.Acts}
|
||||
if t.Kind == triggers.KindFile {
|
||||
out.MinGapSeconds = int(t.GapOrDefault() / time.Second)
|
||||
}
|
||||
@@ -75,6 +76,9 @@ func triggerIn(req schema.ChatsTriggerWrite) studiosvc.TriggerInput {
|
||||
Folder: req.Folder, Quiet: time.Duration(req.QuietSeconds) * time.Second, DailyCap: req.DailyCap,
|
||||
AgentID: req.AgentID, Model: req.Model, SourceChatID: req.SourceChatID, Enabled: req.Enabled}
|
||||
in.MinGap, in.EstTokens = time.Duration(req.MinGapSeconds)*time.Second, req.EstimatedTokens
|
||||
if req.Acts != nil {
|
||||
in.Acts = req.Acts
|
||||
}
|
||||
if req.Plan != nil {
|
||||
in.Reads, in.Writes, in.Widget = req.Plan.Reads, req.Plan.Writes, req.Plan.Widget
|
||||
}
|
||||
|
||||
@@ -1052,6 +1052,10 @@ type ChatsTrigger struct {
|
||||
// file it writes; Cost what a run costs.
|
||||
Plan ChatsTriggerPlan `json:"plan"`
|
||||
Cost ChatsTriggerCost `json:"cost"`
|
||||
// Acts are the action classes that a run with nobody present may use:
|
||||
// act.files, act.addons, act.addons:<id>, act.send, act.spend. A run
|
||||
// without any only reads.
|
||||
Acts []string `json:"acts,omitempty"`
|
||||
}
|
||||
|
||||
// ChatsTriggerProposal is a trigger a chat proposed and what became of it.
|
||||
@@ -1143,6 +1147,10 @@ type ChatsTriggerWrite struct {
|
||||
// EstimatedTokens is about how many tokens one run uses; 0 keeps the
|
||||
// trigger's value.
|
||||
EstimatedTokens int `json:"estimated_tokens,omitempty"`
|
||||
// Acts are the action classes that a run with nobody present may use
|
||||
// (act.files, act.addons, act.addons:<id>, act.send, act.spend). Leave it
|
||||
// out to keep the trigger's value; an empty list takes all of them away.
|
||||
Acts []string `json:"acts,omitempty"`
|
||||
}
|
||||
|
||||
// ChatsTriggerList is the response of the trigger list endpoint.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
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 rec.AgentID == "" || withinCeiling(n, ceiling) {
|
||||
pol.Held = append(pol.Held, n)
|
||||
}
|
||||
}
|
||||
return pol, true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package studio_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio"
|
||||
"github.com/mudler/LocalAI/core/services/studio/agents"
|
||||
"github.com/mudler/LocalAI/core/services/studio/connectors"
|
||||
"github.com/mudler/LocalAI/core/services/studio/studiotest"
|
||||
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
||||
"github.com/mudler/LocalAI/core/services/studio/workspace"
|
||||
"github.com/mudler/LocalAI/pkg/addons/contract"
|
||||
"github.com/mudler/LocalAI/tests/studiobot"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// syncBuffer is a log sink that the service and the spec can share.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
b bytes.Buffer
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.Write(p)
|
||||
}
|
||||
|
||||
func (s *syncBuffer) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.String()
|
||||
}
|
||||
|
||||
var _ = Describe("the action policy of an unattended run", func() {
|
||||
ctx := context.Background()
|
||||
const refused = "I did not run write_file"
|
||||
var (
|
||||
r *connRig
|
||||
b connectors.Binding
|
||||
logs *syncBuffer
|
||||
)
|
||||
// The model of every run reads the folder, then writes a file, then
|
||||
// reports. The spec reads what each tool answered.
|
||||
script := func(_ workspace.Actor, text string) []studiotest.ToolStep {
|
||||
if strings.Contains(text, "WRITE") {
|
||||
return []studiotest.ToolStep{
|
||||
{Tool: "list_files", Args: `{"path":"/"}`},
|
||||
{Tool: "write_file", Args: `{"path":"/report.txt","content":"x"}`},
|
||||
{Say: "finished"},
|
||||
}
|
||||
}
|
||||
return []studiotest.ToolStep{{Say: "ok"}}
|
||||
}
|
||||
// setup binds a connection with the approval under test to a saved agent
|
||||
// that holds the given grants.
|
||||
setup := func(ap connectors.Approval, grants ...string) {
|
||||
logs = &syncBuffer{}
|
||||
sh := newShared()
|
||||
sh.deps = func(d *studio.Deps) { d.Logger = slog.New(slog.NewTextHandler(logs, nil)) }
|
||||
tr := studiobot.NewTransport(studiobot.Addon{ID: "chat-addon", Kind: contract.ConnectorKind{ID: "chatty", Title: "Chatty", Delivery: contract.DeliveryPull}, Runtime: &postAddon{}})
|
||||
r = newConnRigOpts(sh, connStore(), tr, script, nil, nil)
|
||||
ag, err := r.svc.SaveAgent(ctx, alice, agents.Agent{Name: "Worker", Model: "m", Builtins: grants})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
r.agent = ag
|
||||
gate := &capSwitch{}
|
||||
gate.set(studio.CapTriggersRun, true)
|
||||
r.svc.SetCapabilityGate(gate)
|
||||
b = r.bind(studio.ConnectorInput{Kind: "chatty", Token: "tok", Allow: []string{"11"}, Approval: &ap})
|
||||
}
|
||||
// run makes a trigger of the binding, lets it run once and returns what
|
||||
// write_file answered.
|
||||
run := func(in studio.TriggerInput) (out string, err error) {
|
||||
in.Prompt, in.AgentID = "WRITE the report", r.agent.ID
|
||||
tr := r.schedule(in)
|
||||
r.due()
|
||||
end := r.waitEnded(tr.ID)
|
||||
Expect(end.Status).To(Equal(triggers.StatusDone), "run = %+v", end)
|
||||
rs := r.h.ResultsFor("write_file")
|
||||
Expect(rs).To(HaveLen(1), "results = %+v", rs)
|
||||
Expect(r.h.ResultsFor("list_files")).To(HaveLen(1))
|
||||
Expect(r.h.ResultsFor("list_files")[0].Out).NotTo(ContainSubstring("I did not run"), "a read always runs")
|
||||
return rs[0].Out, rs[0].Err
|
||||
}
|
||||
|
||||
It("refuses every non-read tool of a trigger made without asking by an auto_readonly binding, whatever it holds", func() {
|
||||
setup(connectors.Approval{Mode: connectors.ApprovalAutoReadonly}, "act.files")
|
||||
out, err := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID, Auto: true}})
|
||||
Expect(err).NotTo(HaveOccurred(), "a refusal is an answer, not an error")
|
||||
Expect(out).To(ContainSubstring(refused))
|
||||
Expect(out).To(ContainSubstring("read and report only"))
|
||||
Expect(logs.String()).To(And(ContainSubstring("unattended action"), ContainSubstring("tool=write_file"), ContainSubstring("allowed=false"), ContainSubstring("reason=read_only")))
|
||||
})
|
||||
|
||||
It("treats a binding it cannot read as auto_readonly", func() {
|
||||
setup(connectors.Approval{}, "act.files")
|
||||
out, _ := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: "gone", Auto: true}})
|
||||
Expect(out).To(ContainSubstring(refused))
|
||||
})
|
||||
|
||||
It("refuses a trigger that declared its runs only read, even when it holds act.files", func() {
|
||||
setup(connectors.Approval{}, "act.files")
|
||||
out, _ := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID, ReadOnly: true}})
|
||||
Expect(out).To(ContainSubstring(refused))
|
||||
})
|
||||
|
||||
It("starts read only: a trigger with no acts cannot write, and the refusal names the missing grant", func() {
|
||||
setup(connectors.Approval{}, "act.files")
|
||||
out, _ := run(studio.TriggerInput{Route: triggers.Route{Binding: b.ID}})
|
||||
Expect(out).To(ContainSubstring(refused))
|
||||
Expect(out).To(ContainSubstring("act.files"))
|
||||
Expect(logs.String()).To(And(ContainSubstring("allowed=false"), ContainSubstring("reason=not_granted"), ContainSubstring("missing=act.files")))
|
||||
})
|
||||
|
||||
It("lets a trigger write when the owner gave it act.files and the agent allows it", func() {
|
||||
setup(connectors.Approval{}, "act.files")
|
||||
out, err := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(out).NotTo(ContainSubstring("I did not run"))
|
||||
Expect(logs.String()).To(And(ContainSubstring("tool=write_file"), ContainSubstring("allowed=true"), ContainSubstring("reason=granted")))
|
||||
})
|
||||
|
||||
It("keeps the agent's grants as the ceiling of the trigger's acts", func() {
|
||||
setup(connectors.Approval{} /* the agent holds no act.* grant */)
|
||||
out, _ := run(studio.TriggerInput{Acts: []string{"act.files"}, Route: triggers.Route{Binding: b.ID}})
|
||||
Expect(out).To(ContainSubstring(refused))
|
||||
})
|
||||
|
||||
It("lets a trigger write the file its plan names, as the owner saw on the card", func() {
|
||||
setup(connectors.Approval{}, "act.files")
|
||||
out, err := run(studio.TriggerInput{Writes: "report.txt", Route: triggers.Route{Binding: b.ID}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(out).NotTo(ContainSubstring("I did not run"))
|
||||
})
|
||||
|
||||
It("keeps acts on an edit that leaves them out, clears them on an empty list, and refuses an unknown one", func() {
|
||||
setup(connectors.Approval{}, "act.files")
|
||||
tr := r.schedule(studio.TriggerInput{Prompt: "WRITE", AgentID: r.agent.ID, Acts: []string{"act.files"}})
|
||||
Expect(tr.Acts).To(Equal([]string{"act.files"}))
|
||||
edit := func(acts []string) (triggers.Trigger, error) {
|
||||
return r.host.UpdateTrigger(ctx, alice, tr.ID, studio.TriggerInput{Name: tr.Name, Prompt: tr.Prompt, Kind: tr.Kind,
|
||||
Schedule: tr.Schedule, TZ: tr.TZ, Model: tr.Model, AgentID: tr.AgentID, Enabled: true, Acts: acts})
|
||||
}
|
||||
up, err := edit(nil)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(up.Acts).To(Equal([]string{"act.files"}))
|
||||
up, err = edit([]string{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(up.Acts).To(BeEmpty())
|
||||
_, err = edit([]string{"act.nothing"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
@@ -209,6 +209,7 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session
|
||||
}
|
||||
}
|
||||
var tools []session.Tool
|
||||
pol, guarded := s.actPolicy(ctx, rec)
|
||||
gate, _ := s.d.AddonTools.(AddonGate)
|
||||
reasons := map[string]string{}
|
||||
for _, d := range defs {
|
||||
@@ -241,6 +242,9 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session
|
||||
schema = json.RawMessage(`{"type":"object"}`)
|
||||
}
|
||||
if t, ok := s.shadowTool(a, d, schema); ok {
|
||||
if guarded {
|
||||
t = s.gateAddonAct(rec, pol, t, d)
|
||||
}
|
||||
tools = append(tools, t)
|
||||
continue
|
||||
}
|
||||
@@ -258,6 +262,9 @@ func (s *Service) addonTools(ctx context.Context, a workspace.Actor, rec session
|
||||
continue
|
||||
}
|
||||
}
|
||||
if guarded {
|
||||
tool = s.gateAddonAct(rec, pol, tool, d)
|
||||
}
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
installed := map[string]string{}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package builtins
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The action policy of an unattended run: a trigger or an agent job, which
|
||||
// runs with no person at the screen. A run like that starts read only. It may
|
||||
// do more only for the classes of action that the owner granted (the act.*
|
||||
// grants), and a run that the owner fixed as read only may do nothing but
|
||||
// read, whatever it was granted.
|
||||
//
|
||||
// The policy is a pure function of the tool being called and the run's
|
||||
// rights, so every tool of every source goes through the same table and the
|
||||
// answer does not depend on the prompt.
|
||||
|
||||
// The action classes. A tool that only reads has none.
|
||||
const (
|
||||
// ClassFiles is a write inside the fence of the run's folders.
|
||||
ClassFiles = GrantActFiles
|
||||
// ClassAddons is an action tool of an add-on (any kind but read).
|
||||
ClassAddons = GrantActAddons
|
||||
// ClassSend is a message beyond the delivery target of the run.
|
||||
ClassSend = GrantActSend
|
||||
// ClassSpend is a paid or external tool: the network, an MCP server, or an
|
||||
// add-on tool that acts with the person's credential outside LocalAI.
|
||||
ClassSpend = GrantActSpend
|
||||
)
|
||||
|
||||
// addonGrantRE is the shape of a grant of one add-on: act.addons:<id>.
|
||||
var addonGrantRE = regexp.MustCompile(`^act\.addons:[a-z0-9][a-z0-9._-]{0,63}$`)
|
||||
|
||||
// ActGrants lists the act.* names in declaration order.
|
||||
func ActGrants() []string {
|
||||
return []string{ClassFiles, ClassAddons, ClassSend, ClassSpend}
|
||||
}
|
||||
|
||||
// ValidActGrant says whether name is an act.* grant that a trigger may carry:
|
||||
// one of the four classes, or act.addons:<id> for one add-on.
|
||||
func ValidActGrant(name string) bool {
|
||||
return slices.Contains(ActGrants(), name) || addonGrantRE.MatchString(name)
|
||||
}
|
||||
|
||||
// AddonActGrant is the grant that allows the action tools of one add-on.
|
||||
func AddonActGrant(addonID string) string { return ClassAddons + ":" + addonID }
|
||||
|
||||
// ActTool is what the policy needs to know of a tool call.
|
||||
type ActTool struct {
|
||||
// Name is the tool as the model sees it.
|
||||
Name string
|
||||
// Read is true for a tool that only reads. It is the add-on's declared
|
||||
// kind (contract.ToolKindRead) for an add-on tool.
|
||||
Read bool
|
||||
// Class is the class of an acting tool: one of the Class constants.
|
||||
Class string
|
||||
// AddonID names the add-on of an add-on tool, for act.addons:<id>.
|
||||
AddonID string
|
||||
// External is true for an acting tool that reaches outside LocalAI with a
|
||||
// credential or the network; it needs act.spend as well as its class.
|
||||
External bool
|
||||
}
|
||||
|
||||
// ActPolicy is the rights of one unattended run.
|
||||
type ActPolicy struct {
|
||||
// ReadOnly says that the owner fixed the run to read and report: the
|
||||
// binding that made it is in the auto_readonly mode, or the run declared
|
||||
// that it only reads. No grant overrides it.
|
||||
ReadOnly bool
|
||||
// Held are the act.* grants the run holds: what the owner gave the
|
||||
// trigger, within what the agent's grants allow.
|
||||
Held []string
|
||||
}
|
||||
|
||||
// ActVerdict is the answer of the policy.
|
||||
type ActVerdict struct {
|
||||
Allowed bool
|
||||
// Missing is the first grant the run lacks. Empty when allowed, and for a
|
||||
// read only run.
|
||||
Missing string
|
||||
// Reason is a short code for the audit line: read, granted, read_only,
|
||||
// not_granted.
|
||||
Reason string
|
||||
// Sentence is what the model is told when the call is refused. It is a
|
||||
// plain sentence the model can pass on to the person.
|
||||
Sentence string
|
||||
}
|
||||
|
||||
// holds says whether the run holds need. act.addons:<id> is satisfied by
|
||||
// act.addons too.
|
||||
func (p ActPolicy) holds(need string) bool {
|
||||
if slices.Contains(p.Held, need) {
|
||||
return true
|
||||
}
|
||||
if id, ok := strings.CutPrefix(need, ClassAddons+":"); ok && id != "" {
|
||||
return slices.Contains(p.Held, ClassAddons)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Decide checks one tool call. A tool that reads is always allowed. Any other
|
||||
// is refused unless the run is not read only and holds every grant the tool
|
||||
// needs; a tool with no class is refused, never assumed harmless.
|
||||
func (p ActPolicy) Decide(t ActTool) ActVerdict {
|
||||
if t.Read {
|
||||
return ActVerdict{Allowed: true, Reason: "read"}
|
||||
}
|
||||
if p.ReadOnly {
|
||||
return ActVerdict{Reason: "read_only", Sentence: "I did not run " + t.Name + ": this run was set up to read and report only, so it cannot " +
|
||||
"change anything. Say in your answer what you wanted to do, and leave it to the owner."}
|
||||
}
|
||||
needs := t.needs()
|
||||
for _, n := range needs {
|
||||
if !p.holds(n) {
|
||||
return ActVerdict{Missing: n, Reason: "not_granted", Sentence: "I did not run " + t.Name + ": nobody is here to approve it and this run " +
|
||||
"has no permission for " + n + ". Say in your answer what you wanted to do; the owner can allow " + n + " for this run."}
|
||||
}
|
||||
}
|
||||
return ActVerdict{Allowed: true, Reason: "granted"}
|
||||
}
|
||||
|
||||
// needs lists the grants a tool call requires. A tool with an unknown class
|
||||
// needs one nobody holds, so it is refused.
|
||||
func (t ActTool) needs() []string {
|
||||
var out []string
|
||||
switch t.Class {
|
||||
case ClassAddons:
|
||||
if t.AddonID != "" {
|
||||
out = append(out, AddonActGrant(t.AddonID))
|
||||
} else {
|
||||
out = append(out, ClassAddons)
|
||||
}
|
||||
case ClassFiles, ClassSend, ClassSpend:
|
||||
out = append(out, t.Class)
|
||||
default:
|
||||
return []string{"act.unclassified"}
|
||||
}
|
||||
if t.External && t.Class != ClassSpend {
|
||||
out = append(out, ClassSpend)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package builtins_test
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("the action policy of an unattended run", func() {
|
||||
read := builtins.ActTool{Name: "list", Read: true, Class: builtins.ClassAddons, AddonID: "notes"}
|
||||
write := builtins.ActTool{Name: "write_file", Class: builtins.ClassFiles}
|
||||
addon := builtins.ActTool{Name: "post", Class: builtins.ClassAddons, AddonID: "notes"}
|
||||
credential := builtins.ActTool{Name: "push", Class: builtins.ClassAddons, AddonID: "git", External: true}
|
||||
send := builtins.ActTool{Name: "reply", Class: builtins.ClassSend}
|
||||
spend := builtins.ActTool{Name: "mcp_search", Class: builtins.ClassSpend}
|
||||
|
||||
DescribeTable("decides each tool by the mode and the grants",
|
||||
func(p builtins.ActPolicy, t builtins.ActTool, allowed bool, reason, missing string) {
|
||||
v := p.Decide(t)
|
||||
Expect(v.Allowed).To(Equal(allowed))
|
||||
Expect(v.Reason).To(Equal(reason))
|
||||
Expect(v.Missing).To(Equal(missing))
|
||||
if allowed {
|
||||
Expect(v.Sentence).To(BeEmpty())
|
||||
} else {
|
||||
Expect(v.Sentence).To(ContainSubstring("I did not run " + t.Name))
|
||||
}
|
||||
},
|
||||
// A read always runs, even when the run is fixed to read only.
|
||||
Entry("read, no grants", builtins.ActPolicy{}, read, true, "read", ""),
|
||||
Entry("read, read only", builtins.ActPolicy{ReadOnly: true}, read, true, "read", ""),
|
||||
// Deny by default.
|
||||
Entry("file write, no grants", builtins.ActPolicy{}, write, false, "not_granted", "act.files"),
|
||||
Entry("add-on action, no grants", builtins.ActPolicy{}, addon, false, "not_granted", "act.addons:notes"),
|
||||
Entry("send, no grants", builtins.ActPolicy{}, send, false, "not_granted", "act.send"),
|
||||
Entry("external tool, no grants", builtins.ActPolicy{}, spend, false, "not_granted", "act.spend"),
|
||||
// Each class needs its own grant.
|
||||
Entry("file write with act.files", builtins.ActPolicy{Held: []string{"act.files"}}, write, true, "granted", ""),
|
||||
Entry("file write with act.addons only", builtins.ActPolicy{Held: []string{"act.addons", "act.send", "act.spend"}}, write, false, "not_granted", "act.files"),
|
||||
Entry("add-on action with act.addons", builtins.ActPolicy{Held: []string{"act.addons"}}, addon, true, "granted", ""),
|
||||
Entry("add-on action with act.addons for this add-on", builtins.ActPolicy{Held: []string{"act.addons:notes"}}, addon, true, "granted", ""),
|
||||
Entry("add-on action with act.addons for another add-on", builtins.ActPolicy{Held: []string{"act.addons:other"}}, addon, false, "not_granted", "act.addons:notes"),
|
||||
Entry("send with act.send", builtins.ActPolicy{Held: []string{"act.send"}}, send, true, "granted", ""),
|
||||
Entry("external tool with act.spend", builtins.ActPolicy{Held: []string{"act.spend"}}, spend, true, "granted", ""),
|
||||
// A credentialed add-on action also reaches outside.
|
||||
Entry("credentialed action with act.addons only", builtins.ActPolicy{Held: []string{"act.addons"}}, credential, false, "not_granted", "act.spend"),
|
||||
Entry("credentialed action with both", builtins.ActPolicy{Held: []string{"act.addons", "act.spend"}}, credential, true, "granted", ""),
|
||||
// A read only run refuses every action, whatever it holds.
|
||||
Entry("read only run with every grant, file write", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, write, false, "read_only", ""),
|
||||
Entry("read only run with every grant, add-on action", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, addon, false, "read_only", ""),
|
||||
Entry("read only run with every grant, send", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, send, false, "read_only", ""),
|
||||
Entry("read only run with every grant, external tool", builtins.ActPolicy{ReadOnly: true, Held: builtins.ActGrants()}, spend, false, "read_only", ""),
|
||||
// A tool with no class is refused, never assumed harmless.
|
||||
Entry("unclassified tool with every grant", builtins.ActPolicy{Held: builtins.ActGrants()}, builtins.ActTool{Name: "mystery"}, false, "not_granted", "act.unclassified"),
|
||||
)
|
||||
|
||||
It("names the grants a trigger may carry", func() {
|
||||
for _, ok := range []string{"act.files", "act.addons", "act.send", "act.spend", "act.addons:notes", "act.addons:git.hub"} {
|
||||
Expect(builtins.ValidActGrant(ok)).To(BeTrue(), ok)
|
||||
}
|
||||
for _, bad := range []string{"", "files", "act.", "act.addons:", "act.addons:Bad Name", "act.other", "memory.write"} {
|
||||
Expect(builtins.ValidActGrant(bad)).To(BeFalse(), bad)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,8 @@ func (s *Service) scopedHostTools(ctx context.Context, a workspace.Actor) ([]ses
|
||||
tools, missing = append(tools, t...), append(missing, m...)
|
||||
}
|
||||
t, m = s.mcpTools(ctx, a, rec, sc, tools)
|
||||
// MCP tools are external: an unattended run needs act.spend for them.
|
||||
t = s.gateExternalAct(ctx, rec, t)
|
||||
return append(tools, t...), append(missing, m...), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ var (
|
||||
// ErrSavedAgentWithTools means spawn_agent named an agent and tools: the
|
||||
// agent's definition decides its tools.
|
||||
ErrSavedAgentWithTools = errors.New("tools cannot be combined with agent: the saved agent's definition decides its tools")
|
||||
// ErrSavedAgentUnattended means a run that nobody watches (a trigger or a
|
||||
// job) tried to start a saved agent: that agent would not carry the limits
|
||||
// of the run.
|
||||
ErrSavedAgentUnattended = errors.New("a run that nobody watches cannot start a saved agent")
|
||||
)
|
||||
|
||||
const listAgentsDescription = "List the saved agents of the user that you may start as sub-agents: id, name and description, and the folder and mode (read or write) of an agent that works in one. " +
|
||||
|
||||
@@ -245,6 +245,9 @@ func (s *Service) chatTools(ctx context.Context, x session.Access) (session.Turn
|
||||
// load_skill learns from the final list whether the chat can read a
|
||||
// skill's files and run what a skill needs.
|
||||
tools = s.fitLoadSkill(a, tools)
|
||||
// The action policy of a run that nobody watches: the tools that change
|
||||
// files or run commands on this machine are checked at each call.
|
||||
tools = s.gateBuiltinActs(ctx, rec, tools)
|
||||
return session.TurnTools{Tools: tools, Prompt: prompt, Note: note, WorkFolder: work, RefreshKey: key + "\x00" + rec.Settings.SystemPrompt + "\x00" + personaKey + "\x00" + notesKey}, nil
|
||||
}
|
||||
|
||||
@@ -312,6 +315,11 @@ func (s *Service) spawn(ctx context.Context, x session.Access, raw json.RawMessa
|
||||
if err := s.savedAgentsAllowed(ctx, parent); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if unattended(parent.Origin) {
|
||||
// The chat of a saved agent has no origin, so it would not carry the
|
||||
// rights of this run.
|
||||
return "", ErrSavedAgentUnattended
|
||||
}
|
||||
if agent, err = s.resolveAgent(ctx, a.Principal, ref); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -451,6 +459,8 @@ func (s *Service) subset(ctx context.Context, x session.Access, names *[]string)
|
||||
// Built for a sub-agent: bash on this machine only when no card is
|
||||
// needed, since nobody watches the sub-agent's chat.
|
||||
have = s.applyBuiltins(have, s.builtinsFor(ctx, a, parent, true))
|
||||
// A sub-agent of a run that nobody watches has the rights of that run.
|
||||
have = s.gateBuiltinActs(ctx, parent, have)
|
||||
}
|
||||
if names == nil {
|
||||
kept := have[:0:0]
|
||||
|
||||
@@ -191,7 +191,7 @@ func changedFields(a, b triggers.Trigger) []string {
|
||||
func (h *Triggers) inputOf(t triggers.Trigger) TriggerInput {
|
||||
return TriggerInput{Name: t.Name, Prompt: t.Prompt, Kind: t.Kind, Schedule: t.Schedule, TZ: t.TZ, Folder: h.RelFolder(t),
|
||||
Quiet: t.Quiet, DailyCap: t.DailyCap, AgentID: t.AgentID, Model: t.Model, SourceChatID: t.SourceChatID, Enabled: t.Enabled,
|
||||
MinGap: t.MinGap, Reads: t.Reads, Writes: t.Writes, Widget: t.Widget, EstTokens: t.EstTokens}
|
||||
MinGap: t.MinGap, Reads: t.Reads, Writes: t.Writes, Widget: t.Widget, EstTokens: t.EstTokens, Acts: t.Acts}
|
||||
}
|
||||
|
||||
// TriggerPatch is what a model asks to change in a trigger. A nil field, and
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -43,6 +44,8 @@ type triggerRow struct {
|
||||
Change string `gorm:"size:1024"`
|
||||
// Route is the JSON of Trigger.Route, empty on a trigger made in Studio.
|
||||
Route string `gorm:"size:1024"`
|
||||
// Acts is Trigger.Acts, comma separated; empty: read only runs.
|
||||
Acts string `gorm:"size:1024"`
|
||||
}
|
||||
|
||||
func (triggerRow) TableName() string { return "studio_triggers" }
|
||||
@@ -98,7 +101,7 @@ func (r triggerRow) trigger() Trigger {
|
||||
Schedule: r.Schedule, TZ: r.TZ, Folder: r.Folder, Quiet: time.Duration(r.QuietNs), DailyCap: r.DailyCap,
|
||||
Since: utc(r.Since), AgentID: r.AgentID, SourceChatID: r.SourceChatID, Model: r.Model, Proposed: r.Proposed,
|
||||
MinGap: time.Duration(r.MinGapNs), Reads: r.Reads, Writes: r.Writes, Widget: r.Widget, EstTokens: r.EstTokens,
|
||||
Outcome: r.Outcome, OutcomeAt: utc(r.OutcomeAt), Touched: r.Touched, Change: changeOf(r.Change), Route: routeOf(r.Route)}
|
||||
Outcome: r.Outcome, OutcomeAt: utc(r.OutcomeAt), Touched: r.Touched, Change: changeOf(r.Change), Route: routeOf(r.Route), Acts: actsOf(r.Acts)}
|
||||
}
|
||||
|
||||
func changeOf(raw string) Change {
|
||||
@@ -117,6 +120,13 @@ func changeText(c Change) string {
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func actsOf(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(raw, ",")
|
||||
}
|
||||
|
||||
func routeOf(raw string) Route {
|
||||
var r Route
|
||||
if raw != "" {
|
||||
@@ -138,7 +148,7 @@ func (s *gormStore) PutTrigger(ctx context.Context, t Trigger) error {
|
||||
Schedule: t.Schedule, TZ: t.TZ, Folder: t.Folder, QuietNs: int64(t.Quiet), DailyCap: t.DailyCap,
|
||||
Since: utc(t.Since), AgentID: t.AgentID, SourceChatID: t.SourceChatID, Model: t.Model, Proposed: t.Proposed,
|
||||
MinGapNs: int64(t.MinGap), Reads: t.Reads, Writes: t.Writes, Widget: t.Widget, EstTokens: t.EstTokens,
|
||||
Outcome: t.Outcome, OutcomeAt: utc(t.OutcomeAt), Touched: t.Touched, Change: changeText(t.Change), Route: routeText(t.Route)}
|
||||
Outcome: t.Outcome, OutcomeAt: utc(t.OutcomeAt), Touched: t.Touched, Change: changeText(t.Change), Route: routeText(t.Route), Acts: strings.Join(t.Acts, ",")}
|
||||
return s.db.WithContext(ctx).Save(&row).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,11 @@ type Trigger struct {
|
||||
// where its runs report. The zero value is a trigger made in Studio, whose
|
||||
// runs go to the Inbox. The package only stores it.
|
||||
Route Route
|
||||
// Acts are the action classes (act.files, act.addons, act.addons:<id>,
|
||||
// act.send, act.spend) that the owner allowed for the runs of this
|
||||
// trigger. A run with none only reads. The host checks them at each tool
|
||||
// call; the package only stores them.
|
||||
Acts []string
|
||||
}
|
||||
|
||||
// Where the runs of a trigger report. Inbox is the default: the owner's Inbox
|
||||
@@ -160,6 +165,12 @@ const (
|
||||
ModeOnProblem = "on_problem"
|
||||
)
|
||||
|
||||
// Bounds of Trigger.Acts.
|
||||
const (
|
||||
MaxActs = 16
|
||||
MaxActBytes = 80
|
||||
)
|
||||
|
||||
// MaxRefBytes bounds the binding, chat and thread of a Route: the width of
|
||||
// the sender column of the connector stores.
|
||||
const MaxRefBytes = 64
|
||||
@@ -408,6 +419,14 @@ func (t Trigger) Validate() error {
|
||||
if err := t.Route.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(t.Acts) > MaxActs {
|
||||
return fmt.Errorf("a trigger has at most %d acts", MaxActs)
|
||||
}
|
||||
for _, a := range t.Acts {
|
||||
if a == "" || len(a) > MaxActBytes || hasControl(a, false) || strings.Contains(a, ",") {
|
||||
return errors.New("an act is a short name such as act.files")
|
||||
}
|
||||
}
|
||||
switch t.Kind {
|
||||
case KindSchedule:
|
||||
if _, err := ParseSchedule(t.Schedule); err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/studio/agents"
|
||||
"github.com/mudler/LocalAI/core/services/studio/builtins"
|
||||
"github.com/mudler/LocalAI/core/services/studio/files"
|
||||
"github.com/mudler/LocalAI/core/services/studio/proposals"
|
||||
"github.com/mudler/LocalAI/core/services/studio/triggers"
|
||||
@@ -58,6 +59,11 @@ type TriggerInput struct {
|
||||
// Route is the connector place a proposal came from and where its runs
|
||||
// report. A person's form leaves it empty and an edit keeps the old one.
|
||||
Route triggers.Route
|
||||
// Acts are the action classes an unattended run of the trigger may use
|
||||
// (act.files, act.addons, act.addons:<id>, act.send, act.spend). Nil keeps
|
||||
// what the trigger has; an empty list takes everything away. Only a
|
||||
// person sets them: a model's proposal never carries acts.
|
||||
Acts []string
|
||||
}
|
||||
|
||||
// keepPlan fills the fields of the plan and the gap that in leaves empty from
|
||||
@@ -79,6 +85,9 @@ func keepPlan(old triggers.Trigger, in TriggerInput) TriggerInput {
|
||||
if in.MinGap == 0 {
|
||||
in.MinGap = old.MinGap
|
||||
}
|
||||
if in.Acts == nil {
|
||||
in.Acts = old.Acts
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
@@ -149,6 +158,11 @@ func (h *Triggers) build(ctx context.Context, owner string, in TriggerInput) (tr
|
||||
t := triggers.Trigger{Owner: owner, Name: strings.TrimSpace(in.Name), Prompt: in.Prompt, Enabled: in.Enabled, Kind: in.Kind,
|
||||
TZ: in.TZ, Quiet: in.Quiet, DailyCap: in.DailyCap, AgentID: in.AgentID, Model: in.Model, SourceChatID: in.SourceChatID,
|
||||
MinGap: in.MinGap, Reads: strings.TrimSpace(in.Reads), Widget: in.Widget, EstTokens: in.EstTokens, Route: in.Route}
|
||||
acts, err := cleanActs(in.Acts)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.Acts = acts
|
||||
if w := strings.TrimSpace(in.Writes); w != "" {
|
||||
// A path of the home as the person writes it, stored as they see it:
|
||||
// the widget footer and the proposal card show it back.
|
||||
@@ -761,3 +775,18 @@ func (h *Triggers) RelFolder(t triggers.Trigger) string {
|
||||
}
|
||||
return t.Folder
|
||||
}
|
||||
|
||||
// cleanActs checks the act names of a trigger and removes repeats.
|
||||
func cleanActs(names []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, n := range names {
|
||||
n = strings.TrimSpace(n)
|
||||
switch {
|
||||
case !builtins.ValidActGrant(n):
|
||||
return nil, bad("%q is not an act; use act.files, act.addons, act.addons:<id>, act.send or act.spend", n)
|
||||
case !slices.Contains(out, n):
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func newScopedTrig(script func(workspace.Actor, string) []studiotest.ToolStep) *
|
||||
s.mkdirIn(sh, d)
|
||||
}
|
||||
s.trigRig = sh.rig(script, studio.TriggerOptions{})
|
||||
ag, err := s.svc.SaveAgentWithFolder(ctx, alice, agents.Agent{Name: "Acme", Model: "m"},
|
||||
ag, err := s.svc.SaveAgentWithFolder(ctx, alice, agents.Agent{Name: "Acme", Model: "m", Builtins: []string{"act.files"}},
|
||||
&studio.AgentFolderWrite{Path: "/Projects/Acme", Mode: agents.FolderWrite})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
s.agent = ag
|
||||
|
||||
@@ -719,7 +719,8 @@ var _ = Describe("trigger runs of a scoped agent", func() {
|
||||
}
|
||||
})
|
||||
s.put("/Projects/Other/secret.txt", "OUTSIDE-SECRET-MARKER")
|
||||
tr := s.schedule(studio.TriggerInput{AgentID: s.agent.ID})
|
||||
// A run that nobody watches writes only with act.files (the action policy).
|
||||
tr := s.schedule(studio.TriggerInput{AgentID: s.agent.ID, Acts: []string{"act.files"}})
|
||||
s.due()
|
||||
run := s.waitEnded(tr.ID)
|
||||
Expect(run.Status).To(Equal(triggers.StatusDone), "run = %+v", run)
|
||||
|
||||
@@ -2118,6 +2118,13 @@ person to confirm.
|
||||
never as authority.
|
||||
- **Undo.** What a tool does to the user's files is a change in the history of
|
||||
their workspace, and it can be undone there.
|
||||
- **Runs with nobody present.** A trigger or an agent job calls the tools of an
|
||||
add-on under the action policy of unattended runs. The `kind` of the tool
|
||||
decides the class: `read` always runs, and every other kind is refused unless
|
||||
the run holds `act.addons` (or `act.addons:<add-on id>`). A tool that the host
|
||||
gates with a permission, because the add-on holds a credential, also needs
|
||||
`act.spend`. Declare `read` only for a tool that changes nothing. See
|
||||
[Action policy of unattended runs](../chats-agents/#action-policy-of-unattended-runs).
|
||||
|
||||
## Host operations
|
||||
|
||||
|
||||
@@ -276,8 +276,8 @@ on the list. The moment you grant any built-in, the list is the whole answer, so
|
||||
add the connector grants you want to keep (granting only `memory.write` to an agent
|
||||
that proposed triggers from a messaging app turns those off; nothing is deleted
|
||||
and they work again when you grant `triggers.propose`). The `act.*` grants are
|
||||
recorded but not enforced yet: the action policy of unattended runs that uses them
|
||||
is still to come.
|
||||
the ceiling of the action policy of unattended runs (see
|
||||
[Action policy of unattended runs](#action-policy-of-unattended-runs)).
|
||||
|
||||
### Export and import
|
||||
|
||||
@@ -399,6 +399,56 @@ message as rules for the run. Treat it as text from whoever can write there.
|
||||
"the agent's folder is missing" and switches the trigger off. Each of these
|
||||
tells the owner.
|
||||
|
||||
### Action policy of unattended runs
|
||||
|
||||
A trigger run or an agent job has nobody at the screen to approve a step, so it
|
||||
starts read only. LocalAI checks every tool call of such a run at the moment of
|
||||
the call, not in the prompt. A tool that only reads always runs. Any other tool
|
||||
runs only if the run holds the grant for its class. Add-on tools and MCP tools
|
||||
always have a class. The other built-in tools either read or need a grant that a
|
||||
run which started by itself never holds. Nothing is allowed on a doubt: a trigger or a connection
|
||||
that cannot be read at that moment gives a read only run.
|
||||
|
||||
| Class | Grant | Tools |
|
||||
|---|---|---|
|
||||
| Read | none | Tools that only read: the add-on tools of kind `read`, the file reading tools, web search and fetch, and the other built-in tools that only read. |
|
||||
| Files | `act.files` | `write_file`, `edit_file`, `move_file`, `delete_file`, and the built-in `bash` of this machine. |
|
||||
| Add-on actions | `act.addons`, or `act.addons:<add-on id>` for one add-on | Every add-on tool whose kind is not `read` (`action`, `write`, `schema`, `create`), including an add-on that serves `bash`. |
|
||||
| Outside | `act.spend` | MCP tools (of a server or a connector), and an add-on action tool that the add-on declares with a permission, which means it acts outside LocalAI with a credential. The last one needs `act.addons` as well. |
|
||||
| Send | `act.send` | Reserved for messages beyond the delivery target. No tool of this class exists yet. |
|
||||
|
||||
What a run holds:
|
||||
|
||||
- **A trigger run** holds the `acts` of its trigger, limited to the `act.*`
|
||||
grants of its agent (`builtins`). A trigger without an agent has no ceiling but
|
||||
its own `acts`. Set `acts` in the body of `POST /api/chats/triggers` or
|
||||
`PUT /api/chats/triggers/{id}`: leave it out to keep the value, send `[]` to
|
||||
clear it. Only a person can set it. A trigger that a model proposes in a chat
|
||||
never carries `acts`, and neither does the trigger a connector makes without
|
||||
asking. A trigger whose plan names a file it writes (`plan.writes`) also holds
|
||||
`act.files`, because you saw that file on the proposal card. Triggers that exist
|
||||
before this change keep running, but they now read only unless they set `acts`
|
||||
or `plan.writes`.
|
||||
- **An agent job run** holds the `act.*` grants of its agent, and nothing without
|
||||
an agent. A job has no `acts` of its own.
|
||||
- **Read only runs.** A trigger whose proposal said its runs only read
|
||||
(`access: read_only`), or that a connection made without asking while its
|
||||
approval mode is `auto_readonly` (see
|
||||
[Approval modes](../chats-connectors/#approval-modes)), holds nothing: every
|
||||
tool that is not a read is refused, whatever `acts` and the agent say. A
|
||||
connection that cannot be read is treated as `auto_readonly`.
|
||||
- **Sub-agents.** A sub-agent of an unattended run has the same limits as its
|
||||
parent. A run that nobody watches cannot start a saved agent as a sub-agent.
|
||||
|
||||
A refused tool stays in the tool list and answers with one plain sentence instead
|
||||
of running, for example "I did not run write_file: nobody is here to approve it
|
||||
and this run has no permission for act.files. Say in your answer what you wanted
|
||||
to do; the owner can allow act.files for this run." The model then reports it in
|
||||
its answer. The refusal is a result, not an error, so the model does not retry.
|
||||
Each call of a tool that is not a read writes one log line, `studio: unattended
|
||||
action`, with the chat, the trigger or job, the tool, `allowed`, `reason`
|
||||
(`granted`, `not_granted` or `read_only`) and the missing grant.
|
||||
|
||||
### Triggers of an agent with a folder
|
||||
|
||||
A trigger whose agent has a [folder](#folders-of-an-agent) keeps to that
|
||||
|
||||
@@ -615,7 +615,7 @@ page of the connector add-on, or in the `approval` field of the binding
|
||||
|---|---|
|
||||
| `ask` (default) | The bot posts the proposal and waits for a plain answer. |
|
||||
| `auto` | The trigger is made at once. The bot tells the person what was set up and that `cancel` (within 30 minutes) or `cancel CODE` (any time) removes it. |
|
||||
| `auto_readonly` | Like `auto` when the proposal says its runs only read and report (`access: read_only`, no file written); any other proposal asks. |
|
||||
| `auto_readonly` | Like `auto` when the proposal says its runs only read and report (`access: read_only`, no file written); any other proposal asks. The runs of a trigger made this way can use only tools that read: every other tool is refused at the call, whatever the agent holds (see [Action policy of unattended runs](../chats-agents/#action-policy-of-unattended-runs)). |
|
||||
|
||||
The automatic modes are bounded by limits of the owner. Anything over a limit is not
|
||||
refused but asked instead, and the proposal says why.
|
||||
|
||||
@@ -18776,6 +18776,13 @@ const docTemplate = `{
|
||||
"schema.ChatsAdminTrigger": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acts": {
|
||||
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22722,6 +22729,13 @@ const docTemplate = `{
|
||||
"schema.ChatsTrigger": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acts": {
|
||||
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22980,6 +22994,13 @@ const docTemplate = `{
|
||||
"schema.ChatsTriggerWrite": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acts": {
|
||||
"description": "Acts are the action classes that a run with nobody present may use\n(act.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend). Leave it\nout to keep the trigger's value; an empty list takes all of them away.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is optional. Model is needed when neither the agent nor the source\nchat names one.",
|
||||
"type": "string"
|
||||
|
||||
@@ -18773,6 +18773,13 @@
|
||||
"schema.ChatsAdminTrigger": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acts": {
|
||||
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22719,6 +22726,13 @@
|
||||
"schema.ChatsTrigger": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acts": {
|
||||
"description": "Acts are the action classes that a run with nobody present may use:\nact.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend. A run\nwithout any only reads.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -22977,6 +22991,13 @@
|
||||
"schema.ChatsTriggerWrite": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acts": {
|
||||
"description": "Acts are the action classes that a run with nobody present may use\n(act.files, act.addons, act.addons:\u003cid\u003e, act.send, act.spend). Leave it\nout to keep the trigger's value; an empty list takes all of them away.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"agent_id": {
|
||||
"description": "AgentID is optional. Model is needed when neither the agent nor the source\nchat names one.",
|
||||
"type": "string"
|
||||
|
||||
@@ -3131,6 +3131,14 @@ definitions:
|
||||
type: object
|
||||
schema.ChatsAdminTrigger:
|
||||
properties:
|
||||
acts:
|
||||
description: |-
|
||||
Acts are the action classes that a run with nobody present may use:
|
||||
act.files, act.addons, act.addons:<id>, act.send, act.spend. A run
|
||||
without any only reads.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
agent_id:
|
||||
type: string
|
||||
cost:
|
||||
@@ -6145,6 +6153,14 @@ definitions:
|
||||
type: object
|
||||
schema.ChatsTrigger:
|
||||
properties:
|
||||
acts:
|
||||
description: |-
|
||||
Acts are the action classes that a run with nobody present may use:
|
||||
act.files, act.addons, act.addons:<id>, act.send, act.spend. A run
|
||||
without any only reads.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
agent_id:
|
||||
type: string
|
||||
cost:
|
||||
@@ -6327,6 +6343,14 @@ definitions:
|
||||
type: object
|
||||
schema.ChatsTriggerWrite:
|
||||
properties:
|
||||
acts:
|
||||
description: |-
|
||||
Acts are the action classes that a run with nobody present may use
|
||||
(act.files, act.addons, act.addons:<id>, act.send, act.spend). Leave it
|
||||
out to keep the trigger's value; an empty list takes all of them away.
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
agent_id:
|
||||
description: |-
|
||||
AgentID is optional. Model is needed when neither the agent nor the source
|
||||
|
||||
Reference in new issue
Block a user