feat(egress): egress-jail enabling machinery — sidecar image + allowlist filter (PR 1/3)#154
Merged
Conversation
Pre-built tinyproxy sidecar image for contained-mode egress jail (Phase 2, slice 1). Three new files, no topology changes — contained drydock run continues to work unchanged (filter file generated but unused until slice 2). - Dockerfile.egress: debian:12-slim base, tinyproxy installed, non-root user, placeholder filter at /etc/tinyproxy/filter (fail-closed if mount absent). - templates/tinyproxy.conf: Port 8888, FilterDefaultDeny Yes, FilterType ere, ConnectPort 443 only, DisableViaHeader Yes, PidFile /tmp/tinyproxy.pid (A5 — read_only sidecar compatibility), no LogFile directive (probe gotcha). - templates/egress-baseline.conf: PROVISIONAL baseline (api.anthropic.com CONFIRMED, raw.githubusercontent.com SUSPECTED, claude.ai/platform.claude.com COMMENTED — token-auth default). Header marks it for empirical capture (G1). INV-8 note: sidecar runs as non-root tinyproxy user, zero Linux caps added.
…ring
Adds constants (EGRESS_BASELINE, EGRESS_IMAGE) and _generate_egress_filter()
to lib/compose.sh, wired into export_compose_env for contained mode only.
- _generate_egress_filter <proj> <disc>: concatenates baseline + user global
(~/.config/drydock/egress-allowlist) + per-project allowlist; strips comments
and blanks; order-preserving dedup via awk 'NF && !seen[$0]++'; atomic write
via mktemp+mv; exports DRYDOCK_EGRESS_FILTER_FILE. Robust under set -euo
pipefail: { [...] } || true guards each conditional cat; awk exits 0 on
empty input (no grep-empty pipefail trap).
- export_compose_env: calls _generate_egress_filter in contained mode; unsets
DRYDOCK_EGRESS_FILTER_FILE in dood mode (no stale var leak).
- Baseline is always prepended — user files add entries but cannot weaken it.
Tests (TDD, RED→GREEN): R9.14–R9.18 unit tests + pipefail robustness test.
All 1107 suite tests pass; shellcheck + shfmt clean.
Adds mode-gated sidecar image build to cmd_build. In contained mode, a second docker build runs after the main image build, targeting Dockerfile.egress and tagging drydock-egress:latest. In dood mode the build is skipped (no sidecar in dood). Mode is resolved via resolve_run_mode (same resolver as compose_files) so the decision is consistent with the active overlay selection. ensure_image is left unchanged (slice 1 invariant: contained run must keep working; the topology doesn't reference the egress image yet — that's slice 2). Tests (TDD, RED→GREEN): - cmd_build: contained mode — builds drydock-egress image (via DOCKER seam) - cmd_build: dood mode — does NOT build drydock-egress image All 1107 suite tests pass; shellcheck + shfmt clean.
…set executes The dood DRYDOCK_EGRESS_FILTER_FILE test previously passed vacuously: it unset the var before calling export_compose_env, so the assertion would pass even with the production unset line removed. Seeding a stale value makes the assertion contingent on the dood-branch unset actually running. Verified: remove the unset → RED; restore → GREEN.
A present-but-unreadable source file (e.g. chmod 000 on a user allowlist) was silently dropped by the || true pipeline, violating drydock's no-silent-break ethos (§4). Add a readability pre-check in _generate_egress_filter that calls err() (non-zero exit) for any source that -e but ! -r, before mktemp so no temp file leaks on abort. Absent files remain tolerated — the || true pipeline is unchanged. Test: bats guards against root (file perms bypassed), seeds an unreadable global allowlist, and uses run export_compose_env to capture the exit. RED→GREEN confirmed.
Design §5c specifies the contained branch exports DRYDOCK_SIDECAR_NAME
(drydock-<proj>-<disc>-egress) and the dood branch unsets it.
The slice-1 impl omitted this; slice-2's docker-compose.contain.yml
will reference ${DRYDOCK_SIDECAR_NAME:?...} and the :? guard aborts
docker compose config if the var is unset — a slice-2 blocker.
Derive the name as ${DRYDOCK_SESSION_NAME}-egress (both already
exported, structurally impossible to diverge from the agent name).
Mirror the existing DRYDOCK_EGRESS_FILTER_FILE unset in the dood
branch; test seeds a stale value to prove the unset executes.
RED→GREEN confirmed on both the contained and dood assertions.
drydock-egress:latest is a global image — building it only in contained mode was a category mismatch: a dood-pinned cwd at build time left contained-default projects with no sidecar image available. Remove the resolve_run_mode gate; build from Dockerfile.egress after the main image regardless of mode. Test: flip the dood assertion from != to == (same two tests, both modes now assert Dockerfile.egress appears in the docker call log), proving the build is mode-independent. RED→GREEN confirmed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #149 · v0.4.0 · dual-mode-containment Phase 2 (egress jail) · PR 1/3 (stacked-to-dev)
Phase 1 (#151/#152/#153, on
dev) shipped contained mode with open egress. Phase 2 closes that with a deny-by-default domain allowlist enforced by a userspace tinyproxy sidecar (Architecture A: the agent sits on aninternal: truenetwork and egresses only viaHTTPS_PROXY, with zero new Linux capabilities).This is PR 1/3 — the enabling machinery only. It deliberately does NOT change the contained network topology (that is PR 2/3), so contained
drydock runkeeps working after this merges — no broken intermediate ondev.What this PR adds
Dockerfile.egressinternal:truenetwork SERVFAILs external DNS at runtime, soaptcannot run there (R4.5).templates/tinyproxy.confFilterDefaultDeny Yes,FilterType ere,ConnectPort 443only, writable PidFile (/tmp), noLogFile.templates/egress-baseline.confapi.anthropic.com+ suspected set). Finalized by an empirical CONNECT capture before the v0.4.0 release.lib/compose.sh_generate_egress_filter: per-session effective allowlist = baseline + global + per-project user files; add-only (users extend, never weaken the baseline); atomic write (mktemp+mv); contained-only. ExportsDRYDOCK_EGRESS_FILTER_FILE+DRYDOCK_SIDECAR_NAME.lib/commands.shcmd_buildbuilds the globaldrydock-egressimage (unconditional, design §6a).The generated filter file and the image are present but unconsumed until PR 2/3 wires the topology.
Allowlist surface (user-facing)
~/.config/drydock/egress-allowlist(global) +~/.config/drydock/egress-allowlist-<project>(per-project) — user additions only (one domain per line,#comments).Invariants
Dockerfile.egressadds no caps; the agent'scap_drop: ALL+ minimal cap_add set are unchanged; the sidecar (PR 2/3) will runcap_drop: ALL.DRYDOCK_EGRESS_FILTER_FILEandDRYDOCK_SIDECAR_NAMEare unset on the dood path.docker-compose.contain.ymlis byte-for-byte unchanged in this PR.Tests (strict TDD)
scripts/test.sh;shellcheck+shfmt -dclean.cmd_build+ 3 review-fix tests), all written test-first (RED→GREEN). One ghost-pass (R9.18) was caught and de-ghosted via a seeded stale value.Review
Fresh adversarial review (empirical — real
docker build+ tinyproxy startup + mutation testing of the new tests) plus an independent spec/design verification: 0 blockers. Three review fixes were folded in before this PR: fail-loud on a present-but-unreadable allowlist source (no silent drop), exportDRYDOCK_SIDECAR_NAME(unblocks PR 2/3's${…:?}guard), and unconditionalcmd_buildper design §6a.Size
425 lines changed — 228 are tests, 197 are code (Dockerfile / templates / lib). Labeled
size:l.Next
internal: trueagent network + dual-homed sidecar +HTTPS_PROXYwiring + render tests (consumes this PR's machinery;devstays runnable after the merge).docs/security.md, README, architecture, troubleshooting, ROADMAP → Done, CHANGELOG).Part of #149 — the umbrella issue stays open until Phase 2 plus the v0.4.0 release.