Skip to content

feat(sandbox): attach Docker Compose networks and fail closed on sandbox config errors - #334

Merged
jkyberneees merged 22 commits into
mainfrom
feat/sandbox-networks
Oct 25, 2026
Merged

jkyberneees merged 22 commits into
mainfrom
feat/sandbox-networks

Conversation

@jkyberneees

Copy link
Copy Markdown
Contributor

Summary

Lets the Docker sandbox reach services started by Docker Compose (Redis, Postgres, Mongo, …) by name, and makes every sandbox decision fail closed and visible.

docker compose up -d
odek run --sandbox --sandbox-compose myapp "run the integration tests against redis and postgres"

What changes

Attaching existing Docker networks

  • sandbox_networks (ODEK_SANDBOX_NETWORKS, --sandbox-networks a,b) attaches existing user-defined networks.
  • sandbox_compose_project (ODEK_SANDBOX_COMPOSE_PROJECT, --sandbox-compose <project>) attaches every network carrying the project's com.docker.compose.project label. It never guesses <project>_default.
  • What gets attached: only local bridge-driver networks, at most 8. A network is accepted only when docker network inspect returns the requested name, and it is passed to Docker by full ID. Reserved modes, container:/ns: forms and ID-prefix lookups are refused. No alias is added for the sandbox.
  • How: the first network goes to docker run, the rest are connected after start, and the attachment is verified by inspect. Any failure removes the container. Networks are never deleted.
  • Warnings: printed when a network is not Internal, or when bridge is combined with attached networks.
  • Project config: a ./odek.json asking for networks goes through the existing sandbox-override approval. The prompt shows each network's name, full ID, Internal flag and Compose project. The approval key binds those IDs and is byte-identical to today's key for configs without networks. At container creation, the networks are checked again against the approved IDs: a network that joined the project, or was recreated, after the prompt is refused.
  • Sub-agents only narrow: a child attaches a subset of its parent's resolved networks and never one its Compose project gained later.

What the agent is told

  • An execution-environment block is appended to the system prompt on run, continue, repl, serve and sandboxed sub-agents, and to the shell tool description everywhere. It lists:
    • the container and /workspace;
    • the network the container actually has (none, bridge, attached networks only, bridge plus attached networks) and whether the internet is reachable;
    • the attached networks and the names other containers answer to there (Compose service aliases such as redis, postgres);
    • that localhost is the container itself.
  • Network and service names can be chosen by a Compose file, so they are charset-checked, capped, and listed inside a nonce'd untrusted-content wrapper.
  • The startup line and batch approval cards report the same network.

Fail-closed sandbox setup

  • Configuration errors no longer fall back to the host. Before, a sandbox the user didn't explicitly request fell back to unsandboxed host execution on any setup error. Now any of these stops the run, whether or not --sandbox was passed:

    • an invalid sandbox_network;
    • an unresolvable network;
    • an image, limit or user value Docker rejects, or any other docker run failure while the daemon answers.

    Only an unreachable Docker daemon keeps the existing loud fallback (ODEK_REQUIRE_SANDBOX=1 still makes that fatal).

  • sandbox_network accepts only none and bridge. host, container:… or a network name used to be silently rewritten to none; now it's an error. ODEK_SANDBOX_NETWORK_LENIENT=1 restores the downgrade.

Container defaults

  • New defaults: --pids-limit 4096, configurable as sandbox_pids_limit / ODEK_SANDBOX_PIDS_LIMIT / --sandbox-pids-limit (a project may only lower it), plus --init, and nosuid on the /tmp tmpfs.
  • Pinned fallback image: alpine:3.22@sha256:2e0b81d6…b55f instead of the floating alpine:latest.
    • It's read from an embedded internal/sandbox/default.Dockerfile, which Dependabot (docker ecosystem, /internal/sandbox only) keeps current.
    • tests/supplychain asserts the pin.
    • The multi-arch index digest was looked up with docker buildx imagetools inspect alpine:3.22.
  • Settings visible: the startup line (odek: sandbox network=… pids=… image=… networks=…) and config_view (CLI tool and serve /api/config) show the settings actually applied.

Docs

  • SANDBOXING.md:
    • new network-modes table;
    • attached networks, approval and re-check;
    • what the agent is told;
    • corrected the claim that bridge is isolated from host services: it reaches the Docker gateway, host-bound services and cloud metadata.
  • CLI.md, CONFIG.md, SECURITY.md, DEVELOPMENT.md: new flags, env vars and config keys; the fail-closed rules.
  • AGENTS.md: running the Docker-gated tests on Docker Desktop.

Behaviour changes to know about

  • A sandbox_network value other than none/bridge now stops the run. Set ODEK_SANDBOX_NETWORK_LENIENT=1 for the old downgrade.
  • An invalid image reference, a failed Dockerfile.odek build, or a memory, CPU or user value Docker rejects now stops implicit (default-on) runs instead of falling back to the host. An unreachable daemon or an image that cannot be pulled still falls back loudly.
  • Sandboxed containers get a process limit of 4096 by default.
  • With no image configured and no Dockerfile.odek, the sandbox runs the pinned alpine:3.22 digest instead of alpine:latest.

Testing

  • Unit and regression tests:

    • Docker-dependent paths are covered through an injected docker runner.
    • New TestRED_* regression tests cover service-name aliases, the network label, the approval/setup ID re-check, exact-name resolution, start-failure classification, untrusted wrapping of names, the default-image parse and the sub-agent environment block.
    • security_report_validation_test.go gains the sandbox claims.
  • Real Docker (Docker Desktop 29.6.2, scratch HOME, scripted mock model). A Compose project ran Redis on the normal network plus an internal: true backend network, and Postgres on the backend only.

    • --sandbox-compose:
      • redis and postgres resolve by service name, and Redis answers PONG;
      • the agent sees Network: attached networks only, Internet: reachable and postgres, redis;
      • pids.max is 4096, PID 1 is docker-init, and /tmp is nosuid,noexec;
      • the pinned digest is pulled and used.
    • --sandbox-networks <internal net>: services reachable, internet blocked. --sandbox-pids-limit 64 gives 64.
    • Refused with exit 1 and no host execution:
      • a missing network (with and without --sandbox);
      • --sandbox-network host;
      • bridge or container:x as a network name;
      • a bridge ID prefix (ca064c);
      • a Compose project that isn't running;
      • Docker unreachable while networks are configured;
      • --sandbox-memory lots in implicit mode.
    • An image that cannot be pulled (alpine:9.99-nonexistent) in implicit mode keeps the loud fallback.
    • Lenient mode downgrades with a warning.
    • A project odek.json requesting networks is refused without approval and attached once approved. The interactive prompt lists name, ID, internal flag and Compose project.
  • Commands:

    • go test -count=1 for ./internal/sandbox ./internal/config ./internal/loop ./tests/supplychain ./cmd/odek: pass, with the Docker daemon both reachable and unreachable.
    • go test -race on the same packages: pass.
    • DOCKER_HOST=… ODEK_E2E=true go test ./cmd/odek -run 'TestSandbox|TestE2E_Sandbox': pass, nothing skipped.
    • golangci-lint run on the changed packages: 0 issues. govulncheck ./...: no vulnerabilities. gofmt -l: clean.
  • Coverage: internal/sandbox 93.0% → 93.8%, internal/config 87.0% → 87.2%. Changed executable lines in cmd/odek: 84.7% (459/542).

  • Review: an adversarial full-branch review (Opus) found three major issues, all fixed here:

    • setup failures that still fell back to the host;
    • an ID-prefix lookup that reached the default bridge;
    • Compose-chosen names reaching the system prompt unwrapped.

    Also fixed: two of its minor findings (the duplicated image pin that would break Dependabot bumps, the missing sub-agent environment block) and the wording of a third (how sub-agents narrow networks). A follow-up Sonnet pass on the fix commits found that an image that cannot be pulled had become fatal in implicit mode (now pulled separately and kept on the loud fallback) and that short service names that look like hex prefixes were dropped; both are fixed.

🤖 Generated with Claude Code

jkyberneees and others added 22 commits October 25, 2026 08:56
The execution-environment block listed generated container names
(app-redis-1) instead of the names other containers answer to on the
network. Read each attached container's per-network aliases with one
docker inspect, drop its own name and short ID, and fall back to the
container name when inspect fails or no alias exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… none

With sandbox_networks set, the environment block, the startup line and
the batch-card note still said the network was "none" although the
attached networks replace it. Report "attached networks only" or
"bridge plus attached networks" (startup line: attached /
bridge+attached), and tell the model whether the internet is reachable:
bridge or any non-internal attached network reaches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The approval prompt and container setup each resolved the project's
networks, so a network that joined the Compose project (or was recreated
under an approved name) between the two was attached without being
shown. Record the approved IDs per project directory at every approval
path and refuse, at setup, any project-contributed network whose ID was
not approved. The approval key now hashes network IDs in sorted order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and the pinned image

docs/CLI.md gains --sandbox-networks, --sandbox-compose and
--sandbox-pids-limit, the fail-closed --sandbox-network values and a
Compose example. SANDBOXING.md shows the execution-environment block the
model receives. Help text and comments no longer name alpine:latest as
the default image. The weak network-mode test is replaced by the exact
table test added with the label fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er Desktop

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d name

docker network inspect resolves its argument by full ID, then name, then
ID prefix, so a requested name such as "ca064c" (or a Compose network
named after bridge's ID) came back as the predefined bridge network and
was attached under the requested name, bypassing the reserved-mode
refusal. Require the inspected network's own name to equal the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rs when Docker answers

In implicit mode an invalid image, memory, cpus or user value, or any
docker run failure, still fell back to unsandboxed host execution
because those errors were not marked as configuration errors. When the
Docker daemon answers, such a failure comes from the configuration, so
it is now fatal; only an unreachable daemon keeps the loud fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sted data

Network names and Compose service aliases can be chosen by the
project's compose file, and names that pass the charset check can still
read as instructions. They were appended to the system prompt after the
security rules as plain text; they now sit inside a nonce'd
untrusted-content wrapper, while the fixed facts (network, internet,
localhost) stay plain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DefaultImage duplicated the FROM line of default.Dockerfile, so every
Dependabot bump of that file would have failed the consistency test until
someone edited the constant by hand. DefaultImage is now parsed from the
embedded file, and an embedded file without a FROM line is an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sandboxed child only had the block in its shell tool description; its
system prompt never said where commands run or what is reachable. Append
the block for the child's own container after its sandbox starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Classifying every docker run failure as a configuration error also made
an uncached image that cannot be pulled (offline machine, registry
outage) fatal in implicit mode. Pull the image before docker run and
report a failed pull unmarked, so it keeps the loud fallback; a run
failure with the image present still comes from the configuration.
Also keep short service names that happen to be a hex prefix of the
container ID, and document that serve refuses new sandboxes after the
approved project networks change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ingest

The engine-label wrapper is reserved for engine-injected context. Use the
external-content wrapper (with the guard scan) and record no ingest, so
listing network and service names never taints the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
odek c7f83a4 Commit Preview URL

Branch Preview URL
Oct 25 2026, 12:22 PM

@jkyberneees
jkyberneees merged commit 19aeb4b into main Oct 25, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant