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
37 changes: 37 additions & 0 deletions cagent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,10 @@
"name": {
"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)"
}
},
"additionalProperties": false,
Expand Down Expand Up @@ -593,6 +597,39 @@
],
"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
23 changes: 14 additions & 9 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,16 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
}

var (
rt runtime.Runtime
sess *session.Session
rt runtime.Runtime
sess *session.Session
cleanup func()
)
if f.remoteAddress != "" {
rt, sess, err = f.createRemoteRuntimeAndSession(ctx, agentFileName)
if err != nil {
return err
}
cleanup = func() {} // Remote runtime doesn't need local cleanup
} else {
agentSource, err := config.Resolve(agentFileName)
if err != nil {
Expand All @@ -146,7 +148,17 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
if err != nil {
return err
}

// Setup cleanup for local runtime
cleanup = func() {
// Use a fresh context for cleanup since the original may be canceled
cleanupCtx := context.WithoutCancel(ctx)
if err := t.StopToolSets(cleanupCtx); err != nil {
slog.Error("Failed to stop tool sets", "error", err)
}
}
}
defer cleanup()

if f.dryRun {
out.Println("Dry run mode enabled. Agent initialized but will not execute.")
Expand All @@ -166,13 +178,6 @@ func (f *runExecFlags) loadAgentFrom(ctx context.Context, agentSource config.Sou
return nil, err
}

go func() {
<-ctx.Done()
if err := t.StopToolSets(ctx); err != nil {
slog.Error("Failed to stop tool sets", "error", err)
}
}()

return t, nil
}

Expand Down
2 changes: 1 addition & 1 deletion examples/golibrary/builtintool/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func run(ctx context.Context) error {
"root",
"You are an expert hacker",
agent.WithModel(llm),
agent.WithToolSets(builtin.NewShellTool(os.Environ(), &config.RuntimeConfig{Config: config.Config{WorkingDir: "/tmp"}})),
agent.WithToolSets(builtin.NewShellTool(os.Environ(), &config.RuntimeConfig{Config: config.Config{WorkingDir: "/tmp"}}, nil)),
),
),
)
Expand Down
17 changes: 17 additions & 0 deletions examples/sandbox_agent.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env cagent run

agents:
root:
model: openai/gpt-4o
description: |
A helpful assistant that runs shell commands in a sandboxed environment.
All shell commands execute inside a Docker container with limited filesystem access.
instruction: You are a helpful assistant with access to a sandboxed shell environment.
toolsets:
- type: shell
sandbox:
image: alpine:latest
paths:
- .
- /tmp
- "${env.HOME}:ro"
18 changes: 18 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ type Toolset struct {
// For `shell`, `script`, `mcp` or `lsp` tools
Env map[string]string `json:"env,omitempty"`

// For the `shell` tool - sandbox mode
Sandbox *SandboxConfig `json:"sandbox,omitempty"`

// For the `todo` tool
Shared bool `json:"shared,omitempty"`

Expand Down Expand Up @@ -178,6 +181,21 @@ type Remote struct {
Headers map[string]string `json:"headers,omitempty"`
}

// SandboxConfig represents the configuration for running shell commands in a Docker container.
// When enabled, all shell commands run inside a sandboxed Linux container with only
// specified paths bind-mounted.
type SandboxConfig struct {
// Image is the Docker image to use for the sandbox container.
// Defaults to "alpine:latest" if not specified.
Image string `json:"image,omitempty"`

// Paths is a list of paths to bind-mount into the container.
// Each path can optionally have a ":ro" suffix for read-only access.
// Default is read-write (:rw) if no suffix is specified.
// Example: [".", "/tmp", "/config:ro"]
Paths []string `json:"paths"`
}

// DeferConfig represents the deferred loading configuration for a toolset.
// It can be either a boolean (true to defer all tools) or a slice of strings
// (list of tool names to defer).
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/latest/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ func (t *Toolset) validate() error {
if len(t.Env) > 0 && (t.Type != "shell" && t.Type != "script" && t.Type != "mcp" && t.Type != "lsp") {
return errors.New("env can only be used with type 'shell', 'script', 'mcp' or 'lsp'")
}
if t.Sandbox != nil && t.Type != "shell" {
return errors.New("sandbox can only be used with type 'shell'")
}
if t.Shared && t.Type != "todo" {
return errors.New("shared can only be used with type 'todo'")
}
Expand Down Expand Up @@ -74,6 +77,10 @@ func (t *Toolset) validate() error {
}

switch t.Type {
case "shell":
if t.Sandbox != nil && len(t.Sandbox.Paths) == 0 {
return errors.New("sandbox requires at least one path to be set")
}
case "memory":
if t.Path == "" {
return errors.New("memory toolset requires a path to be set")
Expand Down
101 changes: 101 additions & 0 deletions pkg/config/latest/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,104 @@ agents:
})
}
}

func TestToolset_Validate_Sandbox(t *testing.T) {
t.Parallel()

tests := []struct {
name string
config string
wantErr string
}{
{
name: "valid shell with sandbox",
config: `
version: "3"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: shell
sandbox:
image: alpine:latest
paths:
- .
- /tmp
`,
wantErr: "",
},
{
name: "shell sandbox with readonly path",
config: `
version: "3"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: shell
sandbox:
paths:
- ./:rw
- /config:ro
`,
wantErr: "",
},
{
name: "shell sandbox without paths",
config: `
version: "3"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: shell
sandbox:
image: alpine:latest
`,
wantErr: "sandbox requires at least one path to be set",
},
{
name: "sandbox on non-shell toolset",
config: `
version: "3"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: filesystem
sandbox:
paths:
- .
`,
wantErr: "sandbox can only be used with type 'shell'",
},
{
name: "shell without sandbox is valid",
config: `
version: "3"
agents:
root:
model: "openai/gpt-4"
toolsets:
- type: shell
`,
wantErr: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var cfg Config
err := yaml.Unmarshal([]byte(tt.config), &cfg)

if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
}
})
}
}
26 changes: 25 additions & 1 deletion pkg/teamloader/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,31 @@ func createShellTool(ctx context.Context, toolset latest.Toolset, _ string, runC
return nil, fmt.Errorf("failed to expand the tool's environment variables: %w", err)
}
env = append(env, os.Environ()...)
return builtin.NewShellTool(env, runConfig), nil

// Expand sandbox paths with JS interpolation (e.g., ${env.HOME}:ro)
sandboxConfig := expandSandboxPaths(ctx, toolset.Sandbox, runConfig.EnvProvider())

return builtin.NewShellTool(env, runConfig, sandboxConfig), nil
}

// expandSandboxPaths expands environment variable references in sandbox paths.
// Supports JS template literal syntax like ${env.HOME} or ${env.HOME || '/default'}.
func expandSandboxPaths(ctx context.Context, sandbox *latest.SandboxConfig, envProvider environment.Provider) *latest.SandboxConfig {
if sandbox == nil {
return nil
}

expander := js.NewJsExpander(envProvider)

expandedPaths := make([]string, len(sandbox.Paths))
for i, p := range sandbox.Paths {
expandedPaths[i] = expander.Expand(ctx, p)
}

return &latest.SandboxConfig{
Image: sandbox.Image,
Paths: expandedPaths,
}
}

func createScriptTool(ctx context.Context, toolset latest.Toolset, _ string, runConfig *config.RuntimeConfig) (tools.ToolSet, error) {
Expand Down
Loading