Skip to content
Open
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
29 changes: 26 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,32 @@ DOCKER_TLS_VERIFY=
DOCKER_CERT_PATH=

## Docker settings inside primary terminal container
DOCKER_INSIDE=true # enable to use docker socket
DOCKER_NET_ADMIN=true # enable to use net_admin capability
DOCKER_SOCKET=/var/run/docker.sock # path on host machine
# SECURITY NOTE: DOCKER_INSIDE=true mounts a Docker socket into every sandbox
# container so agents can spawn sub-containers (Docker-in-Docker / DinD).
# Mounting the raw host socket grants unrestricted Docker API access, which can
# be exploited via prompt injection to escape the sandbox and compromise the
# host (issue #337). Leave DOCKER_INSIDE=false unless DinD is required.
#
# PARTIAL MITIGATION using docker-socket-proxy (reduces attack surface but does
# NOT fully prevent sandbox escape — tecnativa proxy filters by URL/method, not
# request body, so Privileged containers can still be requested):
# 1. Start the proxy profile: docker compose --profile dind up
# 2. Set DOCKER_INSIDE=true
# 3. Set DOCKER_SANDBOX_SOCKET=tcp://docker-socket-proxy:2375
# The proxy allows: CONTAINERS, IMAGES, NETWORKS, VOLUMES, INFO, PING, VERSION
# The proxy blocks: EXEC, SECRETS, CONFIGS, SWARM, SYSTEM, and other endpoints
# It does NOT inspect request bodies (Privileged, bind-mounts, CapAdd etc.)
# 4. REQUIRED: Set DOCKER_NETWORK=pentagi-network
# Sandbox containers must share a Docker network with the proxy so they
# can resolve the hostname 'docker-socket-proxy'. Without this, the proxy
# address is unreachable and all in-sandbox docker commands will fail.
#
# Safest: DOCKER_INSIDE=false (the default).
DOCKER_INSIDE=false # enable to use docker socket
DOCKER_NET_ADMIN=false # enable to use net_admin capability
DOCKER_SOCKET=/var/run/docker.sock # raw host socket (used by pentagi backend)
# When using the dind profile, set to tcp://docker-socket-proxy:2375 for partial restriction
DOCKER_SANDBOX_SOCKET=
DOCKER_NETWORK=
DOCKER_WORK_DIR=
DOCKER_PUBLIC_IP=0.0.0.0 # public ip of host machine
Expand Down
1 change: 1 addition & 0 deletions backend/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type Config struct {
DockerInside bool `env:"DOCKER_INSIDE" envDefault:"false"`
DockerNetAdmin bool `env:"DOCKER_NET_ADMIN" envDefault:"false"`
DockerSocket string `env:"DOCKER_SOCKET"`
DockerSandboxSocket string `env:"DOCKER_SANDBOX_SOCKET"` // optional proxy socket for sandbox containers (see issue #337)
DockerNetwork string `env:"DOCKER_NETWORK"`
DockerPublicIP string `env:"DOCKER_PUBLIC_IP" envDefault:"0.0.0.0"`
DockerWorkDir string `env:"DOCKER_WORK_DIR"`
Expand Down
93 changes: 63 additions & 30 deletions backend/pkg/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,17 @@ type containerPathStatResult struct {
}

type dockerClient struct {
db database.Querier
logger *logrus.Logger
dataDir string
hostDir string
client *client.Client
inside bool
defImage string
socket string
network string
publicIP string
db database.Querier
logger *logrus.Logger
dataDir string
hostDir string
client *client.Client
inside bool
defImage string
socket string
sandboxSocket string // proxy address for sandbox containers; tcp:// URL or unix socket path; empty = use raw socket
network string
publicIP string
}

type DockerClient interface {
Expand Down Expand Up @@ -111,7 +112,27 @@ func NewDockerClient(ctx context.Context, db database.Querier, cfg *config.Confi
socket = getHostDockerSocket(ctx, cli)
}
inside := cfg.DockerInside
if inside {
logrus.Warn("DOCKER_INSIDE=true: the host Docker socket will be bind-mounted into " +
"every sandbox container. Any process inside the sandbox can use the socket " +
"to ask the host daemon to launch a privileged container, achieving a full " +
"host escape (issue #337). Set DOCKER_INSIDE=false unless DinD is required, " +
"or set DOCKER_SANDBOX_SOCKET to a least-privilege proxy (e.g. tcp://docker-socket-proxy:2375) " +
"to reduce API surface. Note: tecnativa proxy does not inspect request bodies " +
"and cannot block Privileged containers; see issue #337 for full mitigation.")
}
sandboxSocket := cfg.DockerSandboxSocket
if inside && sandboxSocket == "" {
// Fall back to the raw socket if no proxy is configured.
sandboxSocket = socket
}
netName := cfg.DockerNetwork
if inside && strings.HasPrefix(sandboxSocket, "tcp://") && netName == "" {
logrus.Warn("DOCKER_SANDBOX_SOCKET is a tcp:// proxy address but DOCKER_NETWORK is empty: " +
"sandbox containers will run on the default bridge and cannot resolve the proxy hostname. " +
"Set DOCKER_NETWORK to a network shared with the docker-socket-proxy service " +
"(e.g. DOCKER_NETWORK=pentagi-network) — without this, all in-sandbox docker commands will fail.")
}
publicIP := cfg.DockerPublicIP
defImage := strings.ToLower(cfg.DockerDefaultImage)
if defImage == "" {
Expand Down Expand Up @@ -142,28 +163,30 @@ func NewDockerClient(ctx context.Context, db database.Querier, cfg *config.Confi

logger := logrus.StandardLogger()
logger.WithFields(logrus.Fields{
"docker_name": info.Name,
"docker_arch": info.Architecture,
"docker_version": info.ServerVersion,
"client_version": cli.ClientVersion(),
"data_dir": dataDir,
"host_dir": hostDir,
"docker_inside": inside,
"docker_socket": socket,
"public_ip": publicIP,
"docker_name": info.Name,
"docker_arch": info.Architecture,
"docker_version": info.ServerVersion,
"client_version": cli.ClientVersion(),
"data_dir": dataDir,
"host_dir": hostDir,
"docker_inside": inside,
"docker_socket": socket,
"docker_sandbox_socket": sandboxSocket,
"public_ip": publicIP,
}).Debug("Docker client initialized")

return &dockerClient{
db: db,
client: cli,
dataDir: dataDir,
hostDir: hostDir,
logger: logger,
inside: inside,
defImage: defImage,
socket: socket,
network: netName,
publicIP: publicIP,
db: db,
client: cli,
dataDir: dataDir,
hostDir: hostDir,
logger: logger,
inside: inside,
defImage: defImage,
socket: socket,
sandboxSocket: sandboxSocket,
network: netName,
publicIP: publicIP,
}, nil
}

Expand Down Expand Up @@ -280,7 +303,17 @@ func (dc *dockerClient) RunContainer(
hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:%s", hostDir, WorkFolderPathInContainer))

if dc.inside {
hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:%s", dc.socket, defaultDockerSocketPath))
if strings.HasPrefix(dc.sandboxSocket, "tcp://") {
// Proxy speaks TCP — inject DOCKER_HOST so the Docker CLI inside the
// sandbox container routes to the proxy instead of the host socket.
// The proxy container must be on the same Docker network as the sandbox.
config.Env = append(config.Env, "DOCKER_HOST="+dc.sandboxSocket)
} else {
// Unix socket (raw host socket or a socket-based proxy).
// Bind-mount it at the standard location so DOCKER_HOST remains unset
// and the Docker CLI uses unix:///var/run/docker.sock by default.
hostConfig.Binds = append(hostConfig.Binds, fmt.Sprintf("%s:%s", dc.sandboxSocket, defaultDockerSocketPath))
}
}

hostConfig.LogConfig = container.LogConfig{
Expand Down
67 changes: 67 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,69 @@ networks:
name: langfuse-network

services:
# docker-socket-proxy fronts /var/run/docker.sock with an API allowlist
# so sandbox containers have a more restricted view of the Docker daemon.
#
# IMPORTANT — partial mitigation only (issue #337):
# tecnativa/docker-socket-proxy filters by URL/method, NOT by request body.
# With CONTAINERS=1, agents can still POST /containers/create with
# Privileged=true or a host bind-mount in the body. This proxy reduces
# the attack surface (removes EXEC, SECRETS, SWARM, SYSTEM, etc.) but does
# NOT fully prevent a prompt-injected agent from escaping the sandbox.
# The safest option remains DOCKER_INSIDE=false.
#
# The proxy listens on TCP port 2375. Sandbox containers reach it via
# DOCKER_HOST=tcp://docker-socket-proxy:2375 (network reachability required).
# No socket sharing is needed.
#
# Usage: docker compose --profile dind up
# DOCKER_INSIDE=true
# DOCKER_SANDBOX_SOCKET=tcp://docker-socket-proxy:2375
# DOCKER_NETWORK=pentagi-network # REQUIRED: sandboxes must share this network
# # to resolve the proxy hostname
docker-socket-proxy:
image: ${DOCKER_PROXY_IMAGE:-tecnativa/docker-socket-proxy:latest}
restart: unless-stopped
container_name: docker-socket-proxy
hostname: docker-socket-proxy
# No privileged needed — proxy only reads the host socket read-only.
read_only: true
cap_drop: [ALL]
environment:
# Allow only what agents genuinely need for DinD.
# NOTE: CONTAINERS+POST still permits Privileged containers in request bodies;
# see the service comment above.
- CONTAINERS=1
- IMAGES=1
- NETWORKS=1
- VOLUMES=1
- INFO=1
- PING=1
- VERSION=1
- POST=1
# Explicitly block everything else:
- AUTH=0
- BUILD=0
- COMMIT=0
- CONFIGS=0
- DISTRIBUTION=0
- EVENTS=0
- EXEC=0
- GRPC=0
- PLUGINS=0
- SECRETS=0
- SERVICES=0
- SESSION=0
- SWARM=0
- SYSTEM=0
- TASKS=0
volumes:
- ${PENTAGI_DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
networks:
- pentagi-network
profiles:
- dind

pentagi:
image: ${PENTAGI_IMAGE:-vxcontrol/pentagi:latest}
restart: unless-stopped
Expand Down Expand Up @@ -165,6 +228,10 @@ services:
- DOCKER_INSIDE=${DOCKER_INSIDE:-false}
- DOCKER_NET_ADMIN=${DOCKER_NET_ADMIN:-false}
- DOCKER_SOCKET=${DOCKER_SOCKET:-}
# When DOCKER_INSIDE=true, optionally set this to tcp://docker-socket-proxy:2375
# (when using the dind profile) to reduce the Docker API surface exposed to
# sandbox containers. See service comment above for limitations.
- DOCKER_SANDBOX_SOCKET=${DOCKER_SANDBOX_SOCKET:-}
- DOCKER_NETWORK=${DOCKER_NETWORK:-}
- DOCKER_PUBLIC_IP=${DOCKER_PUBLIC_IP:-}
- DOCKER_WORK_DIR=${DOCKER_WORK_DIR:-}
Expand Down