Skip to content

feat(sandbox): alias/runtime sandbox defaults and persistent network allowlist#2888

Open
dgageot wants to merge 5 commits into
docker:mainfrom
dgageot:board/cb21ab76cf435dac
Open

feat(sandbox): alias/runtime sandbox defaults and persistent network allowlist#2888
dgageot wants to merge 5 commits into
docker:mainfrom
dgageot:board/cb21ab76cf435dac

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented May 23, 2026

This PR makes sandbox enablement configurable from three new sources, and lets users curate a persistent network allowlist for hosts the kit's tool resolver can't infer (custom MCP servers, third-party APIs, registries not covered by aqua, ...).

Sandbox-by-default sources

Three new ways to opt an agent into the sandbox without typing --sandbox:

  • Aliases declared in ~/.config/cagent/config.yaml:

    aliases:
      my-agent:
        ref: ...
        sandbox: true

    Also exposed via docker agent alias add --sandbox.

  • Agent runtime config in the YAML itself:

    runtime:
      sandbox: true

    Per-agent counterpart of the alias flag, useful for catalog agents that always need filesystem or network isolation.

  • An explicit --sandbox=false on the CLI still wins over both, matching the precedence the README and JSON schema already promise. The dispatch logic was previously falling through to the alias / runtime defaults when the user passed --sandbox=false because it only checked !f.sandbox; it now consults cmd.Flags().Changed("sandbox") and the precedence is extracted into a testable resolveSandboxDefault helper.

Network allowlist

Two coordinated additions to widen the default-deny proxy policy without sliding back to the conservative host union the kit falls back to on resolver failure:

  • Author-declared, per agent:

    runtime:
      sandbox: true
      network_allowlist:
        - api.example.com
        - registry.npmjs.org
  • User-managed, persistent for hosts the agent author can't anticipate:

    docker agent sandbox allow <host> [<host>...]
    docker agent sandbox deny <host>
    docker agent sandbox list
    

    Entries land in ~/.config/cagent/config.yaml under sandbox_allowlist and are unioned on every subsequent --sandbox run.

Both lists are unioned with the gateway and the kit-resolved tool install hosts. Commas and embedded whitespace are rejected at write time so a single entry can't smuggle several rules into the policy engine. 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.

Files

agent-schema.json                   |  22 +++
cmd/root/alias.go                   |  15 +-
cmd/root/root.go                    |   1 +
cmd/root/run.go                     |  13 +-
cmd/root/sandbox.go                 | 132 ++++++++++++++++-
cmd/root/sandbox_cmd.go             | 157 +++++++++++++++++++++
cmd/root/sandbox_cmd_test.go        |  52 +++++++
cmd/root/sandbox_test.go            | 127 +++++++++++++++-
docs/configuration/sandbox/index.md |  90 ++++++++++++
docs/features/cli/index.md          |  22 +++
examples/sandbox_agent.yaml         |  18 ++-
pkg/config/latest/types.go          |  26 ++++
pkg/config/resolve_test.go          |  18 +++
pkg/userconfig/userconfig.go        |  69 +++++++++-
pkg/userconfig/userconfig_test.go   |  60 +++++++-

dgageot and others added 5 commits May 23, 2026 10:37
Aliases declared in ~/.config/cagent/config.yaml can now carry a
sandbox flag. When set, 'docker agent run <alias>' 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.
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.
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.
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 <host> [<host>...]
  docker agent sandbox deny <host>
  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.
…aults

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.
@dgageot dgageot requested a review from a team as a code owner May 23, 2026 08:40
Copy link
Copy Markdown

@docker-agent docker-agent left a comment

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

The changes are well-structured and implement the sandbox-by-default and persistent network allowlist features cleanly. No bugs were identified in the added code.

Summary of changes reviewed:

  • resolveSandboxDefault helper correctly consults cmd.Flags().Changed("sandbox") so explicit --sandbox=false wins over alias/runtime defaults
  • Host validation for the allowlist rejects commas and embedded whitespace at write time
  • Persistent allowlist entries are stored atomically under ~/.config/cagent/config.yaml
  • Union of user allowlist, agent-declared allowlist, gateway, and tool-install hosts is correctly composed
  • New sandbox allow/deny/list subcommands are properly wired and tested
  • Schema additions for runtime.sandbox and runtime.network_allowlist are consistent with the types in pkg/config/latest/types.go

@aheritier aheritier added area/cli CLI commands, flags, output formatting area/config For configuration parsing, YAML, environment variables area/security Authentication, authorization, secrets, vulnerabilities kind/feat PR adds a new feature (maps to feat: commit prefix) labels May 23, 2026
Copy link
Copy Markdown
Contributor

@aheritier aheritier left a comment

Choose a reason for hiding this comment

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

Two blocking issues must be addressed before merge. Eight non-blocking suggestions below.


🔴 Blocking

1. Agent config loaded 2–3× per sandboxed run

resolveSandboxDefault calls loadAgentConfig once to decide whether to sandbox. Then agentNetworkAllowlist (inside runInSandbox) calls it a second time on the same ref. kit.Build resolves the same config a third time. For OCI-registry refs each config.Load is a network round-trip, so every sandboxed run of an OCI-hosted agent pays 3× that cost before work begins.

Fix: cache the *latestcfg.Config returned from resolveSandboxDefault and thread it through to runInSandbox, eliminating the second loadAgentConfig call in agentNetworkAllowlist. At minimum, document the duplication as a known inefficiency.

2. network_allowlist entries not validated at YAML load time

The comment on NetworkAllowlist says "commas and whitespace are rejected", but that validation only lives in AddSandboxHosts (the CLI write path). An agent YAML with "api.example.com, registry.npmjs.org" as a single entry passes config.Load without error, bypasses AddSandboxHosts entirely, and feeds the malformed host straight into allowSandboxHosts and the proxy policy engine.

Fix: validate entries in agentNetworkAllowlist before returning them, e.g.:

var valid []string
for _, h := range cfg.Runtime.NetworkAllowlist {
    if strings.ContainsAny(h, ", \t") {
        slog.WarnContext(ctx, "Ignoring invalid network_allowlist entry", "host", h)
        continue
    }
    valid = append(valid, h)
}
return valid

🟡 Non-blocking suggestions

3. resolveSandboxDefault signature takes []string but only uses [0]

Narrowing to agentRef string makes the function self-contained and removes the implicit args[0] contract from the call-site.

func resolveSandboxDefault(ctx context.Context, agentRef string, current bool) bool

4. sandbox deny is non-idempotent — errors for an already-absent host

Remove operations are almost universally idempotent in CLIs (docker rm --force, git branch -D, etc.). Returning an error with a non-zero exit code when the host is already absent surprises scripts. Consider slog.WarnContext + return nil.

5. AddSandboxHosts leaves in-memory state partially mutated on error

If a batch contains a valid host followed by a malformed one, c.SandboxAllowlist is mutated for the valid entries before the error is returned. The caller checks err before Save(), so disk is never corrupted — but the in-memory *Config is left inconsistent. A future caller that reuses the object after a failed AddSandboxHosts would observe partial state.

Fix: validate all entries in a first pass, then mutate in a second pass only if all are valid.

6. Sandbox hint shown unconditionally from printToolInstallAllowance

The "hint: persist a missing host..." line is correctly scoped today (only called from runInSandbox), but kit.Build is exported and printToolInstallAllowance could be called from a non-sandbox path in the future. Minor forward-compat note.

7. No test for malformed YAML network_allowlist entries

TestAgentNetworkAllowlist covers valid entries. There is no test asserting what happens when an agent YAML contains "api.example.com, other.com" as a single entry. Without validation at parse time (issue #2), this silently passes through — a test documenting that behaviour makes a future fix verifiable.

8. Misleading comment in runOrExec on alias.Sandbox

The comment says "the sandbox path has already been taken". Since we are in runOrExec (the non-sandbox branch), the sandbox path was not taken. The comment should read: "the sandbox decision was already made in runRunCommand; since we are here, it resolved to false."

9. Redundant args in list telemetry

cobra.NoArgs is set on list, so args is always empty. append([]string{"list"}, args...) is harmless but needlessly confusing — args can be dropped from the telemetry call.

10. Raw hostnames in telemetry

allow and deny forward user-supplied hostnames into TrackCommand. Sandbox hosts are more likely to be sensitive internal names (corp proxies, internal registries) than agent refs. Worth confirming this is intentional before the PR lands.


# Severity File Issue
1 🔴 Blocking cmd/root/sandbox.go Config loaded 2–3× per sandboxed run
2 🔴 Blocking pkg/config/latest/types.go network_allowlist not validated at YAML load
3 🟡 cmd/root/sandbox.go resolveSandboxDefault takes []string, uses [0]
4 🟡 cmd/root/sandbox_cmd.go deny non-idempotent
5 🟡 pkg/userconfig/userconfig.go Partial mutation on validation failure
6 🟡 cmd/root/sandbox.go Sandbox hint unconditional
7 🟡 cmd/root/sandbox_test.go No test for malformed YAML allowlist entries
8 🟡 cmd/root/run.go Misleading alias.Sandbox comment
9 🟡 cmd/root/sandbox_cmd.go Redundant args in list telemetry
10 🟡 cmd/root/sandbox_cmd.go Raw hostnames in telemetry

Comment thread cmd/root/sandbox.go
// 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.

Comment thread cmd/root/sandbox.go
}

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.

// 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"`
}
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 #2] The comment says "commas and whitespace are rejected" but that validation only exists in AddSandboxHosts (the CLI write path). An agent YAML with "api.example.com, registry.npmjs.org" as a single entry passes config.Load without error, bypasses AddSandboxHosts entirely, and feeds the malformed host directly into allowSandboxHosts and the proxy policy engine. Validate entries in agentNetworkAllowlist (or add a Validate() method on RuntimeDefaults) to enforce the invariant on the read path.

Comment thread cmd/root/sandbox.go
// 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

Comment thread cmd/root/sandbox_cmd.go

if !cfg.RemoveSandboxHost(host) {
return fmt.Errorf("host %q is not on the allowlist", host)
}
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 #4] Remove operations are conventionally idempotent in CLIs (docker rm --force, git branch -D). Returning a non-zero exit code when the host is already absent surprises scripts that run deny as a cleanup step. Consider slog.WarnContext(cmd.Context(), "host not on allowlist", "host", host) + return nil.

// (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
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 #5] The error is returned here, but c.SandboxAllowlist is already mutated for any valid hosts that appeared before this entry in the batch. The caller checks err before cfg.Save() so disk is never corrupted, but the in-memory *Config is left partially updated. A future caller reusing the object after a failed AddSandboxHosts would observe inconsistent state. Fix: validate all entries in a first pass before mutating anything.

Comment thread cmd/root/sandbox_cmd.go
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)
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 #9] cobra.NoArgs is set on list, so args is always empty here. The append([]string{"list"}, args...) pattern is harmless but confusing — args can be dropped from this telemetry call.

Comment thread cmd/root/sandbox_cmd.go

func runSandboxAllowCommand(cmd *cobra.Command, args []string) (commandErr error) {
telemetry.TrackCommand(cmd.Context(), "sandbox", append([]string{"allow"}, args...))
defer func() {
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 #10] User-supplied hostnames (e.g. corp-proxy.internal.acme.com) are forwarded into telemetry here. Sandbox hosts are more likely to be sensitive internal infrastructure names than agent refs. Please confirm this is intentional — if so, a brief comment would document the conscious decision.

Comment thread cmd/root/run.go
}
// 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)."

@aheritier aheritier self-requested a review May 23, 2026 17:42
@docker-agent
Copy link
Copy Markdown

PR Review Failed — The review agent encountered an error and could not complete the review. View logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli CLI commands, flags, output formatting area/config For configuration parsing, YAML, environment variables area/security Authentication, authorization, secrets, vulnerabilities kind/feat PR adds a new feature (maps to feat: commit prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants