The manifest key is `egress:` now; finish the rename so the rest of
the codebase matches. Files (Dockerfile.egress, claude_bottle/egress.py
etc.), classes (Egress, EgressConfig, EgressRoute, EgressPlan,
DockerEgress), constants (EGRESS_HOSTNAME, EGRESS_ROUTES, ...),
container name prefix (claude-bottle-egress-*), docker network alias
(egress), the introspection host (_egress.local), the MCP tool IDs
(egress-block, list-egress-routes), and the preflight label all drop
the `-proxy` suffix.
Partial revert of fa06a3a. The role + agent-side provisioner felt
overengineered: anthropic-base-url + npm-registry's only realistic
host values match the tool defaults, so the role tags drove no-op
dotfile writes most of the time. If non-default npm registry / tea
config is needed in a future bottle, we can ship it through a more
direct mechanism then.
What stays from fa06a3a:
- Universal HTTPS git-push block in the egress-proxy addon
(`is_git_push_request` in egress_proxy_addon_core, called from
the request hook before route matching; 403s git-receive-pack
regardless of route). This is the security backstop so git-gate
remains the only outbound write path; PR #29 keeps it.
What gets reverted:
- `Role` field on EgressProxyRoute (manifest + runtime).
- `EGRESS_PROXY_ROLES` + `EGRESS_PROXY_SINGLETON_ROLES` constants
and singleton-role validation.
- `backend/docker/provision/egress_proxy.py` (npmrc + tea config).
- `provision_egress_proxy` slot in `BottleBackend.provision`.
- `prepare.py`'s role-based ANTHROPIC_BASE_URL detection (back to
the token_ref="CLAUDE_CODE_OAUTH_TOKEN" auto-detect).
- Manifest + provisioner tests for the above.
355 unit + 24 integration tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes on top of PR #29's chunk-2 cutover:
1. Universal HTTPS git-push block in the egress-proxy addon
(`is_git_push_request` in egress_proxy_addon_core, called from the
mitmproxy request hook before route matching). 403s any
`/git-receive-pack` or `info/refs?service=git-receive-pack` —
defense in depth so git-gate (PRD 0008) remains the only outbound
path for writes, gitleaks-scanned by its pre-receive. Replicates
cred-proxy's `is_git_push_request` behavior.
2. Restored agent-side role provisioner. Brings back `Role` on
EgressProxyRoute (manifest + runtime) with three roles —
`anthropic-base-url`, `npm-registry`, `tea-login`. Singleton
constraint on the first two carries over from cred-proxy.
`git-insteadof` is intentionally absent (option 1 above handles
the push-bypass concern, and the canonical-URL rewrite has no
function when egress-proxy is on HTTPS_PROXY).
The provisioner (`backend/docker/provision/egress_proxy.py`):
- `~/.npmrc` registry= the canonical upstream URL.
- `~/.config/tea/config.yml` logins[] entry per tea-login route.
- `ANTHROPIC_BASE_URL` env set in prepare.py based on the
anthropic-base-url role (was a token_ref="CLAUDE_CODE_OAUTH_TOKEN"
check in this PR's earlier draft — the role marker is cleaner
and matches the cred-proxy precedent the user wants kept).
All three dotfile values point at canonical upstream URLs; the
agent's HTTPS_PROXY=egress-proxy routes them through the proxy
automatically.
Tests: 11 new role-validation tests, 11 new provisioner-render tests,
the chunk-1 manifest fixture exercise role=anthropic-base-url. 400
tests pass (was 376).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Hard cutover. cred-proxy is deleted; egress-proxy is now the agent's
HTTP_PROXY (when routes are declared) with pipelock on its outbound
leg. Two per-bottle CAs are minted: egress-proxy's (agent trust
store) and pipelock's (egress-proxy's outbound trust store).
Manifest:
- `bottle.cred_proxy` → hard error with a migration recipe.
- `bottle.egress_proxy` is the new shape (PRD 0017 chunk 1).
- CredProxy* types + role validators removed.
Wiring:
- launch.py: `egress_proxy_tls_init` mints the egress-proxy CA
(cert+key concat for mitmproxy + cert-only for agent trust);
`DockerEgressProxy.start` docker-cps both CAs in, sets
`HTTPS_PROXY=pipelock` + `EGRESS_PROXY_UPSTREAM_CA` so mitmdump
trusts pipelock's MITM. Agent's HTTP_PROXY points at
egress-proxy when routes exist, else falls back to pipelock
(no-routes bottles unchanged).
- prepare.py / backend.py: `cred_proxy` arg → `egress_proxy`;
sidecar-orphan probe + plan field + dashboard view all
renamed.
- provision_ca: selects the egress-proxy CA when present, else
pipelock's (filename renamed to claude-bottle-mitm-ca.crt).
- bottle.provision: cred-proxy dotfile rewrites (~/.npmrc,
~/.gitconfig insteadOf, tea config) are gone — HTTP_PROXY
catches everything respecting it.
Pipelock helpers:
- `pipelock_token_hosts` → `pipelock_route_hosts` (now reading
egress_proxy.routes).
- cred-proxy hostname auto-allow → egress-proxy hostname
auto-allow.
- Anthropic seed-phrase workaround now triggers when an
egress_proxy route targets api.anthropic.com (was based on the
cred-proxy `anthropic-base-url` role).
Dockerfile.egress-proxy:
- Entrypoint conditionally passes
`--set ssl_verify_upstream_trusted_ca=$EGRESS_PROXY_UPSTREAM_CA`
(via the `${VAR:+...}` shell expansion) so standalone runs without
a mounted pipelock CA still boot.
- mkdirs `/home/mitmproxy/.mitmproxy` ahead of `docker cp`.
Deleted: claude_bottle/{cred_proxy,cred_proxy_server}.py,
backend/docker/{cred_proxy,provision/cred_proxy}.py,
Dockerfile.cred-proxy, plus the corresponding unit + integration
tests. backend/docker/cred_proxy_apply.py stays as a stub for
chunk 3 to rewrite (its container-name + routes-path constants
are inlined so it survives without the deleted module).
Test changes:
- test_pipelock_allowlist rewritten against egress-proxy routes
+ the new `pipelock_route_hosts`.
- test_manifest_md_load + test_pipelock_yaml + test_yaml_subset
fixtures migrated to the `egress_proxy: { routes: [...] }`
shape.
- test_supervise_sidecar's round-trip test switched from
`dashboard.approve` to `dashboard.reject`: the approval-apply
path on cred-proxy-block proposals hits a deleted sidecar in
chunk 2's transitional state. Chunk 3 restores the approval
test once the remediation flow is retargeted at egress-proxy.
376 tests pass (was 427; net delta is removed cred-proxy tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous provisioner wrote ~/.claude/settings.json with an
mcpServers entry — but claude-code doesn't read its mcpServers from
that path. Inside a bottle, /mcp showed "No MCP servers configured"
even though the sidecar was running.
Switch to the official `claude mcp add` command run via docker exec:
docker exec -u node <agent> \
claude mcp add --scope user --transport http supervise <url>
claude-code owns its config file format (~/.claude.json shape, key
names, scope semantics) and has changed it between versions. The
official command writes to the right place in the right shape for
whatever version is installed.
Failure is logged but not fatal — the bottle still works; you just
have to register the server manually with the command surfaced in
the warning. Worst case is a bad agent claude-code version, not a
bad bottle.
To fix an already-running bottle without restarting, the user can
run the same `docker exec` command directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The supervise sidecar (PRD 0013) has been serving MCP at
http://supervise:9100/ since it landed, but the in-bottle Claude
Code had no `.mcp.json` or settings pointing there — so the agent
couldn't actually call cred-proxy-block / pipelock-block /
capability-block as tools. To exercise the flow you had to curl
the sidecar from a sibling container.
This closes that last mile.
- claude_bottle/backend/docker/provision/supervise.py (new):
provision_supervise(plan, target) writes
~/.claude/settings.json into the running agent container with an
mcpServers.supervise entry of type http pointing at the
per-bottle sidecar. No-op when bottle.supervise is False.
- BottleBackend.provision orchestrator gains provision_supervise as
the last step (after CA, prompt, skills, git, cred-proxy). Default
impl is a no-op so non-Docker backends aren't forced to implement it.
- DockerBottleBackend wires it through to the new module.
- Test covers the rendered settings shape so a future regression in
the MCP entry format would surface in unit-level CI.
To test the full flow end-to-end now:
./cli.py start <agent> --cwd # agent's claude sees supervise
# agent calls cred-proxy-block via MCP
./cli.py dashboard # approve
./cli.py resume <identity> # restart with new capabilities
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three leftovers from the manifest refactor:
1. provision/cred_proxy.py:223 referenced u.kind == 'gitea' for the
tea login count — kind was removed from the runtime class, so any
bottle with a tea-login route raised AttributeError at provision
time. Switch to `'tea-login' in r.roles`.
2. The runtime class CredProxyUpstream is renamed to CredProxyRoute
(its data is a route on the proxy, not an "upstream"; the field
route.upstream is the upstream URL). Module's own naming now
aligns with manifest.CredProxyRoute and routes.json.
3. cred_proxy_upstreams_for_bottle -> cred_proxy_routes_for_bottle;
CredProxyPlan.upstreams -> CredProxyPlan.routes; local
`upstreams` collections become `routes`. Callers in
backend.py, launch.py, prepare.py, bottle_plan.py,
provision/cred_proxy.py, and tests updated.
Also strips lingering `bottle.tokens` references from docstrings
(pipelock.py, cred_proxy.py prepare(), manifest._parse_https_host,
test_pipelock_allowlist.py module doc) and removes dead helpers
from the integration test (the _bottle helper used a tokens field
that no longer parses).
Replace bottle.tokens (with Kind enum and hardcoded per-kind
route/auth tables) with bottle.cred_proxy.routes — each route
declares its own path, upstream, auth_scheme, token_ref, and
optional role[]. The manifest is now the source of truth for the
proxy's runtime route table; adding an upstream is a manifest edit,
not a code change.
Agent-side rewrites move from per-kind dispatch to per-role tags
on routes:
anthropic-base-url -> set ANTHROPIC_BASE_URL=<proxy><path>
npm-registry -> write ~/.npmrc registry=
git-insteadof -> write ~/.gitconfig [url] insteadOf, keyed
off route.upstream (suppressed when
bottle.git brokers the same host)
tea-login -> add a ~/.config/tea/config.yml login
Roles are a list (string accepted as sugar). A gitea route
typically carries ["git-insteadof", "tea-login"]. Singleton roles
(anthropic-base-url, npm-registry) appear on at most one route.
token_env slots are assigned per distinct TokenRef in declaration
order — two routes sharing a token_ref (e.g. github API + git
endpoints) share a slot.
Drops: TOKEN_KINDS, _KIND_ROUTES, _KIND_AUTH_SCHEME, _TOKEN_DEFAULT_HOST,
cred_proxy_route_path_for_gitea, the kind field on CredProxyUpstream,
and the kind-based hardcoding in pipelock_token_hosts (now derives
from route.UpstreamHost).
Legacy bottle.tokens manifests now die with a hint pointing at
bottle.cred_proxy.routes + this PRD. Tests rewritten end-to-end.
Docs + example.json + the dev ~/claude-bottle.json updated to match.
Three coupled fixes that close a documented bypass of git-gate's
gitleaks pre-receive hook:
1. cred-proxy refuses git smart-HTTP push at runtime. Any path
ending in /git-receive-pack or /info/refs?service=git-receive-pack
returns 403 with a pointer at the bottle.git SSH path. Fetch
(upload-pack) is still allowed — the bypass we're closing is
push, where gitleaks is the load-bearing scanner. Hard guarantee.
2. The provisioner suppresses the cred-proxy `~/.gitconfig` insteadOf
rewrite for any host already declared in bottle.git. git-gate is
the canonical git path there; we don't write a competing rule
that would let `git clone https://<host>/...` succeed in ways
that confuse on push. Defense in depth — (1) is the hard guarantee.
3. cred-proxy routes its outbound HTTPS through pipelock. The
sidecar's environ now sets HTTPS_PROXY=<pipelock-url>, and the
image's entrypoint runs `update-ca-certificates` over the
per-bottle pipelock CA (docker cp'd into
/usr/local/share/ca-certificates/pipelock.crt before start) so
the proxy's HTTPS client trusts pipelock's bumped certs.
Consequence: pipelock's allowlist + body scanner now sit in the
cred-proxy egress path the same way they sit in front of direct
agent traffic. The cred-proxy upstream hosts (api.github.com,
github.com, gitea hosts, registry.npmjs.org) come OFF
pipelock's passthrough_domains. Only api.anthropic.com remains
on passthrough (LLM body content legitimately trips DLP).
PRD 0010 updated to reflect all three. Tests adjusted: the
"cred-proxy hosts go on passthrough" assertion in
test_pipelock_allowlist flips to "they don't", a new
TestIsGitPushRequest exercises the smart-HTTP refusal predicate,
and the gitconfig renderer tests cover the per-host suppression
matrix.
provision_cred_proxy(plan, target) drops:
- ~/.npmrc with registry= pointing at /npm/ on the proxy
- ~/.gitconfig insteadOf rules for github (https://github.com/) and
per-gitea hosts, appended after provision_git's git-gate rules
- ~/.config/tea/config.yml with a logins: entry per declared gitea
URL, pointing at /gitea/<host>/ on the proxy
Renderers are pure and unit-tested. The dispatcher reads
plan.cred_proxy_plan.upstreams, which the backend wiring (next
commit) populates on DockerBottlePlan.
ANTHROPIC_BASE_URL is deliberately *not* a dotfile — it goes into
the agent's docker run -e env so claude sees it from process start.
Delete claude_bottle/ssh_gate.py, the DockerSSHGate sidecar,
and the provision_ssh provisioner (~/.ssh/config + ssh-agent
wiring). Unwire the gate from the abstract BottleBackend
(provision orchestration drops the ssh step,
_validate_ssh_entries goes away) and from the Docker backend
(prepare/launch lose the `gate` kwarg, bottle_plan drops the
gate_plan field, dry-run JSON drops the ssh_hosts / ssh_gate
keys, y/N preflight drops the ssh-hosts block). cli/info now
prints declared git remotes instead of ssh hosts. pipelock's
docstring picks up the git-gate framing now that there's no
PRD-0007 boundary to call out.
BREAKING (dry-run JSON): the `ssh_hosts` and `ssh_gate` keys
are gone from `start --dry-run --format=json`. Consumers should
read `git_remotes` / `git_gate` instead.
The agent's ~/.gitconfig now uses insteadOf (not pushInsteadOf),
so every git operation against a declared upstream — push, fetch,
clone, pull, ls-remote — routes through the gate. Matches the
gate's now-bidirectional design: fetch is mirrored via the
access-hook, push is gated via gitleaks.
provision_git now does two things: copy the host cwd's .git (when
--cwd is set, existing behavior) and write ~/.gitconfig with
pushInsteadOf rules for each bottle.git entry. A 'git push <real
upstream URL>' from inside the agent transparently rewrites to
'git://<gate>/<name>.git' so the gate gets first crack at the
incoming refs.
pushInsteadOf (not insteadOf) keeps fetch on the original URL —
v1 of the git-gate is push-only scope per PRD 0008. The render
helper is exposed for testing without docker.
PRD 0007: stop tunneling ssh through pipelock. Each Host block in
the agent's ~/.ssh/config now points at the gate container + the
per-entry listen port; HostKeyAlias preserves host-key validation
against the real upstream name, and CheckHostIP=no skips the
resolved-IP path (which would otherwise hit the gate's IP).
known_hosts collapses to a single entry per upstream keyed on the
alias.
The pipelock_proxy_host_port import is gone from this module; the
function itself becomes dead code and gets removed alongside the
broader pipelock SSH carve-outs in the next commit.
Second step of PRD 0006. With pipelock now doing the bumping, the
agent's TLS library has to trust pipelock's per-bottle CA — or
every CONNECT to api.anthropic.com is a self-signed-cert error.
- BottleBackend.provision gains a non-abstract `provision_ca`
with a default no-op (so non-Docker backends aren't forced to
implement TLS interception) and orchestrates
ca → prompt → skills → ssh → git. CA install runs first so the
agent's trust store is rebuilt before anything else in the
agent makes a TLS call.
- New backend/docker/provision/ca.py: docker-cp's the CA cert
into the agent at /usr/local/share/ca-certificates/...,
`update-ca-certificates`, then emits a one-line stderr log
with the SHA-256 fingerprint (stdlib `ssl` + `hashlib`; no
subprocess for crypto). Module-level constants AGENT_CA_PATH
and AGENT_CA_BUNDLE are imported by launch.py so the env
trio set at docker run time matches the paths the provisioner
writes.
- launch.py: rebinds `plan` after `dataclasses.replace`s on the
pipelock proxy plan so provision_ca (which reads
`plan.proxy_plan.ca_cert_host_path`) sees the populated CA
paths. Three new -e flags on the agent's docker run for the
NODE_EXTRA_CA_CERTS / SSL_CERT_FILE / REQUESTS_CA_BUNDLE trio.
- Dockerfile: adds curl to the apt-get install line. curl
natively respects HTTPS_PROXY and sends CONNECT directly —
the agent doesn't need OS-level DNS for external hostnames
(pipelock resolves them on its side of the bumped tunnel).
This is the "simple HTTPS request" path the earlier turn
needed and Node's stdlib https.request couldn't provide.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Make BottleBackend.prepare a template method that runs a cross-backend
_validate step (agent exists, named skills present on host, SSH
IdentityFiles resolve) and then delegates to a subclass-implemented
_resolve_plan for backend-specific resolution.
A future backend that overrides _resolve_plan can no longer forget to
validate skills or SSH keys; the validation runs unconditionally via
prepare. Backends with additional preconditions can override _validate
and chain via super().
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>