feat(sandbox): alias/runtime sandbox defaults and persistent network allowlist#2888
feat(sandbox): alias/runtime sandbox defaults and persistent network allowlist#2888dgageot wants to merge 5 commits into
Conversation
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.
docker-agent
left a comment
There was a problem hiding this comment.
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:
resolveSandboxDefaulthelper correctly consultscmd.Flags().Changed("sandbox")so explicit--sandbox=falsewins 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/listsubcommands are properly wired and tested - Schema additions for
runtime.sandboxandruntime.network_allowlistare consistent with the types inpkg/config/latest/types.go
aheritier
left a comment
There was a problem hiding this comment.
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) bool4. 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 |
| // 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 { |
There was a problem hiding this comment.
[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.
| } | ||
|
|
||
| agentHosts := agentNetworkAllowlist(ctx, agentRef) | ||
| userHosts := userSandboxAllowlist(ctx) |
There was a problem hiding this comment.
[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"` | ||
| } |
There was a problem hiding this comment.
[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.
| // 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 { |
There was a problem hiding this comment.
[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
|
|
||
| if !cfg.RemoveSandboxHost(host) { | ||
| return fmt.Errorf("host %q is not on the allowlist", host) | ||
| } |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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.
| 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) |
There was a problem hiding this comment.
[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.
|
|
||
| func runSandboxAllowCommand(cmd *cobra.Command, args []string) (commandErr error) { | ||
| telemetry.TrackCommand(cmd.Context(), "sandbox", append([]string{"allow"}, args...)) | ||
| defer func() { |
There was a problem hiding this comment.
[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.
| } | ||
| // 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 |
There was a problem hiding this comment.
[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)."
|
❌ PR Review Failed — The review agent encountered an error and could not complete the review. View logs. |
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:Also exposed via
docker agent alias add --sandbox.Agent runtime config in the YAML itself:
Per-agent counterpart of the alias flag, useful for catalog agents that always need filesystem or network isolation.
An explicit
--sandbox=falseon 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=falsebecause it only checked!f.sandbox; it now consultscmd.Flags().Changed("sandbox")and the precedence is extracted into a testableresolveSandboxDefaulthelper.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:
User-managed, persistent for hosts the agent author can't anticipate:
Entries land in
~/.config/cagent/config.yamlundersandbox_allowlistand are unioned on every subsequent--sandboxrun.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 allowwhen the per-toolset resolver had to fall back.Files