Files
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

278 lines
9.1 KiB
Go

// Package sandbox provides Docker-based code execution isolation.
//
// Agents can run tool commands (exec, shell) inside Docker containers
// instead of the host system. Sandbox modes:
// - off: no sandboxing, execute directly on host
// - non-main: all agents except "main" run in sandbox
// - all: every agent runs in sandbox
//
// Workspace access levels:
// - none: no filesystem access
// - ro: read-only workspace mount
// - rw: read-write workspace mount
//
// Sandbox scope controls container reuse:
// - session: one container per session (max isolation)
// - agent: shared container per agent
// - shared: one container for all agents
package sandbox
import (
"context"
"fmt"
"strings"
)
// Mode determines which agents are sandboxed.
type Mode string
const (
ModeOff Mode = "off" // no sandbox
ModeNonMain Mode = "non-main" // all except "main" agent
ModeAll Mode = "all" // every agent
)
// Access determines workspace filesystem permissions.
type Access string
const (
AccessNone Access = "none" // no filesystem
AccessRO Access = "ro" // read-only
AccessRW Access = "rw" // read-write
)
// Scope determines container reuse granularity.
type Scope string
const (
ScopeSession Scope = "session" // one container per session
ScopeAgent Scope = "agent" // one container per agent
ScopeShared Scope = "shared" // one container for all
)
// Config configures the sandbox system.
// Matches TS SandboxDockerSettings + SandboxConfig.
type Config struct {
Mode Mode `json:"mode"`
Image string `json:"image"`
WorkspaceAccess Access `json:"workspace_access"`
Scope Scope `json:"scope"`
MemoryMB int `json:"memory_mb"`
CPUs float64 `json:"cpus"`
TimeoutSec int `json:"timeout_sec"`
NetworkEnabled bool `json:"network_enabled"`
RestrictedDomains []string `json:"restricted_domains,omitempty"`
Env map[string]string `json:"env,omitempty"`
// Security hardening (matching TS buildSandboxCreateArgs)
ReadOnlyRoot bool `json:"read_only_root"`
CapDrop []string `json:"cap_drop,omitempty"`
Tmpfs []string `json:"tmpfs,omitempty"` // e.g. "/tmp", "/tmp:size=64m"
TmpfsSizeMB int `json:"tmpfs_size_mb,omitempty"` // default size for tmpfs mounts without explicit :size= (0 = Docker default)
PidsLimit int `json:"pids_limit,omitempty"`
User string `json:"user,omitempty"` // container user (e.g. "1000:1000", "nobody")
MaxOutputBytes int `json:"max_output_bytes,omitempty"` // limit exec stdout+stderr capture (default 1MB, 0 = unlimited)
SetupCommand string `json:"setup_command,omitempty"`
ContainerPrefix string `json:"container_prefix,omitempty"`
Workdir string `json:"workdir,omitempty"` // container workdir (default "/workspace")
// Pruning (matching TS SandboxPruneSettings)
IdleHours int `json:"idle_hours,omitempty"` // prune containers idle > N hours (default 24)
MaxAgeDays int `json:"max_age_days,omitempty"` // prune containers older than N days (default 7)
PruneIntervalMin int `json:"prune_interval_min,omitempty"` // check interval in minutes (default 5)
}
// DefaultConfig returns sensible defaults matching TS sandbox defaults.
func DefaultConfig() Config {
return Config{
Mode: ModeOff,
Image: "goclaw-sandbox:bookworm-slim",
WorkspaceAccess: AccessRW,
Scope: ScopeSession,
MemoryMB: 512,
CPUs: 1.0,
TimeoutSec: 300,
NetworkEnabled: false,
ReadOnlyRoot: true,
CapDrop: []string{"ALL"},
Tmpfs: []string{"/tmp", "/var/tmp", "/run"},
PidsLimit: 256,
MaxOutputBytes: 1 << 20, // 1MB
ContainerPrefix: "goclaw-sbx-",
Workdir: "/workspace",
IdleHours: 24,
MaxAgeDays: 7,
PruneIntervalMin: 5,
}
}
// ShouldSandbox returns true if the given agent should run in a sandbox.
func (c Config) ShouldSandbox(agentID string) bool {
switch c.Mode {
case ModeAll:
return true
case ModeNonMain:
return agentID != "main" && agentID != "default"
default:
return false
}
}
// DefaultContainerWorkdir is the default container-side working directory
// used when no custom Workdir is configured.
const DefaultContainerWorkdir = "/workspace"
// ContainerWorkdir returns the container-side working directory.
func (c Config) ContainerWorkdir() string {
if c.Workdir != "" {
return c.Workdir
}
return DefaultContainerWorkdir
}
// ResolveScopeKey maps a session key to a sandbox scope key.
// Matching TS resolveSandboxScopeKey().
func (c Config) ResolveScopeKey(sessionKey string) string {
switch c.Scope {
case ScopeShared:
return "shared"
case ScopeAgent:
// Extract agent ID from session key "agent:{agentId}:{rest}"
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) >= 2 {
return "agent:" + parts[1]
}
return "agent:default"
default: // ScopeSession
if sessionKey == "" {
return "default"
}
return sessionKey
}
}
// ExecResult is the output of a command executed in a sandbox container.
type ExecResult struct {
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
}
// ExecOption configures optional behavior for sandbox Exec calls.
type ExecOption func(*ExecOpts)
// ExecOpts holds optional settings applied via ExecOption.
type ExecOpts struct {
Env map[string]string // extra env vars injected into the container exec
}
// WithEnv injects additional environment variables into the sandbox exec call.
// Used by credentialed exec to pass credentials without shell interpretation.
func WithEnv(env map[string]string) ExecOption {
return func(o *ExecOpts) { o.Env = env }
}
// ApplyExecOpts resolves variadic ExecOption into ExecOpts.
func ApplyExecOpts(opts []ExecOption) ExecOpts {
var o ExecOpts
for _, opt := range opts {
opt(&o)
}
return o
}
// ReadOnlyMount describes an additional runtime-only bind mount.
// HostPath must be an absolute, canonical host path. Destination must be an
// absolute canonical container path strictly beneath the configured workdir.
type ReadOnlyMount struct {
Name string
HostPath string
Destination string
}
// GetOption configures optional behavior for Manager.Get calls.
type GetOption func(*GetOpts)
// GetOpts holds optional settings applied via GetOption.
type GetOpts struct {
ReadOnlyMounts []ReadOnlyMount
WorkspaceAccessOverride *Access
}
// WithReadOnlyMounts adds runtime-only read-only mounts to a sandbox.
func WithReadOnlyMounts(mounts ...ReadOnlyMount) GetOption {
copied := append([]ReadOnlyMount(nil), mounts...)
return func(o *GetOpts) {
o.ReadOnlyMounts = append(o.ReadOnlyMounts, copied...)
}
}
// WithWorkspaceAccessOverride changes access for this isolated sandbox
// instance only. Callers must validate the workspace authority before using it.
func WithWorkspaceAccessOverride(access Access) GetOption {
return func(o *GetOpts) {
override := access
o.WorkspaceAccessOverride = &override
}
}
// ApplyGetOpts resolves variadic GetOption values without retaining caller-owned
// slices.
func ApplyGetOpts(opts []GetOption) GetOpts {
var o GetOpts
for _, opt := range opts {
if opt != nil {
opt(&o)
}
}
o.ReadOnlyMounts = append([]ReadOnlyMount(nil), o.ReadOnlyMounts...)
return o
}
func applyGetOptsToConfig(cfg Config, opts GetOpts) Config {
if opts.WorkspaceAccessOverride != nil {
cfg.WorkspaceAccess = *opts.WorkspaceAccessOverride
}
return cfg
}
// Sandbox is the interface for sandboxed code execution.
type Sandbox interface {
// Exec runs a command inside the sandbox and returns the result.
// Optional ExecOption (e.g. WithEnv) configures per-call behavior.
Exec(ctx context.Context, command []string, workDir string, opts ...ExecOption) (*ExecResult, error)
// Destroy removes the sandbox container and cleans up resources.
Destroy(ctx context.Context) error
// ID returns the sandbox's unique identifier (container ID).
ID() string
}
// Manager manages sandbox lifecycle based on scope.
type Manager interface {
// Get returns (or creates) a sandbox for the given scope key, workspace mount,
// and effective config. Implementations must not reuse one container across
// different mounted workspaces or workspace access settings.
// For session scope: key = sessionKey
// For agent scope: key = agentID
// For shared scope: key = "shared"
// If cfgOverride is non-nil, it is used instead of the global config for new containers.
Get(ctx context.Context, key string, workspace string, cfgOverride *Config, opts ...GetOption) (Sandbox, error)
// Release destroys a sandbox by key.
Release(ctx context.Context, key string) error
// ReleaseAll destroys all active sandboxes.
ReleaseAll(ctx context.Context) error
// Stop signals background goroutines (pruning) to stop.
Stop()
// Stats returns info about active sandboxes.
Stats() map[string]any
}
// ErrSandboxDisabled is returned when sandbox mode is "off".
var ErrSandboxDisabled = fmt.Errorf("sandbox is disabled")