Files
LocalAI/core/services/studio/egressapi.go
T
Ettore Di Giacinto 9fafea8fdf fix(chats): show the network switcher and say why execution status is unknown
The Network row of the Tools menu could never show the Off, Registries and
All switcher: the host stopped setting `applies` because isolation alone does
not prove network-policy enforcement, and nothing else set it.

Let the provider report enforcement in its typed execution status
(`networkPolicyEnforced`). The host sets `applies` only when that report is
true and the status is available and isolated. The Sandbox reports it for the
gVisor and KVM backends, whose guests reach the network only through the proxy
that asks the host. Providers that do not report it keep the "not enforced"
text.

When the host cannot read the status, give the unknown state a reason
(`unknown_reason`) in the API and the Tools menu: owner unreachable, not
reported, stale, invalid, not selected, no answer or reported unknown. Log the
class of each failure, rate limited. After a failure that a restart of the
add-on owner can cause, let a long turn bind and read again, at most once every
five seconds, instead of keeping the first failure for the whole turn.

The Sandbox now reports "unavailable" with a reason when it started without
settings, and explains a backend it does not know.

When the Sandbox document skills are hidden because the Sandbox is not known to
be isolated, say so in the skill list, in the turn note and in the
`load_skill` error, instead of "This chat has no skills now".

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

283 lines
9.5 KiB
Go

package studio
import (
"context"
"errors"
"slices"
"time"
"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"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// The routes of the network of a chat's Sandbox commands: the chat's own
// view and preset for its owner, and the administrator's view.
var (
// ErrEgressPreset means the preset is not off, registries or all.
ErrEgressPreset = errors.New("studio: the network preset must be off, registries or all")
// ErrEgressAboveCeiling means the preset is wider than the administrator
// allows.
ErrEgressAboveCeiling = errors.New("studio: the network preset is above the limit an administrator set")
// ErrEgressNoRule means the chat has no rule for that host and port.
ErrEgressNoRule = errors.New("studio: the chat has no network rule for this host and port")
)
// The reasons the network settings do not apply to a chat.
const (
EgressAppliesHost = "host" // commands run on this machine, with its network
EgressAppliesNone = "none" // no Sandbox serves bash, or it does not report that it enforces the policy
)
// EgressView is the network of one chat, as its network panel shows it.
type EgressView struct {
Preset, Ceiling, Effective egress.Preset
// Applies is true when the provider that runs the commands reports that it
// enforces the network policy. It is false when commands run on this
// machine, no Sandbox serves bash, or the provider does not report
// enforcement; AppliesReason then says which.
Applies bool
AppliesReason string
// Addon is the add-on that runs the chat's commands and carries the
// network-policy host control, which draws the network row of the chat:
// set only for a provider that carries it. Without it no add-on draws the controls, and
// the policy still holds.
Addon string
Registries []egress.Registry
// AllowedHosts is the administrator's always-allowed list, canonical.
AllowedHosts []string
Rules []EgressRuleView
Cards []egress.Card
// CardAbove holds the IDs of the pending cards whose host the ceiling does
// not admit: no answer can make them work.
CardAbove map[string]bool
}
// EgressRuleView is a rule with whether the ceiling still admits it.
type EgressRuleView struct {
egress.Rule
AboveLimit bool
}
// registriesView is the registries list with the administrator's extra hosts
// as one more entry.
func registriesView(s egress.Settings) []egress.Registry {
out := append([]egress.Registry(nil), egress.BuiltinRegistries().List...)
if len(s.ExtraRegistries) > 0 {
out = append(out, egress.Registry{ID: "extra", Title: "Added by an administrator", Hosts: append([]string(nil), s.ExtraRegistries...)})
}
return out
}
// Egress returns the network of a chat for its owner. The chat of another
// user is session.ErrNotFound.
func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, error) {
ctx, closeScope := s.executionScope(ctx, principal(user), conv)
defer closeScope()
p := principal(user)
rec, err := s.d.Store.GetChat(ctx, p, conv)
if err != nil {
return EgressView{}, err
}
set, err := s.egressSettings(ctx)
if err != nil {
return EgressView{}, err
}
pol, err := s.egressPolicyOf(ctx, rec, "")
if err != nil {
return EgressView{}, err
}
v := EgressView{
Preset: pol.chat.Preset, Ceiling: egress.Min(set.Ceiling, egress.PresetAll),
Effective: egress.Min(pol.chat.Preset, set.Ceiling),
Registries: registriesView(set),
AllowedHosts: set.AllowedHostList(),
Rules: []EgressRuleView{},
Cards: append([]egress.Card{}, pol.rec.Egress.Cards...),
}
v.CardAbove = map[string]bool{}
for _, c := range v.Cards {
if c.State == egress.CardPending || c.State == egress.CardRefused {
req := egress.Request{Host: c.Host, Port: c.Port, Kind: c.Kind, TLS: c.Port == 443 && c.Kind == egress.KindConnect, Private: c.Private}
if !egress.Admits(set, req, egress.BuiltinRegistries()) {
v.CardAbove[c.ID] = true
}
}
}
for _, r := range pol.rec.Egress.Rules {
above := r.Allow && !egress.Admits(set, egress.Request{Host: r.Host, Port: r.Port, TLS: r.Port == 443}, egress.BuiltinRegistries())
v.Rules = append(v.Rules, EgressRuleView{Rule: r, AboveLimit: above})
}
if v.Preset == "" {
v.Preset = egress.PresetOff
}
cmds, err := s.CommandRunner(ctx, p, conv)
switch {
case err != nil:
v.AppliesReason = EgressAppliesNone
case cmds.State == CommandsAvailable && (cmds.Provider == BuiltinProvider || cmds.Isolation == "host"):
v.AppliesReason = EgressAppliesHost
case cmds.State == CommandsAvailable:
// Isolation alone does not establish network-policy enforcement: the
// settings apply only when the provider reports that it enforces them
// in a status that is available and isolated.
v.Applies = cmds.ExecutionStatus != nil && cmds.ExecutionStatus.NetworkPolicyEnforced
if !v.Applies {
v.AppliesReason = EgressAppliesNone
}
if c, ok := s.d.AddonTools.(AddonControls); ok && slices.Contains(c.Controls(ctx, p, cmds.Provider), contract.ControlNetworkPolicy) {
v.Addon = cmds.Provider
}
default:
v.AppliesReason = EgressAppliesNone
}
return v, nil
}
// SetEgressPreset sets the preset of a chat. A preset above the ceiling is
// refused; a sub-agent follows its parent and has none of its own.
func (s *Service) SetEgressPreset(ctx context.Context, user, conv string, preset egress.Preset) error {
p := principal(user)
if !preset.Valid() {
return ErrEgressPreset
}
rec, err := s.d.Store.GetChat(ctx, p, conv)
if err != nil {
return err
}
if rec.Kind == session.KindSubAgent {
return ErrSubAgentReadOnly
}
set, err := s.egressSettings(ctx)
if err != nil {
return err
}
if preset.Rank() > egress.Min(set.Ceiling, egress.PresetAll).Rank() {
return ErrEgressAboveCeiling
}
if err := s.d.Store.UpdateEgress(ctx, p, conv, func(st *egress.ChatState) error {
st.Preset = preset
return nil
}); err != nil {
return err
}
s.publishEgress(p, conv, "")
return nil
}
// DeleteEgressRule removes the allow or deny rule of a host and port.
func (s *Service) DeleteEgressRule(ctx context.Context, user, conv, host string, port int) error {
p := principal(user)
host = canonicalHost(host)
err := s.d.Store.UpdateEgress(ctx, p, conv, func(st *egress.ChatState) error {
if !st.DeleteRule(host, port) {
return ErrEgressNoRule
}
return nil
})
if err != nil {
return err
}
s.publishEgress(p, conv, "")
return nil
}
// publishEgress tells every open tab of the chat that its network changed:
// a card (id) or the rules and the preset (empty id).
func (s *Service) publishEgress(p, conv, card string) {
ev := session.Event{Kind: session.KindEgressCard}
if card != "" {
ev.IDs = []string{card}
}
s.bus.Publish(key(p, conv), ev)
}
// EgressAdmin is the administrator's view of the Sandbox network.
type EgressAdmin struct {
Settings egress.Settings
AuditDays int
Registries []egress.Registry
// Sandbox is the add-on that runs commands for the administrator, and
// Isolation how ("isolated", "host" or "" when unknown). Empty Sandbox:
// none serves, and the settings apply to nothing yet.
Sandbox, Isolation string
Last24h audit.Counts
}
// AdminEgress returns the settings in force, the registries list with the
// extra hosts, whether a Sandbox serves and the counts of the last 24 hours.
func (s *Service) AdminEgress(ctx context.Context, user string) (EgressAdmin, error) {
set, err := s.egressSettings(ctx)
if err != nil {
return EgressAdmin{}, err
}
set.Ceiling = egress.Min(set.Ceiling, egress.PresetAll)
set.Default = set.DefaultPreset()
if len(set.Ports) == 0 {
set.Ports = []int{80, 443}
}
out := EgressAdmin{Settings: set, Registries: registriesView(set), AuditDays: DefaultEgressAuditDays}
if s.d.EgressAuditDays != nil {
if d := s.d.EgressAuditDays(); d >= 1 && d <= 365 {
out.AuditDays = d
}
}
out.Sandbox, err = s.servingRunner(ctx, principal(user))
if err != nil {
return EgressAdmin{}, err
}
if out.Sandbox != "" {
if iso, ok := s.d.AddonTools.(CommandIsolation); ok {
out.Isolation = iso.Isolation(ctx, principal(user), out.Sandbox)
}
}
c, err := s.egressAudit().Count(ctx, s.now().Add(-24*time.Hour))
if err != nil {
return EgressAdmin{}, err
}
out.Last24h = c
return out, nil
}
// EgressEvents reads the network audit, newest first.
func (s *Service) EgressEvents(ctx context.Context, q audit.Query) ([]audit.Event, error) {
return s.egressAudit().Events(ctx, q)
}
// servingRunner is the add-on that runs the commands of the user's chats now:
// of the running add-ons that replace bash, the pinned one or the oldest
// install, or "" when none runs or the pinned one is stopped (the built-in
// serves). It asks the broker, so every frontend of a cluster answers the
// same, also right after a restart, and an add-on that was removed is gone.
func (s *Service) servingRunner(ctx context.Context, user string) (string, error) {
pins, err := s.toolPins(ctx)
if err != nil {
return "", err
}
if s.d.AddonTools == nil {
return "", nil
}
defs, err := s.d.AddonTools.Tools(ctx, user)
if err != nil {
return "", nil
}
var running []string
installed := map[string]string{}
for _, d := range defs {
if d.Shadows == commandTool && !slices.Contains(running, d.AddonID) {
running = append(running, d.AddonID)
installed[d.AddonID] = d.InstalledAt
}
}
if len(running) == 0 {
return "", nil
}
pin := pins[commandTool]
if s.shadowersFor(ctx, user).pinnedButIdle(commandTool, pin, running) {
return "", nil
}
return ChooseProvider(running, installed, pin), nil
}