DEV Community

Cover image for Docker Is Not a Filesystem Policy: Evaluating sandbox-runtime for Local Isolation
Hannah Schmidt
Hannah Schmidt

Posted on

Docker Is Not a Filesystem Policy: Evaluating sandbox-runtime for Local Isolation

Docker Is Not a Filesystem Policy: Evaluating sandbox-runtime for Local Isolation

At 3:00 AM, your incident channel lights up: a maintenance script you inherited from a contractor just uploaded your AWS credentials to a public S3 bucket. The script ran from your terminal with your full filesystem access. It was supposed to sync logs. Instead, it read ~/.aws/credentials and phoned home.

A local process inherits everything its developer can read. Dependency scripts, build tools, and third-party executables run with ambient authority—access far beyond what their task requires. Containers can narrow that access, but a convenient bind mount often gives it back. Mounting the home directory exposes credentials. Mounting the Docker socket exposes host control. Running as root turns a writable mount or kernel vulnerability into a full compromise.

The architectural problem is ambient authority: processes receive more filesystem access than their task requires.

Two isolation mechanisms, different responsibilities

I am evaluating anthropics/sandbox-runtime as an external architect. Its stated purpose is to enforce filesystem and network restrictions on arbitrary processes using operating-system mechanisms, without requiring a container.

That changes the deployment boundary. Docker packages an application, supplies its filesystem, and manages its lifecycle. sandbox-runtime constrains a process running against an existing host environment. Neither responsibility automatically replaces the other.

For local commands that need an installed toolchain, process-level restrictions can avoid maintaining another container image. For a service requiring a reproducible runtime, Compose remains useful. In both cases, the policy must describe which paths are readable, which are writable, and which network destinations are reachable.

These are alternative execution paths, not interchangeable configurations. The implementation below establishes the container baseline; it does not install or configure sandbox-runtime.

A baseline with no host filesystem mounts

This example deliberately exposes no host directory and publishes no port. Its HTTP service is reachable through its container network namespace, and its health probe runs inside that namespace.

Run the following in an empty directory with Docker Compose v2 and BuildKit available:

cat > go.mod <<'EOF'
module example.local/isolated-service

go 1.26
EOF

cat > main.go <<'EOF'
package main

import (
    "log"
    "net/http"
    "os"
    "time"
)

func main() {
    if len(os.Args) == 2 && os.Args[1] == "-healthcheck" {
        client := http.Client{Timeout: 2 * time.Second}
        response, err := client.Get("http://127.0.0.1:8080/healthz")
        if err != nil {
            os.Exit(1)
        }
        defer response.Body.Close()
        if response.StatusCode != http.StatusOK {
            os.Exit(1)
        }
        return
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
    server := &http.Server{
        Addr:              ":8080",
        Handler:           mux,
        ReadHeaderTimeout: 3 * time.Second,
        ReadTimeout:       5 * time.Second,
        WriteTimeout:      5 * time.Second,
        IdleTimeout:       30 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}
EOF

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY go.mod main.go ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /server .

FROM scratch
COPY --from=build /server /server
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
EOF

cat > docker-compose.yml <<'EOF'
services:
  api:
    build:
      context: .
    user: "65532:65532"
    read_only: true
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    pids_limit: 64
    mem_limit: 128m
    cpus: 0.50
    tmpfs:
      - /tmp:rw,noexec,nosuid,nodev,size=16m,mode=1777
    healthcheck:
      test: ["CMD", "/server", "-healthcheck"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 5s
    restart: unless-stopped
    stop_grace_period: 10s
    networks:
      - isolated

networks:
  isolated:
    driver: bridge
    internal: true
EOF

docker compose up --build -d --wait
docker compose exec -T api /server -healthcheck
Enter fullscreen mode Exit fullscreen mode

The final image contains only the static executable. It runs as a numeric, non-root identity, with no shell, package manager, or writable root filesystem. BuildKit retains compilation caches outside the final image. For release builds, resolve the builder image to a reviewed digest rather than relying indefinitely on a mutable tag.

Where tutorials become incidents

The important security property is not the small image. It is the absence of host mounts and privileged interfaces.

A root process inside an ordinary container does not automatically control the host. However, root combined with a writable bind mount can overwrite host files accessible through that mount—including system binaries if you mounted /usr/local. A mounted Docker socket (/var/run/docker.sock) is a privileged interface that grants container orchestration control; changing the application's UID does not make exposing it safe. An attacker with socket access can launch a privileged container and escape to the host.

The internal bridge removes ordinary external connectivity for this topology. It is not a hostname allowlist, and it does not isolate future services attached to that same network. Adding another service changes the trust boundary.

Likewise, Compose healthchecks report health; they do not make Docker restart an unhealthy process. Here, the restart policy handles process exits. An external supervisor must decide what persistent unhealthiness means operationally.

What I would verify before adopting sandbox-runtime

For the host-native execution path, I would first validate the repository's current platform requirements and policy semantics. Container restrictions do not establish that a separate process sandbox has equivalent coverage.

Acceptance tests should attempt to read a disposable credential fixture outside the allowed tree, write outside the designated workspace, and reach both permitted and forbidden destinations. Child processes must remain constrained. Network tests should include direct IP connections, redirects, and unavailable policy infrastructure. A failed policy setup must fail closed.

That approach preserves the useful distinction: Compose provides a reproducible service boundary; sandbox-runtime offers a way to constrain host-native processes without packaging them first. The decision turns on required host access, not on which mechanism has the more reassuring name.

The hard question

The real tension is between host-native toolchains and reproducible boundaries. Developers need debuggers, language runtimes, and editor integrations that expect direct filesystem access. Containers provide isolation but add friction. Sandboxes promise the best of both—but only if the policy is correct and enforceable.

Here's the dilemma: a restrictive policy breaks legitimate workflows, but a permissive policy fails to contain threats. Where do you draw that line in your stack? Do you containerize everything and accept the tooling tax, or do you run natively and bet on process-level sandboxing? What broke last time you chose wrong?

I'm curious how others are solving this. Drop your architecture and your scars in the comments.

Top comments (0)