diff --git a/agent-schema.json b/agent-schema.json index 98afc085d..00e42d47c 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,24 @@ }, "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." + }, + "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 + }, "PermissionsConfig": { "type": "object", "description": "Tool permission configuration. Controls tool call approval behavior with optional argument matching.", 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/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/run.go b/cmd/root/run.go index 74f5ff784..5dcda1a2c 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -18,6 +18,7 @@ import ( "github.com/docker/docker-agent/pkg/app" "github.com/docker/docker-agent/pkg/cli" "github.com/docker/docker-agent/pkg/config" + latestcfg "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/hooks" "github.com/docker/docker-agent/pkg/hooks/builtins" pathx "github.com/docker/docker-agent/pkg/path" @@ -170,8 +171,20 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command }() } + // 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. + var agentCfg *latestcfg.Config + if !cmd.Flags().Changed("sandbox") { + var agentRef string + if len(args) > 0 { + agentRef = args[0] + } + f.sandbox, agentCfg = resolveSandboxDefault(ctx, agentRef, f.sandbox) + } + if f.sandbox { - return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx, f.noKit) + return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx, f.noKit, agentCfg) } out := cli.NewPrinter(cmd.OutOrStdout()) @@ -218,7 +231,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 +241,10 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s if alias.HideToolResults && !f.hideToolResults { f.hideToolResults = true } + // alias.Sandbox is consumed earlier in runRunCommand before + // dispatch; reaching runOrExec means the sandbox decision + // resolved to false (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 408af0780..592003bc5 100644 --- a/cmd/root/sandbox.go +++ b/cmd/root/sandbox.go @@ -18,17 +18,103 @@ 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" "github.com/docker/docker-agent/pkg/sandbox/kit" "github.com/docker/docker-agent/pkg/skills" + "github.com/docker/docker-agent/pkg/userconfig" ) +// 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. +// +// The agent config (if any) loaded along the way is returned so +// runInSandbox can reuse it without paying the resolve+load cost a +// second time. cfg is nil when agentRef is empty or fails to load. +func resolveSandboxDefault(ctx context.Context, agentRef string, current bool) (bool, *latestcfg.Config) { + if agentRef == "" { + return current, nil + } + cfg := loadAgentConfig(ctx, agentRef) + if current { + return current, cfg + } + if alias := config.ResolveAlias(agentRef); alias != nil && alias.Sandbox { + return true, cfg + } + return cfg != nil && cfg.Runtime != nil && cfg.Runtime.Sandbox, cfg +} + +// agentNetworkAllowlist returns the hostnames the agent declared in +// runtime.network_allowlist. Entries with embedded commas or +// whitespace are dropped with a warning so a single malformed value +// can't smuggle several rules into the proxy policy. Returns nil +// when cfg is nil, has no Runtime block, or has no allowlist. +func agentNetworkAllowlist(ctx context.Context, cfg *latestcfg.Config) []string { + if cfg == nil || cfg.Runtime == nil { + return nil + } + var valid []string + for _, h := range cfg.Runtime.NetworkAllowlist { + if strings.ContainsAny(h, ", \t") { + slog.WarnContext(ctx, "Ignoring invalid network_allowlist entry; contains comma or whitespace", + "host", h) + continue + } + valid = append(valid, h) + } + return valid +} + +// loadAgentConfig is the shared best-effort loader: it resolves +// agentRef and loads the YAML, returning nil on any failure so +// callers fall through to the normal path that will surface a +// proper error from the eventual load. +func loadAgentConfig(ctx context.Context, agentRef string) *latestcfg.Config { + if agentRef == "" { + return nil + } + source, err := config.Resolve(agentRef, nil) + if err != nil { + return nil + } + cfg, err := config.Load(ctx, source) + if err != nil { + return nil + } + 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. -func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runConfig *config.RuntimeConfig, template string, preferSbx, noKit bool) error { +// +// agentCfg, when non-nil, is the parsed agent config already loaded by +// resolveSandboxDefault and is used to read runtime.network_allowlist +// without re-resolving the ref. +func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runConfig *config.RuntimeConfig, template string, preferSbx, noKit bool, agentCfg *latestcfg.Config) error { if environment.InSandbox() { return fmt.Errorf("already running inside a Docker sandbox (VM %s)", os.Getenv("SANDBOX_VM_ID")) } @@ -85,8 +171,13 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon } } + agentHosts := agentNetworkAllowlist(ctx, agentCfg) + 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 { @@ -106,7 +197,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, userHosts) // Resolve env vars the agent needs and forward them into the sandbox. // Docker Desktop proxies well-known API keys automatically; this handles @@ -182,11 +273,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; @@ -196,15 +288,21 @@ 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, userHosts []string) { var hosts []string hosts = append(hosts, toolInstallHosts...) + hosts = append(hosts, agentHosts...) + hosts = append(hosts, userHosts...) if gatewayURL != "" { if h := gatewayHostPort(gatewayURL); h != "" { @@ -405,4 +503,36 @@ 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 +// 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) + } +} + +// 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..681dc896a --- /dev/null +++ b/cmd/root/sandbox_cmd.go @@ -0,0 +1,161 @@ +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) { + // Raw hostnames may identify private corp endpoints; only count them. + telemetry.TrackCommand(cmd.Context(), "sandbox", []string{"allow"}) + defer func() { + telemetry.TrackCommandError(cmd.Context(), "sandbox", []string{"allow"}, 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", []string{"deny"}) + defer func() { + telemetry.TrackCommandError(cmd.Context(), "sandbox", []string{"deny"}, 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) { + // Idempotent: removing an already-absent host is a no-op so + // scripts can `sandbox deny ` without first checking. + out.Printf("Host %q is not on the persistent sandbox allowlist.\n", host) + return nil + } + 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", []string{"list"}) + defer func() { + telemetry.TrackCommandError(cmd.Context(), "sandbox", []string{"list"}, 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..9878741e5 --- /dev/null +++ b/cmd/root/sandbox_cmd_test.go @@ -0,0 +1,55 @@ +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 is idempotent: it prints a + // notice and exits successfully so scripts don't have to pre-check. + stdout.Reset() + root.SetArgs([]string{"sandbox", "deny", "not.on.list.example.com"}) + require.NoError(t, root.Execute()) + assert.Contains(t, stdout.String(), "is not on the persistent sandbox allowlist") +} diff --git a/cmd/root/sandbox_test.go b/cmd/root/sandbox_test.go index 6cd44aaaf..9ead6b2a8 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" @@ -235,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{}, }, } @@ -251,3 +254,157 @@ 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 + agentRef string + current bool + want bool + wantCfg bool + }{ + {"empty ref, flag false", "", false, false, false}, + {"empty ref, flag already true", "", true, true, false}, + {"runtime.sandbox: true picked up", sbxPath, false, true, true}, + {"plain agent stays false", plainPath, false, false, true}, + {"current=true short-circuits the decision", plainPath, true, true, true}, + {"unresolvable ref stays false", filepath.Join(dir, "missing.yaml"), false, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, cfg := resolveSandboxDefault(t.Context(), tt.agentRef, tt.current) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantCfg, cfg != nil) + }) + } +} + +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)) + + cfg := loadAgentConfig(t.Context(), path) + got := cfg != nil && cfg.Runtime != nil && cfg.Runtime.Sandbox + assert.Equal(t, tt.want, got) + }) + } + + t.Run("empty ref", func(t *testing.T) { + assert.Nil(t, loadAgentConfig(t.Context(), "")) + }) + + t.Run("unresolvable ref", func(t *testing.T) { + assert.Nil(t, loadAgentConfig(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)) + + cfg := loadAgentConfig(t.Context(), path) + require.NotNil(t, cfg) + assert.Equal(t, []string{"api.example.com", "registry.npmjs.org:443"}, + agentNetworkAllowlist(t.Context(), cfg)) +} + +func TestAgentNetworkAllowlist_FiltersMalformed(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + // A YAML where two hosts were typed as one comma-separated string + // must not feed a single bogus rule into the proxy policy. + yamlBody := "runtime:\n" + + " network_allowlist:\n" + + " - api.example.com\n" + + " - \"bad.example.com, also.bad.com\"\n" + + " - \"has space.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)) + + cfg := loadAgentConfig(t.Context(), path) + require.NotNil(t, cfg) + assert.Equal(t, []string{"api.example.com", "registry.npmjs.org:443"}, + agentNetworkAllowlist(t.Context(), cfg)) +} + +func TestAgentNetworkAllowlist_NilCfg(t *testing.T) { + assert.Nil(t, agentNetworkAllowlist(t.Context(), nil)) +} + +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 5d3bff8b6..a34782f89 100644 --- a/docs/configuration/sandbox/index.md +++ b/docs/configuration/sandbox/index.md @@ -51,6 +51,96 @@ 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. + +### 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. + +### 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. + +### 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 2ae2d2a1c..2a95ab37a 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: @@ -389,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/examples/sandbox_agent.yaml b/examples/sandbox_agent.yaml index 07fdd71b2..14cd39185 100644 --- a/examples/sandbox_agent.yaml +++ b/examples/sandbox_agent.yaml @@ -1,10 +1,26 @@ +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 + # 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: 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 94f00f3e2..ec77b1df5 100644 --- a/pkg/config/latest/types.go +++ b/pkg/config/latest/types.go @@ -28,6 +28,32 @@ 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"` + + // 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 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..985f9ea5c 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" @@ -30,11 +31,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 @@ -154,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 @@ -368,3 +379,69 @@ 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. +// +// All entries are validated before any mutation: a malformed value +// in the batch leaves c.SandboxAllowlist unchanged so callers that +// reuse the *Config after a failed call still observe a consistent +// in-memory view. +// +// 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) { + cleaned := make([]string, 0, len(hosts)) + 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) + } + cleaned = append(cleaned, h) + } + + 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 cleaned { + 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 ac3875f25..0e8019410 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 { @@ -935,3 +936,75 @@ 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) +} + +// A failed batch must leave SandboxAllowlist untouched: a valid +// host followed by a malformed one used to mutate the slice for the +// valid entry before bailing out, leaving the in-memory *Config +// inconsistent with what would have been persisted. +func TestConfig_AddSandboxHosts_AllOrNothing(t *testing.T) { + t.Parallel() + + cfg := &Config{SandboxAllowlist: []string{"existing.example.com"}} + + _, err := cfg.AddSandboxHosts("valid.example.com", "bad,host.example.com") + require.Error(t, err) + assert.Equal(t, []string{"existing.example.com"}, cfg.SandboxAllowlist, + "a malformed entry must not partially mutate the allowlist") +} + +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) +}