Skip to content

feat(egress): egress-jail enabling machinery — sidecar image + allowlist filter (PR 1/3)#154

Merged
jraicr merged 7 commits into
devfrom
feat/phase2-egress-1
Jun 5, 2026
Merged

feat(egress): egress-jail enabling machinery — sidecar image + allowlist filter (PR 1/3)#154
jraicr merged 7 commits into
devfrom
feat/phase2-egress-1

Conversation

@jraicr

@jraicr jraicr commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

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 an internal: true network and egresses only via HTTPS_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 run keeps working after this merges — no broken intermediate on dev.

What this PR adds

Area Change
Dockerfile.egress Minimal pre-built tinyproxy sidecar image (debian:12-slim, non-root, zero added caps). Pre-built because the PR 2/3 internal:true network SERVFAILs external DNS at runtime, so apt cannot run there (R4.5).
templates/tinyproxy.conf Deny-by-default proxy config: FilterDefaultDeny Yes, FilterType ere, ConnectPort 443 only, writable PidFile (/tmp), no LogFile.
templates/egress-baseline.conf Provisional shipped baseline allowlist (api.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. Exports DRYDOCK_EGRESS_FILTER_FILE + DRYDOCK_SIDECAR_NAME.
lib/commands.sh cmd_build builds the global drydock-egress image (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)

  • Shipped baseline (in-repo, read-only) — always included, cannot be weakened by user files.
  • ~/.config/drydock/egress-allowlist (global) + ~/.config/drydock/egress-allowlist-<project> (per-project) — user additions only (one domain per line, # comments).

Invariants

  • INV-8 touched-but-preserved: zero new Linux capabilities. Dockerfile.egress adds no caps; the agent's cap_drop: ALL + minimal cap_add set are unchanged; the sidecar (PR 2/3) will run cap_drop: ALL.
  • dood mode unaffected: no filter generation; DRYDOCK_EGRESS_FILTER_FILE and DRYDOCK_SIDECAR_NAME are unset on the dood path. docker-compose.contain.yml is byte-for-byte unchanged in this PR.

Tests (strict TDD)

  • 1110/1110 green via scripts/test.sh; shellcheck + shfmt -d clean.
  • 11 new tests (filter generation R9.14–R9.18 + pipefail robustness + 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), export DRYDOCK_SIDECAR_NAME (unblocks PR 2/3's ${…:?} guard), and unconditional cmd_build per design §6a.

Size

425 lines changed — 228 are tests, 197 are code (Dockerfile / templates / lib). Labeled size:l.

Next

  • PR 2/3 — topology flip: internal: true agent network + dual-homed sidecar + HTTPS_PROXY wiring + render tests (consumes this PR's machinery; dev stays runnable after the merge).
  • PR 3/3 — doctrine + docs flip (INV-9 rewrite, docs/security.md, README, architecture, troubleshooting, ROADMAP → Done, CHANGELOG).
  • Release gates (v0.4.0): empirical baseline capture + a runtime double-gate smoke (reach an allowlisted domain through the proxy and block a non-allowlisted one) on bare Linux.

Part of #149 — the umbrella issue stays open until Phase 2 plus the v0.4.0 release.

jraicr added 7 commits June 5, 2026 11:36
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.
@jraicr jraicr added type:feat Feature work size:l Large: 400+ lines labels Jun 5, 2026
@jraicr jraicr merged commit a4b0e59 into dev Jun 5, 2026
4 checks passed
@jraicr jraicr deleted the feat/phase2-egress-1 branch June 5, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:l Large: 400+ lines type:feat Feature work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant