Files
LocalAI/core/services/studio/egresscheck.go
T
Ettore Di Giacinto 61c54dc833 refactor(studio): word the command provider text without naming an add-on
The prompt, the Tools menu reasons, the network error texts, the CLI help,
the API instructions and the assistant tool descriptions said "Sandbox" for
the add-on that runs commands. Core must not know that name.

The reason a chat has no commands now uses the title the provider declares
("the Zeta add-on is off for this chat") or, when none is installed, says
"the add-on that runs commands". Text that has no provider in hand speaks of
the network of commands or of an add-on that isolates them.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-03 10:43:37 +00:00

260 lines
8.5 KiB
Go

package studio
import (
"context"
"errors"
"sync"
"time"
"github.com/mudler/LocalAI/core/services/addons/toolargs"
"github.com/mudler/LocalAI/core/services/studio/egress"
"github.com/mudler/LocalAI/core/services/studio/egress/audit"
"github.com/mudler/LocalAI/core/services/studio/session"
)
// The host side of the Sandbox proxy: egress.check and egress.audit. The
// add-on names neither the chat nor the user. The user is the actor of the
// live request and the chat is the resource the host put in that request when
// the chat's turn called bash: an action resource ID, a one-way key of the
// conversation (toolargs.ActionResourceID). The service finds the chat of the
// user with that key.
// ErrEgressNoChat means the live request names no chat of the user.
var ErrEgressNoChat = errors.New("studio: the request names no chat of this user")
// egressChats maps the action resource of a chat back to its ID. The mapping
// never changes (it is a hash), so the cache only saves the search; it is per
// process, and each frontend fills its own.
type egressChats struct {
mu sync.Mutex
m map[string]string // principal + resource -> chat ID
}
const (
egressChatsMax = 4096
egressScanPage = 200
egressScanChats = 20000
)
func (c *egressChats) get(k string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
id, ok := c.m[k]
return id, ok
}
func (c *egressChats) put(k, id string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.m == nil || len(c.m) >= egressChatsMax {
c.m = map[string]string{}
}
c.m[k] = id
}
// chatForResource finds the chat of p whose action resource is rid. A chat
// that runs a command is one the user used lately, so the newest chats come
// first.
func (s *Service) chatForResource(ctx context.Context, p, rid string) (session.ChatRecord, error) {
k := p + "\x00" + rid
if id, ok := s.egressChats.get(k); ok {
rec, err := s.d.Store.GetChat(ctx, p, id)
if err == nil {
return rec, nil
}
}
var cur session.ChatCursor
for seen := 0; seen < egressScanChats; {
page, err := s.d.Store.ListChats(ctx, p, cur, egressScanPage)
if err != nil {
return session.ChatRecord{}, err
}
for _, rec := range page {
if toolargs.ActionResourceID(rec.ID) == rid {
s.egressChats.put(k, rec.ID)
return rec, nil
}
}
if len(page) < egressScanPage {
break
}
seen += len(page)
last := page[len(page)-1]
cur = session.ChatCursor{Updated: last.Updated, ID: last.ID}
}
return session.ChatRecord{}, ErrEgressNoChat
}
// egressPolicy is the chat whose policy applies to a request, and whether a
// person watches it. A sub-agent follows its parent's preset and rules with no
// card: nobody watches it, and the block is in its output for the parent. An
// unattended run (trigger, connector) follows its agent's network field and
// never asks.
type egressPolicy struct {
rec session.ChatRecord // the chat that owns the state (the parent for a sub-agent)
chat egress.Chat
attended bool
}
func (s *Service) egressPolicyOf(ctx context.Context, rec session.ChatRecord, op string) (egressPolicy, error) {
owner := rec
attended := rec.Origin == "" && rec.Kind != session.KindSubAgent
preset := rec.Egress.Preset
if rec.Kind == session.KindSubAgent && rec.ParentID != "" {
parent, err := s.d.Store.GetChat(ctx, rec.Principal, rec.ParentID)
if err != nil {
return egressPolicy{}, err
}
owner = parent
preset = parent.Egress.Preset
}
if owner.Origin != "" {
preset = s.agentNetwork(ctx, owner)
}
now := s.now()
st := owner.Egress
var live []egress.Card
if len(st.Pending()) > 0 {
// Only the cards of the turn that runs now count toward the cap and
// read as pending; a frontend that died leaves its cards behind.
st, live = s.liveEgress(st, s.chatTurn(ctx, owner.Principal, owner.ID))
owner.Egress = st
}
return egressPolicy{rec: owner, attended: attended, chat: egress.Chat{
Preset: preset, Rules: st.Rules, Once: st.Grants(op, now), Attended: attended, Pending: live,
}}, nil
}
// agentNetwork is the preset of an unattended run (a trigger or a connector
// message): its saved agent's network field, off when it has none or the agent
// is gone. A run with no agent is off. Decide caps it by the ceiling.
func (s *Service) agentNetwork(ctx context.Context, rec session.ChatRecord) egress.Preset {
if rec.AgentID == "" || s.d.Agents == nil {
return egress.PresetOff
}
ag, err := s.d.Agents.Get(ctx, rec.Principal, rec.AgentID)
if err != nil || !egress.Preset(ag.Network).Valid() {
return egress.PresetOff
}
return egress.Preset(ag.Network)
}
// EgressCheck decides one connection of a command of the live call op, in the
// chat with the action resource rid of user, and audits the verdict.
func (s *Service) EgressCheck(ctx context.Context, user, rid, op string, r egress.Request) (egress.Verdict, error) {
p := principal(user)
r.Host = canonicalHost(r.Host)
line := audit.Event{User: p, Host: r.Host, Port: r.Port, Kind: r.Kind, At: s.now().UTC()}
rec, err := s.chatForResource(ctx, p, rid)
if err != nil {
v := egress.Verdict{Action: egress.Block, Reason: "chat"}
line.Verdict, line.Reason = v.Action, v.Reason
s.auditEgress(ctx, line)
if errors.Is(err, ErrEgressNoChat) {
return v, nil
}
return v, err
}
line.Chat = rec.ID
pol, err := s.egressPolicyOf(ctx, rec, op)
if err != nil {
return egress.Verdict{Action: egress.Block, Reason: "chat"}, err
}
set, err := s.egressSettings(ctx)
if err != nil {
return egress.Verdict{Action: egress.Block, Reason: "policy_unavailable"}, err
}
v := egress.Decide(set, pol.chat, r, egress.BuiltinRegistries())
if v.Action == egress.Ask {
// A card asks the person: see the cards. Without one, a block.
v, err = s.askEgress(ctx, pol, rec, op, r)
}
if v.Action == egress.Allow && v.Reason == egress.ReasonOnce {
s.claimGrant(ctx, pol.rec, op, r)
}
line.Verdict, line.Reason = v.Action, v.Reason
s.auditEgress(ctx, line)
return v, err
}
// claimGrant ties a late "allow once" to the call that used it first, so it
// does not carry on to later calls.
func (s *Service) claimGrant(ctx context.Context, rec session.ChatRecord, op string, r egress.Request) {
now := s.now()
_ = s.d.Store.UpdateEgress(ctx, rec.Principal, rec.ID, func(st *egress.ChatState) error {
for i, g := range st.Once {
if g.Op == "" && g.Host == r.Host && g.Port == r.Port && now.Before(g.Expires) {
st.Once[i].Op = op
st.Once[i].Expires = now.Add(time.Hour)
}
}
return nil
})
}
// EgressEvent is one line the proxy reports about a connection of a call.
type EgressEvent struct {
Host, Kind, Verdict, Reason, Addr string
Port int
BytesUp, BytesDown, DurationMS int64
At time.Time
}
// EgressAudit records what the connections of a call did: the bytes of the
// allowed ones ("done"), and the blocks the proxy made itself (a refused
// address, a TLS name that did not match, a connection while no call ran).
func (s *Service) EgressAudit(ctx context.Context, user, rid string, evs []EgressEvent) error {
p := principal(user)
chat := ""
if rec, err := s.chatForResource(ctx, p, rid); err == nil {
chat = rec.ID
}
now := s.now().UTC()
lines := make([]audit.Event, 0, len(evs))
for _, e := range evs {
at := e.At.UTC()
if at.IsZero() || at.After(now) {
at = now
}
lines = append(lines, audit.Event{User: p, Chat: chat, Host: canonicalHost(e.Host), Port: e.Port, Kind: e.Kind, Verdict: e.Verdict, Reason: e.Reason,
Addr: e.Addr, BytesUp: e.BytesUp, BytesDown: e.BytesDown, DurationMS: e.DurationMS, At: at})
}
return s.egressAudit().Add(ctx, lines...)
}
func (s *Service) auditEgress(ctx context.Context, e audit.Event) {
if err := s.egressAudit().Add(context.WithoutCancel(ctx), e); err != nil {
s.log.Warn("Chats: could not audit a command network request", "error", err)
}
}
// canonicalHost is the form a host is stored and compared in: lower case,
// without a trailing dot, cut at 253 bytes.
func canonicalHost(h string) string {
b := []byte(h)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 'a' - 'A'
}
}
out := string(b)
for len(out) > 0 && out[len(out)-1] == '.' {
out = out[:len(out)-1]
}
if len(out) > 253 {
out = out[:253]
}
return out
}
// ChatOfAction returns the ID of the chat of user whose action resource is
// rid, or "" when there is none. The Sandbox's commits use it, so what a
// command saved belongs to the chat that ran it.
func (s *Service) ChatOfAction(ctx context.Context, user, rid string) string {
rec, err := s.chatForResource(ctx, principal(user), rid)
if err != nil {
return ""
}
return rec.ID
}