refactor(studio): name the network from the add-on's declaration

The chat's network view, the administrator view and the line the model
reads about the network of its commands now take their name from the
capabilities.network an add-on declares, or from the title of its
commands tool when it declares none. The sentences no longer say
"Sandbox" or "the sandbox". The API gains a network object with the
title and the preset titles next to the existing addon field.

The text of the built-in bash and of the host-exec setting names an
add-on that provides bash instead of one add-on.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 10:58:46 +00:00
1 parent 9dac1214bc
commit fc6abfca8c
16 files changed
+299 -81

No files matched your search

+22
View File
@@ -608,6 +608,14 @@ func (l lateStudioAddons) Controls(ctx context.Context, user, addonID string) []
return nil
}
// Network forwards to the broker when it knows the network the add-ons declare.
func (l lateStudioAddons) Network(ctx context.Context, user, addonID string) (studio.AddonNetwork, bool) {
if g, ok := l.a.StudioAddonTools().(studio.AddonNetworks); ok {
return g.Network(ctx, user, addonID)
}
return studio.AddonNetwork{}, false
}
func (l lateStudioAddons) Call(ctx context.Context, user, conv, name string, args json.RawMessage) (string, error) {
b := l.a.StudioAddonTools()
if b == nil {
@@ -717,6 +725,20 @@ func (s *studioAddons) Controls(ctx context.Context, user, addonID string) []str
return out
}
// Network returns the network the add-on declares while the apps list draws
// its network-policy control, as the user reads the list.
func (s *studioAddons) Network(ctx context.Context, user, addonID string) (studio.AddonNetwork, bool) {
actor, err := s.actor(user)
if err != nil {
return studio.AddonNetwork{}, false
}
v, ok, err := s.adapter.Network(ctx, actor, addonID)
if err != nil || !ok {
return studio.AddonNetwork{}, false
}
return studio.AddonNetwork{Title: v.Title, Presets: v.Presets}, true
}
func (s *studioAddons) actor(user string) (contract.Actor, error) {
switch s.mode {
case auth.AddonModeLocalOwner:
+3 -3
View File
@@ -4,8 +4,8 @@ package config
import "github.com/mudler/LocalAI/pkg/egresspolicy"
// EgressPrivatePhrase is what an administrator types to let the Sandbox
// commands reach private addresses (chats_egress_allow_private). It is checked
// EgressPrivatePhrase is what an administrator types to let the commands of an add-on
// reach private addresses (chats_egress_allow_private). It is checked
// by the settings endpoint and never stored.
const EgressPrivatePhrase = egresspolicy.EgressPrivatePhrase
@@ -15,7 +15,7 @@ const MaxEgressExtraRegistries = egresspolicy.MaxEgressExtraRegistries
// MaxEgressAllowedHosts bounds chats_egress_allowed_hosts.
const MaxEgressAllowedHosts = egresspolicy.MaxEgressAllowedHosts
// ValidEgressPreset says whether v is a preset of the Sandbox network: off,
// ValidEgressPreset says whether v is a preset of the network of commands: off,
// registries, all, or empty for the default (off).
func ValidEgressPreset(v string) bool {
return egresspolicy.ValidEgressPreset(v)
+11 -11
View File
@@ -11,9 +11,9 @@ import (
"github.com/mudler/LocalAI/core/services/studio/egress/audit"
)
// GetEgress returns the network of a chat's Sandbox commands.
// @Summary Read the network of a chat's Sandbox commands.
// @Description The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no Sandbox serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.
// GetEgress returns the network of a chat's commands.
// @Summary Read the network of a chat's commands.
// @Description The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no add-on serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.
// @Tags chats
// @Param id path string true "conversation id"
// @Success 200 {object} schema.ChatsEgress "The network of the chat"
@@ -28,7 +28,7 @@ func (h *Handlers) GetEgress(c echo.Context) error {
}
// PutEgress sets the preset of a chat.
// @Summary Set the network preset of a chat's Sandbox commands.
// @Summary Set the network preset of a chat's commands.
// @Description off reaches nothing; registries the HTTPS hosts of the package registries; all every public host. A preset above the administrator's ceiling (chats_egress_ceiling) is 400 egress_above_ceiling. A sub-agent follows its parent: 409. It applies at the next connection of a command.
// @Tags chats
// @Param id path string true "conversation id"
@@ -55,7 +55,7 @@ func (h *Handlers) PutEgress(c echo.Context) error {
// DeleteEgressRule removes an allow or deny rule of a chat.
// @Summary Remove a network rule of a chat.
// @Description Removes the rule that allows or denies one host and port for the chat's Sandbox commands. A host that was denied asks again; one that was allowed follows the preset again.
// @Description Removes the rule that allows or denies one host and port for the chat's commands. A host that was denied asks again; one that was allowed follows the preset again.
// @Tags chats
// @Param id path string true "conversation id"
// @Param host path string true "host name"
@@ -74,8 +74,8 @@ func (h *Handlers) DeleteEgressRule(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
// AdminEgress returns the administrator's view of the Sandbox network.
// @Summary Read the settings of the Sandbox network (administrators).
// AdminEgress returns the administrator's view of the network of commands.
// @Summary Read the settings of the network of commands (administrators).
// @Description The chats_egress_* settings in force (ceiling, default, allow_private, ports, extra_registries, allowed_hosts, audit_days), the package registries with the extra hosts, the add-on that runs commands and how (isolated or host; the settings do not apply to commands on this machine), and the allowed and blocked requests of the last 24 hours. Change the settings with POST /api/settings.
// @Tags chats
// @Success 200 {object} schema.ChatsEgressAdmin "The settings in force"
@@ -88,9 +88,9 @@ func (h *Handlers) AdminEgress(c echo.Context) error {
return c.JSON(http.StatusOK, v.JSON())
}
// AdminEgressEvents returns the audit of the Sandbox network.
// @Summary Read the audit of the Sandbox network requests (administrators).
// @Description Every request a command made through the Sandbox proxy, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.
// AdminEgressEvents returns the audit of the network of commands.
// @Summary Read the audit of the network of commands requests (administrators).
// @Description Every request a command made through the proxy of the add-on, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.
// @Tags chats
// @Param limit query int false "How many lines (default 100, at most 500)"
// @Param user query string false "Only this user"
@@ -110,7 +110,7 @@ func (h *Handlers) AdminEgressEvents(c echo.Context) error {
}
// EgressConsent answers a card that asks whether a command may reach a host.
// @Summary Answer a network card of a chat's Sandbox commands.
// @Summary Answer a network card of a chat's commands.
// @Description A command asked for a host the chat does not allow but the administrator's ceiling would: an egress_consent event carried the card id and host:port, and the card is in GET /api/chats/{id}/egress. once allows the host for the rest of that command (or the next call of the chat within ten minutes when the answer comes late), chat stores an allow rule for the chat, deny stores a deny rule so the host does not ask again. A host inside your network (private) needs an administrator to allow it; anyone who owns the chat may deny. A card that expired at the end of its turn may still be answered. The waiting connection goes on at once; without an answer it gets a 403 after 60 seconds.
// @Tags chats
// @Param id path string true "conversation id"
+30 -7
View File
@@ -1632,7 +1632,10 @@ type ChatsEgress struct {
// network-policy host control: the chat draws its Network row and links
// an administrator to that add-on's settings. Absent when applies is
// false or no such add-on runs the commands.
Addon string `json:"addon,omitempty" example:"sandbox"`
Addon string `json:"addon,omitempty" example:"sandbox"`
// Network names the network of addon in text and gives the preset titles
// the add-on declares. Set with addon.
Network *ChatsEgressNetwork `json:"network,omitempty"`
Registries []ChatsEgressRegistry `json:"registries"`
// AllowedHosts is the administrator's always-allowed list (host or
// host:port), read only here: it applies when the effective preset is
@@ -1642,6 +1645,23 @@ type ChatsEgress struct {
Cards []ChatsEgressCard `json:"cards"`
}
// ChatsEgressNetwork is the network of the add-on that runs the commands, as
// the add-on declares it.
type ChatsEgressNetwork struct {
// Title names the network in text: the declared title, else the title of
// the commands tool.
Title string `json:"title" example:"Sandbox"`
// Presets are the titles the add-on gives the host's presets; a preset
// that is not listed keeps the host's title.
Presets []ChatsEgressNetworkPreset `json:"presets"`
}
// ChatsEgressNetworkPreset is the title an add-on gives one preset.
type ChatsEgressNetworkPreset struct {
ID string `json:"id" enums:"off,registries,all" example:"off"`
Title string `json:"title" example:"Closed"`
}
// ChatsEgressPreset sets the preset of a chat.
type ChatsEgressPreset struct {
Preset string `json:"preset"`
@@ -1654,7 +1674,7 @@ type ChatsEgressConsent struct {
Decision string `json:"decision"`
}
// ChatsEgressAdmin is the administrator's view of the Sandbox network.
// ChatsEgressAdmin is the administrator's view of the network of commands.
type ChatsEgressAdmin struct {
Ceiling string `json:"ceiling"`
Default string `json:"default"`
@@ -1664,15 +1684,18 @@ type ChatsEgressAdmin struct {
AllowedHosts []string `json:"allowed_hosts"`
AuditDays int `json:"audit_days"`
Registries []ChatsEgressRegistry `json:"registries"`
// Sandbox is the add-on that runs commands, empty when none serves;
// Isolation is isolated, host, or empty when unknown.
// Sandbox is the add-on that runs commands, empty when none serves (the
// name of the field is kept for existing clients); Isolation is isolated,
// host, or empty when unknown.
Sandbox string `json:"sandbox,omitempty"`
Isolation string `json:"isolation,omitempty"`
Allowed int64 `json:"allowed_24h"`
Blocked int64 `json:"blocked_24h"`
// Network names the network of that add-on in text. Set with sandbox.
Network *ChatsEgressNetwork `json:"network,omitempty"`
Allowed int64 `json:"allowed_24h"`
Blocked int64 `json:"blocked_24h"`
}
// ChatsEgressEvent is one line of the Sandbox network audit.
// ChatsEgressEvent is one line of the audit of network requests.
type ChatsEgressEvent struct {
User string `json:"user"`
UserName string `json:"user_name,omitempty"`
+18
View File
@@ -398,6 +398,24 @@ func (t *ToolAdapter) Controls(ctx context.Context, actor contract.Actor, addonI
return nil, nil
}
// Network returns the network an add-on declares, when the actor can see the
// add-on and the apps list draws its network-policy control.
func (t *ToolAdapter) Network(ctx context.Context, actor contract.Actor, addonID string) (apps.NetworkView, bool, error) {
if err := checkActor(actor); err != nil {
return apps.NetworkView{}, false, err
}
list, err := t.backend.Apps(ctx, actor)
if err != nil {
return apps.NetworkView{}, false, gateway.ClassifyCheck(err)
}
for _, a := range list.Apps {
if a.ID == addonID && a.Network != nil {
return *a.Network, true, nil
}
}
return apps.NetworkView{}, false, nil
}
// AddonSkills is the skills of one add-on the actor can see.
type AddonSkills struct {
AddonID, Title string
+18
View File
@@ -39,6 +39,24 @@ type AddonControls interface {
Controls(ctx context.Context, user, addonID string) []string
}
// AddonNetwork is the network an add-on declares for the commands it runs,
// as the host shows it (see contract.NetworkCapability).
type AddonNetwork struct {
// Title names the network in text: the declared title, else the add-on's.
Title string
// Presets are the titles the add-on gives the host's network presets.
Presets []contract.NetworkPreset
}
// AddonNetworks is implemented by a broker that knows the network each
// add-on declares. It is optional: an add-on it does not answer for gets the
// title of its commands tool.
type AddonNetworks interface {
// Network returns the network of the add-on while the host draws its
// network-policy control.
Network(ctx context.Context, user, addonID string) (AddonNetwork, bool)
}
// CommandState says whether a chat can run commands.
type CommandState string
+7 -7
View File
@@ -9,8 +9,8 @@ import (
"github.com/mudler/LocalAI/core/services/studio/session"
)
// The network of the Sandbox commands. The guest has no network device: the
// only way out is the proxy of the Sandbox add-on, which asks this service
// The network of the commands an add-on runs. The environment has no network
// device: the only way out is the add-on's proxy, which asks this service
// about each connection through the host operations egress.check and
// egress.audit (see egresscheck.go). The limits are the administrator's
// runtime settings; the chat's preset, rules and cards live in its record.
@@ -73,8 +73,8 @@ func (s *Service) now() time.Time {
// egressLine is the line of the turn's note that tells the model what the
// network of its commands is: the preset that applies (the chat's, its
// parent's for a sub-agent, its agent's for an unattended run) under the
// ceiling.
func (s *Service) egressLine(ctx context.Context, rec session.ChatRecord, web bool) (string, error) {
// ceiling. name is the network of the add-on that runs the commands.
func (s *Service) egressLine(ctx context.Context, rec session.ChatRecord, web bool, name string) (string, error) {
set, err := s.egressSettings(ctx)
if err != nil {
return "", err
@@ -89,7 +89,7 @@ func (s *Service) egressLine(ctx context.Context, rec session.ChatRecord, web bo
}
if ceiling == egress.PresetOff {
// Hint would tell the model to ask the user to turn the network on.
return egress.DescribeLimit(preset, ceiling, egress.BuiltinRegistries(), false), nil
return egress.DescribeLimit(preset, ceiling, egress.BuiltinRegistries(), false, name), nil
}
// A card appears only in a chat a person watches (not an unattended run
// or a sub-agent), and only for a host the ceiling admits beyond the
@@ -97,7 +97,7 @@ func (s *Service) egressLine(ctx context.Context, rec session.ChatRecord, web bo
attended := rec.Origin == "" && rec.Kind != session.KindSubAgent
asks := attended && (ceiling.Rank() > preset.Rank() || set.AllowPrivate)
if capped {
return egress.DescribeLimit(preset, ceiling, egress.BuiltinRegistries(), asks) + " " + egress.Hint(preset, web), nil
return egress.DescribeLimit(preset, ceiling, egress.BuiltinRegistries(), asks, name) + " " + egress.Hint(preset, web, name), nil
}
return egress.Describe(preset, egress.BuiltinRegistries(), asks) + " " + egress.Hint(preset, web), nil
return egress.Describe(preset, egress.BuiltinRegistries(), asks, name) + " " + egress.Hint(preset, web, name), nil
}
+19 -18
View File
@@ -1,10 +1,10 @@
// Package egress is the network policy of the commands a chat runs in the
// Sandbox. The guest has no network device; its only way out is a proxy on the
// host, and every connection the proxy is asked for is decided here: by the
// administrator's ceiling, the chat's preset, the rules the person stored for
// the chat and the answers to the cards that asked them. The package holds the
// policy and nothing that does I/O, so the same decision is made on every
// frontend.
// Package egress is the network policy of the commands a chat runs in an
// add-on that declares a network capability. The environment has no network
// device; its only way out is a proxy that asks the host, and every connection
// the proxy is asked for is decided here: by the administrator's ceiling, the
// chat's preset, the rules the person stored for the chat and the answers to
// the cards that asked them. The package holds the policy and nothing that
// does I/O, so the same decision is made on every frontend.
package egress
import (
@@ -491,11 +491,12 @@ func Admits(s Settings, r Request, reg *Registries) bool {
}
// Describe is the one line the model reads about the network of its commands.
// asks says whether a blocked host can raise a card that a person answers:
// only in a chat a person watches, under a ceiling wider than the chat's
// name is the title of the environment that runs them, from the add-on's
// declaration. asks says whether a blocked host can raise a card that a person
// answers: only in a chat a person watches, under a ceiling wider than the chat's
// preset. Without it the model is told to ask the user in its answer, since
// no card can appear.
func Describe(p Preset, reg *Registries, asks bool) string {
func Describe(p Preset, reg *Registries, asks bool, name string) string {
tail := " A blocked host asks the user; do not try to get around the proxy."
if !asks {
tail = " A blocked host is refused, and nothing asks the user; say which host you need in your answer, and do not try to get around the proxy."
@@ -504,7 +505,7 @@ func Describe(p Preset, reg *Registries, asks bool) string {
case PresetRegistries:
return "Network for commands: package registries only (" + strings.Join(reg.Titles(), ", ") + ")." + tail
case PresetAll:
return "Network for commands: all public sites, through the proxy of the environment that runs them." + tail
return "Network for commands: all public sites, through the proxy that " + name + " sets." + tail
}
return "Network for commands: off." + tail
}
@@ -513,14 +514,14 @@ func Describe(p Preset, reg *Registries, asks bool) string {
// not the chat, keeps the network narrower than the chat asked: the
// administrator's ceiling. Nobody can approve a host outside the limit, so
// the line says not to retry one; only an administrator can change it. asks
// is as for Describe, for the hosts inside the limit.
func DescribeLimit(eff, ceiling Preset, reg *Registries, asks bool) string {
// and name are as for Describe, for the hosts inside the limit.
func DescribeLimit(eff, ceiling Preset, reg *Registries, asks bool, name string) string {
const where = "An administrator can change it on the Settings tab of the add-on that runs the commands, on the Add-ons page."
if normal(ceiling) == PresetOff {
return "Network for commands: off. The server's setting for the network of commands does not allow any access, and no user approval changes that, so do not wait for one or retry a blocked request. " +
return "Network for commands: off. The server's setting for the " + name + " network does not allow any access, and no user approval changes that, so do not wait for one or retry a blocked request. " +
"Tell the user the network is off by the server's setting. " + where
}
return Describe(eff, reg, asks) + " The server's setting for the network of commands limits it; a host outside the limit is blocked by that setting, not by a missing approval, so do not retry it. Tell the user. " + where
return Describe(eff, reg, asks, name) + " The server's setting for the " + name + " network limits it; a host outside the limit is blocked by that setting, not by a missing approval, so do not retry it. Tell the user. " + where
}
// Hint is the sentence after Describe in the turn's note: which tool to use
@@ -528,12 +529,12 @@ func DescribeLimit(eff, ceiling Preset, reg *Registries, asks bool) string {
// anything, so the sentence says that instead. web says whether the chat has
// web_fetch or web_search (the built-in ones or an add-on's): only then are
// they named.
func Hint(p Preset, web bool) string {
func Hint(p Preset, web bool, name string) string {
if normal(p) == PresetOff {
return "curl, pip and git cannot reach anything now; ask the user to turn the network on if a task needs it."
}
if web {
return "curl, pip and git work in the isolated environment through the proxy. To read or search web pages use web_fetch or web_search; use curl for APIs, downloads and installs."
return "curl, pip and git work in " + name + " through the proxy. To read or search web pages use web_fetch or web_search; use curl for APIs, downloads and installs."
}
return "curl, pip and git work in the isolated environment through the proxy."
return "curl, pip and git work in " + name + " through the proxy."
}
+11 -9
View File
@@ -257,33 +257,35 @@ var _ = Describe("the registries list", func() {
})
It("describes the presets to the model", func() {
Expect(egress.Describe(egress.PresetRegistries, reg, true)).To(ContainSubstring("Python (PyPI)"))
Expect(egress.Describe(egress.PresetOff, reg, true)).To(HavePrefix("Network for commands: off."))
Expect(egress.Describe(egress.PresetOff, reg, true)).To(ContainSubstring("A blocked host asks the user"))
Expect(egress.Describe(egress.PresetOff, reg, false)).NotTo(ContainSubstring("A blocked host asks the user"))
Expect(egress.Describe(egress.PresetOff, reg, false)).To(ContainSubstring("nothing asks the user"))
Expect(egress.Describe(egress.PresetRegistries, reg, true, "Runbox")).To(ContainSubstring("Python (PyPI)"))
Expect(egress.Describe(egress.PresetOff, reg, true, "Runbox")).To(HavePrefix("Network for commands: off."))
Expect(egress.Describe(egress.PresetOff, reg, true, "Runbox")).To(ContainSubstring("A blocked host asks the user"))
Expect(egress.Describe(egress.PresetOff, reg, false, "Runbox")).NotTo(ContainSubstring("A blocked host asks the user"))
Expect(egress.Describe(egress.PresetOff, reg, false, "Runbox")).To(ContainSubstring("nothing asks the user"))
Expect(egress.Describe(egress.PresetAll, reg, true, "Runbox")).To(ContainSubstring("through the proxy that Runbox sets"))
})
Describe("the hint on tools", func() {
It("names the web tools only when the chat has them", func() {
on := egress.Hint(egress.PresetAll, true)
on := egress.Hint(egress.PresetAll, true, "Runbox")
Expect(on).To(ContainSubstring("curl"))
Expect(on).To(ContainSubstring("web_fetch or web_search"))
Expect(on).To(ContainSubstring("APIs, downloads and installs"))
Expect(on).To(ContainSubstring("work in Runbox through the proxy"))
})
It("does not name the web tools when they are off", func() {
off := egress.Hint(egress.PresetRegistries, false)
off := egress.Hint(egress.PresetRegistries, false, "Runbox")
Expect(off).To(ContainSubstring("curl, pip and git work"))
Expect(off).NotTo(ContainSubstring("web_"))
})
It("tells the model that nothing is reachable when the network is off", func() {
for _, web := range []bool{true, false} {
h := egress.Hint(egress.PresetOff, web)
h := egress.Hint(egress.PresetOff, web, "Runbox")
Expect(h).To(ContainSubstring("cannot reach anything"))
Expect(h).NotTo(ContainSubstring("web_"))
Expect(h).NotTo(ContainSubstring("work in the isolated environment"))
Expect(h).NotTo(ContainSubstring("work in Runbox"))
}
})
})
+3 -3
View File
@@ -170,15 +170,15 @@ var _ = Describe("egress.check and egress.audit on the chats service", func() {
})
It("tells the model that the server's setting, not an approval, keeps the network off", func() {
off := egress.DescribeLimit(egress.PresetOff, egress.PresetOff, egress.BuiltinRegistries(), false)
off := egress.DescribeLimit(egress.PresetOff, egress.PresetOff, egress.BuiltinRegistries(), false, "Sandbox")
Expect(off).To(SatisfyAll(ContainSubstring("server's setting"), ContainSubstring("do not wait"), ContainSubstring("Settings tab of the add-on that runs the commands")))
Expect(off).NotTo(ContainSubstring("asks the user"))
capped := egress.DescribeLimit(egress.PresetRegistries, egress.PresetRegistries, egress.BuiltinRegistries(), false)
capped := egress.DescribeLimit(egress.PresetRegistries, egress.PresetRegistries, egress.BuiltinRegistries(), false, "Sandbox")
Expect(capped).To(SatisfyAll(ContainSubstring("package registries only"), ContainSubstring("not by a missing approval")))
})
It("tells the model the network of its commands", func() {
Expect(egress.Describe(egress.PresetRegistries, egress.BuiltinRegistries(), true)).To(ContainSubstring("package registries only"))
Expect(egress.Describe(egress.PresetRegistries, egress.BuiltinRegistries(), true, "Sandbox")).To(ContainSubstring("package registries only"))
})
})
+1 -1
View File
@@ -29,7 +29,7 @@ var _ = Describe("the add-on that the network settings apply to", func() {
sandboxOf := func(svc *studio.Service) string {
v, err := svc.AdminEgress(context.Background(), alice)
Expect(err).NotTo(HaveOccurred())
return v.Sandbox
return v.Runner
}
It("is the running add-on that replaces bash, before any chat ran", func() {
+37 -13
View File
@@ -12,7 +12,7 @@ import (
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// The routes of the network of a chat's Sandbox commands: the chat's own
// The routes of the network of a chat's commands: the chat's own
// view and preset for its owner, and the administrator's view.
var (
@@ -28,7 +28,7 @@ var (
// 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
EgressAppliesNone = "none" // no add-on 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.
@@ -36,7 +36,7 @@ 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
// machine, no add-on serves bash, or the provider does not report
// enforcement; AppliesReason then says which.
Applies bool
AppliesReason string
@@ -44,7 +44,10 @@ type EgressView struct {
// 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
Addon string
// Network names the network of Addon in text and gives the titles it
// declares for the presets. Set with Addon.
Network *AddonNetwork
Registries []egress.Registry
// AllowedHosts is the administrator's always-allowed list, canonical.
AllowedHosts []string
@@ -129,6 +132,8 @@ func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, er
}
if c, ok := s.d.AddonTools.(AddonControls); ok && slices.Contains(c.Controls(ctx, p, cmds.Provider), contract.ControlNetworkPolicy) {
v.Addon = cmds.Provider
n := s.networkOf(ctx, p, cmds)
v.Network = &n
}
default:
v.AppliesReason = EgressAppliesNone
@@ -194,20 +199,37 @@ func (s *Service) publishEgress(p, conv, card string) {
s.bus.Publish(key(p, conv), ev)
}
// EgressAdmin is the administrator's view of the Sandbox network.
// networkOf is the network of the add-on that runs the commands in cmds: what
// the add-on declares, else the title of its commands tool.
func (s *Service) networkOf(ctx context.Context, user string, cmds Commands) AddonNetwork {
n := AddonNetwork{Title: cmds.Title}
if b, ok := s.d.AddonTools.(AddonNetworks); ok {
if d, ok := b.Network(ctx, user, cmds.Provider); ok {
n.Presets = d.Presets
if d.Title != "" {
n.Title = d.Title
}
}
}
return n
}
// EgressAdmin is the administrator's view of the network of commands.
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:
// Runner is the add-on that runs commands for the administrator, and
// Isolation how ("isolated", "host" or "" when unknown). Empty Runner:
// none serves, and the settings apply to nothing yet.
Sandbox, Isolation string
Last24h audit.Counts
Runner, Isolation string
// Network names the network of Runner in text, when one serves.
Network *AddonNetwork
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.
// extra hosts, whether an add-on 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 {
@@ -224,14 +246,16 @@ func (s *Service) AdminEgress(ctx context.Context, user string) (EgressAdmin, er
out.AuditDays = d
}
}
out.Sandbox, err = s.servingRunner(ctx, principal(user))
out.Runner, err = s.servingRunner(ctx, principal(user))
if err != nil {
return EgressAdmin{}, err
}
if out.Sandbox != "" {
if out.Runner != "" {
if iso, ok := s.d.AddonTools.(CommandIsolation); ok {
out.Isolation = iso.Isolation(ctx, principal(user), out.Sandbox)
out.Isolation = iso.Isolation(ctx, principal(user), out.Runner)
}
n := s.networkOf(ctx, principal(user), Commands{Provider: out.Runner, Title: s.commandTitle(ctx, principal(user), out.Runner)})
out.Network = &n
}
c, err := s.egressAudit().Count(ctx, s.now().Add(-24*time.Hour))
if err != nil {
+14 -3
View File
@@ -9,7 +9,7 @@ import (
"github.com/mudler/LocalAI/core/services/studio/egress/audit"
)
// The JSON forms of the Sandbox network, shared by the routes and the
// The JSON forms of the network of commands, shared by the routes and the
// in-process assistant tools so that both answer the same.
func registriesJSON(list []egress.Registry) []schema.ChatsEgressRegistry {
@@ -20,6 +20,17 @@ func registriesJSON(list []egress.Registry) []schema.ChatsEgressRegistry {
return out
}
func networkJSON(n *AddonNetwork) *schema.ChatsEgressNetwork {
if n == nil {
return nil
}
out := &schema.ChatsEgressNetwork{Title: n.Title, Presets: []schema.ChatsEgressNetworkPreset{}}
for _, p := range n.Presets {
out.Presets = append(out.Presets, schema.ChatsEgressNetworkPreset{ID: p.ID, Title: p.Title})
}
return out
}
// EgressCardJSON is the JSON form of a card.
func EgressCardJSON(c egress.Card) schema.ChatsEgressCard {
out := schema.ChatsEgressCard{ID: c.ID, Host: c.Host, Port: c.Port, Kind: c.Kind, State: c.State, Private: c.Private, ToolSeq: c.ToolSeq,
@@ -34,7 +45,7 @@ func EgressCardJSON(c egress.Card) schema.ChatsEgressCard {
// JSON is the answer of GET /api/chats/{id}/egress.
func (v EgressView) JSON() schema.ChatsEgress {
out := schema.ChatsEgress{Preset: string(v.Preset), Ceiling: string(v.Ceiling), Effective: string(v.Effective), Applies: v.Applies,
AppliesReason: v.AppliesReason, Addon: v.Addon, Registries: registriesJSON(v.Registries), AllowedHosts: v.AllowedHosts, Rules: []schema.ChatsEgressRule{}, Cards: []schema.ChatsEgressCard{}}
AppliesReason: v.AppliesReason, Addon: v.Addon, Network: networkJSON(v.Network), Registries: registriesJSON(v.Registries), AllowedHosts: v.AllowedHosts, Rules: []schema.ChatsEgressRule{}, Cards: []schema.ChatsEgressCard{}}
for _, r := range v.Rules {
out.Rules = append(out.Rules, schema.ChatsEgressRule{Host: r.Host, Port: r.Port, Allow: r.Allow, By: r.By, Admin: r.Admin, At: r.At, AboveLimit: r.AboveLimit})
}
@@ -55,7 +66,7 @@ func (a EgressAdmin) JSON() schema.ChatsEgressAdmin {
return schema.ChatsEgressAdmin{
Ceiling: string(a.Settings.Ceiling), Default: string(a.Settings.Default), AllowPrivate: a.Settings.AllowPrivate,
Ports: a.Settings.Ports, ExtraRegistries: extra, AllowedHosts: a.Settings.AllowedHostList(), AuditDays: a.AuditDays, Registries: registriesJSON(a.Registries),
Sandbox: a.Sandbox, Isolation: a.Isolation, Allowed: a.Last24h.Allowed, Blocked: a.Last24h.Blocked,
Sandbox: a.Runner, Network: networkJSON(a.Network), Isolation: a.Isolation, Allowed: a.Last24h.Allowed, Blocked: a.Last24h.Blocked,
}
}
@@ -0,0 +1,99 @@
package studio_test
import (
"context"
"strings"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/egress"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// networkAddons is a broker that also says which network each add-on declares.
type networkAddons struct {
controlledAddons
networks map[string]studio.AddonNetwork
}
func (n networkAddons) Network(_ context.Context, _, addonID string) (studio.AddonNetwork, bool) {
v, ok := n.networks[addonID]
return v, ok
}
// A second add-on that runs commands, with another ID and title than the
// shipped Sandbox, and that declares its network: the host draws the same
// control and builds the same text from the declaration.
var _ = Describe("the declared network of the add-on that runs the commands", func() {
// runner is an add-on with a commands tool whose descriptor has title.
runner := func(id, title string) []studio.AddonTool {
bash := addonTool(id, "bash")
bash.Action, bash.Shadows = true, "bash"
bash.Execution = &contract.ExecutionDescriptor{Class: contract.ExecutionClassCommands, Title: title}
return []studio.AddonTool{bash}
}
runbox := studio.AddonNetwork{Title: "Runbox", Presets: []contract.NetworkPreset{{ID: "off", Title: "Closed"}}}
service := func(id, title string, networks map[string]studio.AddonNetwork, settings egress.Settings) (*studio.Service, *studiotest.ToolHarness) {
fa := &fakeAddons{tools: map[string][]studio.AddonTool{alice: runner(id, title)}}
f := newFiles()
return newToolService(func(workspace.Actor, string) []studiotest.ToolStep { return []studiotest.ToolStep{{Say: "ok"}} }, func(d *studio.Deps) {
d.Files = f
d.AddonTools = networkAddons{controlledAddons{isolatedAddons{fa, "isolated"}, map[string][]string{id: {"network-policy"}}}, networks}
d.EgressSettings = func(context.Context) (egress.Settings, error) { return settings, nil }
})
}
turn := func(svc *studio.Service, h *studiotest.ToolHarness) string {
id := create(svc, alice)
sent(svc, alice, id, "hello")
idle(svc, alice, id)
return h.TurnPrompt(id) + "\n" + strings.Join(h.Sent(id), "\n")
}
It("gives the chat the declared title and preset titles for its row", func() {
svc, _ := service("devbox", "Runbox", map[string]studio.AddonNetwork{"devbox": runbox}, egress.Settings{Ceiling: egress.PresetAll})
v, err := svc.Egress(context.Background(), alice, create(svc, alice))
Expect(err).NotTo(HaveOccurred())
j := v.JSON()
Expect(j.Addon).To(Equal("devbox"))
Expect(j.Network).NotTo(BeNil())
Expect(j.Network.Title).To(Equal("Runbox"))
Expect(j.Network.Presets).To(ConsistOf(HaveField("Title", "Closed")))
})
It("falls back to the title of the commands tool for an add-on that declares no network", func() {
svc, _ := service("sandbox", "Sandbox", nil, egress.Settings{Ceiling: egress.PresetAll})
v, _ := svc.Egress(context.Background(), alice, create(svc, alice))
Expect(v.JSON().Addon).To(Equal("sandbox"))
Expect(v.JSON().Network.Title).To(Equal("Sandbox"))
})
It("writes the declared title in the line the model reads and names no other add-on", func() {
svc, h := service("devbox", "Runbox", map[string]studio.AddonNetwork{"devbox": runbox}, egress.Settings{Ceiling: egress.PresetOff})
got := turn(svc, h)
Expect(got).To(ContainSubstring("The server's setting for the Runbox network"))
Expect(strings.ToLower(got)).NotTo(ContainSubstring("sandbox"))
svc, h = service("devbox", "Runbox", map[string]studio.AddonNetwork{"devbox": runbox}, egress.Settings{Ceiling: egress.PresetAll, Default: egress.PresetAll})
got = turn(svc, h)
Expect(got).To(ContainSubstring("through the proxy that Runbox sets"))
Expect(got).To(ContainSubstring("curl, pip and git work in Runbox through the proxy"))
Expect(strings.ToLower(got)).NotTo(ContainSubstring("sandbox"))
})
It("writes the same line for the shipped Sandbox from its own declaration", func() {
svc, h := service("sandbox", "Sandbox", map[string]studio.AddonNetwork{"sandbox": {Title: "Sandbox"}}, egress.Settings{Ceiling: egress.PresetOff})
Expect(turn(svc, h)).To(ContainSubstring("The server's setting for the Sandbox network"))
})
It("tells the administrator view which network the settings apply to", func() {
svc, _ := service("devbox", "Runbox", map[string]studio.AddonNetwork{"devbox": runbox}, egress.Settings{Ceiling: egress.PresetAll})
a, err := svc.AdminEgress(context.Background(), alice)
Expect(err).NotTo(HaveOccurred())
Expect(a.JSON().Network.Title).To(Equal("Runbox"))
Expect(a.JSON().Sandbox).To(Equal("devbox"), "the field keeps its name for existing clients")
})
})
+2 -2
View File
@@ -189,10 +189,10 @@ func (s *Service) chatTools(ctx context.Context, x session.Access) (session.Turn
if line := s.hintLine(ctx, a, rec); line != "" {
note = strings.TrimSpace(line + " " + note)
}
// The network of the Sandbox commands, in one line: it follows the
// The network of the commands an add-on runs, in one line: it follows the
// chat's preset at each turn and needs no restart of the harness.
if cmds.State == CommandsAvailable && cmds.Isolation == "isolated" && cmds.Provider != BuiltinProvider {
line, err := s.egressLine(ctx, rec, hasTool(tools, webSearchName) || hasTool(tools, webFetchName))
line, err := s.egressLine(ctx, rec, hasTool(tools, webSearchName) || hasTool(tools, webFetchName), s.networkOf(ctx, a.Principal, cmds).Title)
if err != nil {
return session.TurnTools{}, err
}
+4 -4
View File
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: MIT
// Package egresspolicy parses Sandbox network policy without application dependencies.
// Package egresspolicy parses the network policy of commands without application dependencies.
package egresspolicy
import (
@@ -14,8 +14,8 @@ import (
"golang.org/x/net/idna"
)
// EgressPrivatePhrase is what an administrator types to let the Sandbox
// commands reach private addresses (chats_egress_allow_private). It is checked
// EgressPrivatePhrase is what an administrator types to let the commands of an add-on
// reach private addresses (chats_egress_allow_private). It is checked
// by the settings endpoint and never stored.
const EgressPrivatePhrase = "I understand that commands can reach my private network"
@@ -25,7 +25,7 @@ const MaxEgressExtraRegistries = 64
// MaxEgressAllowedHosts bounds chats_egress_allowed_hosts.
const MaxEgressAllowedHosts = 64
// ValidEgressPreset says whether v is a preset of the Sandbox network: off,
// ValidEgressPreset says whether v is a preset of the network of commands: off,
// registries, all, or empty for the default (off).
func ValidEgressPreset(v string) bool {
switch strings.TrimSpace(v) {