Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0cb4c2c
fix(sandbox): use configured GOCLAW_SANDBOX_WORKSPACE_PATH instead of…
Erudition Mar 11, 2026
c192bed
fix(config): allow setting sandbox Workdir via GOCLAW_SANDBOX_WORKSPA…
Erudition Mar 11, 2026
ed6b525
fix(sandbox): resolve host mount dynamically for DooD volumes
Erudition Mar 11, 2026
b625bef
fix(agent): correctly resolve default workspace relative to global wo…
Erudition Mar 11, 2026
72f72f7
fix(agent): remove unused fmt import breaking build
Erudition Mar 11, 2026
4e6c671
fix(sandbox): prevent docker exec path escapes for out-of-bounds agen…
Erudition Mar 11, 2026
c12d6ab
fix(ui): use variable proxy_pass in nginx with docker resolver to pre…
Erudition Mar 11, 2026
c266db9
fix(agent): migrate legacy ~/.goclaw/ workspace paths to GlobalWorksp…
Erudition Mar 11, 2026
3eebe89
fix(agent): handle absolute paths in legacy workspace migration
Erudition Mar 11, 2026
0284885
fix(agent): migrate legacy user profile workspaces to global workspace
Erudition Mar 11, 2026
ba45646
fix(agent): ensure every agent has a persisted default workspace
Erudition Mar 11, 2026
aa21e2c
Fix Sandbox Isolation Breach: map host paths to container paths in fi…
Erudition Mar 11, 2026
61e7047
Fix Sandbox Isolation Breach: map host paths to container paths in fi…
Erudition Mar 11, 2026
5ebe291
Fix build failure: remove unused path/filepath import in shell.go
Erudition Mar 11, 2026
a7a2885
feat: add networking utilities and passwordless sudo to sandbox
Erudition Mar 11, 2026
088bd2c
feat: propagate sandbox network status and relax security policy for …
Erudition Mar 11, 2026
f3e93a2
feat: improve sandbox networking and add security policy tests
Erudition Mar 11, 2026
40af1b3
feat: auto-build sandbox image via compose
Erudition Mar 11, 2026
de934e6
fix: propagate per-agent NetworkEnabled to Docker container creation
Erudition Mar 11, 2026
6feedf2
feat(ui): support space-separated and quoted arguments in MCP server …
Erudition Mar 11, 2026
50b0071
fix: clean up outdated context keys
Erudition Mar 11, 2026
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
21 changes: 13 additions & 8 deletions Dockerfile.sandbox
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@ ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
python3-pip \
ripgrep \
bash \
ca-certificates \
curl \
dnsutils \
git \
iputils-ping \
jq \
python3 \
python3-pip \
ripgrep \
sudo \
&& echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/sandbox \
&& chmod 0440 /etc/sudoers.d/sandbox \
&& rm -rf /var/lib/apt/lists/*

RUN useradd --create-home --shell /bin/bash sandbox
Expand Down
2 changes: 1 addition & 1 deletion cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ func runGateway() {
if mcpMgr != nil {
mcpToolLister = mcpMgr
}
agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister)
agentsH, skillsH, tracesH, mcpH, customToolsH, channelInstancesH, providersH, delegationsH, builtinToolsH, pendingMessagesH := wireHTTP(pgStores, cfg.Gateway.Token, msgBus, toolsReg, providerRegistry, permPE.IsOwner, gatewayAddr, mcpToolLister, workspace)
if agentsH != nil {
server.SetAgentsHandler(agentsH)
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/gateway_http_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + custom tools + channel instances + providers + delegations + builtin tools + pending messages).
func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler) {
func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister, workspace string) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.CustomToolsHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.DelegationsHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
Expand All @@ -27,7 +27,7 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
if providerReg != nil {
summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus)
}
agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner, isOwner)
agentsH = httpapi.NewAgentsHandler(stores.Agents, token, workspace, msgBus, summoner, isOwner)
if stores.Activity != nil {
agentsH.SetActivityStore(stores.Activity)
}
Expand Down
4 changes: 4 additions & 0 deletions cmd/gateway_managed.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ func wireExtras(

// 4. Compute global sandbox defaults for resolver
sandboxEnabled := sandboxMgr != nil
sandboxNetworkEnabled := false
sandboxContainerDir := ""
sandboxWorkspaceAccess := ""
if sandboxEnabled {
Expand All @@ -108,6 +109,7 @@ func wireExtras(
resolved := sbCfg.ToSandboxConfig()
sandboxContainerDir = resolved.ContainerWorkdir()
sandboxWorkspaceAccess = string(resolved.WorkspaceAccess)
sandboxNetworkEnabled = resolved.NetworkEnabled
}
}

Expand Down Expand Up @@ -142,6 +144,7 @@ func wireExtras(
CompactionCfg: appCfg.Agents.Defaults.Compaction,
ContextPruningCfg: appCfg.Agents.Defaults.ContextPruning,
SandboxEnabled: sandboxEnabled,
SandboxNetworkEnabled: sandboxNetworkEnabled,
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
DynamicLoader: dynamicLoader,
Expand All @@ -154,6 +157,7 @@ func wireExtras(
MediaStore: mediaStore,
ModelPricing: appCfg.Telemetry.ModelPricing,
TracingStore: stores.Tracing,
GlobalWorkspace: workspace,
OnEvent: func(event agent.AgentEvent) {
msgBus.Broadcast(bus.Event{
Name: protocol.EventAgent,
Expand Down
18 changes: 15 additions & 3 deletions docker-compose.sandbox.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
# Sandbox overlay — enables Docker-based sandbox for agent code execution.
#
# Prerequisites:
# 1. Build the sandbox image: docker build -t goclaw-sandbox:bookworm-slim -f Dockerfile.sandbox .
# 2. Ensure Docker socket is accessible
# 1. Ensure Docker socket is accessible
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.sandbox.yml up
# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.sandbox.yml up --build
#
# The sandbox image is built automatically via the sandbox-image service below.
# Using --build ensures the image is rebuilt when Dockerfile.sandbox changes
# (Docker layer caching makes this near-instant when nothing has changed).
#
# SECURITY NOTE: Mounting Docker socket gives the container control over host Docker.
# Only use in trusted environments where agent code execution isolation is required.

services:
sandbox-image:
image: goclaw-sandbox:bookworm-slim
pull_policy: build
build:
context: .
dockerfile: Dockerfile.sandbox
deploy:
replicas: 0

goclaw:
build:
args:
Expand Down
6 changes: 6 additions & 0 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
if l.selfEvolve {
ctx = store.WithSelfEvolve(ctx, true)
}
// Inject sandbox container directory for path mapping in tools
if l.sandboxContainerDir != "" {
ctx = tools.WithToolSandboxDir(ctx, l.sandboxContainerDir)
ctx = tools.WithToolSandboxNetwork(ctx, l.sandboxNetworkEnabled)
}
// Inject original sender ID for group file writer permission checks
if req.SenderID != "" {
ctx = store.WithSenderID(ctx, req.SenderID)
Expand Down Expand Up @@ -96,6 +101,7 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
}
}
// Expand ~ and convert to absolute for filesystem operations.
ws = MigrateLegacyPath(ws, l.workspace)
ws = config.ExpandHome(ws)
if !filepath.IsAbs(ws) {
ws, _ = filepath.Abs(ws)
Expand Down
3 changes: 3 additions & 0 deletions internal/agent/loop_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ type Loop struct {

// Sandbox info
sandboxEnabled bool
sandboxNetworkEnabled bool
sandboxContainerDir string
sandboxWorkspaceAccess string

Expand Down Expand Up @@ -183,6 +184,7 @@ type LoopConfig struct {

// Sandbox info (injected into system prompt)
SandboxEnabled bool
SandboxNetworkEnabled bool
SandboxContainerDir string // e.g. "/workspace"
SandboxWorkspaceAccess string // "none", "ro", "rw"

Expand Down Expand Up @@ -283,6 +285,7 @@ func NewLoop(cfg LoopConfig) *Loop {
compactionCfg: cfg.CompactionCfg,
contextPruningCfg: cfg.ContextPruningCfg,
sandboxEnabled: cfg.SandboxEnabled,
sandboxNetworkEnabled: cfg.SandboxNetworkEnabled,
sandboxContainerDir: cfg.SandboxContainerDir,
sandboxWorkspaceAccess: cfg.SandboxWorkspaceAccess,
traceCollector: cfg.TraceCollector,
Expand Down
36 changes: 36 additions & 0 deletions internal/agent/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ type ResolverDeps struct {
CompactionCfg *config.CompactionConfig
ContextPruningCfg *config.ContextPruningConfig
SandboxEnabled bool
SandboxNetworkEnabled bool
SandboxContainerDir string
SandboxWorkspaceAccess string

// Global default workspace
GlobalWorkspace string

// Dynamic custom tools
DynamicLoader *tools.DynamicToolLoader

Expand Down Expand Up @@ -242,6 +246,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
contextPruningCfg = c
}
sandboxEnabled := deps.SandboxEnabled
sandboxNetworkEnabled := deps.SandboxNetworkEnabled
sandboxContainerDir := deps.SandboxContainerDir
sandboxWorkspaceAccess := deps.SandboxWorkspaceAccess
var sandboxCfgOverride *sandbox.Config
Expand All @@ -250,15 +255,30 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
sandboxContainerDir = resolved.ContainerWorkdir()
sandboxWorkspaceAccess = string(resolved.WorkspaceAccess)
sandboxCfgOverride = &resolved
sandboxNetworkEnabled = resolved.NetworkEnabled
}

// Expand ~ in workspace path and ensure directory exists
workspace := ag.Workspace
if workspace != "" {
workspace = MigrateLegacyPath(workspace, deps.GlobalWorkspace)
workspace = config.ExpandHome(workspace)
if !filepath.IsAbs(workspace) {
workspace, _ = filepath.Abs(workspace)
}
}
if workspace == "" && deps.GlobalWorkspace != "" {
workspace = filepath.Join(deps.GlobalWorkspace, ag.AgentKey+"-workspace")
// Proactively update the record in the database so the UI shows it too.
// Run in background to avoid blocking the first chat response.
go func(as store.AgentStore, id uuid.UUID, ws string) {
if err := as.Update(context.Background(), id, map[string]any{"workspace": ws}); err != nil {
slog.Warn("failed to persist default agent workspace", "agent_id", id, "workspace", ws, "error", err)
}
}(deps.AgentStore, ag.ID, workspace)
}

if workspace != "" {
if err := os.MkdirAll(workspace, 0755); err != nil {
slog.Warn("failed to create agent workspace directory", "workspace", workspace, "agent", agentKey, "error", err)
}
Expand Down Expand Up @@ -381,6 +401,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
CompactionCfg: compactionCfg,
ContextPruningCfg: contextPruningCfg,
SandboxEnabled: sandboxEnabled,
SandboxNetworkEnabled: sandboxNetworkEnabled,
SandboxContainerDir: sandboxContainerDir,
SandboxWorkspaceAccess: sandboxWorkspaceAccess,
BuiltinToolSettings: builtinSettings,
Expand Down Expand Up @@ -424,3 +445,18 @@ func derefInt(p *int) int {
return *p
}

// MigrateLegacyPath converts legacy hardcoded workspace paths to the current GlobalWorkspace.
// This handles older agents that received "~/.goclaw/...", "/app/.goclaw/...", or "/.goclaw/..."
// as their default workspace, which are unmounted ephemeral locations in Docker.
func MigrateLegacyPath(path, globalWorkspace string) string {
if globalWorkspace == "" {
return path
}
legacyPrefixes := []string{"~/.goclaw/", "/app/.goclaw/", "/.goclaw/"}
for _, p := range legacyPrefixes {
if strings.HasPrefix(path, p) {
return filepath.Join(globalWorkspace, strings.TrimPrefix(path, p))
}
}
return path
}
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ type SandboxConfig struct {
User string `json:"user,omitempty"` // container user (e.g. "1000:1000", "nobody")
TmpfsSizeMB int `json:"tmpfs_size_mb,omitempty"` // default tmpfs size in MB (0 = Docker default)
MaxOutputBytes int `json:"max_output_bytes,omitempty"` // limit exec output capture (default 1MB)
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)
Expand Down Expand Up @@ -281,6 +282,9 @@ func (sc *SandboxConfig) ToSandboxConfig() sandbox.Config {
if sc.MaxOutputBytes > 0 {
cfg.MaxOutputBytes = sc.MaxOutputBytes
}
if sc.Workdir != "" {
cfg.Workdir = sc.Workdir
}

// Pruning
if sc.IdleHours > 0 {
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,10 @@ func (c *Config) applyEnvOverrides() {
ensureSandbox()
c.Agents.Defaults.Sandbox.NetworkEnabled = v == "true" || v == "1"
}
if v := os.Getenv("GOCLAW_SANDBOX_WORKSPACE_PATH"); v != "" {
ensureSandbox()
c.Agents.Defaults.Sandbox.Workdir = v
}

// Browser (for Docker-compose browser sidecar overlay)
envStr("GOCLAW_BROWSER_REMOTE_URL", &c.Tools.Browser.RemoteURL)
Expand Down
26 changes: 14 additions & 12 deletions internal/http/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package http

import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"strings"

"github.com/google/uuid"
Expand All @@ -18,23 +18,25 @@ import (

// AgentsHandler handles agent CRUD and sharing endpoints.
type AgentsHandler struct {
agents store.AgentStore
token string
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
activity store.ActivityStore // optional audit logging (nil = disabled)
agents store.AgentStore
token string
workspace string
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
activity store.ActivityStore // optional audit logging (nil = disabled)
}

// SetActivityStore sets the optional activity audit store.
func (h *AgentsHandler) SetActivityStore(a store.ActivityStore) { h.activity = a }

// NewAgentsHandler creates a handler for agent management endpoints.
// isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled).
func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler {
return &AgentsHandler{agents: agents, token: token, msgBus: msgBus, summoner: summoner, isOwner: isOwner}
func NewAgentsHandler(agents store.AgentStore, token string, workspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler {
return &AgentsHandler{agents: agents, token: token, workspace: workspace, msgBus: msgBus, summoner: summoner, isOwner: isOwner}
}


// isOwnerUser checks if the given user ID is a system owner.
func (h *AgentsHandler) isOwnerUser(userID string) bool {
return userID != "" && h.isOwner != nil && h.isOwner(userID)
Expand Down Expand Up @@ -162,10 +164,10 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
if req.MaxToolIterations <= 0 {
req.MaxToolIterations = 20
}
if req.Workspace == "" {
req.Workspace = fmt.Sprintf("~/.goclaw/%s-workspace", req.AgentKey)
}
req.RestrictToWorkspace = true
if req.Workspace == "" && h.workspace != "" {
req.Workspace = filepath.Join(h.workspace, req.AgentKey+"-workspace")
}

// Default: enable compaction and memory for new agents
if len(req.CompactionConfig) == 0 {
Expand Down
23 changes: 18 additions & 5 deletions internal/sandbox/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ func newDockerSandbox(ctx context.Context, name string, cfg Config, workspace st
if cfg.WorkspaceAccess == AccessRO {
mountOpt = "ro"
}
args = append(args, "-v", fmt.Sprintf("%s:%s:%s", workspace, containerWorkdir, mountOpt))
hostAccessPath := resolveHostWorkspacePath(ctx, workspace)
args = append(args, "-v", fmt.Sprintf("%s:%s:%s", hostAccessPath, containerWorkdir, mountOpt))
}
args = append(args, "-w", containerWorkdir)

Expand Down Expand Up @@ -240,8 +241,20 @@ func (m *DockerManager) Get(ctx context.Context, key string, workspace string, c
return nil, ErrSandboxDisabled
}

// Apply per-request network override from context.
if netOverride, ok := NetworkOverrideFromCtx(ctx); ok {
cfg.NetworkEnabled = netOverride
}

// Include network mode in the cache key so that agents with
// networking enabled get a different container from those without.
effectiveKey := key
if cfg.NetworkEnabled {
effectiveKey = key + "-net"
}

m.mu.RLock()
if sb, ok := m.sandboxes[key]; ok {
if sb, ok := m.sandboxes[effectiveKey]; ok {
m.mu.RUnlock()
return sb, nil
}
Expand All @@ -251,21 +264,21 @@ func (m *DockerManager) Get(ctx context.Context, key string, workspace string, c
defer m.mu.Unlock()

// Double-check
if sb, ok := m.sandboxes[key]; ok {
if sb, ok := m.sandboxes[effectiveKey]; ok {
return sb, nil
}

prefix := cfg.ContainerPrefix
if prefix == "" {
prefix = "goclaw-sbx-"
}
name := prefix + sanitizeKey(key)
name := prefix + sanitizeKey(effectiveKey)
sb, err := newDockerSandbox(ctx, name, cfg, workspace)
if err != nil {
return nil, err
}

m.sandboxes[key] = sb
m.sandboxes[effectiveKey] = sb
return sb, nil
}

Expand Down
Loading