From 8da4f16c4acc9844837b2bcce6269a0e541e55cb Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 22 May 2026 18:05:25 +0200 Subject: [PATCH 1/5] feat(sandbox): support sandbox: true on user-config aliases Aliases declared in ~/.config/cagent/config.yaml can now carry a sandbox flag. When set, 'docker agent run ' takes the sandbox path automatically without needing --sandbox on the command line. The flag joins yolo, model, and hide_tool_results in Alias and is exposed via 'docker agent alias add --sandbox'. An explicit --sandbox=false on the command line still wins, matching the precedence other alias options follow. --- cmd/root/alias.go | 15 ++++++++++++++- cmd/root/run.go | 14 +++++++++++++- docs/configuration/sandbox/index.md | 13 +++++++++++++ docs/features/cli/index.md | 2 ++ pkg/config/resolve_test.go | 18 ++++++++++++++++++ pkg/userconfig/userconfig.go | 4 +++- pkg/userconfig/userconfig_test.go | 3 ++- 7 files changed, 65 insertions(+), 4 deletions(-) diff --git a/cmd/root/alias.go b/cmd/root/alias.go index a9a7fbee6..6cbba9882 100644 --- a/cmd/root/alias.go +++ b/cmd/root/alias.go @@ -47,6 +47,7 @@ type aliasAddFlags struct { yolo bool model string hideToolResults bool + sandbox bool } func newAliasAddCmd() *cobra.Command { @@ -62,7 +63,8 @@ the alias is used: --yolo Automatically approve all tool calls without prompting --model Override the agent's model (format: [agent=]provider/model) - --hide-tool-results Hide tool call results in the TUI`, + --hide-tool-results Hide tool call results in the TUI + --sandbox Always run the agent inside a Docker sandbox`, Example: ` # Create a simple alias docker-agent alias add code agentcatalog/notion-expert @@ -75,6 +77,9 @@ the alias is used: # Create an alias with hidden tool results docker-agent alias add quiet agentcatalog/coder --hide-tool-results + # Create an alias that always runs in a sandbox + docker-agent alias add safe-coder agentcatalog/coder --sandbox + # Create an alias with multiple options docker-agent alias add turbo agentcatalog/coder --yolo --model anthropic/claude-sonnet-4-0`, Args: cobra.ExactArgs(2), @@ -86,6 +91,7 @@ the alias is used: cmd.Flags().BoolVar(&flags.yolo, "yolo", false, "Automatically approve all tool calls without prompting") cmd.Flags().StringVar(&flags.model, "model", "", "Override agent model (format: [agent=]provider/model)") cmd.Flags().BoolVar(&flags.hideToolResults, "hide-tool-results", false, "Hide tool call results in the TUI") + cmd.Flags().BoolVar(&flags.sandbox, "sandbox", false, "Always run the agent inside a Docker sandbox") return cmd } @@ -144,6 +150,7 @@ func runAliasAddCommand(cmd *cobra.Command, args []string, flags *aliasAddFlags) Yolo: flags.yolo, Model: flags.model, HideToolResults: flags.hideToolResults, + Sandbox: flags.sandbox, } // Store the alias @@ -168,6 +175,9 @@ func runAliasAddCommand(cmd *cobra.Command, args []string, flags *aliasAddFlags) if flags.hideToolResults { out.Printf(" Hide tool results: enabled\n") } + if flags.sandbox { + out.Printf(" Sandbox: enabled\n") + } if name == "default" { out.Printf("\nYou can now run: docker agent run %s (or even docker agent run)\n", name) @@ -224,6 +234,9 @@ func runAliasListCommand(cmd *cobra.Command, args []string) (commandErr error) { if alias.HideToolResults { options = append(options, "hide-tool-results") } + if alias.Sandbox { + options = append(options, "sandbox") + } if len(options) > 0 { out.Printf(" %s%s → %s [%s]\n", name, padding, alias.Path, strings.Join(options, ", ")) diff --git a/cmd/root/run.go b/cmd/root/run.go index 74f5ff784..cfc9fb4cd 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -170,6 +170,15 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command }() } + // Resolve alias-driven sandbox opt-in before dispatch so aliases + // like `docker-agent run scary-agent` (where the alias declares + // sandbox: true) take the sandbox path without an explicit flag. + if !f.sandbox && len(args) > 0 { + if alias := config.ResolveAlias(args[0]); alias != nil && alias.Sandbox { + f.sandbox = true + } + } + if f.sandbox { return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx, f.noKit) } @@ -218,7 +227,7 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s // Apply alias options if this is an alias reference // Alias options only apply if the flag wasn't explicitly set by the user if alias := config.ResolveAlias(agentFileName); alias != nil { - slog.DebugContext(ctx, "Applying alias options", "yolo", alias.Yolo, "model", alias.Model, "hide_tool_results", alias.HideToolResults) + slog.DebugContext(ctx, "Applying alias options", "yolo", alias.Yolo, "model", alias.Model, "hide_tool_results", alias.HideToolResults, "sandbox", alias.Sandbox) if alias.Yolo && !f.autoApprove { f.autoApprove = true } @@ -228,6 +237,9 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s if alias.HideToolResults && !f.hideToolResults { f.hideToolResults = true } + if alias.Sandbox && !f.sandbox { + f.sandbox = true + } } // Build global permissions checker from user config settings. diff --git a/docs/configuration/sandbox/index.md b/docs/configuration/sandbox/index.md index 5d3bff8b6..6d5feb357 100644 --- a/docs/configuration/sandbox/index.md +++ b/docs/configuration/sandbox/index.md @@ -51,6 +51,19 @@ docker agent run --sandbox --sbx=false agent.yaml docker agent run --sandbox --no-kit agent.yaml ``` +### Always sandbox a given agent + +Add `--sandbox` to an [alias]({{ '/features/cli/' | relative_url }}#aliases) so the +sandbox path is taken automatically whenever that alias is invoked: + +```bash +docker agent alias add safe-coder agentcatalog/coder --sandbox +docker agent run safe-coder +``` + +An explicit `--sandbox=false` on the command line still wins, so you can opt +out of the sandbox for a single run without touching the alias. + ## Example ```yaml diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index 2ae2d2a1c..de80147c9 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -347,6 +347,7 @@ $ docker agent alias add other ociReference # Add an alias with runtime options $ docker agent alias add yolo-coder agentcatalog/coder --yolo $ docker agent alias add fast-coder agentcatalog/coder --model openai/gpt-4o-mini +$ docker agent alias add safe-coder agentcatalog/coder --sandbox $ docker agent alias add turbo agentcatalog/coder --yolo --model anthropic/claude-sonnet-4-5 # Use an alias @@ -359,6 +360,7 @@ $ docker agent run yolo-coder - `--yolo` — Auto-approve all tool calls when running the alias - `--model <ref>` — Override the model for the alias - `--hide-tool-results` — Hide tool call results in the TUI when running the alias +- `--sandbox` — Always run the alias inside a [Docker sandbox]({{ '/configuration/sandbox/' | relative_url }}) When listing aliases, options are shown in brackets: diff --git a/pkg/config/resolve_test.go b/pkg/config/resolve_test.go index 71733ed85..3ea38ad08 100644 --- a/pkg/config/resolve_test.go +++ b/pkg/config/resolve_test.go @@ -534,6 +534,24 @@ func TestResolveAlias_WithBothOptions(t *testing.T) { assert.Equal(t, "anthropic/claude-sonnet-4-0", alias.Model) } +func TestResolveAlias_WithSandboxOption(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cfg, err := userconfig.Load() + require.NoError(t, err) + require.NoError(t, cfg.SetAlias("safe-coder", &userconfig.Alias{ + Path: "agentcatalog/coder", + Sandbox: true, + })) + require.NoError(t, cfg.Save()) + + alias := ResolveAlias("safe-coder") + require.NotNil(t, alias) + assert.True(t, alias.Sandbox) + assert.False(t, alias.Yolo) +} + func TestResolveAlias_NoOptions(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index 757a049b3..ecf86e979 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -30,11 +30,13 @@ type Alias struct { Model string `yaml:"model,omitempty"` // HideToolResults hides tool call results in the TUI HideToolResults bool `yaml:"hide_tool_results,omitempty"` + // Sandbox runs the agent inside a Docker sandbox by default. + Sandbox bool `yaml:"sandbox,omitempty"` } // HasOptions returns true if the alias has any runtime options set func (a *Alias) HasOptions() bool { - return a != nil && (a.Yolo || a.Model != "" || a.HideToolResults) + return a != nil && (a.Yolo || a.Model != "" || a.HideToolResults || a.Sandbox) } // Settings represents global user settings diff --git a/pkg/userconfig/userconfig_test.go b/pkg/userconfig/userconfig_test.go index ac3875f25..c7d1a9708 100644 --- a/pkg/userconfig/userconfig_test.go +++ b/pkg/userconfig/userconfig_test.go @@ -607,7 +607,8 @@ func TestAlias_HasOptions(t *testing.T) { {"yolo only", &Alias{Path: "test", Yolo: true}, true}, {"model only", &Alias{Path: "test", Model: "openai/gpt-4o"}, true}, {"hide_tool_results only", &Alias{Path: "test", HideToolResults: true}, true}, - {"all options", &Alias{Path: "test", Yolo: true, Model: "openai/gpt-4o", HideToolResults: true}, true}, + {"sandbox only", &Alias{Path: "test", Sandbox: true}, true}, + {"all options", &Alias{Path: "test", Yolo: true, Model: "openai/gpt-4o", HideToolResults: true, Sandbox: true}, true}, } for _, tt := range tests { From 6b828038246d205d92be9ad7e8c6657d67ef2d81 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 22 May 2026 18:11:45 +0200 Subject: [PATCH 2/5] feat(sandbox): support runtime.sandbox: true in agent config Agent authors can now declare a sandbox default in the YAML itself: runtime: sandbox: true Any caller of the agent then takes the sandbox path automatically, without having to remember --sandbox. An explicit --sandbox=false on the CLI still wins, matching the precedence aliases follow. This is the per-agent counterpart of the per-alias sandbox flag, and is especially useful for catalog agents that always need filesystem or network isolation. Adds the new RuntimeDefaults block to the latest config schema, the matching JSON schema definition, and a refreshed sandbox_agent.yaml example demonstrating the feature. --- agent-schema.json | 15 ++++++++++ cmd/root/run.go | 9 ++++++ cmd/root/sandbox.go | 20 +++++++++++++ cmd/root/sandbox_test.go | 44 +++++++++++++++++++++++++++++ docs/configuration/sandbox/index.md | 28 ++++++++++++++++++ examples/sandbox_agent.yaml | 10 ++++++- pkg/config/latest/types.go | 11 ++++++++ 7 files changed, 136 insertions(+), 1 deletion(-) diff --git a/agent-schema.json b/agent-schema.json index e70a17de6..a95869e61 100644 --- a/agent-schema.json +++ b/agent-schema.json @@ -75,6 +75,10 @@ "permissions": { "$ref": "#/definitions/PermissionsConfig", "description": "Tool permission configuration for controlling tool approval behavior" + }, + "runtime": { + "$ref": "#/definitions/RuntimeDefaults", + "description": "Execution-time defaults the agent author wants applied. Values act as defaults only — explicit CLI flags or user-config settings always win." } }, "additionalProperties": false, @@ -1399,6 +1403,17 @@ }, "additionalProperties": false }, + "RuntimeDefaults": { + "type": "object", + "description": "Execution-time defaults baked into the agent config. Values act as defaults only; explicit CLI flags and user-config settings still take precedence.", + "properties": { + "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." + } + }, + "additionalProperties": false + }, "PermissionsConfig": { "type": "object", "description": "Tool permission configuration. Controls tool call approval behavior with optional argument matching.", diff --git a/cmd/root/run.go b/cmd/root/run.go index cfc9fb4cd..9fc829be4 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -179,6 +179,15 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command } } + // Honour `runtime.sandbox: true` declared by the agent author. + // Best-effort: a config we can't peek at falls through to the + // normal path which will surface the load error properly. + if !f.sandbox && len(args) > 0 { + if peekAgentSandbox(ctx, args[0]) { + f.sandbox = true + } + } + if f.sandbox { return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx, f.noKit) } diff --git a/cmd/root/sandbox.go b/cmd/root/sandbox.go index 408af0780..a68d83a1f 100644 --- a/cmd/root/sandbox.go +++ b/cmd/root/sandbox.go @@ -25,6 +25,26 @@ import ( "github.com/docker/docker-agent/pkg/skills" ) +// peekAgentSandbox returns true when the agent referenced by +// agentRef declares runtime.sandbox: true in its config. It is +// best-effort: any failure to resolve, load, or parse the config +// 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 { + if agentRef == "" { + return false + } + source, err := config.Resolve(agentRef, nil) + if err != nil { + return false + } + cfg, err := config.Load(ctx, source) + if err != nil { + return false + } + return cfg.Runtime != nil && cfg.Runtime.Sandbox +} + // runInSandbox delegates the current command to a Docker sandbox. // It ensures a sandbox exists (creating or recreating as needed), then // executes docker agent inside it via the sandbox exec command. diff --git a/cmd/root/sandbox_test.go b/cmd/root/sandbox_test.go index 6cd44aaaf..91f9e31e1 100644 --- a/cmd/root/sandbox_test.go +++ b/cmd/root/sandbox_test.go @@ -2,6 +2,8 @@ package root import ( "errors" + "os" + "path/filepath" "slices" "strings" "testing" @@ -251,3 +253,45 @@ func TestPrintToolInstallAllowance(t *testing.T) { }) } } + +func TestPeekAgentSandbox(t *testing.T) { + tests := []struct { + name string + yaml string + want bool + }{ + { + name: "runtime sandbox true", + yaml: "runtime:\n sandbox: true\nagents:\n root:\n model: openai/gpt-4o\n description: t\n instruction: t\n", + want: true, + }, + { + name: "runtime sandbox false", + yaml: "runtime:\n sandbox: false\nagents:\n root:\n model: openai/gpt-4o\n description: t\n instruction: t\n", + want: false, + }, + { + name: "runtime block absent", + yaml: "agents:\n root:\n model: openai/gpt-4o\n description: t\n instruction: t\n", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + require.NoError(t, os.WriteFile(path, []byte(tt.yaml), 0o600)) + + assert.Equal(t, tt.want, peekAgentSandbox(t.Context(), path)) + }) + } + + t.Run("empty ref", func(t *testing.T) { + assert.False(t, peekAgentSandbox(t.Context(), "")) + }) + + t.Run("unresolvable ref", func(t *testing.T) { + assert.False(t, peekAgentSandbox(t.Context(), "/nonexistent/agent.yaml")) + }) +} diff --git a/docs/configuration/sandbox/index.md b/docs/configuration/sandbox/index.md index 6d5feb357..5c0f4cb6d 100644 --- a/docs/configuration/sandbox/index.md +++ b/docs/configuration/sandbox/index.md @@ -64,6 +64,34 @@ docker agent run safe-coder An explicit `--sandbox=false` on the command line still wins, so you can opt out of the sandbox for a single run without touching the alias. +### Bake the default into the agent config + +Agent authors can declare a sandbox default in the YAML itself. Any caller of +the agent then gets the sandbox path automatically, without having to know +(or remember) to pass `--sandbox`: + +```yaml +# agent.yaml +runtime: + sandbox: true + +agents: + root: + model: openai/gpt-4o + description: A helpful assistant + instruction: You are a helpful assistant. + toolsets: + - type: shell +``` + +```bash +docker agent run agent.yaml # runs in a sandbox automatically +``` + +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. + ## Example ```yaml diff --git a/examples/sandbox_agent.yaml b/examples/sandbox_agent.yaml index 07fdd71b2..c0c4308a2 100644 --- a/examples/sandbox_agent.yaml +++ b/examples/sandbox_agent.yaml @@ -1,10 +1,18 @@ +runtime: + # Always run this agent inside a Docker sandbox so callers don't need + # to remember --sandbox on the command line. An explicit + # --sandbox=false on the CLI still wins. + sandbox: true + agents: root: model: openai/gpt-4o description: | A helpful assistant that runs shell commands in a sandboxed environment. All commands execute inside a Docker container with limited filesystem access. - Use the --sandbox flag on the command line to enable sandboxing: + The agent declares runtime.sandbox: true above, so: + docker agent run examples/sandbox_agent.yaml + is equivalent to: docker agent run --sandbox examples/sandbox_agent.yaml instruction: You are a helpful assistant with access to a sandboxed shell environment. toolsets: diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go index 104f0caa4..82945da11 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -28,6 +28,17 @@ type Config struct { RAG map[string]RAGToolset `json:"rag,omitempty"` Metadata Metadata `json:"metadata"` Permissions *PermissionsConfig `json:"permissions,omitempty"` + Runtime *RuntimeDefaults `json:"runtime,omitempty"` +} + +// RuntimeDefaults captures execution-time defaults the agent author +// wants applied when this config is run. The values act as defaults +// only: an explicit CLI flag or user-config setting always wins. +type RuntimeDefaults struct { + // Sandbox, when true, runs the agent inside a Docker sandbox by + // 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"` } // MCPToolset is a reusable MCP server definition stored in the top-level From 0e76e1b4fb6843cf01fe17ab829313e27050950a Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 22 May 2026 18:15:05 +0200 Subject: [PATCH 3/5] 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 a95869e61..341cbc9ce 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 82945da11..58ab88074 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 From dda220d65c8462f22b6dbea142b3bd803c98b946 Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 22 May 2026 18:22:09 +0200 Subject: [PATCH 4/5] feat(sandbox): persist user network allowlist via 'sandbox allow' Hosts the kit's per-toolset resolver can't infer (custom MCP endpoints, third-party APIs, registries not covered by aqua) showed up as a 403 from the sandbox proxy and forced the run to fall back to the wider conservative host union. Authors can now declare them in runtime.network_allowlist, but users running someone else's agent still had no easy fix. Add a persistent user-level allowlist plus three subcommands to manage it: docker agent sandbox allow [...] docker agent sandbox deny docker agent sandbox list Entries land in ~/.config/cagent/config.yaml under sandbox_allowlist and are unioned with the gateway, the kit-resolved tool install hosts, and the agent-declared list on every subsequent --sandbox run. The launch summary now prints each source on its own line so it's clear which layer punched which hole, and the tool-install summary surfaces a hint pointing at 'sandbox allow' when the per-toolset resolver had to fall back. Commas and embedded whitespace are rejected at write time so a single entry can't smuggle several rules into the policy engine. --- cmd/root/root.go | 1 + cmd/root/sandbox.go | 38 ++++++- cmd/root/sandbox_cmd.go | 157 ++++++++++++++++++++++++++++ cmd/root/sandbox_cmd_test.go | 52 +++++++++ cmd/root/sandbox_test.go | 3 +- docs/configuration/sandbox/index.md | 24 +++++ docs/features/cli/index.md | 20 ++++ pkg/userconfig/userconfig.go | 65 ++++++++++++ pkg/userconfig/userconfig_test.go | 57 ++++++++++ 9 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 cmd/root/sandbox_cmd.go create mode 100644 cmd/root/sandbox_cmd_test.go diff --git a/cmd/root/root.go b/cmd/root/root.go index 1dfaf6fec..24e9b95c8 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -160,6 +160,7 @@ We collect anonymous usage data to help improve docker agent. To disable: newModelsCmd(), newDebugCmd(), newAliasCmd(), + newSandboxCmd(), newServeCmd(), ) diff --git a/cmd/root/sandbox.go b/cmd/root/sandbox.go index 3892ec3b5..c3e1be21e 100644 --- a/cmd/root/sandbox.go +++ b/cmd/root/sandbox.go @@ -24,6 +24,7 @@ import ( "github.com/docker/docker-agent/pkg/sandbox" "github.com/docker/docker-agent/pkg/sandbox/kit" "github.com/docker/docker-agent/pkg/skills" + "github.com/docker/docker-agent/pkg/userconfig" ) // peekAgentSandbox returns true when the agent referenced by @@ -66,6 +67,19 @@ func loadAgentConfig(ctx context.Context, agentRef string) *latestcfg.Config { return cfg } +// userSandboxAllowlist returns the persistent host list the user has +// taught docker-agent to open via `docker agent sandbox allow`. +// Best-effort: a missing or unreadable user config returns nil so +// the sandbox falls back to the inferred set only. +func userSandboxAllowlist(ctx context.Context) []string { + cfg, err := userconfig.Load() + if err != nil { + slog.DebugContext(ctx, "Failed to load user config; skipping persistent sandbox allowlist", "error", err) + return nil + } + return cfg.SandboxAllowlist +} + // runInSandbox delegates the current command to a Docker sandbox. // It ensures a sandbox exists (creating or recreating as needed), then // executes docker agent inside it via the sandbox exec command. @@ -127,10 +141,12 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon } agentHosts := agentNetworkAllowlist(ctx, agentRef) + userHosts := userSandboxAllowlist(ctx) printModelsGateway(cmd.OutOrStdout(), runConfig.ModelsGateway) printToolInstallAllowance(cmd.OutOrStdout(), kitResult) printAgentNetworkAllowlist(cmd.OutOrStdout(), agentHosts) + printUserSandboxAllowlist(cmd.OutOrStdout(), userHosts) name, err := backend.Ensure(ctx, wd, extras, template, configDir) if err != nil { @@ -150,7 +166,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, agentHosts) + allowSandboxHosts(ctx, backend, name, runConfig.ModelsGateway, toolHosts, agentHosts, userHosts) // Resolve env vars the agent needs and forward them into the sandbox. // Docker Desktop proxies well-known API keys automatically; this handles @@ -251,10 +267,11 @@ func dockerAgentArgs(cmd *cobra.Command, args []string, configDir string) []stri // 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, agentHosts []string) { +func allowSandboxHosts(ctx context.Context, backend *sandbox.Backend, name, gatewayURL string, toolInstallHosts, agentHosts, userHosts []string) { var hosts []string hosts = append(hosts, toolInstallHosts...) hosts = append(hosts, agentHosts...) + hosts = append(hosts, userHosts...) if gatewayURL != "" { if h := gatewayHostPort(gatewayURL); h != "" { @@ -455,6 +472,9 @@ func printToolInstallAllowance(w io.Writer, kitResult *kit.Result) { for _, e := range kitResult.ToolInstallHostsResolutionErr { fmt.Fprintf(w, " ! %s (using fallback host set)\n", e.Error()) } + if len(kitResult.ToolInstallHostsResolutionErr) > 0 { + fmt.Fprintln(w, " hint: persist a missing host with `docker agent sandbox allow `") + } } // printAgentNetworkAllowlist prints the host(s) the agent's config @@ -471,3 +491,17 @@ func printAgentNetworkAllowlist(w io.Writer, hosts []string) { fmt.Fprintf(w, " - %s\n", h) } } + +// printUserSandboxAllowlist prints the host(s) the user has added +// via `docker agent sandbox allow`. Kept on its own line (separate +// from the agent-declared list) so it's clear which hosts persist +// across runs vs which travel with the agent config. +func printUserSandboxAllowlist(w io.Writer, hosts []string) { + if len(hosts) == 0 { + return + } + fmt.Fprintf(w, "User sandbox allowlist: allowlisting %d host(s) from `docker agent sandbox allow`:\n", len(hosts)) + for _, h := range hosts { + fmt.Fprintf(w, " - %s\n", h) + } +} diff --git a/cmd/root/sandbox_cmd.go b/cmd/root/sandbox_cmd.go new file mode 100644 index 000000000..13fa354b1 --- /dev/null +++ b/cmd/root/sandbox_cmd.go @@ -0,0 +1,157 @@ +package root + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/docker/docker-agent/pkg/cli" + "github.com/docker/docker-agent/pkg/telemetry" + "github.com/docker/docker-agent/pkg/userconfig" +) + +// newSandboxCmd assembles the `docker agent sandbox` subcommand +// group. The group is intentionally narrow today: it only manages +// the persistent network allowlist users build up via +// `docker agent sandbox allow ` so a 403 from the in-sandbox +// proxy can be turned into a one-line fix that survives across runs. +// VM lifecycle commands (ls / rm / prune) live with the backend CLI. +func newSandboxCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sandbox", + Short: "Manage docker-agent sandbox settings", + Long: "Manage persistent sandbox network allowlist entries shared across runs.", + GroupID: "advanced", + } + cmd.AddCommand(newSandboxAllowCmd()) + cmd.AddCommand(newSandboxDenyCmd()) + cmd.AddCommand(newSandboxListCmd()) + return cmd +} + +func newSandboxAllowCmd() *cobra.Command { + return &cobra.Command{ + Use: "allow [...]", + Short: "Add host(s) to the persistent sandbox network allowlist", + Long: `Add hosts to the user-level sandbox network allowlist. + +Listed hosts are added to the sandbox proxy's allow rules on every +subsequent --sandbox run, in addition to the gateway, the kit-resolved +tool install hosts, and any runtime.network_allowlist declared by the +agent. Each entry is a hostname with an optional ":port" suffix. + +This is the recommended fix for "Blocked by network policy" 403s on a +host the auto-installer can't infer (custom MCP endpoint, third-party +API, registry not covered by the aqua resolver): + + docker agent sandbox allow api.example.com`, + Args: cobra.MinimumNArgs(1), + RunE: runSandboxAllowCommand, + } +} + +func newSandboxDenyCmd() *cobra.Command { + return &cobra.Command{ + Use: "deny ", + Aliases: []string{"remove", "rm"}, + Short: "Remove a host from the persistent sandbox network allowlist", + Args: cobra.ExactArgs(1), + RunE: runSandboxDenyCommand, + } +} + +func newSandboxListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List the persistent sandbox network allowlist", + Args: cobra.NoArgs, + RunE: runSandboxListCommand, + } +} + +func runSandboxAllowCommand(cmd *cobra.Command, args []string) (commandErr error) { + telemetry.TrackCommand(cmd.Context(), "sandbox", append([]string{"allow"}, args...)) + defer func() { + telemetry.TrackCommandError(cmd.Context(), "sandbox", append([]string{"allow"}, args...), commandErr) + }() + + out := cli.NewPrinter(cmd.OutOrStdout()) + + cfg, err := userconfig.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + added, err := cfg.AddSandboxHosts(args...) + if err != nil { + return err + } + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + if len(added) == 0 { + out.Println("All requested hosts were already on the allowlist.") + return nil + } + out.Printf("Added %d host(s) to the persistent sandbox allowlist:\n", len(added)) + for _, h := range added { + out.Printf(" + %s\n", h) + } + if skipped := len(args) - len(added); skipped > 0 { + out.Printf("(%d already present)\n", skipped) + } + return nil +} + +func runSandboxDenyCommand(cmd *cobra.Command, args []string) (commandErr error) { + telemetry.TrackCommand(cmd.Context(), "sandbox", append([]string{"deny"}, args...)) + defer func() { + telemetry.TrackCommandError(cmd.Context(), "sandbox", append([]string{"deny"}, args...), commandErr) + }() + + out := cli.NewPrinter(cmd.OutOrStdout()) + host := strings.TrimSpace(args[0]) + + cfg, err := userconfig.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if !cfg.RemoveSandboxHost(host) { + return fmt.Errorf("host %q is not on the allowlist", host) + } + if err := cfg.Save(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + out.Printf("Removed %s from the persistent sandbox allowlist.\n", host) + return nil +} + +func runSandboxListCommand(cmd *cobra.Command, args []string) (commandErr error) { + telemetry.TrackCommand(cmd.Context(), "sandbox", append([]string{"list"}, args...)) + defer func() { + telemetry.TrackCommandError(cmd.Context(), "sandbox", append([]string{"list"}, args...), commandErr) + }() + + out := cli.NewPrinter(cmd.OutOrStdout()) + + cfg, err := userconfig.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + if len(cfg.SandboxAllowlist) == 0 { + out.Println("Persistent sandbox allowlist is empty.") + out.Println("\nAdd a host with: docker agent sandbox allow ") + return nil + } + + out.Printf("Persistent sandbox allowlist (%d):\n\n", len(cfg.SandboxAllowlist)) + for _, h := range cfg.SandboxAllowlist { + out.Printf(" %s\n", h) + } + return nil +} diff --git a/cmd/root/sandbox_cmd_test.go b/cmd/root/sandbox_cmd_test.go new file mode 100644 index 000000000..def411d3a --- /dev/null +++ b/cmd/root/sandbox_cmd_test.go @@ -0,0 +1,52 @@ +package root + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/userconfig" +) + +// TestSandboxAllowDenyList exercises the sandbox subcommand group end +// to end against a temp HOME, verifying that allow / list / deny +// round-trip through the user config. +func TestSandboxAllowDenyList(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + root := NewRootCmd() + root.SetContext(t.Context()) + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + + root.SetArgs([]string{"sandbox", "allow", "api.example.com", "registry.npmjs.org:443"}) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "+ api.example.com") + assert.Contains(t, stdout.String(), "+ registry.npmjs.org:443") + + cfg, err := userconfig.Load() + require.NoError(t, err) + assert.Equal(t, []string{"api.example.com", "registry.npmjs.org:443"}, cfg.SandboxAllowlist) + + stdout.Reset() + root.SetArgs([]string{"sandbox", "list"}) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "api.example.com") + assert.Contains(t, stdout.String(), "registry.npmjs.org:443") + + stdout.Reset() + root.SetArgs([]string{"sandbox", "deny", "api.example.com"}) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "Removed api.example.com") + + cfg, err = userconfig.Load() + require.NoError(t, err) + assert.Equal(t, []string{"registry.npmjs.org:443"}, cfg.SandboxAllowlist) + + // Denying a host that isn't on the list returns an error. + root.SetArgs([]string{"sandbox", "deny", "not.on.list.example.com"}) + require.Error(t, root.Execute()) +} diff --git a/cmd/root/sandbox_test.go b/cmd/root/sandbox_test.go index 4cf8890b3..e6077e0b1 100644 --- a/cmd/root/sandbox_test.go +++ b/cmd/root/sandbox_test.go @@ -237,7 +237,8 @@ func TestPrintToolInstallAllowance(t *testing.T) { }, want: "Tool install: agent has at least one MCP/LSP toolset, allowlisting 1 package host(s) in the sandbox proxy:\n" + " - api.github.com\n" + - " ! resolving install hosts for \"gopls\"@\"golang/tools@v0.21.0\": boom (using fallback host set)\n", + " ! resolving install hosts for \"gopls\"@\"golang/tools@v0.21.0\": boom (using fallback host set)\n" + + " hint: persist a missing host with `docker agent sandbox allow `\n", wantNot: []string{}, }, } diff --git a/docs/configuration/sandbox/index.md b/docs/configuration/sandbox/index.md index 771525654..a34782f89 100644 --- a/docs/configuration/sandbox/index.md +++ b/docs/configuration/sandbox/index.md @@ -117,6 +117,30 @@ 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. +### Persist your own allowlist + +For hosts you keep needing across agents (a corporate proxy, a +self-hosted registry, ...) `docker agent sandbox allow` writes the +entry into `~/.config/cagent/config.yaml` once and unions it with the +inferred and agent-declared sets on every subsequent `--sandbox` run: + +```bash +# I just got a `Blocked by network policy` 403 on api.example.com. +docker agent sandbox allow api.example.com + +# See what's currently persisted. +docker agent sandbox list + +# Drop a host you no longer need. +docker agent sandbox deny api.example.com +``` + +When the kit's per-toolset host resolver fails (the `! using fallback +host set` line in the launch summary), the runner now prints a hint +pointing at this command so you can turn the missing host into a +one-line, persistent fix instead of relying on the wider conservative +fallback host set. + ## Example ```yaml diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index de80147c9..2a95ab37a 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -391,6 +391,26 @@ Run an alias with: docker agent run +### `docker agent sandbox` + +Manage settings shared by every [`--sandbox`]({{ '/configuration/sandbox/' | relative_url }}) run — today, the persistent network allowlist that turns a `Blocked by network policy` 403 into a one-line, durable fix: + +```bash +# Allow a host on every subsequent --sandbox run. +$ docker agent sandbox allow api.example.com + +# Or several at once. +$ docker agent sandbox allow api.example.com registry.npmjs.org:443 + +# See what's persisted in ~/.config/cagent/config.yaml. +$ docker agent sandbox list + +# Drop a host you no longer need. +$ docker agent sandbox deny api.example.com +``` + +Entries are unioned with the gateway, the kit-resolved tool install hosts, and any `runtime.network_allowlist` declared by the agent. The launch summary lists every source separately so you can see which holes were punched by which layer. + ## Global Flags These flags are available on every `docker agent` command: diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go index ecf86e979..2dac7a927 100644 --- a/pkg/userconfig/userconfig.go +++ b/pkg/userconfig/userconfig.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "regexp" + "strings" "sync" "github.com/goccy/go-yaml" @@ -156,6 +157,14 @@ type Config struct { Settings *Settings `yaml:"settings,omitempty"` // CredentialHelper configures an external command to retrieve Docker credentials CredentialHelper *CredentialHelper `yaml:"credential_helper,omitempty"` + // SandboxAllowlist is the persistent list of hosts the user has + // taught docker-agent to open in the sandbox proxy on every run + // (in addition to the gateway, the kit-resolved tool install + // hosts, and the agent-declared runtime.network_allowlist). + // Managed via `docker agent sandbox allow/deny/list`. Each entry + // is a hostname with an optional ":port" suffix; commas and + // whitespace are rejected at write time. + SandboxAllowlist []string `yaml:"sandbox_allowlist,omitempty"` } // Path returns the path to the config file @@ -370,3 +379,59 @@ func Get() *Settings { } return cfg.GetSettings() } + +// AddSandboxHosts appends host(s) to SandboxAllowlist, preserving +// insertion order and skipping duplicates. Each entry is trimmed of +// surrounding whitespace; commas and embedded whitespace are +// rejected because the sandbox network policy joins entries with +// commas downstream and a single value containing one of those +// would silently smuggle several distinct rules into the engine. +// +// Returns the list of hosts that were actually added (i.e. not +// already present), so callers can report "already allowed" without +// re-walking the slice. +func (c *Config) AddSandboxHosts(hosts ...string) ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + + existing := make(map[string]struct{}, len(c.SandboxAllowlist)) + for _, h := range c.SandboxAllowlist { + existing[h] = struct{}{} + } + + var added []string + for _, h := range hosts { + h = strings.TrimSpace(h) + if h == "" { + continue + } + if strings.ContainsAny(h, ", \t") { + return nil, fmt.Errorf("refusing to allowlist host %q: contains comma or whitespace", h) + } + if _, ok := existing[h]; ok { + continue + } + existing[h] = struct{}{} + c.SandboxAllowlist = append(c.SandboxAllowlist, h) + added = append(added, h) + } + return added, nil +} + +// RemoveSandboxHost drops host from SandboxAllowlist. Returns true +// when the host was present. +func (c *Config) RemoveSandboxHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + for i, h := range c.SandboxAllowlist { + if h == host { + c.SandboxAllowlist = append(c.SandboxAllowlist[:i], c.SandboxAllowlist[i+1:]...) + return true + } + } + return false +} diff --git a/pkg/userconfig/userconfig_test.go b/pkg/userconfig/userconfig_test.go index c7d1a9708..7833b84e7 100644 --- a/pkg/userconfig/userconfig_test.go +++ b/pkg/userconfig/userconfig_test.go @@ -936,3 +936,60 @@ func TestConfig_PermissionsRoundTrip(t *testing.T) { assert.Equal(t, original.Settings.Permissions.Deny, loaded.Settings.Permissions.Deny) assert.Equal(t, original.Settings.Permissions.Ask, loaded.Settings.Permissions.Ask) } + +func TestConfig_AddSandboxHosts(t *testing.T) { + t.Parallel() + + cfg := &Config{} + + added, err := cfg.AddSandboxHosts("api.example.com", "registry.npmjs.org:443") + require.NoError(t, err) + assert.Equal(t, []string{"api.example.com", "registry.npmjs.org:443"}, added) + assert.Equal(t, []string{"api.example.com", "registry.npmjs.org:443"}, cfg.SandboxAllowlist) + + // Adding an existing host is a no-op and does not duplicate it. + added, err = cfg.AddSandboxHosts("api.example.com", "new.example.com") + require.NoError(t, err) + assert.Equal(t, []string{"new.example.com"}, added) + assert.Equal(t, []string{"api.example.com", "registry.npmjs.org:443", "new.example.com"}, cfg.SandboxAllowlist) + + // Whitespace is trimmed; embedded whitespace and commas are rejected. + added, err = cfg.AddSandboxHosts(" trimmed.example.com ") + require.NoError(t, err) + assert.Equal(t, []string{"trimmed.example.com"}, added) + + _, err = cfg.AddSandboxHosts("a.example.com,b.example.com") + require.Error(t, err) + + _, err = cfg.AddSandboxHosts("has space.example.com") + require.Error(t, err) +} + +func TestConfig_RemoveSandboxHost(t *testing.T) { + t.Parallel() + + cfg := &Config{SandboxAllowlist: []string{"a.example.com", "b.example.com"}} + + assert.True(t, cfg.RemoveSandboxHost("a.example.com")) + assert.Equal(t, []string{"b.example.com"}, cfg.SandboxAllowlist) + + assert.False(t, cfg.RemoveSandboxHost("missing.example.com")) + assert.False(t, cfg.RemoveSandboxHost("")) +} + +func TestConfig_SandboxAllowlistRoundTrip(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + original := &Config{ + Aliases: make(map[string]*Alias), + SandboxAllowlist: []string{"api.example.com", "registry.npmjs.org:443"}, + } + require.NoError(t, original.saveTo(configFile)) + + loaded, err := loadFrom(configFile, "") + require.NoError(t, err) + assert.Equal(t, original.SandboxAllowlist, loaded.SandboxAllowlist) +} From 1f4dbcc77b4aa0b5164601c02fb09285062b94a5 Mon Sep 17 00:00:00 2001 From: docker-agent Date: Sat, 23 May 2026 10:20:26 +0200 Subject: [PATCH 5/5] fix(sandbox): let explicit --sandbox=false override alias/runtime defaults The dispatch logic only checked !f.sandbox before consulting the alias and the agent's runtime.sandbox, so passing --sandbox=false on the CLI silently fell through to either of the lower-priority sources. The README and the schema both promise that the explicit flag wins, so detect whether the user actually set it via cmd.Flags().Changed and skip the override path in that case. Extract the precedence into resolveSandboxDefault so the rule is testable on its own, and add a regression test covering both the "flag not set" and "flag explicitly false" paths. --- cmd/root/run.go | 28 +++++++++------------------- cmd/root/sandbox.go | 19 +++++++++++++++++++ cmd/root/sandbox_test.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/cmd/root/run.go b/cmd/root/run.go index 9fc829be4..9dfaa8282 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -170,22 +170,11 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command }() } - // Resolve alias-driven sandbox opt-in before dispatch so aliases - // like `docker-agent run scary-agent` (where the alias declares - // sandbox: true) take the sandbox path without an explicit flag. - if !f.sandbox && len(args) > 0 { - if alias := config.ResolveAlias(args[0]); alias != nil && alias.Sandbox { - f.sandbox = true - } - } - - // Honour `runtime.sandbox: true` declared by the agent author. - // Best-effort: a config we can't peek at falls through to the - // normal path which will surface the load error properly. - if !f.sandbox && len(args) > 0 { - if peekAgentSandbox(ctx, args[0]) { - f.sandbox = true - } + // Resolve alias / runtime-declared sandbox opt-in before dispatch. + // An explicit --sandbox= on the CLI always wins, so we only + // consult the lower-priority sources when the flag wasn't set. + if !cmd.Flags().Changed("sandbox") { + f.sandbox = resolveSandboxDefault(ctx, args, f.sandbox) } if f.sandbox { @@ -246,9 +235,10 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s if alias.HideToolResults && !f.hideToolResults { f.hideToolResults = true } - if alias.Sandbox && !f.sandbox { - f.sandbox = true - } + // alias.Sandbox is consumed earlier in runRunCommand before + // dispatch; by the time we reach runOrExec the sandbox path + // has already been taken (or the user opted out via + // --sandbox=false), so flipping it here would be a no-op. } // Build global permissions checker from user config settings. diff --git a/cmd/root/sandbox.go b/cmd/root/sandbox.go index c3e1be21e..180fe2ce6 100644 --- a/cmd/root/sandbox.go +++ b/cmd/root/sandbox.go @@ -37,6 +37,25 @@ func peekAgentSandbox(ctx context.Context, agentRef string) bool { return cfg != nil && cfg.Runtime != nil && cfg.Runtime.Sandbox } +// resolveSandboxDefault decides whether the sandbox path should be +// taken when the user did not pass --sandbox on the CLI. The first +// source that declares sandbox: true wins; in priority order: +// +// 1. an alias entry (`docker agent alias add ... --sandbox`); +// 2. the agent's own `runtime.sandbox: true`. +// +// Callers must only invoke this when the CLI flag was not set; an +// explicit --sandbox= always wins and bypasses this logic. +func resolveSandboxDefault(ctx context.Context, args []string, current bool) bool { + if current || len(args) == 0 { + return current + } + if alias := config.ResolveAlias(args[0]); alias != nil && alias.Sandbox { + return true + } + return peekAgentSandbox(ctx, args[0]) +} + // 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 diff --git a/cmd/root/sandbox_test.go b/cmd/root/sandbox_test.go index e6077e0b1..a8cb1d1ff 100644 --- a/cmd/root/sandbox_test.go +++ b/cmd/root/sandbox_test.go @@ -255,6 +255,38 @@ func TestPrintToolInstallAllowance(t *testing.T) { } } +func TestResolveSandboxDefault(t *testing.T) { + dir := t.TempDir() + sbxPath := filepath.Join(dir, "runtime-sandbox.yaml") + require.NoError(t, os.WriteFile(sbxPath, + []byte("runtime:\n sandbox: true\nagents:\n root:\n model: openai/gpt-4o\n description: t\n instruction: t\n"), + 0o600)) + plainPath := filepath.Join(dir, "plain.yaml") + require.NoError(t, os.WriteFile(plainPath, + []byte("agents:\n root:\n model: openai/gpt-4o\n description: t\n instruction: t\n"), + 0o600)) + + tests := []struct { + name string + args []string + current bool + want bool + }{ + {"no args, flag false", nil, false, false}, + {"no args, flag already true", nil, true, true}, + {"runtime.sandbox: true picked up", []string{sbxPath}, false, true}, + {"plain agent stays false", []string{plainPath}, false, false}, + {"current=true short-circuits the load", []string{plainPath}, true, true}, + {"unresolvable ref stays false", []string{filepath.Join(dir, "missing.yaml")}, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolveSandboxDefault(t.Context(), tt.args, tt.current)) + }) + } +} + func TestPeekAgentSandbox(t *testing.T) { tests := []struct { name string