The flag from #453 was parsed off the manifest and honored by the
addon, but `_route_to_yaml_fields` never wrote it, so it was dropped on
the way to the proxy and has never worked end to end. Every registry
route silently kept the unconditional Authorization strip.
That is the whole of the Docker Hub / GHCR "unauthorized" blocker in
#392: podman fetched a valid 2658-byte bearer from auth.docker.io, the
proxy stripped it, and registry-1.docker.io answered the resulting
anonymous request with a fresh WWW-Authenticate challenge — which reads
exactly like a credential problem rather than a serializer gap.
Found by diffing a bottle's rendered routes.yaml against its manifest:
preserve_auth was in the manifest and absent from the output.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a per-route boolean field preserve_auth (default false) that skips
the gateway's Authorization header stripping for that host. Intended for
registry endpoints like Docker Hub (registry-1.docker.io) and GHCR
(ghcr.io) where the agent must supply its own per-scope bearer token.
Threaded through ManifestEgressRoute → EgressRoute → Route, serialized
in route_to_yaml_dict, and parsed in parse_routes. The strip at
egress_addon.py now checks route.preserve_auth before popping the header.
Closes#392
Reads the host's Claude OAuth session key from ~/.claude.json at launch
and forwards it only to the egress sidecar (never to the agent), placing
a placeholder CLAUDE_CODE_OAUTH_TOKEN in the agent env so Claude Code
starts without seeing the real credential.
Mirrors the existing Codex forward_host_credentials flow (PRD 0029).
Adds claude_auth.py to extract and validate the sessionKey, a
CLAUDE_HOST_CREDENTIAL_TOKEN_REF constant in egress.py, and updates
manifest_agent.py to allow the flag for both 'codex' and 'claude'
templates. Also adds a mutual-exclusion check that rejects setting
both auth_token and forward_host_credentials together.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(claude): read credentials from ~/.claude/.credentials.json
The actual OAuth token is in ~/.claude/.credentials.json under
claudeAiOauth.accessToken, not in ~/.claude.json.
~/.claude.json holds only UI state and profile metadata (oauthAccount
has no token fields). expiresAt in the credentials file is milliseconds,
not seconds.
Discovered after testing against Claude Code 2.1.198.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(claude): fall back to macOS Keychain for credentials
On macOS, Claude Code stores credentials in the Keychain under
service "Claude Code-credentials" rather than in a file. When
~/.claude/.credentials.json is absent, shell out to:
security find-generic-password -s "Claude Code-credentials" -w
and parse the result as the same JSON schema.
~/.claude.json holds only profile/UI metadata (oauthAccount has
no token fields). expiresAt in the credentials is milliseconds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(prd): fix credential path references (~/.claude/.credentials.json)
fix(test): suppress gitleaks false positives on synthetic Claude tokens
Completes the de-sidecar cleanup: no live code, test, current doc,
script, or nix file mentions or is named "sidecar" any more. Only the
dated PRD/research docs keep the term as historical record (agreed on
the #385 thread).
- Rename `sidecar_init.py`→`gateway_init.py` was done earlier; this pass
sweeps the remaining descriptive uses: the egress / git-gate / supervise
components are the gateway's *daemons*, the shared container is the
*gateway*, the old per-bottle container was the *companion container*.
- Rename `tests/integration/test_sidecar_bundle_image.py`→`test_gateway_image.py`
and its class; update `docs/ci.md` + `tests/README.md` for the renamed/
removed integration tests.
- `SIDECAR_PORTS` shell var in `scripts/firecracker-netpool.sh`→`GATEWAY_PORTS`.
Full unit suite green (bar the pre-existing `/bin/sleep`-missing env
errors in test_gateway_init); docker integration — gateway singleton,
broker, real two-bottle multitenant isolation, and the gateway-image
build — all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
The per-agent companion container (the egress/git-gate/supervise data
plane run once per bottle) is the pre-consolidation architecture. Remove
it and disable the backends that still depend on it, per the #385 thread.
- Delete `backend/docker/sidecar_bundle.py`; docker's live path uses the
consolidated shared gateway, not a per-bottle bundle.
- Disable the firecracker and macos-container backends: their `launch()`
fails closed (they launched a per-bottle companion; firecracker's
consolidated relaunch is #354, macos follows). Their `enumerate` return
empty and `cleanup` drop the companion-container discovery (firecracker
keeps VMM/run-dir cleanup).
- Fail-close both backends' `egress_apply` reload (it signalled the
per-bottle container); consolidated egress policy resolves per-request
against the orchestrator, so gateway-side apply is a follow-up.
- Rename `egress_sidecar_env_entries` → `egress_gateway_env_entries`,
`SIDECAR_PORTS` → `GATEWAY_PORTS`.
- Move the shared DockerBottlePlan fixture to `tests/unit/_docker_bottle_plan.py`;
delete tests for the removed launch paths; update cleanup/egress-apply tests.
Docker consolidated launch verified end-to-end (multitenant isolation
integration test passes). macos/firecracker are intentionally disabled.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBMWTEtQdJ4W5UrWuLHCck
Replace die()/RuntimeError at the point of detection with a typed
MissingEnvVarError, and catch it in the CLI dispatcher alongside
ManifestError. Library code stays free of stderr output; the CLI
renders all user-facing error lines in one place.
Sites updated: egress.egress_resolve_token_values (unset/empty token),
git_gate._provision_dynamic_key and revoke_git_gate_provisioned_keys
(missing forge_token_env), env.resolve_env (unset interpolated var).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#258.
`egress_render_routes` and `_render_match_entry` now pass all manifest
strings (host, auth_scheme, token_env, path/header values) through
`_yaml_str_escape` before interpolating into double-quoted YAML scalars,
preventing stray `"` or newlines from corrupting routes.yaml.
`git_gate_render_gitconfig` now calls `_gitconfig_validate_value` on
each Upstream value (and the derived alias) before writing the
`insteadOf` line, rejecting any value containing a newline that would
inject arbitrary gitconfig keys.
EgressPlan gains a `canary: str` field (default "") populated in Egress.prepare()
using secrets.token_urlsafe(32). Each launched bottle:
- sidecar receives EGRESS_TOKEN_CANARY=<value> (literal env entry, scanned by
existing known-secrets detector without any detector code changes)
- agent receives BOT_BOTTLE_CANARY=<value> (visible fake secret that signals
exfiltration with zero false positives if it appears in outbound traffic)
Docker compose and macos-container backends updated; smolmachines shares docker
compose and so picks this up automatically. Unit tests cover canary uniqueness,
detection via scan_known_secrets, and EgressPlan backward-compat default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Provider routes (the agent talking to its own LLM API — api.anthropic.com,
the Codex backend, etc.) carry the whole conversation payload, which is the
worst source of token-shaped false positives. egress_routes_for_bottle now
fills outbound_on_match=redact on any provider route that doesn't set it
explicitly, so a match there is scrubbed and forwarded rather than blocked
or queued for the operator. A provider that sets the policy keeps its
choice; manifest routes still default to supervise.
Tests: provider route gets redact default, explicit provider policy
preserved, manifest route unaffected. README + PRD 0062 updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnvBjPZC5V7qeQpFbQdDmS
Give each egress route a policy for what the proxy does when an outbound
DLP detector matches a token, defaulting to the supervise flow added in
the previous commit. The goal is cutting false-positive friction without
weakening default-deny.
- redact: scrub the matched value(s) from the body, non-host headers, and
path/query via redact_tokens, then re-scan. Forward if clean; fail
closed with a 403 if a match remains on a surface redaction can't
rewrite (the hostname, or a unicode-evasion token). For routes where a
token-shaped value is noise the upstream doesn't need.
- block: the original hard 403, never overridable.
- supervise (default, unset): hold the request for operator approval.
Structural blocks (CRLF, no safelist-able value) stay hard 403s under
every policy.
Threads outbound_on_match from the bottle manifest (manifest_egress)
through the resolved EgressRoute and rendered routes.yaml (egress.py) to
the addon's Route (egress_addon_core), and round-trips it via the
list-egress-routes introspection endpoint. The allow/egress-block tool
descriptions document the new key.
Tests: manifest parse/validation, core parse/validation, full
manifest->render->addon round-trip for redact. README + PRD 0062 updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HnvBjPZC5V7qeQpFbQdDmS
Level 0 (off, default): no stderr output beyond boot line.
Level 1 (blocks): each block/warn emitted as JSON with reason and
request context (host, method, path, response_status for inbound).
Level 2 (full): level-1 events + egress_request and egress_response
JSON lines for every forwarded connection.
Block logging at level 1+ replaces the previous plain-text stderr write.
DLP warn logging is also gated on level 1+. All block call sites now pass
_req_ctx(flow) so the blocked request is visible in the log entry.
Boot message shows log level label (off/blocks/full).
Adds PRD 0053 documenting wire format, manifest format, and all log event
shapes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a top-level `log: true` option to the egress config that logs the
full request (method, path, headers, body) and response (status, headers,
body) for every forwarded connection as JSON lines on stderr.
Wire format: `log: true` at the root of routes.yaml, parsed into the new
`Config` dataclass alongside `routes`. The sidecar addon switches from
`self.routes` to `self.config` and writes `_log_request` / `_log_response`
JSON lines when `self.config.log` is set.
Manifest: `egress.log: true` in bottle YAML flows through `EgressConfig.Log`
→ `Egress.prepare()` → `egress_render_routes(..., log=)` → routes.yaml.
`EgressPlan` also carries the flag for introspection.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add explicit type annotations to _route_to_yaml_fields return type and fields dict
- Add type: ignore for path_allowlist iteration which has object type
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Remove 35+ unused imports across 20+ files (W0611). Wrap 19 lines
to fit under 100 character limit (C0301). Add type casts and
annotations in egress_addon_core.py to resolve pyright errors
caused by JSON parsing of untyped objects.
Key changes:
- Remove unused imports (abstractmethod, mock utilities, etc)
- Split long lines at logical breaks (method calls, error messages)
- Add typing.cast() for proper type inference in JSON parsing
- Explicit type annotations for dict/list accesses
Results:
- Pylint rating: 8.73/10
- egress_addon_core.py: 0 pyright errors (was 15)
- All W0611 and C0301 issues fixed
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
EgressRoute now extends egress_addon_core.Route, which holds the four
wire-visible fields (host, path_allowlist, auth_scheme, token_env).
EgressRoute adds only the three host-side fields (token_ref, roles,
tls_passthrough) that are never serialised to the sidecar.
_route_to_yaml_fields is typed as Route -> dict, making the host→wire
boundary explicit: only fields declared on the base class cross into the
YAML the addon reads.
Replace _merge_provider_route's five-case nested conditional with a flat
provisioned-wins merge: provider routes claim their hosts outright, manifest
routes for unclaimed hosts append unchanged. Token slot assignment moves to a
single _assign_token_slots pass over the merged list.
Add _route_to_yaml_fields as the single authoritative EgressRoute→YAML mapping,
eliminating the risk of EgressRoute and egress_addon_core.Route silently
drifting apart when new fields are added.
egress_manifest_routes is now a pure lifter with no slot assignment.
_merge_provider_route and _find_or_alloc_token_env are removed.
Tests updated: conflict-die case removed, upgrade-bare replaced with
provider-wins semantics, slot-assignment tests moved to TestSlotAssignment.
Add `provisioned_env: dict[str, str]` to `AgentProvisionPlan`. When
`forward_host_credentials=True`, `agent_provision_plan` reads the host
Codex access token at prepare time and stores it under
`CODEX_HOST_CREDENTIAL_TOKEN_REF`. Both backends merge `provisioned_env`
over `os.environ` before calling `egress_resolve_token_values`, so the
token slot resolves like any other manifest-declared token ref.
Removes `egress_resolve_token_values_with_provider` and the sentinel
`continue` skip from `egress_resolve_token_values`. The function is now
fully generic — it neither knows nor cares about provider identity.
Extract egress_resolve_token_values_with_provider into bot_bottle/egress.py.
Both docker and smolmachines launch paths now call the shared function
instead of duplicating the forward_host_credentials / CODEX_HOST_CREDENTIAL_TOKEN_REF
resolution block.
Also fixes the host_env: object annotation on smolmachines._resolve_token_env
to the correct dict[str, str].
Closes#118.
EGRESS_ROLES, EGRESS_SINGLETON_ROLES, and PROVIDER_EGRESS_ROLES were
all empty frozensets after the codex_auth and claude_code_oauth roles
were removed. Delete the constants and all validation code that iterated
over them (the singleton-role loop and provider-role check in
_validate_egress_routes, the EGRESS_ROLES membership test in
EgressRoute.from_dict). EgressRoute.from_dict now rejects any role
string unconditionally; _validate_egress_routes loses its
agent_provider_template parameter entirely.
Assisted-by: Claude Code
manifest → agent_provider → egress → manifest created a cycle that
caused ImportError on any module import. With from __future__ import
annotations already present, Bottle is only needed at type-check time
(annotations are lazy strings under PEP 563).
Assisted-by: Claude Code
Remove provider-specific branching from egress.py and pipelock.py.
Previously, `egress_routes_for_bottle` and `pipelock_effective_tls_passthrough`
both contained `template == "codex"` checks — the same pattern the rest
of the PR moved out of the backends.
Root cause: `EgressRoute` had no `tls_passthrough` field, so pipelock
couldn't learn from the synthesised Codex routes that they needed
passthrough. Fix:
- Add `EgressRoute.tls_passthrough: bool`. `egress_manifest_routes` lifts
the existing `pipelock.tls_passthrough` manifest flag here; provider
routes set it directly.
- Add `AgentProvisionPlan.egress_routes`. `agent_provision_plan` populates
it for Codex + `forward_host_credentials`, including `tls_passthrough=True`.
- Replace Codex-specific `egress_routes_for_bottle` logic with a generic
`_merge_provider_route` helper. Backends call `egress_routes_for_bottle(bottle,
plan.egress_routes)`; no provider type checks inside egress or pipelock.
- Rewrite `pipelock_effective_tls_passthrough` to read `route.tls_passthrough`
from the merged route set instead of re-implementing the provider check.
- Both backends now call `agent_provision_plan` before `Egress.prepare` and
`PipelockProxy.prepare`, threading `plan.egress_routes` to both. `has_provider_auth`
is derived from `egress_manifest_routes` (manifest routes only — provider
routes carry no auth roles, so the result is identical).
Assisted-by: Claude Code