From fc6abfca8c8ef52e507455ebe9ad8ffeb3053339 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 10:20:07 +0000 Subject: [PATCH] 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 --- core/application/addon_gateway.go | 22 +++++ core/config/chats_egress.go | 6 +- core/http/endpoints/studio/egress.go | 22 ++--- core/schema/chats.go | 37 ++++++-- core/services/addons/tools/adapter.go | 18 ++++ core/services/studio/capabilities.go | 18 ++++ core/services/studio/egress.go | 14 +-- core/services/studio/egress/egress.go | 37 ++++---- core/services/studio/egress/egress_test.go | 20 +++-- core/services/studio/egress_test.go | 6 +- core/services/studio/egressadmin_test.go | 2 +- core/services/studio/egressapi.go | 50 ++++++++--- core/services/studio/egressjson.go | 17 +++- core/services/studio/egressnetwork_test.go | 99 ++++++++++++++++++++++ core/services/studio/subagent.go | 4 +- pkg/egresspolicy/policy.go | 8 +- 16 files changed, 299 insertions(+), 81 deletions(-) create mode 100644 core/services/studio/egressnetwork_test.go diff --git a/core/application/addon_gateway.go b/core/application/addon_gateway.go index dc4864dd1c..f1323591b4 100644 --- a/core/application/addon_gateway.go +++ b/core/application/addon_gateway.go @@ -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: diff --git a/core/config/chats_egress.go b/core/config/chats_egress.go index 1606420440..cfa87d2a11 100644 --- a/core/config/chats_egress.go +++ b/core/config/chats_egress.go @@ -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) diff --git a/core/http/endpoints/studio/egress.go b/core/http/endpoints/studio/egress.go index d40d3587ce..a537e18b2f 100644 --- a/core/http/endpoints/studio/egress.go +++ b/core/http/endpoints/studio/egress.go @@ -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" diff --git a/core/schema/chats.go b/core/schema/chats.go index 6bc99966e6..88ce10388b 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -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"` diff --git a/core/services/addons/tools/adapter.go b/core/services/addons/tools/adapter.go index ae35081c69..424627ffbc 100644 --- a/core/services/addons/tools/adapter.go +++ b/core/services/addons/tools/adapter.go @@ -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 diff --git a/core/services/studio/capabilities.go b/core/services/studio/capabilities.go index e5b4d4839c..51d1164629 100644 --- a/core/services/studio/capabilities.go +++ b/core/services/studio/capabilities.go @@ -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 diff --git a/core/services/studio/egress.go b/core/services/studio/egress.go index d83bd999a9..93475f99b2 100644 --- a/core/services/studio/egress.go +++ b/core/services/studio/egress.go @@ -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 } diff --git a/core/services/studio/egress/egress.go b/core/services/studio/egress/egress.go index 293e0f539e..d5d774fbb6 100644 --- a/core/services/studio/egress/egress.go +++ b/core/services/studio/egress/egress.go @@ -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." } diff --git a/core/services/studio/egress/egress_test.go b/core/services/studio/egress/egress_test.go index 5321ae5c86..f5ba0a28c4 100644 --- a/core/services/studio/egress/egress_test.go +++ b/core/services/studio/egress/egress_test.go @@ -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")) } }) }) diff --git a/core/services/studio/egress_test.go b/core/services/studio/egress_test.go index e37c587fe2..0beb6eb4c7 100644 --- a/core/services/studio/egress_test.go +++ b/core/services/studio/egress_test.go @@ -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")) }) }) diff --git a/core/services/studio/egressadmin_test.go b/core/services/studio/egressadmin_test.go index 62e506d04b..3a7ee69b2e 100644 --- a/core/services/studio/egressadmin_test.go +++ b/core/services/studio/egressadmin_test.go @@ -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() { diff --git a/core/services/studio/egressapi.go b/core/services/studio/egressapi.go index e27ddcd2cc..4994533ce7 100644 --- a/core/services/studio/egressapi.go +++ b/core/services/studio/egressapi.go @@ -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 { diff --git a/core/services/studio/egressjson.go b/core/services/studio/egressjson.go index 0238c21a1c..c7c8370082 100644 --- a/core/services/studio/egressjson.go +++ b/core/services/studio/egressjson.go @@ -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, } } diff --git a/core/services/studio/egressnetwork_test.go b/core/services/studio/egressnetwork_test.go new file mode 100644 index 0000000000..19e46e73d1 --- /dev/null +++ b/core/services/studio/egressnetwork_test.go @@ -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") + }) +}) diff --git a/core/services/studio/subagent.go b/core/services/studio/subagent.go index 06b890eb41..f03a2b9e0f 100644 --- a/core/services/studio/subagent.go +++ b/core/services/studio/subagent.go @@ -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 } diff --git a/pkg/egresspolicy/policy.go b/pkg/egresspolicy/policy.go index c12419bc22..f53cbedc54 100644 --- a/pkg/egresspolicy/policy.go +++ b/pkg/egresspolicy/policy.go @@ -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) {