The "Manifest" section now describes the per-file MD layout under
~/.claude-bottle/{bottles,agents}/, the filename-as-key convention,
the YAML subset constraints, and the trust boundary (bottles are
home-only by filesystem layout). Includes a working bottle example
with comments inside the frontmatter and a working agent example
showing the Markdown body as the system prompt.
Drops claude-bottle.example.json. The new examples/ tree —
examples/bottles/dev.md, examples/agents/implementer.md,
examples/agents/researcher.md — verifies the parser end-to-end via
Manifest.from_md_dirs(examples/, None).
Manifest.resolve walks $HOME/.claude-bottle/{bottles,agents}/ and
$CWD/.claude-bottle/agents/ instead of reading claude-bottle.json.
A bottles/ subdir under $CWD is logged as a warn and ignored —
the filesystem layout IS the trust boundary, no resolver check
needed.
If claude-bottle.json exists alongside no .claude-bottle/ dir at
either location, dies with a clear pointer at the README — the
manifest format changed and we don't silently fall back.
Manifest.from_md_dirs(home, cwd) is the programmatic entry point
tests use to build a Manifest from fixture directories without
touching os.environ. Manifest.from_json_obj is preserved for
tests that still want to build manifests in-memory.
Bottle / agent frontmatter goes through Bottle.from_dict /
Agent.from_dict — same validators as today's JSON path. Unknown
top-level frontmatter keys die with a "did you mean" pointer
listing accepted keys. Filenames that don't match [a-z][a-z0-9-]*
are skipped with a warn.
Agent files accept the Claude Code subagent passthrough fields
(name, description, model, color, memory) so the same file can
drop into ~/.claude/agents/ — claude-bottle ignores them at
launch but doesn't reject.
The dry-run integration test ships a real MD fixture tree now;
all 200 unit + 17 integration tests stay green.
claude_bottle/yaml_subset.py — stdlib-only, ~450 lines. Parses the
bounded shape claude-bottle's manifest files use:
- Block mappings (top-level + nested via indentation)
- Block lists (under a key, items can be scalars or block-style
mappings whose keys align with the rest after the dash)
- Inline lists `[a, b]` and inline dicts `{a: 1}` for one-level
leaves
- Quoted (single + double) and bare strings
- Scalars: string, int, true/false, null/~
Rejects, each with a clear pointer at the line number:
- `yes`/`no`/`on`/`off`/`Y`/`N`/`TRUE`/`FALSE` — only literal
`true` / `false` are bools (the Norway problem stays solved by
"quote your strings if they look like bools")
- Bare strings that look like dates / octals / hex / floats
- Anchors (`&`/`*`), aliases, YAML tags (`!!str`)
- Multi-line block scalars (`|`, `>`)
- Tabs in indentation
- Nested flow style (only one level allowed)
Public API:
parse_yaml_subset(text) -> dict[str, object]
Top level must be a mapping.
parse_frontmatter(text) -> (dict, body_text)
Strips `---` delimiters, parses content as YAML subset, returns
the verbatim body text after the closing fence.
46 unit tests covering every construct the real manifest files use
(the cred_proxy.routes structure, role-as-inline-list, nested
ExtraHosts dicts) plus every rejection case listed in PRD 0011.
claude-bottle has a single primary user today; an automated
JSON → MD migration tool is overkill. Hand-rewriting one file
is the migration cost. The resolver still dies with a pointer
at the README's manifest section if a stale claude-bottle.json
is found alongside no .claude-bottle/ directory, so the breaking
change isn't silent.
Drops: SC #6 (migration tool), the "Migration command" In Scope
sub-bullet, the migrate_manifest.py / cli wiring entries from
Existing code touched, the tests/integration/test_migrate_manifest.py
entry from Tests, the destructive-vs-additive open question.
Renumbers the remaining success criteria 6, 7 (formerly 7, 8).
Backward-compat section rewritten around hand-rewrite.
Specs the implementation chosen in the PR #16 closing comment:
per-file MD-with-YAML-frontmatter layout for both bottles and
agents, with a hand-rolled YAML subset parser (no PyYAML).
Layout:
- $HOME/.claude-bottle/bottles/<name>.md (home-only)
- $HOME/.claude-bottle/agents/<name>.md (home agents)
- $CWD/.claude-bottle/agents/<name>.md (repo-supplied agents)
The trust boundary that PRD-0011-v1 (closed PR #15) tried to
enforce in the resolver now falls out of filesystem layout —
$CWD/.claude-bottle/ has no bottles/ subdir, the loader doesn't
look there. Filesystem layout IS the enforcement.
Eight success criteria, including: stdlib-only (no new runtime
dep), idempotent migration command, agent files shaped close to
Claude Code's existing subagent spec so the same file can drop
into ~/.claude/agents/.
PRD-only; no implementation in this commit. PRD slot 0011 is
intentionally reused — the v1 file was never merged to main.
Captures the two open questions surfaced by PRD 0011: should bottles and agents stay grouped in one file or split per file, and should the format stay JSON or move to YAML / MD-with-frontmatter.
Recommends per-file MD-with-frontmatter (with agents shaped close to Claude Code's subagent spec so they can drop into ~/.claude/agents/ as a side effect), explicitly flags the PyYAML runtime dependency as a user-decision crossing the project's "low deps by default" line, and leaves several other choices (hidden dotdir vs visible, migration tooling) as open questions.
Companion to docs/prds/0011-cwd-manifest-trust-boundary.md (which solves the trust problem at the resolver layer); this doc explores a structural alternative that would make the boundary self-documenting on disk.
The previous diagram showed three parallel egress lanes — agent ↔
pipelock, agent ↔ git-gate, agent ↔ cred-proxy — each going off-box
independently. That was true of an earlier shape but is now wrong on
two counts:
1. cred-proxy's outbound HTTPS routes through pipelock (set when
the SSRF / CA-trust wiring landed). All cred-proxy upstream
bytes pass pipelock's allowlist + body scanner.
2. git-gate's SSH push/fetch is direct out the egress network and
has never gone through pipelock — pipelock is HTTP-only.
Reflect both: the diagram now collapses to one HTTP/HTTPS chokepoint
(pipelock) that the agent and cred-proxy share, plus a separate SSH
lane for git-gate. Prose paragraph above the diagram updated to call
out the "everything except SSH" framing explicitly.
Verified against the current code: HTTPS_PROXY=pipelock set on the
agent in launch.py and on cred-proxy in DockerCredProxy.start;
git-gate's create-args carry no proxy env vars.
claude-code sends Accept-Encoding: gzip, deflate, br on every
request. api.anthropic.com honors it and returns gzip-compressed
SSE responses. Pipelock 2.3.0 has no decompression path; its
response scanner fails closed with "blocked: compressed
sse_stream response cannot be scanned" — and that gate fires
even with response_scanning.enabled=false and sse_streaming
disabled. Verified empirically against the real pipelock image.
Cleanest fix that preserves DLP coverage end-to-end: have
cred-proxy ask upstream for uncompressed bytes. Strip the
agent's Accept-Encoding when building the upstream headers and
inject `Accept-Encoding: identity`. Upstream returns plaintext;
pipelock can scan; no 403.
Bandwidth cost is the gzip ratio one-way (cred-proxy ↔ upstream
through pipelock). For LLM SSE streams that's a few KB extra per
turn — trivial compared to the alternative of leaving
pipelock's response scanner blind.
The previous attempt added a `suppress: [{rule, path}]` entry. The
yaml validated and the entry showed up in the live pipelock's
config, but the BIP-39 detector kept firing — `suppress` only
silences alerts, not enforcement.
Reproduced the failure in isolation, probed three knobs against a
real pipelock with a canonical BIP-39 body
(`abandon abandon ... about`):
suppress: [{rule: "BIP-39 Seed Phrase", path: "/anthropic/**"}]
-> still 403
rules.disabled: ["dlp:BIP-39 Seed Phrase"]
-> still 403
seed_phrase_detection: { enabled: false }
-> 200 (forwarded)
Only the global toggle actually stops the block. Pipelock 2.3.0
has no per-path / per-host knob for this detector, so the
trade-off is: when the bottle declares an `anthropic-base-url`
route, BIP-39 detection comes off globally for that bottle. Every
other DLP pattern (gh*_, sk-ant-, AKIA, etc.) keeps firing — the
ones that actually map to claude-bottle's threat model.
Drops the `suppress:` emitter from pipelock_build_config /
pipelock_render_yaml; replaces with a `seed_phrase_detection:
{ enabled: false }` block driven by
`pipelock_seed_phrase_detection_enabled(bottle)`. Tests flip from
suppress-shape to seed_phrase shape. End-to-end probe through the
real pipelock image confirms BIP-39 bodies forward.
claude-code's chat bodies legitimately trip pipelock's BIP-39 seed-
phrase detector — any 12+ English words that pass the BIP-39
checksum match. The direct path to api.anthropic.com already sits
on tls_interception.passthrough_domains so no body scan runs
there, but the cred-proxy hop is plain HTTP through pipelock and
the body scanner fires.
Add an anthropic-route-specific suppress entry:
suppress:
- rule: "BIP-39 Seed Phrase"
path: "/anthropic/**"
Just this one detector, only on this one path. Every other DLP
pattern (AKIA, gh*_, sk-ant-, etc.) keeps firing — those are
unambiguous credential shapes with no legitimate reason to appear
in a chat completion. Other detectors that fire on natural
language can be added to the suppress list when/if they surface.
Wiring: pipelock_effective_suppress(bottle) computes the entries
from bottle.cred_proxy.routes; pipelock_build_config accepts them
and emits a `suppress:` block; pipelock_render_yaml renders it.
Probed schema with `pipelock check --config` to confirm the
{rule, path} shape; full yaml validates clean.
The agent's HTTP_PROXY points at pipelock, so a request to
http://cred-proxy:9099/... arrives at pipelock; pipelock resolves
the host, sees an RFC1918 address (the bottle's internal Docker
network sits in 172.x), and 403's "SSRF blocked: cred-proxy
resolves to internal IP 172.20.0.4". Bypassing pipelock entirely
would also remove its body scanner from the agent->cred-proxy leg
— we want to keep that DLP coverage.
Pipelock has `ssrf.ip_allowlist` for exactly this: CIDRs that
override the built-in internal-IP block while api_allowlist + body
scanning + tls_interception keep firing.
Wiring:
- `pipelock_build_config` accepts `ssrf_ip_allowlist`; when
non-empty, emits an `ssrf: { ip_allowlist: [...] }` block.
- `pipelock_render_yaml` renders that block.
- `PipelockProxyPlan` gains `internal_network_cidr`.
- New `network_inspect_cidr(name)` helper reads the Docker-assigned
subnet via `docker network inspect`.
- launch.py: after `network_create_internal`, inspect the CIDR,
re-render the yaml with `ssrf_ip_allowlist=(cidr,)`, overwrite
the file in place; `DockerPipelockProxy.start` then docker-cp's
the updated content. Prepare's initial render stays unchanged
(CIDR isn't known yet at prepare time).
The exception scope is the bottle's own internal network only —
agent ↔ pipelock / git-gate / cred-proxy. Body scanning still
applies to the bytes flowing through pipelock; pipelock just no
longer treats those internal IPs as exfil targets.
The agent's HTTP_PROXY env points at pipelock, so an
ANTHROPIC_BASE_URL like http://cred-proxy:9099/anthropic doesn't
short-circuit through Docker's embedded DNS — it gets forwarded
through pipelock, which then checks its api_allowlist for the
hostname `cred-proxy` and 403's because the name isn't there. The
agent surfaces the failure as "API Error: 403 blocked: domain not
in allowlist: cred-proxy" on Claude's first call.
Fix: pipelock_effective_allowlist auto-adds CRED_PROXY_HOSTNAME
when bottle.cred_proxy.routes is non-empty (i.e., when the
sidecar will actually be running and reachable).
Move CRED_PROXY_HOSTNAME from backend/docker/cred_proxy.py to the
backend-agnostic claude_bottle/cred_proxy.py so pipelock can
reference it without a layering violation; the docker concrete
imports it from the same place.
Removes the legacy `CLAUDE_BOTTLE_OAUTH_TOKEN` -> `CLAUDE_CODE_OAUTH_TOKEN`
forward in prepare.py. Bottles that need claude-code to authenticate
must declare a cred_proxy route with role: "anthropic-base-url" — there
is no fallback that hands the token to the agent directly.
Drops the now-dead BottleSpec.forward_oauth_token field, the CLI
setter that read CLAUDE_BOTTLE_OAUTH_TOKEN from the host env at
prepare time, and the forward_oauth_token=False arg in the six
pipelock integration tests.
PRD 0010 and README updated; the dev ~/claude-bottle.json gains an
anthropic-base-url route so the implementer/researcher agents keep
working.
BREAKING: bottles previously relying on the implicit OAuth forward
will now produce an agent environ without any Anthropic credential.
Verified with --dry-run: a bottle with no anthropic-base-url route
yields env_names: [] (no token at all); a bottle that declares the
route yields ANTHROPIC_BASE_URL plus a non-secret placeholder for
CLAUDE_CODE_OAUTH_TOKEN.
Two failure-clarity paper cuts from the cred-proxy debugging:
1. Every docker create / start / network-connect call on the three
sidecars (pipelock, git-gate, cred-proxy) was piping stderr to
DEVNULL. A stuck orphan from a previous run produced "failed to
create pipelock sidecar claude-bottle-pipelock-demo" with no
pointer at the real cause ("Conflict. The container name ... is
already in use ..."). Switch each call to capture_output=True and
include the stripped stderr in the die() message.
2. The agent container had a container_exists() probe in resolve_plan
that fails fast with a hint, but the sidecars (whose names are
deterministic from the slug) didn't. So an orphan caused launch()
to bail deep inside docker create. Add a probe in resolve_plan for
each sidecar this launch will actually try to create: pipelock
always; git-gate when bottle.git is non-empty; cred-proxy when
bottle.cred_proxy.routes is non-empty. Die with a "./cli.py
cleanup" pointer.
Smoke-tested with an orphaned pipelock-<slug> container — the new
probe fires with the expected hint before any sidecar build/start
work begins.
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.
git-gate holds an SSH IdentityFile for push/fetch; cred-proxy holds
a PAT for HTTPS REST API calls. The two brokers are orthogonal —
the common dev setup names both on the same host (e.g. gitea.dideric.is
SSH for push, gitea.dideric.is PAT for `tea pr create`).
The original PRD 0010 wording called this a "configuration smell"
and rejected it at parse time. That was wrong; this drops the
overlap rejection from the validator and updates the PRD prose to
match. Tests flip from "rejection" to "coexistence" assertions.
- Architecture diagram gains the cred-proxy lane (agent talks plain
HTTP via bearer-auth-injection; sidecar talks HTTPS to the real
upstream with the manifest token).
- Adds a cred-proxy entry under the sidecar bullet list, with a
pointer to PRD 0010.
- Manifest example illustrates the `tokens` array on a bottle.
- Auth section notes that declaring an `anthropic` token routes
CLAUDE_BOTTLE_OAUTH_TOKEN through the sidecar instead of into
the agent's environ.
- claude-bottle.example.json gains an `agentic` bottle declaring
all four token kinds, plus a paired `agentic-helper` agent.
Drives DockerCredProxy.start through the production code path against
a fake upstream container running on the same egress network. The
"agent" is a curl container on the bottle's internal network — same
access topology the agent uses in production.
Covers PRD 0010 success criteria:
- SC3 (the request reaches upstream, header round-trip works)
- SC6 (inbound Authorization stripped; the proxy injects the
configured token even when the agent tries to smuggle one in)
- partial SC2 (cred-proxy reachable by the alias from the internal
network)
- 404 for unconfigured routes
Live-network tests against real Anthropic / GitHub / Gitea / npm
upstreams (SC4 and SC5 specifically) are deferred — the fake-upstream
shape covers the routing + header layer that's actually under test
here.
bottle.tokens declarations contribute their upstream hosts to both
pipelock's allowlist (so cred-proxy can reach them) and
passthrough_domains (so pipelock doesn't MITM the connection —
cred-proxy validates real upstream certs with the system CA bundle).
Mapping: anthropic -> api.anthropic.com (already on defaults);
github -> api.github.com + github.com; gitea -> the entry's host;
npm -> registry.npmjs.org.
- DockerBottleBackend instantiates DockerCredProxy alongside pipelock
and git-gate; threads it through prepare and launch.
- DockerBottlePlan gains cred_proxy_plan; preflight rendering shows
the declared kinds + TokenRefs and to_dict emits a cred_proxy
array matching the routing table.
- prepare.py: when bottle.tokens has an anthropic entry, route the
agent at the proxy via ANTHROPIC_BASE_URL, drop the agent-side
CLAUDE_CODE_OAUTH_TOKEN forward (the token goes to the sidecar's
environ instead, set a non-secret placeholder so claude-code's
startup check passes), and default the telemetry-off env vars.
- launch.py: bring up the cred-proxy sidecar in ExitStack before the
agent container so DNS resolution for `cred-proxy` succeeds on the
agent's first call.
- backend/__init__.py: add provision_cred_proxy to the provision
template (runs after provision_git so it can append to ~/.gitconfig).
- bottle_plan _view: env_names is derived from the forwarded_env dict,
so the preflight reflects the PRD 0010 switch without ad-hoc
branching on spec.forward_oauth_token.
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.
Mirrors DockerGitGate: build the image, docker create on the internal
network with --network-alias cred-proxy, docker cp the routes.json
into /run/cred-proxy/, attach the egress network, docker start. stop()
is idempotent.
Token values flow host env -> subprocess env -> sidecar env via
docker create -e NAME (no =VALUE on argv). The resolver fails early
with a clear pointer at the missing host env var name if any TokenRef
is unset.
Helpers (cred_proxy_container_name, cred_proxy_url) are agent-side
stable: the URL uses the network alias, not the slugged container
name, so the provisioner can write a fixed http://cred-proxy:9099/
URL regardless of which bottle is running.
Stdlib-only Python proxy: reads /run/cred-proxy/routes.json on boot,
listens on 0.0.0.0:9099, strips inbound Authorization, injects the
configured header (Bearer or token) using the route's token_env env
var, forwards over HTTPS to the upstream, and streams the response
back chunk-by-chunk (SSE-safe).
Hop-by-hop headers are stripped per RFC 7230, including anything
listed in `Connection:`. Content-Length is dropped so http.client
recomputes it on the upstream leg. Tokens never reach routes.json —
they arrive via the container's environ.
Dockerfile.cred-proxy builds on python:3.13-alpine pinned by digest;
mkdir /run/cred-proxy is baked in so docker cp can drop the route
table at start time. No pip install layer.
Smoke-tested: container boots, logs listen line, returns 404 for
unmatched paths. Full request/response cycle covered by the
integration tests in a follow-up commit.
Lifts bottle.tokens into a per-route CredProxyUpstream table, renders a
mode-600 routes.json that carries no token values or host env-var
names, and derives the {token_env: TokenRef} map the launch step will
use to forward host env values into the sidecar's environ.
Shape mirrors GitGate/PipelockProxy: abstract base does the host-side
prepare; start/stop is backend-specific. No backend wiring yet.
TokenEntry carries Kind (anthropic / github / gitea / npm), TokenRef
(name of host env var the CLI resolves at launch), and an optional Url
(required for gitea, fixed for the other kinds). Validation rejects
unknown kinds, duplicate non-gitea entries, duplicate gitea Urls, and
overlap with bottle.git hosts (where git-gate is already brokering).
No wiring yet — the field exists on Bottle but cred-proxy is the next
step. Adds tests/unit/test_manifest_tokens.py.
Make the cred-proxy a per-bottle sidecar container on the bottle's
internal docker network instead of a root-owned process inside the
agent container. The boundary becomes container namespace
separation, matching pipelock and git-gate. Update summary,
problem, goals, in-scope, architecture diagram, components,
existing code touched, external deps, and open questions; add a
"Considered alternatives" section recording the rejected
in-container shape.
Per-bottle reverse proxy that holds API tokens (Anthropic OAuth,
GitHub PAT, Gitea PAT, npm) in a root-owned process; agent gets
only URLs in its environ. AWS / SigV4 explicitly out of scope.
Squashes the demo-build arc: initial GIF + scripts, refactor to drive
recording through real cli.py, theme/timing tweaks, and the switch to
prompt-driven probes.
- README architecture diagram drops the socat/ssh image box and
the agent's ~/.ssh/config; the prose-bullets section drops the
ssh image; the manifest example swaps `ssh:` for `git:` so
someone copy-pasting it picks up the new shape.
- claude-bottle.example.json: `default` bottle's `"ssh": []` is
gone (now just an empty bottle); the gitea-dev example already
uses `git:` since the ExtraHosts work.
- PRD 0007 carries a "Superseded by PRD 0009" header at the top
with a one-paragraph block explaining why; the file stays so
the rationale of the prior design is still in-tree.
- git_gate.py: drop the now-stale shadow-route mention from a
docstring (the validator went away in the manifest layer).
- Delete tests/unit/test_ssh_gate.py and the fixture_with_ssh helpers.
- test_pipelock_yaml: drop the ssh-leak guard (structurally
impossible now); the remaining tests switch to fixture_minimal.
- test_pipelock_allowlist: rewrite the union/dedup test to
exercise an egress.allowlist that duplicates a baked default
(the property the ssh-leak assertion was hitching onto).
- test_manifest_git: shadow-route assertion becomes a legacy-ssh-
dies-with-hint assertion, since bottle.ssh is now parse-fail.
- test_orphan_cleanup: drop the SSHGate.stop idempotency check;
pipelock equivalent stays.
- test_dry_run_plan: drop assertions on the removed ssh_hosts /
ssh_gate keys.
52 unit tests pass.
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.
Drop the SshEntry dataclass, the Bottle.ssh field, the shadow-
route validator, and the SSH-only _opt_port helper. A legacy
bottle.ssh key now parse-fails with a one-line hint pointing at
bottle.git (PRD 0008), which is the replacement.
BREAKING: manifests carrying bottle.ssh will not load. Migration
is per-entry: drop the ssh entry, add a git entry with a Name +
full Upstream URL + IdentityFile.
ssh-gate was built for non-git SSH (PRD 0007), but every
upstream currently declared in any bottle is a git remote, and
those now flow through git-gate (PRD 0008) with credential
isolation, gitleaks scanning, and `insteadOf` URL rewrites.
ssh-gate is left doing L4 forwarding with no gating value over
git-gate's path; carrying it means a redundant sidecar lifecycle,
a shadow-route validator between bottle.ssh and bottle.git, and
a third place to keep an SSH identity in sync.
Goal is straightforward deletion: bottle.ssh becomes a parse
error pointing at bottle.git, the SshEntry / SSHGate / socat
provisioner / pipelock allowlist branch all go away, and PRD
0007 carries a "Superseded by PRD 0009" header so the rationale
of the prior design stays in the tree.
Consolidates oauth-token-exposure-to-claude.md and
tea-token-isolation-via-proxy.md into agent-credential-proxy-landscape.md,
adding a May-2026 survey of existing tools (Docker AI Sandboxes,
Cloudflare Sandbox Auth, Infisical Agent Vault, nono, Aembit, LiteLLM
CVE-2026-42208, Portkey, Helicone, etc.) and a build-vs-adopt verdict.
Adds secret-minimization-over-dlp.md explaining why pipelock's body
DLP and gitleaks's pre-receive scan cannot stop encoding/splitting
exfil, and why moving credentials out of the bottle (the git-gate
pattern, generalized) is the only robust answer.
Updates git-secret-scanning-hardening.md's reference to point at
the new consolidated landscape doc.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- example manifest swaps the gitea-dev bottle from ssh: to git:
and shows ExtraHosts pinning gitea.dideric.is to its Tailscale IP
- README's git-gate paragraph names the field and the case it
solves (upstream resolvable on the host but not from the gate
container's default DNS)
- PRD 0008's manifest-field bullet mentions the field for parity
GitGateUpstream carries each entry's extra_hosts; a new
git_gate_aggregate_extra_hosts() merges them into one map for the
gate container's /etc/hosts. Same host -> same IP is harmless
duplication; same host -> different IPs is a manifest bug
(/etc/hosts is per-container, not per-upstream) and dies with
the conflicting upstream names.
DockerGitGate.start passes one --add-host host:ip per merged
entry on docker create. Empty map (the default) emits no flags
and is a no-op for bottles that don't need DNS overrides.
Optional `ExtraHosts: { hostname: ip }` map per git entry. The
docker backend will surface these to the gate sidecar via
--add-host so the gate can resolve upstreams whose default
container DNS doesn't point at the reachable IP (e.g.
Tailscale-only hosts with a public DNS A record pointed
elsewhere). The agent-side insteadOf rewrite still keys off
the original hostname, so the manifest's Upstream URL stays
human-readable.
Architecture diagram + bullet now reflect that the gate fronts
every git operation, not just push: pre-receive gitleaks-gates
the push path; an access-hook refreshes from upstream before each
upload-pack so fetch / clone / pull / ls-remote see whatever the
upstream has at that moment (fail-closed if unreachable).
A pair of integration tests against a real sshd-based "upstream"
sibling container that prove every operation through the gate is
observably equivalent to the same operation against the upstream:
- test_clone_and_refetch_reflect_upstream: clone via gate
returns the upstream's current commit; an out-of-band commit
on the upstream shows up via the gate on the next ls-remote.
- test_push_through_gate_lands_on_upstream: a clean push routed
through the gate lands on the upstream's bare repo.
The upstream container is a tiny inline-built alpine image with
openssh-server, a `git` user (passwd -u so sshd doesn't reject
the locked account), and a baked bare repo seeded with one
commit. Host keys are baked in at build so the test can pin
KnownHostKey on the manifest entry before the container starts.
While wiring this up the access-hook gained a one-shot HEAD
sync: `git init --bare` defaults HEAD to refs/heads/master, and
upstreams that use main would leave the bare repo's HEAD
unresolvable — clones came through but the working tree was
empty. The hook now does a `rev-parse --verify HEAD` check
after the first fetch and runs `ls-remote --symref` to repoint
HEAD if it doesn't resolve. One extra round-trip on first
fetch only.
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.
The gate is now a transparent mirror, not push-only. Per-repo
init now runs `git remote add --mirror=fetch origin <url>` so a
later `git fetch origin` mirrors the upstream's full ref graph at
canonical paths. The pre-receive hook forwards accepted refs via
`git push origin` (renamed from upstream).
New: an access-hook script wired via `git daemon --access-hook`
runs `git fetch origin --prune` against the real upstream before
every upload-pack request (clone, fetch, pull, ls-remote). On
upstream error the hook exits non-zero — the agent's fetch fails
rather than the gate serving stale data.
The pre-existing smoke test (ls-remote against unreachable
upstream returns refs) had to invert: under the bidirectional
design any ls-remote success is necessarily a success against
the upstream, so the unreachable-upstream case now correctly
fails closed.
The gate now fronts every git operation, not just push. Fetch
(clone, pull, ls-remote) is mirrored via git daemon's
--access-hook running 'git fetch origin --prune' against the
real upstream before each upload-pack; fail-closed if upstream
is unreachable so the agent never serves stale data.
Push path is unchanged in concept (gitleaks gate → forward) but
the hook now pushes to 'origin' rather than 'upstream', matching
the remote name the entrypoint configures.