From 93a1538cf14489a7814f24de8a754b893f37b86c Mon Sep 17 00:00:00 2001 From: David Gageot Date: Sun, 24 May 2026 10:33:36 +0200 Subject: [PATCH] feat(sandbox): support runtime.network_allowlist in agent config Agents that talk to endpoints the auto-installer can't infer (custom MCP servers, third-party APIs, registries not covered by the aqua resolver, ...) saw a default-deny 403 from the sandbox proxy on first contact, then had to fall back to the wider conservative host union the kit uses on resolution failures. Let agent authors declare those hosts explicitly: runtime: sandbox: true network_allowlist: - api.example.com - registry.npmjs.org The list is unioned with the gateway and tool-install hosts the runner already opens automatically and printed before launch so users can audit exactly which holes the run punched in the default-deny policy. Commas and whitespace are rejected so a single entry can't smuggle several rules into the policy engine. --- agent-schema.json | 7 +++ cmd/root/sandbox.go | 67 ++++++++++++++++++++++++----- cmd/root/sandbox_test.go | 48 +++++++++++++++++++++ docs/configuration/sandbox/index.md | 25 +++++++++++ examples/sandbox_agent.yaml | 8 ++++ pkg/config/latest/types.go | 15 +++++++ 6 files changed, 159 insertions(+), 11 deletions(-) diff --git a/agent-schema.json b/agent-schema.json index 308239d5a..00e42d47c 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -1410,6 +1410,13 @@ "sandbox": { "type": "boolean", "description": "When true, run the agent inside a Docker sandbox by default — equivalent to passing --sandbox on the command line. An explicit --sandbox=false on the CLI still wins." + }, + "network_allowlist": { + "type": "array", + "description": "Hosts to add to the sandbox's default-deny network proxy when this agent runs in a sandbox. Each entry is a hostname with an optional ':port' suffix (e.g. 'api.example.com', 'registry.npmjs.org:443'). Unioned with the gateway and tool-install hosts the runner already opens automatically. Use this for hosts the auto-installer can't infer (custom MCP endpoints, third-party APIs, registries not covered by the aqua resolver) instead of relying on the wider fallback host set.", + "items": { + "type": "string" + } } }, "additionalProperties": false diff --git a/cmd/root/sandbox.go b/cmd/root/sandbox.go index a68d83a1f..3892ec3b5 100644 --- a/cmd/root/sandbox.go +++ b/cmd/root/sandbox.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/pflag" "github.com/docker/docker-agent/pkg/config" + latestcfg "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/paths" "github.com/docker/docker-agent/pkg/sandbox" @@ -31,18 +32,38 @@ import ( // returns false so the caller falls through to the normal path, // which will surface a proper error from the eventual load. func peekAgentSandbox(ctx context.Context, agentRef string) bool { + cfg := loadAgentConfig(ctx, agentRef) + return cfg != nil && cfg.Runtime != nil && cfg.Runtime.Sandbox +} + +// agentNetworkAllowlist returns the hostnames the agent declared in +// runtime.network_allowlist. Same best-effort contract as +// peekAgentSandbox: any failure to load the config returns nil so +// the run continues with the inferred host set only. +func agentNetworkAllowlist(ctx context.Context, agentRef string) []string { + cfg := loadAgentConfig(ctx, agentRef) + if cfg == nil || cfg.Runtime == nil { + return nil + } + return cfg.Runtime.NetworkAllowlist +} + +// loadAgentConfig is the shared best-effort loader behind +// peekAgentSandbox / agentNetworkAllowlist. Returns nil on any +// resolve or load failure. +func loadAgentConfig(ctx context.Context, agentRef string) *latestcfg.Config { if agentRef == "" { - return false + return nil } source, err := config.Resolve(agentRef, nil) if err != nil { - return false + return nil } cfg, err := config.Load(ctx, source) if err != nil { - return false + return nil } - return cfg.Runtime != nil && cfg.Runtime.Sandbox + return cfg } // runInSandbox delegates the current command to a Docker sandbox. @@ -105,8 +126,11 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon } } + agentHosts := agentNetworkAllowlist(ctx, agentRef) + printModelsGateway(cmd.OutOrStdout(), runConfig.ModelsGateway) printToolInstallAllowance(cmd.OutOrStdout(), kitResult) + printAgentNetworkAllowlist(cmd.OutOrStdout(), agentHosts) name, err := backend.Ensure(ctx, wd, extras, template, configDir) if err != nil { @@ -126,7 +150,7 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon if kitResult != nil { toolHosts = kitResult.ToolInstallHosts } - allowSandboxHosts(ctx, backend, name, runConfig.ModelsGateway, toolHosts) + allowSandboxHosts(ctx, backend, name, runConfig.ModelsGateway, toolHosts, agentHosts) // Resolve env vars the agent needs and forward them into the sandbox. // Docker Desktop proxies well-known API keys automatically; this handles @@ -202,11 +226,12 @@ func dockerAgentArgs(cmd *cobra.Command, args []string, configDir string) []stri // allowSandboxHosts adds per-sandbox allow-network rules for every // host the in-sandbox runtime is known to need: the configured -// models gateway (when set) and the package hosts the auto-installer +// models gateway (when set), the package hosts the auto-installer // reaches for (when the kit build identified at least one -// auto-installable toolset). The default sandbox proxy denies all of -// them; without this, the inner agent's first request returns a -// misleading "403 Blocked by network policy". +// auto-installable toolset), and any extra hosts the agent author +// declared in runtime.network_allowlist. The default sandbox proxy +// denies all of them; without this, the inner agent's first request +// returns a misleading "403 Blocked by network policy". // // Holes are punched only when the corresponding feature is in play: // - the gateway host is added only when gatewayURL is non-empty; @@ -216,15 +241,20 @@ func dockerAgentArgs(cmd *cobra.Command, args []string, configDir string) []stri // module proxy + toolchain bootstrap for go_install packages, // GitHub release hosts for github_release packages). When a // lookup failed, the kit folds in [toolinstall.FallbackHosts] -// so the run can still succeed. +// so the run can still succeed; +// - the agent-declared hosts come straight from the YAML and are +// unioned with the inferred set so authors can add hosts the +// resolver doesn't know about (custom MCP endpoints, third-party +// APIs, ...). // // Best-effort: a malformed gateway URL or a backend that doesn't // support per-sandbox policies is logged at debug level and the run // proceeds. The user will then see a network-policy 403 from the // inner and we surface that diagnostic verbatim. -func allowSandboxHosts(ctx context.Context, backend *sandbox.Backend, name, gatewayURL string, toolInstallHosts []string) { +func allowSandboxHosts(ctx context.Context, backend *sandbox.Backend, name, gatewayURL string, toolInstallHosts, agentHosts []string) { var hosts []string hosts = append(hosts, toolInstallHosts...) + hosts = append(hosts, agentHosts...) if gatewayURL != "" { if h := gatewayHostPort(gatewayURL); h != "" { @@ -426,3 +456,18 @@ func printToolInstallAllowance(w io.Writer, kitResult *kit.Result) { fmt.Fprintf(w, " ! %s (using fallback host set)\n", e.Error()) } } + +// printAgentNetworkAllowlist prints the host(s) the agent's config +// asked us to add to the sandbox proxy. Surfacing them next to the +// kit / gateway lines makes it obvious which holes were punched by +// the agent author vs auto-discovered, so an unexpected 403 has a +// short list of suspects. +func printAgentNetworkAllowlist(w io.Writer, hosts []string) { + if len(hosts) == 0 { + return + } + fmt.Fprintf(w, "Agent network allowlist: allowlisting %d host(s) declared in runtime.network_allowlist:\n", len(hosts)) + for _, h := range hosts { + fmt.Fprintf(w, " - %s\n", h) + } +} diff --git a/cmd/root/sandbox_test.go b/cmd/root/sandbox_test.go index 91f9e31e1..4cf8890b3 100644 --- a/cmd/root/sandbox_test.go +++ b/cmd/root/sandbox_test.go @@ -295,3 +295,51 @@ func TestPeekAgentSandbox(t *testing.T) { assert.False(t, peekAgentSandbox(t.Context(), "/nonexistent/agent.yaml")) }) } + +func TestAgentNetworkAllowlist(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + yamlBody := "runtime:\n" + + " network_allowlist:\n" + + " - api.example.com\n" + + " - registry.npmjs.org:443\n" + + "agents:\n root:\n model: openai/gpt-4o\n description: t\n instruction: t\n" + require.NoError(t, os.WriteFile(path, []byte(yamlBody), 0o600)) + + assert.Equal(t, []string{"api.example.com", "registry.npmjs.org:443"}, agentNetworkAllowlist(t.Context(), path)) +} + +func TestPrintAgentNetworkAllowlist(t *testing.T) { + tests := []struct { + name string + hosts []string + want string + }{ + { + name: "empty", + hosts: nil, + want: "", + }, + { + name: "single host", + hosts: []string{"api.example.com"}, + want: "Agent network allowlist: allowlisting 1 host(s) declared in runtime.network_allowlist:\n" + + " - api.example.com\n", + }, + { + name: "multiple", + hosts: []string{"a.example.com", "b.example.com"}, + want: "Agent network allowlist: allowlisting 2 host(s) declared in runtime.network_allowlist:\n" + + " - a.example.com\n" + + " - b.example.com\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + printAgentNetworkAllowlist(&buf, tt.hosts) + assert.Equal(t, tt.want, buf.String()) + }) + } +} diff --git a/docs/configuration/sandbox/index.md b/docs/configuration/sandbox/index.md index 5c0f4cb6d..771525654 100644 --- a/docs/configuration/sandbox/index.md +++ b/docs/configuration/sandbox/index.md @@ -92,6 +92,31 @@ The rule is the same as for aliases: an explicit `--sandbox=false` on the CLI overrides the config default, so you can debug an agent on the host without editing its YAML. +### Declare a network allowlist + +The runner already opens the [tool install hosts](#network-allowlist) and +the [models gateway](#how-it-works) automatically, but agents that talk +to endpoints those resolvers can't infer (custom MCP servers, third-party +APIs, registries not covered by the aqua resolver) would still see a 403 +from the sandbox proxy on first contact. + +Declare those hosts in `runtime.network_allowlist` and they are unioned +with the inferred set, so the agent can reach them on its first request: + +```yaml +# agent.yaml +runtime: + sandbox: true + network_allowlist: + - api.example.com + - registry.npmjs.org +``` + +Each entry is a hostname with an optional `:port` suffix. Commas and +whitespace are rejected to keep a single entry from smuggling several +rules into the policy engine. The runner prints the resulting allowlist +before launch so you can audit exactly which hosts the run opens up. + ## Example ```yaml diff --git a/examples/sandbox_agent.yaml b/examples/sandbox_agent.yaml index c0c4308a2..14cd39185 100644 --- a/examples/sandbox_agent.yaml +++ b/examples/sandbox_agent.yaml @@ -3,6 +3,14 @@ runtime: # to remember --sandbox on the command line. An explicit # --sandbox=false on the CLI still wins. sandbox: true + # Hosts the agent's tools need to reach. Unioned with the + # auto-discovered tool-install hosts and the configured models + # gateway, then added to the sandbox proxy's default-deny + # allowlist. Use this for endpoints the aqua-based resolver + # can't infer. + network_allowlist: + - api.example.com + - registry.npmjs.org agents: root: diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index c0e47067e..ec77b1df5 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -39,6 +39,21 @@ type RuntimeDefaults struct { // default — equivalent to passing --sandbox on the command line. // Useful for agents that always need filesystem/network isolation. Sandbox bool `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` + + // NetworkAllowlist is the list of hosts that should be added to + // the sandbox's default-deny network proxy when this agent runs in + // a sandbox. Each entry is a hostname with an optional ":port" + // suffix (e.g. "api.example.com", "registry.npmjs.org:443"). The + // list is unioned with the gateway and tool-install hosts the + // runner already opens automatically; commas and whitespace are + // rejected to keep a single entry from smuggling several rules + // into the policy engine. + // + // Use this when an agent's tools call hosts the auto-installer + // can't infer (custom MCP endpoints, third-party APIs, registries + // not covered by the aqua resolver, etc.) instead of relying on + // the wider fallback host set the kit uses on resolution failures. + NetworkAllowlist []string `json:"network_allowlist,omitempty" yaml:"network_allowlist,omitempty"` } // MCPToolset is a reusable MCP server definition stored in the top-level