Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ COPY --from=builder /binaries/cagent-$TARGETOS-$TARGETARCH* cagent
FROM scratch AS cross
COPY --from=builder /binaries .

FROM docker/sandbox-templates:cagent AS sandbox-template
ARG TARGETOS TARGETARCH
COPY --from=builder /binaries/cagent-$TARGETOS-$TARGETARCH /usr/local/bin/cagent

FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ca-certificates docker-cli && \
addgroup -S cagent && adduser -S -G cagent cagent && \
Expand Down
4 changes: 4 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ tasks:
desc: Build and Push Docker image
cmd: docker buildx build --push --platform linux/amd64,linux/arm64 -t docker/cagent -t docker/docker-agent {{.BUILD_ARGS}} .

build-sandbox-template:
desc: Build a sandbox template with the local cagent binary
cmd: docker buildx build --target=sandbox-template -t cagent-dev {{.BUILD_ARGS}} --load .

record-demo:
desc: Record demo gif
cmd: vhs ./docs/recordings/demo.tape
46 changes: 0 additions & 46 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -831,10 +831,6 @@
"type": "string",
"description": "Name for the a2a tool"
},
"sandbox": {
"$ref": "#/definitions/SandboxConfig",
"description": "Sandbox configuration for running shell commands in a Docker container (shell tool only)"
},
"file_types": {
"type": "array",
"description": "File extensions this LSP server handles (e.g., [\".go\", \".mod\"]). Only for lsp toolsets.",
Expand Down Expand Up @@ -1012,48 +1008,6 @@
],
"additionalProperties": false
},
"SandboxConfig": {
"type": "object",
"description": "Configuration for running shell commands inside a sandboxed Docker container",
"properties": {
"image": {
"type": "string",
"description": "Docker image to use for the sandbox container. Defaults to 'alpine:latest' if not specified.",
"examples": [
"alpine:latest",
"ubuntu:22.04",
"python:3.12-alpine",
"node:20-alpine"
]
},
"paths": {
"type": "array",
"description": "List of paths to bind-mount into the container. Each path can have an optional ':ro' suffix for read-only access (default is read-write ':rw'). Relative paths are resolved from the agent's working directory.",
"items": {
"type": "string"
},
"minItems": 1,
"examples": [
[
".",
"/tmp"
],
[
"./src",
"./config:ro"
],
[
"/data:rw",
"/secrets:ro"
]
]
}
},
"required": [
"paths"
],
"additionalProperties": false
},
"ScriptShellToolConfig": {
"type": "object",
"description": "Configuration for custom shell tool",
Expand Down
9 changes: 9 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ type runExecFlags struct {
cpuProfile string
memProfile string
forceTUI bool
sandbox bool
sandboxTemplate string

// Exec only
exec bool
Expand Down Expand Up @@ -111,6 +113,8 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
_ = cmd.PersistentFlags().MarkHidden("memprofile")
cmd.PersistentFlags().BoolVar(&flags.forceTUI, "force-tui", false, "Force TUI mode even when not in a terminal")
_ = cmd.PersistentFlags().MarkHidden("force-tui")
cmd.PersistentFlags().BoolVar(&flags.sandbox, "sandbox", false, "Run the agent inside a Docker sandbox (requires Docker Desktop with sandbox support)")
cmd.PersistentFlags().StringVar(&flags.sandboxTemplate, "template", "", "Template image for the sandbox (passed to docker sandbox create -t)")
cmd.MarkFlagsMutuallyExclusive("fake", "record")

// --exec only
Expand All @@ -120,6 +124,11 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) {
}

func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) error {
// If --sandbox is set, delegate everything to `docker sandbox run cagent`.
if f.sandbox {
return runInSandbox(cmd, &f.runConfig, f.sandboxTemplate)
}

if f.exec {
telemetry.TrackCommand("exec", args)
} else {
Expand Down
76 changes: 76 additions & 0 deletions cmd/root/sandbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package root

import (
"cmp"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"

"github.com/spf13/cobra"

"github.com/docker/cagent/pkg/config"
"github.com/docker/cagent/pkg/environment"
"github.com/docker/cagent/pkg/paths"
"github.com/docker/cagent/pkg/sandbox"
)

// runInSandbox delegates the current command to a Docker sandbox.
// It ensures a sandbox exists (creating or recreating as needed), then
// executes cagent inside it via `docker sandbox exec`.
func runInSandbox(cmd *cobra.Command, runConfig *config.RuntimeConfig, template string) error {
if environment.InSandbox() {
return fmt.Errorf("already running inside a Docker sandbox (VM %s)", os.Getenv("SANDBOX_VM_ID"))
}

ctx := cmd.Context()
if err := sandbox.CheckAvailable(ctx); err != nil {
return err
}

cagentArgs := sandbox.BuildCagentArgs(os.Args)
agentRef := sandbox.AgentRefFromArgs(cagentArgs)
configDir := paths.GetConfigDir()

// Always forward config directory paths so the sandbox-side
// cagent resolves it to the same host directories
// (which is mounted read-write by ensureSandbox).
cagentArgs = sandbox.AppendFlagIfMissing(cagentArgs, "--config-dir", configDir)

stopTokenWriter := sandbox.StartTokenWriterIfNeeded(ctx, configDir, runConfig.ModelsGateway)
defer stopTokenWriter()

// Ensure a sandbox with the right workspace mounts exists.
wd := cmp.Or(runConfig.WorkingDir, ".")
name, err := sandbox.Ensure(ctx, wd, sandbox.ExtraWorkspace(wd, agentRef), template, configDir)
if err != nil {
return err
}

// Resolve env vars the agent needs and forward them into the sandbox.
// Docker Desktop proxies well-known API keys automatically; this handles
// any additional vars (e.g. MCP tool secrets).
envFlags, envVars := sandbox.EnvForAgent(ctx, agentRef, environment.NewDefaultProvider())

// Forward the gateway as an env var so docker sandbox exec sets it
// directly inside the sandbox.
if gateway := runConfig.ModelsGateway; gateway != "" {
envFlags = append(envFlags, "-e", envModelsGateway+"="+gateway)
}

dockerCmd := sandbox.BuildExecCmd(ctx, name, cagentArgs, envFlags, envVars)
slog.Debug("Executing in sandbox", "name", name, "args", dockerCmd.Args)

if err := dockerCmd.Run(); err != nil {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
// Clean up the token writer before exiting so the temp
// file is removed (defers won't run after os.Exit).
stopTokenWriter()
os.Exit(exitErr.ExitCode()) //nolint:gocritic // intentional exit to propagate sandbox exit code
}
return fmt.Errorf("docker sandbox exec failed: %w", err)
}

return nil
}
4 changes: 0 additions & 4 deletions docs/configuration/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,10 +232,6 @@ agents:
toolsets:
- type: filesystem
- type: shell
sandbox:
image: golang:1.23-alpine
paths:
- "."
- type: think
- type: todo

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/overview/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Models can be referenced inline or defined in the `models` section:
<a class="card" href="{{ '/configuration/sandbox/' | relative_url }}">
<div class="card-icon">📦</div>
<h3>Sandbox Mode</h3>
<p>Run shell commands in an isolated Docker container for security.</p>
<p>Run agents in an isolated Docker container for security.</p>
</a>
<a class="card" href="{{ '/configuration/structured-output/' | relative_url }}">
<div class="card-icon">📋</div>
Expand Down
113 changes: 21 additions & 92 deletions docs/configuration/sandbox/index.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
---
title: "Sandbox Mode"
description: "Run shell commands in an isolated Docker container for enhanced security."
description: "Run agents in an isolated Docker container for enhanced security."
permalink: /configuration/sandbox/
---

# Sandbox Mode

_Run shell commands in an isolated Docker container for enhanced security._
_Run agents in an isolated Docker container for enhanced security._

## Overview

Sandbox mode runs shell tool commands inside a Docker container instead of directly on the host system. This provides an additional layer of isolation, limiting the potential impact of unintended or malicious commands.
Sandbox mode runs the entire agent inside a Docker container instead of directly on the host system. This provides an additional layer of isolation, limiting the potential impact of unintended or malicious commands.

<div class="callout callout-info">
<div class="callout-title">ℹ️ Requirements
Expand All @@ -19,111 +19,44 @@ Sandbox mode runs shell tool commands inside a Docker container instead of direc

</div>

## Configuration
## Usage

Enable sandbox mode with the `--sandbox` flag on the `docker agent run` command:

```bash
docker agent run --sandbox agent.yaml
```

This runs the agent inside a Docker container with the current working directory mounted.

## Example

```yaml
# agent.yaml
agents:
root:
model: openai/gpt-4o
description: Agent with sandboxed shell
instruction: You are a helpful assistant.
toolsets:
- type: shell
sandbox:
image: alpine:latest # Docker image to use
paths: # Directories to mount
- "." # Current directory (read-write)
- "/data:ro" # Read-only mount
```

## Properties

| Property | Type | Default | Description |
| -------- | ------ | --------------- | --------------------------------------------- |
| `image` | string | `alpine:latest` | Docker image to use for the sandbox container |
| `paths` | array | `[]` | Host paths to mount into the container |

## Path Mounting

Paths can be specified with optional access modes:

| Format | Description |
| ------------ | ----------------------------------------------- |
| `/path` | Mount with read-write access (default) |
| `/path:rw` | Explicitly read-write |
| `/path:ro` | Read-only mount |
| `.` | Current working directory |
| `./relative` | Relative path (resolved from working directory) |

Paths are mounted at the same location inside the container as on the host, so file paths in commands work the same way.

## Example: Development Agent

```yaml
agents:
developer:
model: anthropic/claude-sonnet-4-0
description: Development agent with sandboxed shell
instruction: |
You are a software developer. Use the shell tool to run
build commands and tests. Your shell runs in a sandbox.
toolsets:
- type: shell
sandbox:
image: node:20-alpine # Node.js environment
paths:
- "." # Project directory
- "/tmp:rw" # Temp directory for builds
- type: filesystem
```bash
docker agent run --sandbox agent.yaml
```

## How It Works

1. When the agent first uses the shell tool, docker-agent starts a Docker container
2. The container runs with the specified image and mounted paths
3. Shell commands execute inside the container via `docker exec`
4. The container persists for the session (commands share state)
5. When the session ends, the container is automatically stopped and removed

## Container Configuration

Sandbox containers are started with these Docker options:

- `--rm` — Automatically remove when stopped
- `--init` — Use init process for proper signal handling
- `--network host` — Share host network (commands can access network)
- Environment variables from host are forwarded to container

## Orphan Container Cleanup

If docker-agent crashes or is killed, sandbox containers may be left running. docker-agent automatically cleans up orphaned containers from previous runs when it starts. Containers are identified by labels and the PID of the docker-agent process that created them.

## Choosing an Image

Select a Docker image that has the tools your agent needs:

| Use Case | Suggested Image |
| ---------------------- | -------------------- |
| General scripting | `alpine:latest` |
| Node.js development | `node:20-alpine` |
| Python development | `python:3.12-alpine` |
| Go development | `golang:1.23-alpine` |
| Full Linux environment | `ubuntu:24.04` |

<div class="callout callout-tip">
<div class="callout-title">💡 Custom Images
</div>
<p>For complex setups, build a custom Docker image with all required tools pre-installed. This avoids installation time during agent execution.</p>

</div>
1. When `--sandbox` is specified, docker-agent launches a Docker container
2. The current working directory is mounted into the container
3. All agent tools (shell, filesystem, etc.) operate inside the container
4. When the session ends, the container is automatically stopped and removed

<div class="callout callout-warning">
<div class="callout-title">⚠️ Limitations
</div>

- Only the <code>shell</code> tool runs in the sandbox; other tools (filesystem, MCP) run on the host
- Host network access means network-based attacks are still possible
- Mounted paths are accessible according to their access mode
- Container starts fresh each session (no persistence between sessions)

</div>
Expand All @@ -140,10 +73,6 @@ agents:
instruction: You are a helpful assistant.
toolsets:
- type: shell
sandbox:
image: node:20-alpine
paths:
- ".:rw"
- type: filesystem

permissions:
Expand Down
1 change: 0 additions & 1 deletion docs/configuration/tools/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ The agent has access to the full system shell and environment variables. Command
| Property | Type | Description |
| --------- | ------ | -------------------------------------------------------------------------------- |
| `env` | object | Environment variables to set for all shell commands |
| `sandbox` | object | Run commands in a Docker container. See [Sandbox Mode](/configuration/sandbox/). |

### Think

Expand Down
Loading