feat(egress-proxy): retarget remediation flow (PRD 0017 chunk 3) #30

Merged
didericis merged 18 commits from egress-proxy-block-remediation into main 2026-05-25 20:34:24 -04:00

18 Commits

Author SHA1 Message Date
didericis 6c886200d9 revert(egress-proxy): drop wildcard host support entirely
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m3s
The apex-vs-subdomain question, the cert/SNI mismatch when
pipelock-passthrough hosts have wildcard certs, and the
mirror-divergence corner cases stacked up faster than the feature
earned its keep. Going back to exact-host match only.

Addon (`match_route`): single pass, case-insensitive exact match.
`*.foo.com` in a route table is now a literal string that won't
match anything — operators that want subdomains declare them
individually.

Pipelock mirror (`_pipelock_safe_hosts`): silently drops hosts
that don't fit pipelock's `[A-Za-z0-9_.-]+` charset (wildcards,
IPv6 literals, stray chars). Previously normalised wildcards to
their suffix; now just drops them, which matches egress-proxy's
behavior of not matching them either.

8 wildcard test cases removed; 2 lightweight "wildcards are not
supported" assertions retained as documentation. 386 unit pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:48:35 -04:00
didericis 6177c0518e fix(egress-proxy-addon): wildcard hosts also match the apex
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m6s
`*.example.com` now matches `example.com` itself in addition to
every subdomain. RFC 6125 TLS-wildcard semantics excluded the
apex; an allowlist's natural reading of `*.example.com` is "all
of example.com" — and the pipelock mirror already strips
`*.example.com` to `example.com`, so without the apex match the
two layers disagreed (pipelock allowed the apex, egress-proxy
blocked it).

Behavior:
  - `*.example.com` matches `example.com`     (apex)
  - `*.example.com` matches `foo.example.com` (subdomain)
  - `*.example.com` matches `a.b.example.com` (nested)
  - `*.example.com` does NOT match `barexample.com` (label
    boundary required)

Test renamed: `test_wildcard_does_not_match_apex` →
`test_wildcard_matches_apex`. 395 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:16:33 -04:00
didericis 811a6fbfe9 feat(egress-proxy-addon): wildcard host matching with exact-match precedence
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m3s
PRD 0017 v1 deliberately punted wildcards ("Exact match in v1 —
globs / wildcards are a follow-up"). Now that the supervise mirror
strips `*.` to its suffix for pipelock, the addon needs to actually
match wildcard hosts on its side or the route is dead weight.

Addon `match_route` now does two passes:
  1. Exact (case-insensitive) literal match on the hostname.
  2. Wildcard suffix match: a route whose host starts with `*.`
     matches any request host that ends with `.<suffix>`. So
     `*.example.com` matches `foo.example.com` and
     `a.b.example.com`, but NOT the apex `example.com` and not
     `barexample.com` (the leading `.` of the suffix is
     required).

Exact wins — operators can layer a specific route (e.g.
`api.github.com` with auth) on top of a broader wildcard (e.g.
`*.github.com` bare-pass).

8 new unit tests: direct subdomain match, nested subdomain match,
apex rejection, overlapping-suffix rejection, case-insensitive,
exact-wins-over-wildcard (both route orders), no-match
fall-through. 395 unit + integration pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:10:22 -04:00
didericis e26fe874e4 fix(egress-proxy-apply): wildcard hosts normalise to suffix in pipelock mirror
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m3s
Previous fix stripped wildcard hosts entirely from the pipelock
mirror; the operator wanted the suffix kept so pipelock pins the
base hostname. Now `*.example.com` becomes `example.com` in the
mirror — egress-proxy keeps the wildcard for its own host match,
pipelock allows the suffix.

Behavior change:
  - `*.example.com` → `example.com`     (was: dropped)
  - `*.foo.bar.com` → `foo.bar.com`     (one `*.` strip, not
                                         recursive)
  - `*`             → dropped            (normalises to empty)
  - `example.com`   → `example.com`     (unchanged)
  - `[::1]`, etc.   → dropped            (still off pipelock's
                                         charset after any prefix
                                         strip)

Adds explicit de-dup so `*.example.com` + `example.com` collapse
to one entry. Existing wildcard-strip test reshaped + 3 new
edge-case tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:00:06 -04:00
didericis 93f7d248f6 fix(egress-proxy-apply): strip pipelock-incompatible hosts from mirror
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m4s
Pipelock's allowlist parser only accepts `[A-Za-z0-9_.-]+`
literal hostnames. Wildcard routes (`*.example.com`) that
egress-proxy's route table accepts trip pipelock's parser the
moment the mirror tries to render them into the new yaml; the
whole apply fails before pipelock is even touched. Symptom:
operator approves an egress-proxy-block proposal, gets
"pipelock allowlist mirror failed: allowlist line N: '<wildcard>'
has disallowed characters."

Fix: `_mirror_hosts_to_pipelock` filters through
`_pipelock_safe_hosts` before merging — anything outside
pipelock's allowed charset is silently skipped. Wildcard routes
stay live on egress-proxy; pipelock just won't pin a hostname
for the wildcard-matched traffic (caller's call to accept the
hostname-only enforcement gap there).

Adds 4 unit tests covering normal hostnames pass-through,
wildcard stripping, IPv6-literal stripping, and order
preservation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:54:30 -04:00
didericis db1b523881 fix(egress-proxy-apply): correct misleading "egress-proxy updated" wording
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m9s
`_mirror_hosts_to_pipelock` runs BEFORE the egress-proxy write in
`apply_routes_change` — if it raises, egress-proxy is left intact.
The error message claimed the opposite ("egress-proxy routes
updated but pipelock allowlist mirror failed"), pointing the
operator at the wrong half-state.

Reword to make the actual state clear: pipelock failed,
egress-proxy NOT updated, fix pipelock manually with
`pipelock edit <bottle>` then retry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:50:36 -04:00
didericis 1542ee0b93 feat(egress-proxy-block): single-route input + merge-on-apply
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m14s
Instead of asking the agent to compose and submit a full routes
file, the tool now takes ONE proposed route — host + optional
path_allowlist + optional auth — and the supervisor merges it
into the live routes table at approval time. The agent no longer
needs to fetch / reproduce / extend the existing allowlist; it
just describes the host it wants reachable.

Tool input (new):
  - `host` (required)
  - `path_allowlist` (optional, array of absolute path prefixes)
  - `auth` (optional, {scheme, token_ref})
  - `justification` (required)

Merge semantics (in `egress_proxy_apply._merge_single_route`):
  - Host NOT in current routes → append the proposed route as a
    new entry. If `auth` is set, assign the next EGRESS_PROXY_TOKEN_N
    slot.
  - Host already present → union the proposed `path_allowlist`
    with the existing one (proposed entries appended after
    existing, deduped). Existing `auth_scheme` / `token_env`
    preserved; proposed `auth` ignored (operator-controlled, not
    agent-controlled).
  - Hostname comparison is case-insensitive.

Dashboard wiring: `approve()` on an egress-proxy-block proposal
now calls `add_route(slug, proposed_route_json)` instead of
`apply_routes_change(slug, full_file)`. add_route fetches the
current routes from the running egress-proxy, merges, and calls
apply_routes_change with the merged content — so the
pipelock-mirror + SIGHUP plumbing from chunk 3 still runs
end-to-end. Audit diff still captures the full-file before/after.

Tool description rewritten to make the new shape obvious and to
stop pointing the agent at the routes file. The
`list-egress-proxy-routes` tool stays available for agents that
want to see what's currently allowed.

Tests: 9 new `_merge_single_route` cases (host absent/present,
path-allowlist union+dedup, auth-slot indexing, case-insensitive
match, existing-auth preservation, missing-host rejection,
malformed-current rejection). 407 unit + integration pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:45:17 -04:00
didericis 3be70eb07a feat(supervise): list-egress-proxy-routes MCP tool, defaults on egress-proxy
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m7s
Reshape the allowlist topology so the egress-proxy is the bottle's
single allowlist surface, and replace the agent-side
routes/allowlist file mounts with a live MCP tool.

Policy change (move defaults to egress-proxy):

  - `egress_proxy_routes_for_bottle(bottle)` now folds in
    DEFAULT_ALLOWLIST (the claude-code defaults) and
    `bottle.egress.allowlist` (user adds) as bare-pass routes (no
    auth, no path filter), on top of the bottle's
    `egress_proxy.routes`. Manifest routes win on host collision.
  - `pipelock_effective_allowlist(bottle)` mirrors egress-proxy's
    effective host set when egress-proxy is in use. Pipelock is
    no longer the bottle's primary allowlist authority; it
    enforces a downstream copy as defense-in-depth + does DLP body
    scanning.
  - Split out `egress_proxy_manifest_routes(bottle)` for callers
    that want just the manifest entries (tests, internal use).
  - DEFAULT_ALLOWLIST moves from `pipelock.py` to `egress_proxy.py`
    (pipelock re-imports for the no-egress-proxy fallback path).
  - Dropped the `egress-proxy` auto-allow on pipelock's allowlist
    — the agent never dials egress-proxy via the proxy mechanism;
    pipelock only sees upstream hostnames from egress-proxy's
    CONNECTs.

Introspection endpoint (existing mitmproxy feature):

  - Egress-proxy addon recognises requests to the magic host
    `_egress-proxy.local` and synthesizes responses via
    `flow.response = http.Response.make(...)` — no upstream
    connection, no allowlist enforcement on the magic host.
  - `GET /allowlist` returns the in-memory route table as JSON
    (host + path_allowlist + auth_scheme + token_env per route;
    no token VALUES).
  - Smoke-tested end-to-end against a real egress-proxy container.

MCP tool (existing supervise plumbing):

  - New `list-egress-proxy-routes` tool (no inputs, no operator
    approval). Handler fetches via egress-proxy's introspection
    endpoint using urllib's ProxyHandler against
    `EGRESS_PROXY_FORWARD_PROXY`. Returns the JSON payload as the
    tool's text content; `isError: true` if the proxy is
    unreachable.
  - `egress-proxy-block` description now points the agent at
    `list-egress-proxy-routes` instead of a staged file path.
  - `pipelock-block` description acknowledges the mirror — agents
    should prefer `egress-proxy-block` to add hosts; pipelock-block
    stays for the rare divergence case.

Drop agent-side file mounts:

  - Supervise's `current-config` dir staging no longer writes
    routes.yaml / allowlist. Only `Dockerfile` remains
    (capability-block still reads it from
    `/etc/claude-bottle/current-config/Dockerfile`).
  - `prepare.py` stops passing `routes_content` /
    `allowlist_content` to `supervise.prepare`.
  - `Supervise.prepare` signature simplified to one
    `dockerfile_content` kwarg.

Tests: 400 unit + integration pass. Added coverage for
defaults-folding (`TestRoutesForBottleFoldsDefaults`), the new
tool definition + handler, and the updated supervise.prepare
shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:23:01 -04:00
didericis 1cec0d9aa6 feat(egress-proxy-apply): mirror new route hosts into pipelock allowlist
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m7s
When the operator approves an egress-proxy-block proposal that
adds a host to egress-proxy's routes, the request would still 403
downstream at pipelock — pipelock's hostname allowlist is set at
bottle launch and doesn't learn about routes added later. The
agent saw "Approved" but the very next retry still failed.

Fix: `apply_routes_change` now mirrors every host in the proposed
routes onto pipelock's allowlist before flipping egress-proxy.
Order matters — pipelock first so a pipelock failure doesn't
leave egress-proxy in a half-state:

  1. Validate the new routes content.
  2. Extract the hosts.
  3. Merge them onto pipelock's current allowlist
     (`apply_allowlist_change` — restarts pipelock with the merged
     yaml). No-op when every host is already present.
  4. docker cp the new routes.yaml into egress-proxy + SIGHUP.

If pipelock's restart fails, egress-proxy is untouched and the
operator gets a clear error pointing at the pipelock half-state.
If egress-proxy's update fails after pipelock succeeded, pipelock
just has the host pre-allowlisted — harmless extra-permissive
until the operator retries.

Adds `_hosts_in_routes` helper using the addon's own parser
(so the mirrored host set matches exactly what the addon will
match on). 4 new unit tests; 368 total pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:34:10 -04:00
didericis d75d5f3e48 fix(egress-proxy-apply): chmod tmp file 0644 so mitmproxy can read post-cp
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m3s
apply_routes_change wrote the proposed routes via
`tempfile.mkstemp` (default mode 0600) then `docker cp`'d into the
egress-proxy container. docker cp preserves mode + host uid, so
the file landed inside the container as 0600 owned by the host
user's uid — which is not the mitmproxy user (uid 1000) the
addon runs as. The SIGHUP-triggered reload then failed with
PermissionError on the re-read, the old routes table stayed in
memory, and the operator-approved route never took effect.

Symptoms reported:
  - Operator approves egress-proxy-block proposal that adds
    google.com to routes.
  - Agent retries `curl https://google.com` and still gets 403
    "egress-proxy: host 'google.com' is not in the bottle's
    egress_proxy.routes allowlist."
  - `docker exec <egress-proxy> cat /etc/egress-proxy/routes.yaml`
    returns "Permission denied" (mitmproxy user can't read it,
    so the reload couldn't either).

Fix: chmod 0644 on the host tmp file before docker cp. Mirrors
the same pattern in DockerEgressProxy.start which already chmods
the original routes.yaml + the CAs before cp. The proposed routes
content carries no secrets (tokens live in the egress-proxy
container's environ, not the routes file), so 0644 in /tmp for
the brief window between write and cp is safe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:25:35 -04:00
didericis fad76d3364 fix(supervise): stage current-config routes file as routes.yaml
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m6s
The supervise sidecar mounted a snapshot named routes.json into
the agent at /etc/claude-bottle/current-config/routes.json, but
the egress-proxy-block tool description (and the live proxy file
the apply step writes) say routes.yaml. The agent couldn't find
the file at the documented path, composed proposals against stale
or empty current state, and reported "routes wasn't updated on
disk" because it was looking at the wrong filename.

Rename the staged file to routes.yaml so the tool description,
the staged snapshot, and the live proxy file all agree on the
name. Content stays JSON-in-a-yaml-extension (per PRD 0017
chunk 1's decision: every JSON document is valid YAML, stdlib
parsers handle it on both ends).

Note: the staged file is still a one-shot snapshot taken at
bottle prep time. It does NOT auto-update when the operator
approves an egress-proxy-block. Agents that want to verify
their proposal took effect should retry the request that
triggered the block — a successful upstream response is the
real signal. Fixing the snapshot-staleness UX is a separate
follow-up.

Tests migrated from routes.json → routes.yaml. 364 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:01:12 -04:00
didericis c4cf2453e2 fix(launch): also set lowercase {http,https,no}_proxy on the agent
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m5s
CVE-2016-5388 ("httpoxy") mitigation: libcurl ignores uppercase
HTTP_PROXY for http:// URLs to prevent untrusted CGI HTTP_*
headers from hijacking the proxy. Only lowercase http_proxy is
honored for HTTP. Without the lowercase var, plain-HTTP requests
from the agent skip egress-proxy entirely — they go direct,
which is "network unreachable" on the agent's --internal bridge,
not the egress-proxy 403 we expect.

Confirmed against a live bottle: `curl http://1.1.1.1/` reported
"Immediate connect fail for 1.1.1.1: Network is unreachable"
instead of the addon's "host not in allowlist" 403. With both
cases set the agent's curl honors the proxy and our allowlist
enforcement kicks in.

Also set lowercase HTTPS_PROXY + NO_PROXY for symmetry. Some
tools check one case only; sending both means we don't have to
audit which convention each tool uses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 16:46:23 -04:00
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 b9c70f7daa fix(egress-proxy): build combined trust bundle (system + pipelock CA)
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m2s
`--set ssl_verify_upstream_trusted_ca` REPLACES mitmproxy's default
trust store with the file we point it at. The earlier wiring
pointed it at just pipelock's CA, which broke for any host pipelock
passes through (api.anthropic.com is in DEFAULT_TLS_PASSTHROUGH):
pipelock CONNECT-tunnels the handshake to the real upstream,
egress-proxy sees the real public cert (signed by e.g. DigiCert),
and refuses to validate because pipelock's CA doesn't sign it.

Fix in Dockerfile entrypoint: when EGRESS_PROXY_UPSTREAM_CA is
set, concatenate /etc/ssl/certs/ca-certificates.crt + the pipelock
CA into /home/mitmproxy/.mitmproxy/combined-trust.pem, and pass
that as ssl_verify_upstream_trusted_ca. Covers both legs:

  - pipelock-MITM'd hosts → leaf cert signed by pipelock CA →
    validates against the pipelock half of the bundle.
  - pipelock-passthrough hosts (api.anthropic.com et al.) → real
    upstream cert → validates against the system half.

Standalone runs of the image (no EGRESS_PROXY_UPSTREAM_CA) skip
the concat and use mitmproxy's default trust store.

Reproduces against today's main: agent gets "Unable to connect to
API: SSL certificate verification failed" on api.anthropic.com,
egress-proxy logs "Server TLS handshake failed. Certificate verify
failed: unable to get local issuer certificate". After this patch
the trust bundle includes the real upstream root + pipelock's CA
and both validation paths succeed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:52:08 -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 f04fbb68a9 feat(egress-proxy): drive claude-code OAuth placeholder off a role marker
test / unit (pull_request) Successful in 18s
test / integration (pull_request) Successful in 1m3s
The chunk 2 detection keyed on `token_ref == "CLAUDE_CODE_OAUTH_TOKEN"`,
which broke any bottle whose host env var has a different name (e.g.
`CLAUDE_BOTTLE_OAUTH_TOKEN`). The token_ref is the user's choice —
the placeholder-env trigger shouldn't be locked to one specific
string.

Restoring a minimal `role` marker on `EgressProxyRoute`:

  - `EGRESS_PROXY_ROLES = frozenset({"claude_code_oauth"})` — one
    marker for now; the field is back so we can grow it.
  - `EGRESS_PROXY_SINGLETON_ROLES` — claude_code_oauth is a
    singleton (only one route per bottle can carry it).
  - `Role: tuple[str, ...]` field on `EgressProxyRoute` (manifest +
    runtime), parsed as string or list-of-strings; unknown roles
    are rejected so typos can't become silent no-ops.

`prepare.py:has_anthropic_auth` now checks for `"claude_code_oauth"
in r.roles` instead of matching a literal token_ref string. Bottles
can name their host OAuth env var anything; the role marker is what
flips on `CLAUDE_CODE_OAUTH_TOKEN=<placeholder>` and the
telemetry-off env vars on the agent.

Test coverage: 7 new manifest tests (omitted / string / list /
unknown role rejected / non-string rejected / list-item non-string
rejected / singleton enforced).

364 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:28:11 -04:00
didericis 9cd583fbbb feat(egress-proxy): retarget remediation at egress-proxy (PRD 0017 chunk 3)
test / unit (pull_request) Successful in 19s
test / integration (pull_request) Successful in 1m6s
Finishes PRD 0017. The `cred-proxy-block` MCP tool is renamed and
its remediation apply path is repointed at egress-proxy.

  - `claude_bottle/supervise.py` — `TOOL_CRED_PROXY_BLOCK` →
    `TOOL_EGRESS_PROXY_BLOCK`; `COMPONENT_FOR_TOOL` maps the new
    tool ID to `egress-proxy` for audit-log routing.

  - `claude_bottle/supervise_server.py` — tool definition renamed
    + description rewritten: "Call when egress-proxy refused your
    HTTPS request ... Read the current routes.yaml from /etc/
    claude-bottle/current-config/routes.yaml, compose a modified
    version, pass the full new file plus a justification." The
    syntactic validator dispatches on the new tool ID.

  - `claude_bottle/backend/docker/egress_proxy_apply.py` — renamed
    from `cred_proxy_apply.py`. Reads routes.yaml from
    /etc/egress-proxy/routes.yaml via `docker exec cat`; validates
    via `egress_proxy_addon_core.load_routes` (so both sides use
    the same parser); writes via `docker cp`; SIGHUPs egress-proxy
    with `docker kill --signal HUP`. `EgressProxyApplyError`
    replaces `CredProxyApplyError`.

  - `claude_bottle/cli/dashboard.py` — wires the new apply +
    `discover_egress_proxy_slugs` helper; the operator-initiated
    `routes edit <bottle>` verb now writes to egress-proxy with
    `.yaml` suffix. Stale follow-up comment about path-aware
    filtering removed — PRD 0017 settled that question.

  - `tests/integration/test_supervise_sidecar.py` — restores the
    approval round-trip test (chunk 2 had switched it to a reject
    path because no cred-proxy existed). Approval stubs
    `apply_routes_change` so the test focuses on the supervise
    queue/response plumbing rather than docker-exec into a real
    egress-proxy sidecar (that's covered separately).

  - `tests/unit/test_egress_proxy_apply.py` — rewritten against
    the new validator; covers JSON shape, missing routes key,
    partial-auth-pair rejection (the addon-core parser catches
    these before SIGHUP).

  - PRDs 0010 + 0014 — status headers updated to
    Superseded / Retargeted with a callout block pointing at PRD
    0017's migration section. Historical text preserved.

384 unit + integration tests pass.

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