diff --git a/cmd/firewall.go b/cmd/firewall.go
new file mode 100644
index 0000000..59c155a
--- /dev/null
+++ b/cmd/firewall.go
@@ -0,0 +1,126 @@
+// ExitBox - Multi-Agent Container Sandbox
+// Copyright (C) 2026 Cloud Exit B.V.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "fmt"
+ "strconv"
+
+ "github.com/cloud-exit/exitbox/internal/config"
+ "github.com/cloud-exit/exitbox/internal/network"
+ "github.com/cloud-exit/exitbox/internal/ui"
+ "github.com/spf13/cobra"
+)
+
+func newFirewallCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "firewall",
+ Short: "Manage firewall exclusions",
+ }
+ ports := &cobra.Command{
+ Use: "ports",
+ Short: "Manage host ports reachable as localhost: from agents",
+ }
+ ports.AddCommand(newFirewallPortsListCmd())
+ ports.AddCommand(newFirewallPortsAddCmd())
+ ports.AddCommand(newFirewallPortsRemoveCmd())
+ cmd.AddCommand(ports)
+ return cmd
+}
+
+func newFirewallPortsListCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "List persistent host port exclusions",
+ Run: func(cmd *cobra.Command, args []string) {
+ al := config.LoadAllowlistOrDefault()
+ ports := network.NormalizeHostPorts(al.HostPorts)
+ if len(ports) == 0 {
+ ui.Info("No host ports configured.")
+ return
+ }
+ for _, port := range ports {
+ fmt.Println(port)
+ }
+ },
+ }
+}
+
+func newFirewallPortsAddCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "add [port...]",
+ Short: "Allow host ports from agents via localhost:",
+ Args: cobra.MinimumNArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ ports := parseFirewallPorts(args)
+ al := config.LoadAllowlistOrDefault()
+ al.HostPorts = network.NormalizeHostPorts(append(al.HostPorts, ports...))
+ if err := config.SaveAllowlist(al); err != nil {
+ ui.Errorf("Failed to save firewall ports: %v", err)
+ }
+ ui.Successf("Allowed host ports: %s", network.HostPortsEnvValue(al.HostPorts))
+ },
+ }
+}
+
+func newFirewallPortsRemoveCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "remove [port...]",
+ Short: "Remove persistent host port exclusions",
+ Args: cobra.MinimumNArgs(1),
+ Run: func(cmd *cobra.Command, args []string) {
+ ports := parseFirewallPorts(args)
+ remove := make(map[int]struct{}, len(ports))
+ for _, port := range ports {
+ remove[port] = struct{}{}
+ }
+
+ al := config.LoadAllowlistOrDefault()
+ var kept []int
+ for _, port := range network.NormalizeHostPorts(al.HostPorts) {
+ if _, ok := remove[port]; !ok {
+ kept = append(kept, port)
+ }
+ }
+ al.HostPorts = kept
+ if err := config.SaveAllowlist(al); err != nil {
+ ui.Errorf("Failed to save firewall ports: %v", err)
+ }
+ if len(al.HostPorts) == 0 {
+ ui.Success("No host ports allowed.")
+ } else {
+ ui.Successf("Allowed host ports: %s", network.HostPortsEnvValue(al.HostPorts))
+ }
+ },
+ }
+}
+
+func parseFirewallPorts(args []string) []int {
+ var ports []int
+ for _, arg := range args {
+ port, err := strconv.Atoi(arg)
+ if err != nil || port < 1 || port > 65535 {
+ ui.Errorf("Invalid port %q: must be 1-65535", arg)
+ }
+ ports = append(ports, port)
+ }
+ return ports
+}
+
+func init() {
+ rootCmd.AddCommand(newFirewallCmd())
+}
diff --git a/cmd/root.go b/cmd/root.go
index 4374a39..000d86a 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -80,6 +80,7 @@ func init() {
rootCmd.PersistentFlags().StringSliceP("include-dir", "i", nil, "Mount host dir inside /workspace")
rootCmd.PersistentFlags().StringSliceP("tools", "t", nil, "Add Alpine packages to the image")
rootCmd.PersistentFlags().StringSliceP("allow-urls", "a", nil, "Allow extra domains for this session")
+ rootCmd.PersistentFlags().IntSlice("host-port", nil, "Allow localhost:PORT in the agent to reach the host")
rootCmd.AddCommand(versionCmd)
diff --git a/cmd/run.go b/cmd/run.go
index 39634af..228ebf6 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -95,6 +95,8 @@ Flags (passed after the agent name):
-t, --tools PKG Add Alpine packages to the image
-i, --include-dir DIR Mount host dir inside /workspace
-a, --allow-urls DOM Allow extra domains for this session
+ --host-port PORT Allow localhost:PORT in the agent to reach the host
+ Persist ports with: exitbox firewall ports add PORT
--ollama Use host Ollama for local models
--memory SIZE Container memory limit (default: 8g)
--cpus COUNT Container CPU limit (default: 4)
@@ -164,6 +166,9 @@ func runAgent(agentName string, passthrough []string) {
flags := parseRunFlags(passthrough, cfg.Settings.DefaultFlags)
flags = applySessionResumeDefaults(flags)
+ if len(flags.HostPortErrors) > 0 {
+ ui.Errorf("Invalid --host-port value(s): %s", strings.Join(flags.HostPortErrors, ", "))
+ }
if flags.Verbose {
ui.Verbose = true
@@ -312,6 +317,7 @@ func runAgent(agentName string, passthrough []string) {
EnvVars: flags.EnvVars,
IncludeDirs: flags.IncludeDirs,
AllowURLs: flags.AllowURLs,
+ HostPorts: flags.HostPorts,
Passthrough: flags.Remaining,
Verbose: flags.Verbose,
StatusBar: cfg.Settings.StatusBar,
@@ -418,6 +424,8 @@ type parsedFlags struct {
EnvVars []string
IncludeDirs []string
AllowURLs []string
+ HostPorts []int
+ HostPortErrors []string
Tools []string
Remaining []string
}
@@ -498,6 +506,13 @@ func parseRunFlags(passthrough []string, defaults config.DefaultFlags) parsedFla
i++
f.AllowURLs = append(f.AllowURLs, passthrough[i])
}
+ case "--host-port":
+ if i+1 < len(passthrough) {
+ i++
+ ports, errs := parseHostPortList(passthrough[i])
+ f.HostPorts = append(f.HostPorts, ports...)
+ f.HostPortErrors = append(f.HostPortErrors, errs...)
+ }
case "--ollama":
f.Ollama = true
case "--memory":
@@ -521,6 +536,30 @@ func parseRunFlags(passthrough []string, defaults config.DefaultFlags) parsedFla
return f
}
+func parseHostPortList(raw string) ([]int, []string) {
+ fields := strings.FieldsFunc(raw, func(r rune) bool {
+ return r == ',' || r == ' '
+ })
+ var ports []int
+ var errs []string
+ for _, field := range fields {
+ field = strings.TrimSpace(field)
+ if field == "" {
+ continue
+ }
+ port, err := strconv.Atoi(field)
+ if err != nil || port < 1 || port > 65535 {
+ errs = append(errs, field)
+ continue
+ }
+ ports = append(ports, port)
+ }
+ if len(fields) == 0 && strings.TrimSpace(raw) != "" {
+ errs = append(errs, raw)
+ }
+ return ports, errs
+}
+
func applySessionResumeDefaults(f parsedFlags) parsedFlags {
// Named sessions (--name) imply auto-resume: if the session already
// exists it will be resumed, otherwise a fresh one is created.
diff --git a/cmd/run_flags_test.go b/cmd/run_flags_test.go
index 4cd292a..a290b0b 100644
--- a/cmd/run_flags_test.go
+++ b/cmd/run_flags_test.go
@@ -68,6 +68,32 @@ func TestParseRunFlags_Tools(t *testing.T) {
}
}
+func TestParseRunFlags_HostPort(t *testing.T) {
+ f := parseRunFlags([]string{"--host-port", "3000", "--host-port", "5173,8080"}, config.DefaultFlags{})
+ want := []int{3000, 5173, 8080}
+ if len(f.HostPorts) != len(want) {
+ t.Fatalf("expected %d host ports, got %d: %v", len(want), len(f.HostPorts), f.HostPorts)
+ }
+ for i := range want {
+ if f.HostPorts[i] != want[i] {
+ t.Fatalf("HostPorts[%d] = %d, want %d", i, f.HostPorts[i], want[i])
+ }
+ }
+ if len(f.HostPortErrors) != 0 {
+ t.Fatalf("unexpected host port errors: %v", f.HostPortErrors)
+ }
+}
+
+func TestParseRunFlags_HostPortInvalid(t *testing.T) {
+ f := parseRunFlags([]string{"--host-port", "0,abc,70000"}, config.DefaultFlags{})
+ if len(f.HostPorts) != 0 {
+ t.Fatalf("expected no valid host ports, got %v", f.HostPorts)
+ }
+ if len(f.HostPortErrors) != 3 {
+ t.Fatalf("expected 3 host port errors, got %v", f.HostPortErrors)
+ }
+}
+
func TestParseRunFlags_DoubleDash(t *testing.T) {
f := parseRunFlags([]string{"-f", "--", "-r", "extra"}, config.DefaultFlags{})
if !f.NoFirewall {
diff --git a/internal/config/types.go b/internal/config/types.go
index 9d9f5d9..b744f89 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -113,6 +113,7 @@ type Allowlist struct {
Development []string `yaml:"development"`
CloudServices []string `yaml:"cloud_services"`
CommonServices []string `yaml:"common_services"`
+ HostPorts []int `yaml:"host_ports,omitempty"`
Custom []string `yaml:"custom,omitempty"`
}
diff --git a/internal/network/network.go b/internal/network/network.go
index 233222a..34df8f3 100644
--- a/internal/network/network.go
+++ b/internal/network/network.go
@@ -22,7 +22,9 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strconv"
"strings"
+ "time"
"github.com/cloud-exit/exitbox/internal/config"
"github.com/cloud-exit/exitbox/internal/container"
@@ -35,6 +37,48 @@ const (
SquidContainer = "exitbox-squid"
)
+const (
+ squidLockTimeout = 60 * time.Second
+ squidLockStaleAfter = 2 * time.Minute
+)
+
+func withSquidLock(fn func() error) error {
+ unlock, err := acquireSquidLock()
+ if err != nil {
+ return err
+ }
+ defer unlock()
+ return fn()
+}
+
+func acquireSquidLock() (func(), error) {
+ lockDir := filepath.Join(config.Cache, "squid.lock")
+ deadline := time.Now().Add(squidLockTimeout)
+
+ for {
+ if err := os.MkdirAll(filepath.Dir(lockDir), 0755); err != nil {
+ return nil, fmt.Errorf("creating squid lock parent: %w", err)
+ }
+ err := os.Mkdir(lockDir, 0755)
+ if err == nil {
+ _ = os.WriteFile(filepath.Join(lockDir, "owner"), []byte(fmt.Sprintf("%d\n", os.Getpid())), 0644)
+ return func() { _ = os.RemoveAll(lockDir) }, nil
+ }
+ if !os.IsExist(err) {
+ return nil, fmt.Errorf("acquiring squid lock: %w", err)
+ }
+
+ if info, statErr := os.Stat(lockDir); statErr == nil && time.Since(info.ModTime()) > squidLockStaleAfter {
+ _ = os.RemoveAll(lockDir)
+ continue
+ }
+ if time.Now().After(deadline) {
+ return nil, fmt.Errorf("timed out waiting for Squid proxy lock")
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+}
+
// reconfigureSquid sends a HUP to the running squid process. If that fails
// (stale PID, process crashed), it restarts squid inside the container.
func reconfigureSquid(cmd string) {
@@ -115,42 +159,77 @@ func sessionDir() string {
// RegisterSessionURLs writes a session file for a container's extra URLs.
func RegisterSessionURLs(containerName string, urls []string) error {
+ return RegisterSessionAccess(containerName, urls, nil)
+}
+
+// RegisterSessionAccess writes a session file for a container's firewall extras.
+func RegisterSessionAccess(containerName string, urls []string, hostPorts []int) error {
dir := sessionDir()
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
- content := strings.Join(urls, "\n") + "\n"
- return os.WriteFile(filepath.Join(dir, containerName+".urls"), []byte(content), 0644)
+ var b strings.Builder
+ for _, url := range urls {
+ if strings.TrimSpace(url) == "" {
+ continue
+ }
+ b.WriteString(url)
+ b.WriteByte('\n')
+ }
+ for _, port := range NormalizeHostPorts(hostPorts) {
+ fmt.Fprintf(&b, "host-port:%d\n", port)
+ }
+ return os.WriteFile(filepath.Join(dir, containerName+".urls"), []byte(b.String()), 0644)
+}
+
+func sessionFileCount() int {
+ entries, err := os.ReadDir(sessionDir())
+ if err != nil {
+ return 0
+ }
+ count := 0
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".urls") {
+ count++
+ }
+ }
+ return count
}
// RemoveSessionURLs removes a container's session file and regenerates squid config.
func RemoveSessionURLs(rt container.Runtime, containerName string) {
- dir := sessionDir()
- _ = os.Remove(filepath.Join(dir, containerName+".urls"))
-
- // Collect remaining URLs from all sessions and regenerate config
- remaining := collectAllSessionURLs()
- changed, writeErr := writeSquidConfig(rt, remaining)
- if writeErr != nil {
- ui.Warnf("Failed to regenerate squid config: %v", writeErr)
- return
- }
+ if err := withSquidLock(func() error {
+ dir := sessionDir()
+ _ = os.Remove(filepath.Join(dir, containerName+".urls"))
+
+ // Collect remaining URLs from all sessions and regenerate config
+ remaining := collectAllSessionURLs()
+ remainingHostPorts := collectAllSessionHostPorts()
+ changed, writeErr := writeSquidConfig(rt, remaining, remainingHostPorts)
+ if writeErr != nil {
+ ui.Warnf("Failed to regenerate squid config: %v", writeErr)
+ return nil
+ }
- // Reconfigure squid if running and config changed
- if !changed {
- return
- }
- cmd := container.Cmd(rt)
- names, err := rt.PS("", "{{.Names}}")
- if err != nil {
- ui.Warnf("Failed to list containers: %v", err)
- return
- }
- for _, n := range names {
- if n == SquidContainer {
- reconfigureSquid(cmd)
- break
+ // Reconfigure squid if running and config changed
+ if !changed {
+ return nil
+ }
+ cmd := container.Cmd(rt)
+ names, err := rt.PS("", "{{.Names}}")
+ if err != nil {
+ ui.Warnf("Failed to list containers: %v", err)
+ return nil
}
+ for _, n := range names {
+ if n == SquidContainer {
+ reconfigureSquid(cmd)
+ break
+ }
+ }
+ return nil
+ }); err != nil {
+ ui.Warnf("Failed to update Squid session state: %v", err)
}
}
@@ -173,7 +252,10 @@ func collectAllSessionURLs() []string {
}
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
line = strings.TrimSpace(line)
- if line != "" && !seen[line] {
+ if line == "" || strings.HasPrefix(line, "host-port:") {
+ continue
+ }
+ if !seen[line] {
seen[line] = true
urls = append(urls, line)
}
@@ -182,19 +264,58 @@ func collectAllSessionURLs() []string {
return urls
}
+// collectAllSessionHostPorts reads all session files and returns deduplicated host ports.
+func collectAllSessionHostPorts() []int {
+ dir := sessionDir()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil
+ }
+ var ports []int
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".urls") {
+ continue
+ }
+ data, err := os.ReadFile(filepath.Join(dir, e.Name()))
+ if err != nil {
+ continue
+ }
+ for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "host-port:") {
+ continue
+ }
+ p, err := strconv.Atoi(strings.TrimPrefix(line, "host-port:"))
+ if err == nil {
+ ports = append(ports, p)
+ }
+ }
+ }
+ return NormalizeHostPorts(ports)
+}
+
// StartSquidProxy starts the Squid proxy container.
-func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []string) error {
+func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []string, hostPorts []int) error {
+ err := withSquidLock(func() error {
+ return startSquidProxyLocked(rt, containerName, extraURLs, hostPorts)
+ })
+ if err != nil {
+ _ = os.Remove(filepath.Join(sessionDir(), containerName+".urls"))
+ }
+ return err
+}
+
+func startSquidProxyLocked(rt container.Runtime, containerName string, extraURLs []string, hostPorts []int) error {
cmd := container.Cmd(rt)
// Register session URLs
- if len(extraURLs) > 0 {
- if err := RegisterSessionURLs(containerName, extraURLs); err != nil {
- ui.Warnf("Failed to register session URLs: %v", err)
- }
+ if err := RegisterSessionAccess(containerName, extraURLs, hostPorts); err != nil {
+ ui.Warnf("Failed to register session firewall access: %v", err)
}
// Collect all session URLs for config generation
allExtraURLs := collectAllSessionURLs()
+ allHostPorts := collectAllSessionHostPorts()
// Check if already running
names, err := rt.PS("", "{{.Names}}")
@@ -204,7 +325,7 @@ func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []str
for _, n := range names {
if n == SquidContainer {
// Regenerate config and reload only if it changed.
- changed, writeErr := writeSquidConfig(rt, allExtraURLs)
+ changed, writeErr := writeSquidConfig(rt, allExtraURLs, allHostPorts)
if writeErr != nil {
return writeErr
}
@@ -222,7 +343,7 @@ func StartSquidProxy(rt container.Runtime, containerName string, extraURLs []str
EnsureNetworks(rt)
// Generate config
- if _, err := writeSquidConfig(rt, allExtraURLs); err != nil {
+ if _, err := writeSquidConfig(rt, allExtraURLs, allHostPorts); err != nil {
return err
}
@@ -285,40 +406,51 @@ func GetProxyEnvVars(rt container.Runtime) []string {
// CleanupSquidIfUnused stops squid if no agent containers are running.
func CleanupSquidIfUnused(rt container.Runtime) {
- cmd := container.Cmd(rt)
- names, err := rt.PS("", "{{.Names}}")
- if err != nil {
- ui.Warnf("Failed to list containers: %v", err)
- return
- }
- running := 0
- squidRunning := false
- for _, n := range names {
- if n == SquidContainer {
- squidRunning = true
- continue
+ if err := withSquidLock(func() error {
+ cmd := container.Cmd(rt)
+ names, err := rt.PS("", "{{.Names}}")
+ if err != nil {
+ ui.Warnf("Failed to list containers: %v", err)
+ return nil
}
- if strings.HasPrefix(n, "exitbox-") {
- running++
+ running := 0
+ squidRunning := false
+ for _, n := range names {
+ if n == SquidContainer {
+ squidRunning = true
+ continue
+ }
+ if strings.HasPrefix(n, "exitbox-") {
+ running++
+ }
}
- }
- if running == 0 && squidRunning {
- ui.Info("Stopping Squid proxy (no running agents)...")
- // Stop first (handles restart policy), then remove.
- _ = exec.Command(cmd, "stop", SquidContainer).Run()
- if rmErr := exec.Command(cmd, "rm", "-f", SquidContainer).Run(); rmErr != nil {
- ui.Warnf("Failed to remove Squid proxy: %v", rmErr)
+ if running == 0 && sessionFileCount() == 0 && squidRunning {
+ ui.Info("Stopping Squid proxy (no running agents)...")
+ // Stop first (handles restart policy), then remove.
+ _ = exec.Command(cmd, "stop", SquidContainer).Run()
+ if rmErr := exec.Command(cmd, "rm", "-f", SquidContainer).Run(); rmErr != nil {
+ ui.Warnf("Failed to remove Squid proxy: %v", rmErr)
+ }
}
- }
- if running == 0 {
- // Clean stale session files
- _ = os.RemoveAll(sessionDir())
+ if running == 0 && sessionFileCount() == 0 {
+ // Clean stale session files
+ _ = os.RemoveAll(sessionDir())
+ }
+ return nil
+ }); err != nil {
+ ui.Warnf("Failed to clean up Squid proxy: %v", err)
}
}
// AddSessionURLAndReload adds a domain to a container's session URLs and
// hot-reloads Squid so the change takes effect immediately.
func AddSessionURLAndReload(rt container.Runtime, containerName string, domain string) error {
+ return withSquidLock(func() error {
+ return addSessionURLAndReloadLocked(rt, containerName, domain)
+ })
+}
+
+func addSessionURLAndReloadLocked(rt container.Runtime, containerName string, domain string) error {
cmd := container.Cmd(rt)
dir := sessionDir()
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -328,12 +460,20 @@ func AddSessionURLAndReload(rt container.Runtime, containerName string, domain s
// Read existing session URLs for this container.
urlFile := filepath.Join(dir, containerName+".urls")
var urls []string
+ var hostPorts []int
if data, err := os.ReadFile(urlFile); err == nil {
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
line = strings.TrimSpace(line)
- if line != "" {
- urls = append(urls, line)
+ if line == "" {
+ continue
+ }
+ if strings.HasPrefix(line, "host-port:") {
+ if p, err := strconv.Atoi(strings.TrimPrefix(line, "host-port:")); err == nil {
+ hostPorts = append(hostPorts, p)
+ }
+ continue
}
+ urls = append(urls, line)
}
}
@@ -346,12 +486,13 @@ func AddSessionURLAndReload(rt container.Runtime, containerName string, domain s
urls = append(urls, domain)
// Write back and regenerate config.
- if err := RegisterSessionURLs(containerName, urls); err != nil {
+ if err := RegisterSessionAccess(containerName, urls, hostPorts); err != nil {
return err
}
allURLs := collectAllSessionURLs()
- changed, writeErr := writeSquidConfig(rt, allURLs)
+ allHostPorts := collectAllSessionHostPorts()
+ changed, writeErr := writeSquidConfig(rt, allURLs, allHostPorts)
if writeErr != nil {
return writeErr
}
@@ -377,7 +518,7 @@ func AddSessionURLAndReload(rt container.Runtime, containerName string, domain s
// writeSquidConfig generates and writes the squid config. Returns (changed, error)
// where changed indicates whether the config file content actually changed.
-func writeSquidConfig(rt container.Runtime, extraURLs []string) (bool, error) {
+func writeSquidConfig(rt container.Runtime, extraURLs []string, sessionHostPorts []int) (bool, error) {
subnet, err := GetNetworkSubnet(rt, InternalNetwork)
if err != nil {
return false, fmt.Errorf("could not detect internal network subnet: %w", err)
@@ -385,8 +526,9 @@ func writeSquidConfig(rt container.Runtime, extraURLs []string) (bool, error) {
al := config.LoadAllowlistOrDefault()
domains := al.AllDomains()
+ hostPorts := NormalizeHostPorts(append(append([]int{}, al.HostPorts...), sessionHostPorts...))
- content := GenerateSquidConfig(subnet, domains, extraURLs)
+ content := GenerateSquidConfigWithHostPorts(subnet, domains, extraURLs, hostPorts)
configFile := filepath.Join(config.Cache, "squid.conf")
if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
return false, fmt.Errorf("failed to create config directory: %w", err)
diff --git a/internal/network/session_test.go b/internal/network/session_test.go
index 0055129..bb63dc3 100644
--- a/internal/network/session_test.go
+++ b/internal/network/session_test.go
@@ -57,3 +57,50 @@ func TestSessionURLLifecycle(t *testing.T) {
t.Errorf("expected 0 URLs after cleanup, got %d", len(empty))
}
}
+
+func TestRegisterSessionURLsCreatesMarkerWithoutExtraURLs(t *testing.T) {
+ tmpDir := t.TempDir()
+ origCache := config.Cache
+ config.Cache = tmpDir
+ defer func() { config.Cache = origCache }()
+
+ if err := RegisterSessionURLs("container-a", nil); err != nil {
+ t.Fatalf("RegisterSessionURLs: %v", err)
+ }
+ if got := sessionFileCount(); got != 1 {
+ t.Fatalf("sessionFileCount() = %d, want 1", got)
+ }
+ if urls := collectAllSessionURLs(); len(urls) != 0 {
+ t.Fatalf("collectAllSessionURLs() = %v, want empty", urls)
+ }
+}
+
+func TestSessionHostPortsLifecycle(t *testing.T) {
+ tmpDir := t.TempDir()
+ origCache := config.Cache
+ config.Cache = tmpDir
+ defer func() { config.Cache = origCache }()
+
+ if err := RegisterSessionAccess("container-a", []string{"example.com"}, []int{3000, 5173, 3000, 0}); err != nil {
+ t.Fatalf("RegisterSessionAccess(a): %v", err)
+ }
+ if err := RegisterSessionAccess("container-b", nil, []int{8080, 5173}); err != nil {
+ t.Fatalf("RegisterSessionAccess(b): %v", err)
+ }
+
+ ports := collectAllSessionHostPorts()
+ want := []int{3000, 5173, 8080}
+ if len(ports) != len(want) {
+ t.Fatalf("host ports = %v, want %v", ports, want)
+ }
+ for i := range want {
+ if ports[i] != want[i] {
+ t.Fatalf("host ports = %v, want %v", ports, want)
+ }
+ }
+
+ urls := collectAllSessionURLs()
+ if len(urls) != 1 || urls[0] != "example.com" {
+ t.Fatalf("session URLs = %v, want [example.com]", urls)
+ }
+}
diff --git a/internal/network/squidconf.go b/internal/network/squidconf.go
index da3cbce..e6996b3 100644
--- a/internal/network/squidconf.go
+++ b/internal/network/squidconf.go
@@ -19,6 +19,7 @@ package network
import (
"fmt"
"net"
+ "sort"
"strings"
"github.com/cloud-exit/exitbox/internal/ui"
@@ -26,7 +27,14 @@ import (
// GenerateSquidConfig generates the squid.conf content.
func GenerateSquidConfig(subnet string, domains []string, extraURLs []string) string {
+ return GenerateSquidConfigWithHostPorts(subnet, domains, extraURLs, nil)
+}
+
+// GenerateSquidConfigWithHostPorts generates the squid.conf content, including
+// host loopback ports that agent containers may access via localhost relays.
+func GenerateSquidConfigWithHostPorts(subnet string, domains []string, extraURLs []string, hostPorts []int) string {
var b strings.Builder
+ hostPorts = NormalizeHostPorts(hostPorts)
b.WriteString(`# Squid Configuration for Agentbox
http_port 3128
@@ -34,6 +42,11 @@ shutdown_lifetime 1 seconds
# Access Control Lists
acl SSL_ports port 443
+`)
+ if len(hostPorts) > 0 {
+ fmt.Fprintf(&b, "acl SSL_ports port %s\t# configured host loopback ports\n", joinPorts(hostPorts))
+ }
+ b.WriteString(`
acl Safe_ports port 80 # http
acl Safe_ports port 21 # ftp
acl Safe_ports port 443 # https
@@ -44,6 +57,11 @@ acl Safe_ports port 280 # http-mgmt
acl Safe_ports port 488 # gss-http
acl Safe_ports port 591 # filemaker
acl Safe_ports port 777 # multiling http
+`)
+ if len(hostPorts) > 0 {
+ fmt.Fprintf(&b, "acl Safe_ports port %s\t# configured host loopback ports\n", joinPorts(hostPorts))
+ }
+ b.WriteString(`
acl CONNECT method CONNECT
# Deny requests to certain unsafe ports
@@ -115,6 +133,10 @@ acl localhost src 127.0.0.1/32
for _, e := range ipEntries {
fmt.Fprintf(&b, "acl allowed_ips dst %s\n", e)
}
+ if len(hostPorts) > 0 {
+ b.WriteString("acl host_loopback dstdomain host.docker.internal\n")
+ fmt.Fprintf(&b, "acl host_loopback_ports port %s\n", joinPorts(hostPorts))
+ }
hasDomains := len(domainEntries) > 0
if !hasDomains && len(ipEntries) == 0 {
@@ -130,6 +152,9 @@ acl localhost src 127.0.0.1/32
if len(ipEntries) > 0 {
b.WriteString("http_access allow agent_sources allowed_ips\n")
}
+ if len(hostPorts) > 0 {
+ b.WriteString("http_access allow agent_sources host_loopback host_loopback_ports\n")
+ }
b.WriteString(`
# Deny everything else
@@ -148,6 +173,37 @@ negative_dns_ttl 30 seconds
return b.String()
}
+// NormalizeHostPorts returns valid TCP ports sorted and deduplicated.
+func NormalizeHostPorts(ports []int) []int {
+ seen := make(map[int]struct{}, len(ports))
+ var out []int
+ for _, p := range ports {
+ if p < 1 || p > 65535 {
+ continue
+ }
+ if _, ok := seen[p]; ok {
+ continue
+ }
+ seen[p] = struct{}{}
+ out = append(out, p)
+ }
+ sort.Ints(out)
+ return out
+}
+
+func joinPorts(ports []int) string {
+ parts := make([]string, 0, len(ports))
+ for _, p := range ports {
+ parts = append(parts, fmt.Sprintf("%d", p))
+ }
+ return strings.Join(parts, " ")
+}
+
+// HostPortsEnvValue serializes valid host ports for the container entrypoint.
+func HostPortsEnvValue(ports []int) string {
+ return strings.ReplaceAll(joinPorts(NormalizeHostPorts(ports)), " ", ",")
+}
+
// removeRedundantSubdomains filters out entries that are subdomains of another
// entry. E.g. if ".icy-veins.com" is present, ".www.icy-veins.com" is removed
// since the parent already covers all subdomains in squid's dstdomain matching.
diff --git a/internal/network/squidconf_test.go b/internal/network/squidconf_test.go
index d0e827f..3dd8e66 100644
--- a/internal/network/squidconf_test.go
+++ b/internal/network/squidconf_test.go
@@ -158,6 +158,22 @@ func TestGenerateSquidConfig_AllowAccess(t *testing.T) {
}
}
+func TestGenerateSquidConfig_HostPorts(t *testing.T) {
+ conf := GenerateSquidConfigWithHostPorts("10.89.0.0/24", []string{"example.com"}, nil, []int{5173, 3000, 3000, 0})
+
+ for _, required := range []string{
+ "acl SSL_ports port 3000 5173",
+ "acl Safe_ports port 3000 5173",
+ "acl host_loopback dstdomain host.docker.internal",
+ "acl host_loopback_ports port 3000 5173",
+ "http_access allow agent_sources host_loopback host_loopback_ports",
+ } {
+ if !strings.Contains(conf, required) {
+ t.Errorf("config missing host-port directive: %q", required)
+ }
+ }
+}
+
func TestGetSquidDNSServers_Default(t *testing.T) {
os.Unsetenv("EXITBOX_SQUID_DNS")
servers := getSquidDNSServers()
diff --git a/internal/profile/dockerfile.go b/internal/profile/dockerfile.go
index 1c69e11..1417e5f 100644
--- a/internal/profile/dockerfile.go
+++ b/internal/profile/dockerfile.go
@@ -65,22 +65,7 @@ RUN python3 -m venv /home/user/.venv && \
ENV PATH="/home/user/.venv/bin:$PATH"
`
case "go":
- return `RUN set -e && \
- case "$(uname -m)" in \
- x86_64|amd64) GO_ARCH="amd64" ;; \
- aarch64|arm64) GO_ARCH="arm64" ;; \
- *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; \
- esac && \
- GO_VERSION="$(wget -qO- https://go.dev/VERSION?m=text | head -n1)" && \
- GO_TARBALL="${GO_VERSION}.linux-${GO_ARCH}.tar.gz" && \
- GO_SHA256="$(wget -qO- https://go.dev/dl/?mode=json | jq -r --arg f "$GO_TARBALL" '.[0].files[] | select(.filename == $f) | .sha256')" && \
- test -n "$GO_SHA256" && \
- wget -q -O /tmp/go.tar.gz "https://go.dev/dl/${GO_TARBALL}" && \
- echo "${GO_SHA256} /tmp/go.tar.gz" | sha256sum -c - && \
- tar -C /usr/local -xzf /tmp/go.tar.gz && \
- rm -f /tmp/go.tar.gz && \
- ln -sf /usr/local/go/bin/go /usr/local/bin/go && \
- ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
+ return `# Go profile - compiler and gofmt are installed via apk package "go".
RUN set -e && \
case "$(uname -m)" in \
x86_64|amd64) LINT_ARCH="amd64" ;; \
diff --git a/internal/profile/dockerfile_test.go b/internal/profile/dockerfile_test.go
new file mode 100644
index 0000000..8dababe
--- /dev/null
+++ b/internal/profile/dockerfile_test.go
@@ -0,0 +1,46 @@
+// ExitBox - Multi-Agent Container Sandbox
+// Copyright (C) 2026 Cloud Exit B.V.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package profile
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestGoProfileUsesAPKGoPackage(t *testing.T) {
+ pkgs := CollectPackages([]string{"go"})
+ if len(pkgs) != 1 || pkgs[0] != "go" {
+ t.Fatalf("CollectPackages([go]) = %v, want [go]", pkgs)
+ }
+}
+
+func TestGoCustomSnippetDoesNotDownloadGoDevToolchain(t *testing.T) {
+ snippet := CustomSnippet("go")
+ for _, forbidden := range []string{
+ "go.dev/VERSION",
+ "go.dev/dl",
+ "GO_TARBALL",
+ "/usr/local/go",
+ } {
+ if strings.Contains(snippet, forbidden) {
+ t.Fatalf("Go CustomSnippet contains %q; compiler should be installed via apk", forbidden)
+ }
+ }
+ if !strings.Contains(snippet, "golangci-lint") {
+ t.Fatal("Go CustomSnippet should still install golangci-lint")
+ }
+}
diff --git a/internal/profile/profile.go b/internal/profile/profile.go
index 69d337b..1540cb4 100644
--- a/internal/profile/profile.go
+++ b/internal/profile/profile.go
@@ -36,7 +36,7 @@ func AllRoles() []Role {
{"javascript", "Compatibility alias for node profile", "nodejs npm"},
{"python", "Python 3 with pip and venv", ""},
{"rust", "Rust toolchain (rust + cargo via apk)", "rust cargo"},
- {"go", "Go runtime (latest stable for host arch, checksum verified)", ""},
+ {"go", "Go toolchain (go + gofmt via apk)", "go"},
{"java", "OpenJDK with Maven and Gradle", "openjdk17-jdk maven gradle"},
{"dotnet", ".NET 8 SDK (dotnet CLI)", "dotnet8-sdk"},
{"ruby", "Ruby runtime with bundler", "ruby ruby-dev readline-dev yaml-dev sqlite-dev sqlite libxml2-dev libxslt-dev curl-dev"},
diff --git a/internal/run/run.go b/internal/run/run.go
index 72970ef..9474e46 100644
--- a/internal/run/run.go
+++ b/internal/run/run.go
@@ -64,6 +64,7 @@ type Options struct {
EnvVars []string
IncludeDirs []string
AllowURLs []string
+ HostPorts []int
Passthrough []string
Verbose bool
StatusBar bool
@@ -147,6 +148,7 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) {
// Ollama mode: route traffic through the firewall to host Ollama.
if opts.Ollama {
opts.AllowURLs = append(opts.AllowURLs, "host.docker.internal")
+ opts.HostPorts = append(opts.HostPorts, 11434)
ui.Infof("Ollama mode: routing traffic through firewall to host")
}
@@ -156,13 +158,22 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) {
// all container ports directly (e.g. OAuth callbacks).
args = append(args, "--network", "host")
} else {
+ allowlist := config.LoadAllowlistOrDefault()
+ hostPorts := network.NormalizeHostPorts(append(append([]int{}, allowlist.HostPorts...), opts.HostPorts...))
network.EnsureNetworks(rt)
args = append(args, "--network", network.InternalNetwork)
- if err := network.StartSquidProxy(rt, containerName, opts.AllowURLs); err != nil {
+ if err := network.StartSquidProxy(rt, containerName, opts.AllowURLs, opts.HostPorts); err != nil {
return 1, fmt.Errorf("failed to start firewall (Squid proxy): %w", err)
}
+ defer func() {
+ network.RemoveSessionURLs(rt, containerName)
+ network.CleanupSquidIfUnused(rt)
+ }()
proxyArgs := network.GetProxyEnvVars(rt)
args = append(args, proxyArgs...)
+ if envValue := network.HostPortsEnvValue(hostPorts); envValue != "" {
+ args = append(args, "-e", "EXITBOX_HOST_PORTS="+envValue)
+ }
// Publish OAuth callback ports so browser redirects to localhost
// reach the agent inside the container. A socat relay in
@@ -238,14 +249,6 @@ func AgentContainer(rt container.Runtime, opts Options) (int, error) {
}
}
- // Ensure squid cleanup runs on ALL return paths (including early errors).
- defer func() {
- if len(opts.AllowURLs) > 0 {
- network.RemoveSessionURLs(rt, containerName)
- }
- network.CleanupSquidIfUnused(rt)
- }()
-
// Resource limits
memory := "8g"
if opts.Memory != "" {
@@ -534,6 +537,7 @@ func isReservedEnvVar(key string) bool {
"EXITBOX_RESUME_TOKEN": true,
"EXITBOX_SESSION_NAME": true,
"EXITBOX_KEYBINDINGS": true,
+ "EXITBOX_HOST_PORTS": true,
"EXITBOX_VAULT_ENABLED": true,
"EXITBOX_VAULT_READONLY": true,
"EXITBOX_RTK": true,
diff --git a/internal/run/run_test.go b/internal/run/run_test.go
index 2791da3..0e3829f 100644
--- a/internal/run/run_test.go
+++ b/internal/run/run_test.go
@@ -88,6 +88,7 @@ func TestIsReservedEnvVar(t *testing.T) {
"EXITBOX_AUTO_RESUME",
"EXITBOX_SESSION_NAME",
"EXITBOX_KEYBINDINGS",
+ "EXITBOX_HOST_PORTS",
"EXITBOX_VAULT_ENABLED",
"EXITBOX_VAULT_READONLY",
"EXITBOX_IDE_PORT",
diff --git a/internal/wizard/wizard.go b/internal/wizard/wizard.go
index 1ae518f..4f6f71f 100644
--- a/internal/wizard/wizard.go
+++ b/internal/wizard/wizard.go
@@ -149,11 +149,11 @@ func applyResult(state State, existingCfg *config.Config) error {
cfg.Settings.StatusBar = state.StatusBar
cfg.Settings.RTK = state.RTK
cfg.Settings.DefaultFlags = config.DefaultFlags{
- NoFirewall: !state.EnableFirewall,
- AutoResume: state.AutoResume,
- NoEnv: !state.PassEnv,
- ReadOnly: state.ReadOnly,
- FullGitSupport: state.FullGitSupport,
+ NoFirewall: !state.EnableFirewall,
+ AutoResume: state.AutoResume,
+ NoEnv: !state.PassEnv,
+ ReadOnly: state.ReadOnly,
+ FullGitSupport: state.FullGitSupport,
}
// Persist keybindings — only store non-default values (omitempty in YAML).
@@ -210,7 +210,9 @@ func applyResult(state State, existingCfg *config.Config) error {
var al *config.Allowlist
if len(state.DomainCategories) > 0 {
+ existingAllowlist := config.LoadAllowlistOrDefault()
al = categoriesToAllowlist(state.DomainCategories)
+ al.HostPorts = existingAllowlist.HostPorts
} else {
al = config.LoadAllowlistOrDefault()
}
@@ -275,7 +277,6 @@ func applyLanguageDelta(original []string, selectedLanguages []string) []string
return result
}
-
func upsertWorkspace(list []config.Workspace, item config.Workspace) []config.Workspace {
for i := range list {
if list[i].Name == item.Name {
diff --git a/static/build/docker-entrypoint b/static/build/docker-entrypoint
index 1d0d283..c41c202 100755
--- a/static/build/docker-entrypoint
+++ b/static/build/docker-entrypoint
@@ -159,6 +159,7 @@ start_opencode_callback_relay() {
}
IDE_RELAY_PID=""
+HOST_PORT_RELAY_PIDS=""
start_ide_relay() {
[[ "$AGENT" != "claude" ]] && return
@@ -179,6 +180,39 @@ cleanup_ide_relay() {
[[ -n "$IDE_RELAY_PID" ]] && kill "$IDE_RELAY_PID" >/dev/null 2>&1 || true
}
+start_host_port_relays() {
+ [[ -n "${EXITBOX_HOST_PORTS:-}" ]] || return 0
+ [[ -n "${http_proxy:-}" ]] || return 0
+ command -v socat >/dev/null 2>&1 || return 0
+
+ # Parse proxy host and port from http_proxy (e.g. http://172.18.0.2:3128).
+ local proxy_url="${http_proxy#http://}"
+ proxy_url="${proxy_url#https://}"
+ proxy_url="${proxy_url%%/*}"
+ local proxy_host="${proxy_url%%:*}"
+ local proxy_port="${proxy_url##*:}"
+ [[ -z "$proxy_host" ]] && return 0
+ [[ "$proxy_port" == "$proxy_host" ]] && proxy_port="3128"
+
+ local ports="${EXITBOX_HOST_PORTS//,/ }"
+ local port
+ for port in $ports; do
+ [[ "$port" =~ ^[0-9]+$ ]] || continue
+ (( port >= 1 && port <= 65535 )) || continue
+ socat "TCP-LISTEN:${port},bind=127.0.0.1,reuseaddr,fork" \
+ "PROXY:${proxy_host}:host.docker.internal:${port},proxyport=${proxy_port}" \
+ >"/tmp/exitbox-host-port-${port}.log" 2>&1 &
+ HOST_PORT_RELAY_PIDS="${HOST_PORT_RELAY_PIDS} $!"
+ done
+}
+
+cleanup_host_port_relays() {
+ local pid
+ for pid in ${HOST_PORT_RELAY_PIDS:-}; do
+ kill "$pid" >/dev/null 2>&1 || true
+ done
+}
+
ensure_workspace_files() {
mkdir -p "$(dirname "$GLOBAL_CONFIG_FILE")"
mkdir -p "$GLOBAL_WORKSPACE_ROOT"
@@ -1799,8 +1833,9 @@ fi
start_ide_relay
setup_git_credential_helper
setup_ssh_proxy_tunnel
+start_host_port_relays
setup_rtk
-trap 'cleanup_relay; cleanup_ide_relay' EXIT INT TERM
+trap 'cleanup_relay; cleanup_ide_relay; cleanup_host_port_relays' EXIT INT TERM
if [[ "${1:-}" == "__agent-loop" ]]; then
shift
diff --git a/static/build/docker-entrypoint_test.sh b/static/build/docker-entrypoint_test.sh
index cb570c6..f11b661 100644
--- a/static/build/docker-entrypoint_test.sh
+++ b/static/build/docker-entrypoint_test.sh
@@ -57,7 +57,7 @@ trap 'rm -rf "$TEST_TMPDIR"' EXIT
unset EXITBOX_PROJECT_KEY EXITBOX_WORKSPACE_NAME EXITBOX_WORKSPACE_SCOPE
unset EXITBOX_AGENT EXITBOX_AUTO_RESUME EXITBOX_IPC_SOCKET EXITBOX_KEYBINDINGS
unset EXITBOX_SESSION_NAME EXITBOX_RESUME_TOKEN EXITBOX_VAULT_ENABLED
-unset EXITBOX_VAULT_READONLY EXITBOX_RTK
+unset EXITBOX_VAULT_READONLY EXITBOX_RTK EXITBOX_HOST_PORTS
# Extract functions from the entrypoint using awk (handles nested braces)
extract_func() {
@@ -1474,6 +1474,73 @@ test_ssh_proxy_tunnel_correct_permissions
test_ssh_proxy_tunnel_writes_known_hosts
test_ssh_proxy_tunnel_strict_host_key_checking
+# ============================================================================
+# Host port relay tests
+# ============================================================================
+echo ""
+echo "Testing host port relays..."
+
+START_HOST_PORT_RELAYS_FUNC="$(extract_func start_host_port_relays)"
+CLEANUP_HOST_PORT_RELAYS_FUNC="$(extract_func cleanup_host_port_relays)"
+
+test_host_port_relays_skip_without_ports() {
+ local tmpdir
+ tmpdir="$(mktemp -d)"
+ local result
+ result="$(
+ export HOME="$tmpdir"
+ unset EXITBOX_HOST_PORTS
+ export http_proxy="http://172.18.0.2:3128"
+ eval "$START_HOST_PORT_RELAYS_FUNC"
+ start_host_port_relays
+ echo "ok"
+ )" 2>/dev/null
+ assert_eq "host port relay skips without ports" "ok" "$result"
+ rm -rf "$tmpdir"
+}
+
+test_host_port_relays_start_configured_ports() {
+ local tmpdir bindir
+ tmpdir="$(mktemp -d)"
+ bindir="$tmpdir/bin"
+ mkdir -p "$bindir"
+ cat > "$bindir/socat" <<'FAKESOCAT'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >> "$SOCAT_LOG"
+sleep 30
+FAKESOCAT
+ chmod +x "$bindir/socat"
+
+ (
+ export PATH="$bindir:$PATH"
+ export SOCAT_LOG="$tmpdir/socat.log"
+ export EXITBOX_HOST_PORTS="3000,5173 bad 0"
+ export http_proxy="http://172.18.0.2:3128"
+ eval "$START_HOST_PORT_RELAYS_FUNC"
+ eval "$CLEANUP_HOST_PORT_RELAYS_FUNC"
+ start_host_port_relays
+ sleep 0.2
+ cleanup_host_port_relays
+ ) 2>/dev/null
+
+ local content
+ content="$(cat "$tmpdir/socat.log" 2>/dev/null)"
+ assert_contains "host port relay listens on 3000" "$content" "TCP-LISTEN:3000,bind=127.0.0.1"
+ assert_contains "host port relay proxies 3000 to host" "$content" "PROXY:172.18.0.2:host.docker.internal:3000,proxyport=3128"
+ assert_contains "host port relay listens on 5173" "$content" "TCP-LISTEN:5173,bind=127.0.0.1"
+ assert_contains "host port relay proxies 5173 to host" "$content" "PROXY:172.18.0.2:host.docker.internal:5173,proxyport=3128"
+ if [[ "$content" == *"bad"* || "$content" == *"TCP-LISTEN:0"* ]]; then
+ ((FAIL++))
+ ERRORS+=("FAIL: host port relay should ignore invalid ports")
+ else
+ ((PASS++))
+ fi
+ rm -rf "$tmpdir"
+}
+
+test_host_port_relays_skip_without_ports
+test_host_port_relays_start_configured_ports
+
# ============================================================================
# RTK setup tests
# ============================================================================
diff --git a/static/config/allowlist.yaml b/static/config/allowlist.yaml
index 5b2db5f..c3dfab7 100644
--- a/static/config/allowlist.yaml
+++ b/static/config/allowlist.yaml
@@ -51,4 +51,6 @@ common_services:
- sentry.io
- statsig.com
+host_ports: []
+
custom: []