diff --git a/docs/configuration/tools/index.md b/docs/configuration/tools/index.md index c84f65b0b..75ab35b17 100644 --- a/docs/configuration/tools/index.md +++ b/docs/configuration/tools/index.md @@ -19,6 +19,7 @@ Built-in tools are included with docker-agent and require no external dependenci | --- | --- | --- | | `filesystem` | Read, write, list, search, navigate | [Filesystem](../../tools/filesystem/index.md) | | `shell` | Execute shell commands synchronously | [Shell](../../tools/shell/index.md) | +| `sandbox` | Run code snippets in an ephemeral, isolated container | [Sandbox](../../tools/sandbox/index.md) | | `background_jobs` | Run and manage long-running shell commands | [Background Jobs](../../tools/background-jobs/index.md) | | `scheduler` | Schedule instructions to run at a time or on a recurring interval | [Scheduler](../../tools/scheduler/index.md) | | `think` | Reasoning scratchpad | [Think](../../tools/think/index.md) | diff --git a/docs/tools/sandbox/index.md b/docs/tools/sandbox/index.md new file mode 100644 index 000000000..4779ea770 --- /dev/null +++ b/docs/tools/sandbox/index.md @@ -0,0 +1,80 @@ +--- +title: "Sandbox Tool" +description: "Run code snippets in an ephemeral, isolated container." +keywords: docker agent, ai agents, tools, toolsets, sandbox tool, code interpreter +linkTitle: "Sandbox" +weight: 125 +canonical: https://docs.docker.com/ai/docker-agent/tools/sandbox/ +--- + +_Run code snippets in an ephemeral, isolated container._ + +## Overview + +The sandbox toolset lets an agent execute a code snippet (`python`, `node`, or `bash`) inside a throwaway, isolated container and get back stdout, stderr, and the exit code. Every run is disposable (`--rm`), resource-limited, and **network-disabled by default**, so the agent can run computed or model-generated code without touching the host. + +Execution goes through a docker-compatible container runtime — **docker**, **podman**, or **nerdctl** — whichever is available. + +> [!NOTE] +> Unlike the [`shell`](../shell/index.md) tool, which runs on the host with full access, the sandbox runs code in a disposable container isolated from the host filesystem and (by default) the network. + +## Configuration + +```yaml +toolsets: + - type: sandbox +``` + +No configuration options. Requires a container runtime (docker, podman, or nerdctl) on the host; `run_code` returns a clear error if none is found. + +## Tools + +### `run_code` + +| Parameter | Required | Description | +| --- | --- | --- | +| `language` | Yes | `python`, `node`, or `bash` (aliases: `py`, `js`, `sh`, …). | +| `code` | Yes | The source code to execute (piped to the interpreter's stdin). | +| `timeout_seconds` | No | Maximum run time (default 30, capped at 300). | +| `network` | No | Allow network access (default `false` = fully isolated). | + +Returns the exit code plus stdout and stderr. + +## How it runs + +Each call runs, for example: + +```text +docker run --rm -i --memory 512m --cpus 1 --pids-limit 256 --network none python:3.12-slim python3 - +``` + +with the code piped to the container's stdin. Languages map to pinned images (`python:3.12-slim`, `node:22-alpine`, `bash:5`). + +## Example + +```yaml +agents: + root: + model: openai/gpt-5-mini + description: A data assistant + instruction: | + When you need to compute something, use run_code with python. + toolsets: + - type: sandbox +``` + +Example result: + +```text +run_code(language="python", code="print(2 + 2)") + +Exit code: 0 + +--- stdout --- +4 +``` + +> [!TIP] +> **When to use** +> +> Use the sandbox to run untrusted or model-generated code, compute a result, or reproduce a snippet in a clean environment. Set `network: true` only when the code genuinely needs it (for example, to install a package). diff --git a/pkg/teamloader/toolsets/catalog.go b/pkg/teamloader/toolsets/catalog.go index 7f0e2db41..f4a5c5e92 100644 --- a/pkg/teamloader/toolsets/catalog.go +++ b/pkg/teamloader/toolsets/catalog.go @@ -24,6 +24,7 @@ var BuiltinToolsets = []BuiltinToolsetInfo{ builtinToolset("openapi", "openapi", "Generate tools automatically from an OpenAPI specification"), builtinToolset("plan", "plan", "Shared persistent scratchpad for multi-agent collaboration"), builtinToolset("rag", "rag", "Search document knowledge bases with hybrid retrieval (RAG)"), + builtinToolset("sandbox", "sandbox", "Run code snippets in an ephemeral, isolated container"), builtinToolset("scheduler", "scheduler", "Schedule instructions to run at a time or on a recurring interval"), builtinToolset("script", "script", "Define custom shell scripts as named tools with typed parameters"), builtinToolset("session_context", "session_context", "Reference a previous session as context in the current one"), diff --git a/pkg/teamloader/toolsets/toolsets.go b/pkg/teamloader/toolsets/toolsets.go index 8bb7e6333..090bee634 100644 --- a/pkg/teamloader/toolsets/toolsets.go +++ b/pkg/teamloader/toolsets/toolsets.go @@ -21,6 +21,7 @@ import ( "github.com/docker/docker-agent/pkg/tools/builtin/openurl" "github.com/docker/docker-agent/pkg/tools/builtin/plan" "github.com/docker/docker-agent/pkg/tools/builtin/rag" + "github.com/docker/docker-agent/pkg/tools/builtin/sandbox" "github.com/docker/docker-agent/pkg/tools/builtin/scheduler" "github.com/docker/docker-agent/pkg/tools/builtin/sessioncontext" "github.com/docker/docker-agent/pkg/tools/builtin/sessionplan" @@ -117,5 +118,8 @@ func DefaultToolsetCreators() map[string]teamloader.ToolsetCreator { "rag": func(ctx context.Context, toolset latest.Toolset, parentDir string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { return rag.CreateToolSet(ctx, toolset, parentDir, runConfig) }, + "sandbox": func(_ context.Context, _ latest.Toolset, _ string, runConfig *config.RuntimeConfig, _ string) (tools.ToolSet, error) { + return sandbox.CreateToolSet(runConfig) + }, } } diff --git a/pkg/tools/builtin/sandbox/sandbox.go b/pkg/tools/builtin/sandbox/sandbox.go new file mode 100644 index 000000000..afbdc74e1 --- /dev/null +++ b/pkg/tools/builtin/sandbox/sandbox.go @@ -0,0 +1,252 @@ +package sandbox + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/tools" +) + +const ( + ToolNameRunCode = "run_code" + + category = "sandbox" + + defaultMemory = "512m" + defaultCPUs = "1" + defaultPidsLimit = "256" + + defaultTimeout = 30 * time.Second + maxTimeout = 300 * time.Second +) + +var supportedRuntimes = []string{"docker", "podman", "nerdctl"} + +type RunSpec struct { + Language string + Code string + Timeout time.Duration + Network bool +} + +type Result struct { + Stdout string + Stderr string + ExitCode int +} + +type Executor interface { + Run(ctx context.Context, spec RunSpec) (Result, error) +} + +type langSpec struct { + image string + argv []string +} + +var languages = map[string]langSpec{ + "python": {"python:3.12-slim", []string{"python3", "-"}}, + "node": {"node:22-alpine", []string{"node"}}, + "bash": {"bash:5", []string{"bash"}}, +} + +func resolveLang(l string) (langSpec, string, bool) { + name := strings.ToLower(strings.TrimSpace(l)) + switch name { + case "py", "python", "python3": + name = "python" + case "js", "javascript", "node": + name = "node" + case "sh", "bash", "shell": + name = "bash" + } + ls, ok := languages[name] + return ls, name, ok +} + +func buildRunArgs(spec RunSpec) ([]string, error) { + ls, _, ok := resolveLang(spec.Language) + if !ok { + return nil, fmt.Errorf("unsupported language %q (supported: bash, node, python)", spec.Language) + } + args := []string{ + "run", "--rm", "-i", + "--memory", defaultMemory, + "--cpus", defaultCPUs, + "--pids-limit", defaultPidsLimit, + } + if !spec.Network { + args = append(args, "--network", "none") + } + args = append(args, ls.image) + args = append(args, ls.argv...) + return args, nil +} + +type cliExecutor struct { + program string +} + +func (c cliExecutor) Run(ctx context.Context, spec RunSpec) (Result, error) { + if c.program == "" { + return Result{}, fmt.Errorf("no container runtime found (looked for %s); install Docker or a compatible runtime", + strings.Join(supportedRuntimes, ", ")) + } + args, err := buildRunArgs(spec) + if err != nil { + return Result{}, err + } + + timeout := spec.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, c.program, args...) + cmd.Stdin = strings.NewReader(spec.Code) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + res := Result{Stdout: stdout.String(), Stderr: stderr.String()} + + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + res.ExitCode = exitErr.ExitCode() + return res, nil + } + if runErr != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return res, fmt.Errorf("execution timed out after %s", timeout) + } + return res, fmt.Errorf("%s run: %w", c.program, runErr) + } + return res, nil +} + +func detectRuntime() string { + for _, p := range supportedRuntimes { + if _, err := exec.LookPath(p); err == nil { + return p + } + } + return "" +} + +type ToolSet struct { + exec Executor + defaultTimeout time.Duration + maxTimeout time.Duration +} + +var ( + _ tools.ToolSet = (*ToolSet)(nil) + _ tools.Instructable = (*ToolSet)(nil) +) + +func New() *ToolSet { + return &ToolSet{ + exec: cliExecutor{program: detectRuntime()}, + defaultTimeout: defaultTimeout, + maxTimeout: maxTimeout, + } +} + +func CreateToolSet(_ *config.RuntimeConfig) (tools.ToolSet, error) { + return New(), nil +} + +type RunCodeArgs struct { + Language string `json:"language" jsonschema:"Language to run: python, node, or bash"` + Code string `json:"code" jsonschema:"The source code to execute"` + TimeoutSeconds int `json:"timeout_seconds,omitempty" jsonschema:"Maximum seconds to run (default 30, capped at 300)"` + Network bool `json:"network,omitempty" jsonschema:"Allow network access (default false = fully isolated)"` +} + +func (t *ToolSet) runCode(ctx context.Context, args RunCodeArgs) (*tools.ToolCallResult, error) { + if strings.TrimSpace(args.Code) == "" { + return tools.ResultError("Error: code is required."), nil + } + if _, _, ok := resolveLang(args.Language); !ok { + return tools.ResultError(fmt.Sprintf("Error: unsupported language %q (supported: bash, node, python).", args.Language)), nil + } + + timeout := t.defaultTimeout + if args.TimeoutSeconds > 0 { + timeout = time.Duration(args.TimeoutSeconds) * time.Second + } + if timeout > t.maxTimeout { + timeout = t.maxTimeout + } + + res, err := t.exec.Run(ctx, RunSpec{ + Language: args.Language, + Code: args.Code, + Timeout: timeout, + Network: args.Network, + }) + if err != nil { + return tools.ResultError("Error: " + err.Error()), nil + } + return tools.ResultSuccess(formatResult(res)), nil +} + +func formatResult(res Result) string { + var b strings.Builder + fmt.Fprintf(&b, "Exit code: %d\n", res.ExitCode) + if res.Stdout != "" { + fmt.Fprintf(&b, "\n--- stdout ---\n%s", ensureTrailingNewline(res.Stdout)) + } + if res.Stderr != "" { + fmt.Fprintf(&b, "\n--- stderr ---\n%s", ensureTrailingNewline(res.Stderr)) + } + if res.Stdout == "" && res.Stderr == "" { + b.WriteString("\n(no output)\n") + } + return b.String() +} + +func ensureTrailingNewline(s string) string { + if strings.HasSuffix(s, "\n") { + return s + } + return s + "\n" +} + +func (t *ToolSet) Tools(context.Context) ([]tools.Tool, error) { + return []tools.Tool{ + { + Name: ToolNameRunCode, + Category: category, + Description: "Run a code snippet (python, node, or bash) in an ephemeral, isolated container and return stdout, stderr, and exit code. Network is disabled by default; set network=true only when required.", + Parameters: tools.MustSchemaFor[RunCodeArgs](), + OutputSchema: tools.MustSchemaFor[string](), + Handler: tools.NewHandler(t.runCode), + Annotations: tools.ToolAnnotations{Title: "Run Code (Sandbox)"}, + AddDescriptionParameter: true, + }, + }, nil +} + +func (t *ToolSet) Instructions() string { + return `## Sandbox Tool + +Run a code snippet in an ephemeral, isolated container: + +- run_code(language, code, timeout_seconds?, network?) executes python, node, or + bash and returns stdout, stderr, and the exit code. +- Every run is disposable (removed on exit), resource-limited, and + network-disabled by default. Set network=true only when needed (e.g. to install + a package). + +Requires a docker-compatible container runtime (docker, podman, or nerdctl).` +} diff --git a/pkg/tools/builtin/sandbox/sandbox_test.go b/pkg/tools/builtin/sandbox/sandbox_test.go new file mode 100644 index 000000000..fa68e9e86 --- /dev/null +++ b/pkg/tools/builtin/sandbox/sandbox_test.go @@ -0,0 +1,152 @@ +package sandbox + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type fakeExecutor struct { + spec RunSpec + res Result + err error +} + +func (f *fakeExecutor) Run(_ context.Context, spec RunSpec) (Result, error) { + f.spec = spec + return f.res, f.err +} + +func newTestToolSet(exec Executor) *ToolSet { + ts := New() + ts.exec = exec + return ts +} + +func TestBuildRunArgs(t *testing.T) { + t.Parallel() + + args, err := buildRunArgs(RunSpec{Language: "python", Code: "print(1)"}) + require.NoError(t, err) + require.Equal(t, "run", args[0]) + require.Contains(t, args, "--rm") + require.Contains(t, args, "-i") + require.Contains(t, args, "--network") + require.Contains(t, args, "none") + require.Contains(t, args, "--memory") + require.Contains(t, args, "--pids-limit") + require.Contains(t, args, "python:3.12-slim") +} + +func TestBuildRunArgsNetworkOptIn(t *testing.T) { + t.Parallel() + + args, err := buildRunArgs(RunSpec{Language: "node", Code: "x", Network: true}) + require.NoError(t, err) + require.False(t, slices.Contains(args, "none"), args) + require.Contains(t, args, "node:22-alpine") +} + +func TestBuildRunArgsUnknownLanguage(t *testing.T) { + t.Parallel() + + _, err := buildRunArgs(RunSpec{Language: "cobol", Code: "x"}) + require.Error(t, err) +} + +func TestResolveLangAliases(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ in, want string }{ + {"py", "python"}, {"python3", "python"}, {"Python", "python"}, + {"js", "node"}, {"javascript", "node"}, + {"sh", "bash"}, {"shell", "bash"}, + } { + _, got, ok := resolveLang(tc.in) + require.True(t, ok, tc.in) + require.Equal(t, tc.want, got, tc.in) + } +} + +func TestRunCodeSuccess(t *testing.T) { + t.Parallel() + + fe := &fakeExecutor{res: Result{Stdout: "hello\n", ExitCode: 0}} + ts := newTestToolSet(fe) + + res, err := ts.runCode(context.Background(), RunCodeArgs{Language: "python", Code: "print('hello')"}) + require.NoError(t, err) + require.False(t, res.IsError, res.Output) + require.Contains(t, res.Output, "hello") + require.Equal(t, "python", fe.spec.Language) + require.Equal(t, "print('hello')", fe.spec.Code) + require.False(t, fe.spec.Network) +} + +func TestRunCodeNonZeroExit(t *testing.T) { + t.Parallel() + + fe := &fakeExecutor{res: Result{Stderr: "boom\n", ExitCode: 1}} + ts := newTestToolSet(fe) + + res, err := ts.runCode(context.Background(), RunCodeArgs{Language: "bash", Code: "exit 1"}) + require.NoError(t, err) + require.Contains(t, res.Output, "1") + require.Contains(t, res.Output, "boom") +} + +func TestRunCodeRequiresCode(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeExecutor{}) + res, err := ts.runCode(context.Background(), RunCodeArgs{Language: "python", Code: " "}) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestRunCodeUnknownLanguage(t *testing.T) { + t.Parallel() + + ts := newTestToolSet(&fakeExecutor{}) + res, err := ts.runCode(context.Background(), RunCodeArgs{Language: "rust", Code: "fn main(){}"}) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestRunCodeTimeoutCapped(t *testing.T) { + t.Parallel() + + fe := &fakeExecutor{res: Result{Stdout: "ok"}} + ts := newTestToolSet(fe) + + _, err := ts.runCode(context.Background(), RunCodeArgs{Language: "python", Code: "x", TimeoutSeconds: 99999}) + require.NoError(t, err) + require.Equal(t, ts.maxTimeout, fe.spec.Timeout) +} + +func TestRunCodeExecutorError(t *testing.T) { + t.Parallel() + + fe := &fakeExecutor{err: context.DeadlineExceeded} + ts := newTestToolSet(fe) + + res, err := ts.runCode(context.Background(), RunCodeArgs{Language: "python", Code: "x"}) + require.NoError(t, err) + require.True(t, res.IsError) +} + +func TestToolSetInterfaces(t *testing.T) { + t.Parallel() + + ts := New() + require.NotEmpty(t, ts.Instructions()) + toolz, err := ts.Tools(context.Background()) + require.NoError(t, err) + require.Len(t, toolz, 1) + require.Equal(t, ToolNameRunCode, toolz[0].Name) + require.Positive(t, ts.defaultTimeout) + _ = time.Second +}