Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
Expand Down
15 changes: 14 additions & 1 deletion cmd/root/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type aliasAddFlags struct {
yolo bool
model string
hideToolResults bool
sandbox bool
}

func newAliasAddCmd() *cobra.Command {
Expand All @@ -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

Expand All @@ -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),
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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, ", "))
Expand Down
1 change: 1 addition & 0 deletions cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ We collect anonymous usage data to help improve docker agent. To disable:
newModelsCmd(),
newDebugCmd(),
newAliasCmd(),
newSandboxCmd(),
newServeCmd(),
)

Expand Down
13 changes: 12 additions & 1 deletion cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command
}()
}

// Resolve alias / runtime-declared sandbox opt-in before dispatch.
// An explicit --sandbox=<bool> 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 {
return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx, f.noKit)
}
Expand Down Expand Up @@ -218,7 +225,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
}
Expand All @@ -228,6 +235,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; by the time we reach runOrExec the sandbox path
// has already been taken (or the user opted out via
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion #8] Slightly misleading: the comment says "the sandbox path has already been taken" but since we are in runOrExec (the non-sandbox branch), the sandbox path was not taken — it resolved to false. Suggested wording: "alias.Sandbox is consumed in runRunCommand; since we are here, the sandbox decision resolved to false (explicit --sandbox=false, or neither the alias nor the agent config requested it)."

// --sandbox=false), so flipping it here would be a no-op.
}

// Build global permissions checker from user config settings.
Expand Down
132 changes: 125 additions & 7 deletions cmd/root/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,87 @@ 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"
)

// 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 {
cfg := loadAgentConfig(ctx, agentRef)
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=<bool> always wins and bypasses this logic.
func resolveSandboxDefault(ctx context.Context, args []string, current bool) bool {
if current || len(args) == 0 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion #3] The function only reads args[0]; accepting the full slice is misleading. Consider narrowing to agentRef string so the function is self-contained and callers pass args[0] explicitly:\ngo\nfunc resolveSandboxDefault(ctx context.Context, agentRef string, current bool) bool\n

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
// 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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocking #1] Second loadAgentConfig call for the same agentRef. resolveSandboxDefault already called it once (via peekAgentSandbox) to decide whether to sandbox. For OCI refs that is two network round-trips before work begins, and kit.Build adds a third. Cache the resolved *latestcfg.Config in resolveSandboxDefault and thread it into runInSandbox so agentNetworkAllowlist can consume it without another load.

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 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.
Expand Down Expand Up @@ -85,8 +159,13 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon
}
}

agentHosts := agentNetworkAllowlist(ctx, agentRef)
userHosts := userSandboxAllowlist(ctx)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Blocking #1 — call site] agentNetworkAllowlist triggers the redundant loadAgentConfig call here. If the resolved config were threaded from resolveSandboxDefault, this call and its extra network round-trip for OCI refs would be unnecessary.


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 {
Expand All @@ -106,7 +185,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
Expand Down Expand Up @@ -182,11 +261,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;
Expand All @@ -196,15 +276,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 != "" {
Expand Down Expand Up @@ -405,4 +491,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 <host>`")
}
}

// 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)
}
}
Loading
Loading