Commit Graph

5 Commits

Author SHA1 Message Date
didericis f807ed1149 fix(egress-proxy): force traffic through pipelock + block unallowlisted hosts
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m5s
Two issues stopping the bottle's egress allowlist from being
enforced:

1. mitmproxy was bypassing pipelock. We set HTTPS_PROXY=pipelock
   in the egress-proxy container's env, but mitmproxy is a proxy
   *server* — it does NOT honor HTTP(S)_PROXY env vars on its
   outbound side the way HTTP-client libraries do. All
   post-MITM traffic was going direct to the upstream, never
   touching pipelock's hostname allowlist or DLP scanner.

   Fix: use mitmproxy's `--mode upstream:URL` flag. The Dockerfile
   entrypoint now reads a new `EGRESS_PROXY_UPSTREAM_PROXY` env
   (set by `DockerEgressProxy.start` to the pipelock URL when
   pipelock is in the topology) and switches mitmdump to
   upstream-proxy mode. Standalone runs of the image without the
   env still get `--mode regular@9099` direct-to-upstream — useful
   for unit-test boots. Confirmed in the boot log: "HTTP(S) proxy
   (upstream mode) listening at *:9099."

2. egress-proxy was forwarding unrecognized hosts. The addon's
   `decide()` returned `Decision(action="forward")` whenever no
   route matched the request host, deferring to pipelock to gate.
   With #1 broken pipelock wasn't gating either; even with #1
   fixed, defense-in-depth wants both layers enforcing.

   Fix: no-route-match → 403 with a "host not in allowlist"
   reason. The egress allowlist is now strictly the set of hosts
   declared in `bottle.egress_proxy.routes`; bare-pass routes
   (host with no auth, no path_allowlist) cover the passthrough
   case for hosts that just need reach. path_allowlist enforcement
   on matched routes is unchanged.

Test updated: `test_no_matching_route_forwards` →
`test_no_matching_route_blocks`. 364 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:38:18 -04:00
didericis 5dc33f3acc fix(egress-proxy): mint CA via openssl req so leaf AKI matches CA SKI
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m1s
Root cause of the persistent SSL handshake failure: pipelock's
`tls init` stamps a non-standard `Subject Key Identifier` on the
CAs it generates (random rather than SHA-1 of the pubkey).
mitmproxy computes the `Authority Key Identifier` on each leaf
cert it mints as SHA-1(issuer's pubkey). openssl's chain validator
uses the leaf's AKI to find the issuer cert by SKI; with pipelock's
SKI off by definition, the lookup fails and openssl returns
"unable to get local issuer certificate" — even though the CA is
right there in the trust store with the matching SHA-256
fingerprint. (Also, pipelock generates EC CAs; the cert+key concat
fit in 834 bytes vs ~2.3KB for RSA, which was the first red
flag.)

Diagnostic from a live bottle confirmed:

  leaf cert AKI:   A8:F0:D5:E3:B5:B9:C2:38:2B:9F:DD:4A:DF:26:8C:72:19:A2:5E:94
  CA cert SKI:     81:CA:6D:4C:ED:5C:C2:B1:48:0C:3E:E8:8D:73:86:97:B9:89:B4:3D
  CA cert + leaf cert: same Pipelock-named subject, same public key bytes
  openssl verify -CAfile <our CA> <leaf>: error 20

Fix: switch `egress_proxy_tls_init` from `pipelock tls init` to
host `openssl req` with an explicit `subjectKeyIdentifier=hash`
extension. SHA-1(pubkey) for the SKI matches what mitmproxy puts
in the AKI, so chain validation works. The generated CA is also
RSA-2048 / sha256WithRSAEncryption — mitmproxy's most-tested
configuration.

The new generator is independent of pipelock entirely (no docker
run on the pipelock image to mint the CA), so the egress-proxy
CA generation now requires only `openssl` on the host. macOS +
Linux dev images both have it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:29:27 -04:00
didericis 57a9707e1c fix(egress-proxy): chmod 644 host CAs so mitmproxy user can read after docker cp
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m3s
mitmdump crashed at boot with PermissionError on
~/.mitmproxy/mitmproxy-ca.pem. Cause: `docker cp` preserves the
host file's mode AND uid. The CA files were 0600 owned by the host
user (uid 501 on macOS), so inside the container the mitmproxy
user (uid 1000, set by USER directive in Dockerfile) couldn't read
them.

Fix:
  - `egress_proxy_tls_init`: chmod 644 the cert-only + the cert+key
    concat on the host stage dir.
  - `DockerEgressProxy.start`: chmod 644 routes.yaml and the
    pipelock CA before `docker cp` into the egress-proxy container
    (pipelock itself runs as root so its in-pipelock copy is
    unaffected).

The host stage_dir is mode 700 — other host users still can't
traverse in, so the cert+key concat isn't actually exposed despite
the 644 mode. The container side gets world-readable, which is
fine inside the per-bottle container.

Reproduces against today's main: bottle's egress-proxy sidecar
crashes with PermissionError; after this patch mitmdump boots and
listens on :9099.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:42:51 -04:00
didericis 70f773ac61 feat(egress-proxy): cutover from cred-proxy (PRD 0017 chunk 2)
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m3s
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>
2026-05-25 14:30:39 -04:00
didericis 3df54573d4 feat(egress-proxy): add mitmproxy-based sidecar core (PRD 0017 chunk 1)
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m39s
Lands the new egress-proxy artifact alongside cred-proxy. Chunk 2
wires the agent's HTTP_PROXY to it and removes cred-proxy.

  - `Dockerfile.egress-proxy` — mitmproxy 11.1.3 base, COPY addon
    files flat to /app, mkdir routes dir at /etc/egress-proxy/.
    Digest pin deferred to chunk 2.
  - `egress_proxy_addon_core.py` — pure-logic parse + decide
    (host-importable; 21 unit tests).
  - `egress_proxy_addon.py` — mitmproxy hook wrapper, container-only
    (boot + SIGHUP reload, strip-Authorization + decide + 403/inject).
  - `egress_proxy.py` — host helpers: manifest lift, routes.yaml
    render (JSON content), token-env-map, Plan + abstract class.
  - `backend/docker/egress_proxy.py` — `DockerEgressProxy` start/stop
    mirroring `DockerCredProxy`; not yet called from launch.py.
  - `manifest.py` — new `EgressProxyRoute` + `EgressProxyConfig` types
    with the nested `auth: { scheme, token_ref }` block per PRD;
    `bottle.egress_proxy` added to the bottle key set alongside
    `cred_proxy` (chunk 2 hard-fails on the latter).

All 427 unit tests pass. Image builds; `docker run` boots mitmdump
and the addon loads routes from a mounted routes.yaml.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:58:24 -04:00