Skip to content
Open
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
21 changes: 19 additions & 2 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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=<bool> 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())
Expand Down Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
146 changes: 138 additions & 8 deletions cmd/root/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=<bool> 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 {
Comment thread
dgageot marked this conversation as resolved.
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"))
}
Expand Down Expand Up @@ -85,8 +171,13 @@ func runInSandbox(ctx context.Context, cmd *cobra.Command, args []string, runCon
}
}

agentHosts := agentNetworkAllowlist(ctx, agentCfg)
userHosts := userSandboxAllowlist(ctx)
Comment thread
dgageot marked this conversation as resolved.

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 +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
Expand Down Expand Up @@ -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;
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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 <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