Skip to content

feat(run): --egress-lock pins model traffic to the --api endpoint (internal network + logging squid sidecar) - #101

Open
sbp-bvanb wants to merge 2 commits into
feat/api-endpoint-v2from
feat/egress-allowlist-proxy
Open

sbp-bvanb wants to merge 2 commits into
feat/api-endpoint-v2from
feat/egress-allowlist-proxy

Conversation

@sbp-bvanb

@sbp-bvanb sbp-bvanb commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #75

What

--egress-lock (with --api) locks Claude Code's model traffic to the ANTHROPIC_BASE_URL gateway and logs every connection the session makes. Everything else (git, npm, PyPI, the web) stays reachable, and there is no allowlist to maintain. Plain --api sessions and sessions without --api are unchanged: no sidecar, no network change, no log. --egress-lock without --api refuses to start.

This matches the requirement as clarified in review: only model traffic has to stay in the EU, only Claude Code is in scope, and the proof is network-control logs.

op run --env-file team-eu.env --no-masking -- claude-docker --api --egress-lock ~/repo
  • The network is the boundary.
    • The agent sits only on an --internal network. The only way out is a per-session squid sidecar (squid from the agent image, run as proxy with --cap-drop ALL; CapBnd stays 0xc5).
    • A proxy-unaware client or a raw IP has no route out, so the proxy's log is complete.
    • HTTPS goes through CONNECT, with no TLS interception.
  • The rule:
    • the ANTHROPIC_BASE_URL host is allowed;
    • *.anthropic.com, *.claude.ai and *.claude.com are refused;
    • everything else on 80/443 is allowed;
    • metadata, link-local and loopback are always denied, as are ports other than 80/443.
  • Fail fast. run.sh aborts before any container resource exists when ANTHROPIC_BASE_URL is unset, points at a provider host, or isn't a valid hostname/IPv4. The host goes into the squid config, so this is also the injection check.
  • Evidence. The EXIT trap saves the proxy's access log plus a .meta file (start/end, user, host, workspaces, image and image ID, endpoint) to $XDG_STATE_HOME/claude-docker/egress/, which is never mounted into a container. run.sh doesn't rotate or delete them. Building a report from them is left to the team that needs one.
  • --gh composition: squid resolves the three GitHub hosts to the gh sidecar on the internal network.

Changes after the 2026-09-27 review

  1. Own opt-in. The lock moved from --api to --egress-lock, which requires --api.
  2. No --report. egress_report.py and the --report flag are gone, and with them the host python3 dependency.
  3. Stacked on feat: add --api opt-in for custom model endpoints #104 only. The --az base and every --az-only hunk are gone.

The three earlier commits (deny-all allowlist, its archive, the model-only rework) are squashed into one. The first design was deleted by the third, so replaying them only meant resolving conflicts in dead code.

OpenSpec: 2026-09-30-egress-lock-opt-in (on top of 2026-09-26-api-egress-policy and 2026-09-27-api-egress-model-lock). The api-egress-policy Purpose line still mentions --api / --report, because archiving doesn't touch Purpose and specs aren't hand-edited.

Stack

Based on #104 (feat/api-endpoint-v2), next to #105. It is no longer in a GitHub stack; #104 ← #105 is now stack #118.

Tests

  • tests/test_egress_policy.py (stdlib, no docker): --egress-lock without --api, --api alone skips the lock, no endpoint, provider endpoints, newline/space injection, valid gateways. The full unit suite passes (122).
  • smoke/egress.sh (CI) drives run.sh --api --egress-lock. It checks the endpoint aborts, reachable/refused hosts, no proxy-unaware route or external DNS, metadata/loopback/port denies, the saved log + meta, and teardown. There's also a --gh cell.
  • openspec validate --strict passes. There's no docker, shellcheck or hadolint here, so CI verifies the build, lint and smoke.

🤖 Generated with Claude Code

@sbp-bvanb
sbp-bvanb force-pushed the feat/egress-allowlist-proxy branch from 322113e to 6562aec Compare September 25, 2026 21:18
@sbp-bvanb
sbp-bvanb force-pushed the feat/egress-allowlist-proxy branch from 6562aec to 3789468 Compare September 25, 2026 21:48
@sbp-bvanb
sbp-bvanb changed the base branch from main to refactor/smoke-drives-run-sh September 25, 2026 21:48
@sbp-bvanb
sbp-bvanb added this pull request to stack #102 September 25, 2026 21:48
@sbp-bvanb
sbp-bvanb force-pushed the feat/egress-allowlist-proxy branch from 3789468 to d4439d8 Compare September 26, 2026 21:56
@sbp-bvanb sbp-bvanb changed the title feat(run): add --egress-allowlist for default-deny egress via an internal network and squid sidecar feat(run): --api blocks all egress; open hosts via a host-side egress-policy.yaml (internal network + squid sidecar) Sep 26, 2026
@sbp-bvanb
sbp-bvanb removed this pull request from stack #102 September 26, 2026 21:59
@sbp-bvanb
sbp-bvanb changed the base branch from refactor/smoke-drives-run-sh to feat/azure-devops-v2 September 26, 2026 21:59
@sbp-bvanb
sbp-bvanb added this pull request to stack #106 September 26, 2026 21:59
@stefanwb

Copy link
Copy Markdown
Contributor

Holding this until the requirements behind issue #75 are clear; discussing with Ben first.

@sbp-bvanb

Copy link
Copy Markdown
Collaborator Author

@stefanwb thanks for the questions. Answers:

  1. Which traffic: only model traffic, i.e. prompts to the LLM.
  2. In-EU or non-US: the same answer as 1. Git, PyPI, npm and so on are out of scope, wherever their servers are.
  3. Scope: only Claude Code, so a network-level proxy for all tooling would be more than we need.
  4. Proof: logs from a network control, plus a PDF report built from them.

The image isn't in scope either. Hash-pinned packages are identical whichever mirror served them. The requirement is about what the running container sends to a model.

That makes the deny-all allowlist too strict. It would cost every user the configuration effort you warned about in #75, and a tool the team doesn't adopt proves nothing to the customer. So I've re-scoped #101:

  • Kept: the --internal network + squid sidecar. It is the only way out, so its log is a complete record of the session's connections, which is what makes it evidence.
  • Dropped: CLAUDE_DOCKER_EGRESS_POLICY, the YAML parser and the private-range deny. That's a net deletion in run.sh.
  • The rule now:
    • The ANTHROPIC_BASE_URL host is allowed.
    • *.anthropic.com, *.claude.ai and *.claude.com are refused.
    • Every other host on 80/443 stays open.
    • Metadata, loopback and the port rules are unchanged.
    • --api refuses to start without ANTHROPIC_BASE_URL, or with one that points at a provider host.
    • CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 is set in the container.
  • Evidence: at session end the proxy log and a .meta file are saved under ~/.local/state/claude-docker/egress/. That directory is never mounted into a container.
  • Report: claude-docker --report builds a PDF from the saved logs with a stdlib-only script. Per session it lists the meta, the log's sha256, all model traffic, and the rest as out-of-scope traffic. It exits 1 if a provider request got through.

For a colleague it's one env file and one command: op run --env-file team-eu.env -- claude-docker --api ~/repo.

Pushed in e162ec7. Does this fit how you see the project?

🤖 Generated with Claude Code

@stefanwb

Copy link
Copy Markdown
Contributor

Thanks Ben, that answers it, and the narrower scope fits much better: no allowlist to maintain, and the proxy log is the evidence. Three things before I'd take it in:

  1. Its own opt-in, not --api. Most gateway users don't need an egress lock, and with this they'd all get the sidecar, the internal network, and a log of every connection kept on their host with no rotation. Shall we put it behind its own switch (a flag or env var) that composes with --api?
  2. Keep --report out of claude-docker. The saved logs are the evidence. Turning them into a PDF is reporting for one audit, and it adds a PDF writer and a host python3 dependency here. Could the report live with the team that needs it?
  3. Stack it on PR feat: add --api opt-in for custom model endpoints #104 only. It doesn't need --az, and restacking lets the two land independently.

Since it's been reshaped a few times, it might help to agree on these three before more code. What do you think?

sbp-bvanb and others added 2 commits September 30, 2026 18:14
Rebased onto #104 (feat/api-endpoint-v2) without the --az base. This is
the net result of the earlier d99475a, d4439d8 and e162ec7: the deny-all
allowlist from the first commit was dropped by the third, so replaying
them one by one would only have resolved conflicts in deleted code. The
--az-only hunks (REQUESTS_CA_BUNDLE handling and docs) are gone, because
#104 has no --az.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #101: most gateway users don't need the sidecar, the internal
network and an unrotated log of every connection, so the lock gets its
own opt-in that requires --api. Plain --api is back to #104's behaviour.
--report and egress_report.py go: the saved .log/.meta files are the
evidence, and a PDF for one audit (plus a host python3 dependency)
belongs with the team that needs it.

OpenSpec: egress-lock-opt-in, archived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sbp-bvanb
sbp-bvanb force-pushed the feat/egress-allowlist-proxy branch from e162ec7 to e638ed0 Compare September 30, 2026 18:18
@sbp-bvanb
sbp-bvanb removed this pull request from stack #106 September 30, 2026 18:18
@sbp-bvanb
sbp-bvanb changed the base branch from feat/azure-devops-v2 to feat/api-endpoint-v2 September 30, 2026 18:18
@sbp-bvanb sbp-bvanb changed the title feat(run): --api blocks all egress; open hosts via a host-side egress-policy.yaml (internal network + squid sidecar) feat(run): --egress-lock pins model traffic to the --api endpoint (internal network + logging squid sidecar) Sep 30, 2026
@sbp-bvanb

Copy link
Copy Markdown
Collaborator Author

Thanks @stefanwb. I agree with all three, and they're in e638ed0 (with 4b31ca5 as the rebased base):

  1. Its own opt-in. It's now --egress-lock, which requires --api and exits 1 without it. Plain --api is back to exactly feat: add --api opt-in for custom model endpoints #104: no sidecar, no internal network, no log, and CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC isn't set. The endpoint checks (ANTHROPIC_BASE_URL required, no provider host) only apply under --egress-lock. On rotation: since it's opt-in, run.sh deliberately doesn't rotate or prune the saved logs, because they're the evidence. The README says so.
  2. --report is out. egress_report.py, the flag, its tests and the smoke step are gone, so there's no host python3 dependency. The .log (squid's default format) and .meta files are unchanged, so the team that needs a PDF can build it from them.
  3. Stacked on feat: add --api opt-in for custom model endpoints #104 only. The base is feat/api-endpoint-v2, and every --az-only hunk is gone. The stale --az base had also been reverting feat: add --az opt-in for Azure DevOps #105's CLAUDE_DOCKER_AZ_CA change. This PR is no longer in a GitHub stack; feat: add --api opt-in for custom model endpoints #104 ← feat: add --az opt-in for Azure DevOps #105 is stack #118.

The three earlier commits are squashed into 4b31ca5, because the first design (deny-all allowlist) was deleted by the third. The OpenSpec change is 2026-09-30-egress-lock-opt-in. Unit tests pass (122). Build, lint and smoke are for CI to confirm.

🤖 Generated with Claude Code

@sbp-bvanb
sbp-bvanb requested a review from stefanwb September 30, 2026 18:19
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.

--api: block all network egress, open hosts only via a host-side egress-policy.yaml (internal network + forward-proxy sidecar)

2 participants