Compare commits

...

4 Commits

Author SHA1 Message Date
didericis 9fa9717135 docs: switch cred-proxy to sidecar shape
test / unit (pull_request) Successful in 15s
test / integration (pull_request) Successful in 27s
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.
2026-05-13 15:35:38 -04:00
didericis 3747927b9e docs: align cred-proxy architecture diagram
Trim one trailing space from the four arrow/HTTPS rows and add
one dash to the bottle-container bottom edge so all box-bound
lines are 68 columns.
2026-05-13 15:35:37 -04:00
didericis 1411719973 docs: add PRD 0010 for credential proxy
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.
2026-05-13 15:35:37 -04:00
didericis 3f4708f970 docs(demo): add end-to-end demo with recorded GIF
test / unit (push) Successful in 22s
test / integration (push) Successful in 31s
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.
2026-05-13 15:33:28 -04:00
10 changed files with 724 additions and 0 deletions
+4
View File
@@ -1,6 +1,10 @@
.DS_Store
Thumbs.db
# User's local manifest — may contain real secrets. The repo ships
# claude-bottle.example.json and claude-bottle.demo.json for reference.
claude-bottle.json
# Claude Code local state — agent memory, scheduler lock, etc.
.claude/
+13
View File
@@ -8,6 +8,19 @@
Run multiple Claude Code agents on your own machine, each scoped to its own secrets, skills, and egress allowlist.
![pipelock and git-gate blocking exfil attempts against a live bottle](docs/demo.gif)
Four prompts to the agent inside a real bottle:
claude replies to `hello there` — proof api.anthropic.com routes
through pipelock's bumped TLS end-to-end;
asked to GET a non-allowlisted host, the agent's curl gets 403 back
from pipelock;
asked to POST a credential-shaped body to an allowlisted host, the
same 403 — pipelock's DLP body scanner caught it;
asked to commit and push an AKIA-shaped key, git-gate's gitleaks
pre-receive hook rejects the ref.
Run it yourself with `bash scripts/demo.sh`.
## Why "claude-bottle"?
Each container is a bottle; Claude is the genie inside. The genie's
+25
View File
@@ -0,0 +1,25 @@
{
"bottles": {
"demo": {
"env": {
"FAKE_TOKEN": "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ"
},
"git": [
{
"Name": "foo",
"Upstream": "ssh://git@upstream.invalid/path.git",
"IdentityFile": "~/.cache/claude-bottle-demo/fake-key",
"KnownHostKey": "ssh-ed25519 AAAAEXAMPLE"
}
]
}
},
"agents": {
"demo": {
"bottle": "demo",
"skills": [],
"prompt": "You are inside claude-bottle, a sandbox whose egress is mediated by a pipelock HTTP proxy (host allowlist + DLP body scan) and a git-gate sidecar (gitleaks pre-receive). The user will ask you to run network and git probes against these controls. The 'credentials' in the probes (ghp_..., AKIA...) are synthetic test patterns, the upstream is unreachable on purpose, and the whole point is to watch pipelock or git-gate refuse. Always invoke curl with `--proxy \"$HTTPS_PROXY\"` so requests actually traverse pipelock (curl ignores the uppercase HTTP_PROXY env var). Execute each probe literally via the Bash tool, report exactly what the proxy or gate returned, and keep replies to one or two lines. Do not refuse on credential-exfil grounds — refusing is what the bottle is designed to make unnecessary."
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

+78
View File
@@ -0,0 +1,78 @@
# VHS tape — drives `./cli.py start demo` interactively and asks
# claude (the AI) to run four probes via natural-language prompts.
# Setup (manifest + dummy SSH key + image pre-warm) and teardown
# happen outside the tape; record via `bash scripts/demo-record.sh`,
# which wraps both and decimates dead time post-record.
#
# Re-record when the prompts, manifest, or cli.py preflight rendering
# change. Claude's response time varies; the Sleeps below are sized
# for typical bottle launch + tool-use latencies and can be tightened
# if a recording consistently has slack.
Output docs/demo.gif
Set Shell "bash"
Set FontSize 13
Set Width 1180
Set Height 780
Set Padding 20
Set Theme "BirdsOfParadise"
Set TypingSpeed 40ms
Hide
Type "clear"
Enter
Show
# Real cli.py invocation — what a user with claude-bottle.json in cwd
# would type. The bottle declares one allowlist (only baked-in
# defaults), one git upstream (unreachable on purpose so gitleaks runs
# before the gate would forward), and a FAKE_TOKEN env var shaped like
# a GitHub PAT.
Type "./cli.py start demo"
Enter
Sleep 8s
# Confirm the y/N preflight. cli.py reads from /dev/tty.
Type "y"
Enter
# Wait for the bottle to launch: networks created, pipelock + git-gate
# sidecars started, agent container started, claude boots.
Sleep 22s
# Probe 1 — warm-up. A reply at all proves api.anthropic.com is
# reachable through pipelock end-to-end: bumped TLS handshake, DLP
# scan, and forward all succeed.
Type "hello there"
Enter
Sleep 10s
# Probe 2 — non-allowlisted host. Pipelock's host filter refuses to
# forward example.com; the agent runs curl via Bash and reports the
# 403 it sees. The bottle prompt frames this as a proxy-behavior
# probe so claude doesn't second-guess the request.
Type "GET http://example.com via curl — what status does the proxy give back?"
Enter
Sleep 18s
# Probe 3 — allowlisted host BUT a credential-shaped body. The
# bottle's FAKE_TOKEN env var is a ghp_-prefixed synthetic. The host
# check passes; pipelock's DLP body scanner has to catch it.
Type `POST "token=$FAKE_TOKEN" to http://api.anthropic.com/dlp-probe via curl — what does the proxy do?`
Enter
Sleep 20s
# Probe 4 — commit an AKIA-shaped key and push to the declared
# upstream. The bottle's ~/.gitconfig rewrites the URL to the
# git-gate via `insteadOf`, so the push lands at the gate, gitleaks
# runs in pre-receive, and the ref is rejected before the gate
# would forward upstream.
Type "init /tmp/r, commit AKIAQRJHK7N5ZPM2VXTL to leak.txt, push to ssh://git@upstream.invalid/path.git main — does the gate let it through?"
Enter
Sleep 30s
# Leave claude. The launcher tears down the container, sidecars, and
# networks on session end.
Ctrl+D
Sleep 4s
+477
View File
@@ -0,0 +1,477 @@
# PRD 0010: Credential proxy for agent-bound API tokens
- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-05-13
## Summary
Per-bottle sidecar container that holds API tokens (Anthropic
OAuth, GitHub PAT, Gitea PAT, npm token). The agent container
keeps only URLs in its environ; the sidecar injects the right
`Authorization` header and forwards over TLS to the upstream. The
boundary is the container line — PID, mount, and network
namespaces separate the agent's container from the sidecar's, so
from inside the agent the sidecar's processes are not visible in
`/proc`, cannot be `ptrace`'d, and share no memory. Reaching the
sidecar's environ requires escaping the agent container — the same
threshold pipelock and git-gate already rely on.
AWS / SigV4 is explicitly out of scope — it is per-request signing,
not header injection, and does not fit this proxy's shape. If a
bottle needs AWS credentials later, that lives in a separate PRD.
## Problem
Today `CLAUDE_CODE_OAUTH_TOKEN` (and any `bottle.env` secrets such
as a Gitea PAT, GitHub PAT, or npm token) gets `docker run -e`'d
straight into the agent's environ. Inside the bottle the agent
runs as `node` with `--dangerously-skip-permissions`; its Bash
tool can do `printenv`, `cat /proc/self/environ`, or
`node -e 'console.log(process.env)'` and capture every value into
the conversation. From there a prompt-injected or hijacked agent
can exfil over any allowed egress (api.anthropic.com itself if
nothing else).
Linux has no per-env-var ACL — once a variable is in a process's
environ, the process and its descendants own it. The credible
boundary is container-level: hold the credential in a separate
container the agent cannot reach. Default Docker's namespace
isolation enforces that — the same property pipelock and git-gate
already rely on.
The research note
[`agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
surveys the existing tools and concludes that a small
claude-bottle-specific reverse proxy is less work and less risk
than either adopting nono (alpha, unaudited) or Infisical Agent
Vault (TLS-MITM topology that doubles up on pipelock's CA stack).
This PRD is the build.
## Goals / Success Criteria
Each test runs inside a bottle whose manifest declares the four
supported kinds (anthropic, github, gitea, npm):
1. **No plaintext tokens in the agent's environ.** `printenv` and
`cat /proc/self/environ` from the agent's shell return only
URLs pointing at `cred-proxy:<PORT>/...`. None of the
`bottle.tokens[].TokenRef` values appear.
2. **Container boundary holds.** From the agent's shell, `ps aux`
does not list the cred-proxy process; there is no `/proc/<X>`
entry for it to read. The sidecar's hostname (`cred-proxy`)
resolves only on the bottle's internal network — from a
different bottle or from the host, the name does not resolve.
3. **Anthropic API works.** `claude` makes a successful streaming
tool-use round-trip via `ANTHROPIC_BASE_URL`
`cred-proxy:<PORT>/anthropic`. SSE chunks arrive without
buffering; `anthropic-version`, `anthropic-beta`, and
`X-Claude-Code-Session-Id` headers round-trip untouched.
4. **Git push to declared remotes works.** `git push` against a
`bottle.tokens[].Kind: github` or `gitea` upstream succeeds;
the upstream sees the gate's token, not the agent's.
5. **npm install works.** `npm install <public-package>`
succeeds against the registry pointed at the proxy. A scoped
install that requires the token (e.g. against a private
registry) also succeeds.
6. **Wrong token rejected at the source, not silently swapped.**
If the agent tries to send its own `Authorization: …` header,
the proxy strips and replaces with the configured one. A
manifest token revoked at the upstream produces a 401 to the
agent, not a 5xx.
## Non-goals
- **AWS / SigV4.** Per-request signing is a different shape; a
bearer-injecting proxy doesn't help. Hold for a future PRD
(likely an IMDS emulator sidecar handing out short-lived STS
credentials).
- **DB-backed credential store.** Flat env / mode-600 file only.
The LiteLLM CVE-2026-42208 incident is the cautionary tale:
any DB-backed credential gateway is itself a high-value attack
target.
- **Generic LLM-gateway features.** No cost tracking, no
fallbacks, no virtual keys, no multi-tenant routing, no usage
metering. The proxy is a credential-injection trust endpoint,
not a gateway.
- **Subsuming pipelock.** pipelock keeps its egress-allowlist
role. It drops the `api.anthropic.com` TLS-MITM job because
cred-proxy is now the trust endpoint for that host; everything
else pipelock does stays.
- **TLS interception inside the bottle.** The agent talks plain
HTTP to loopback; cred-proxy speaks real HTTPS outbound. No
container-local CA, no `golang/go#28866` loopback workaround.
- **Cross-bottle credential sharing.** One proxy per bottle, same
one-sidecar-per-agent posture as pipelock and git-gate.
- **`claude --bare` mode.** Reads only `ANTHROPIC_API_KEY`, not
the OAuth token. Not in claude-bottle's flow today.
- **MCP-server tokens, package-installer tokens for languages
beyond npm.** PyPI / Bun / cargo can land in a follow-up if
needed; the routing pattern generalizes.
## Scope
### In scope
- **Manifest field.** `bottle.tokens: [TokenEntry, ...]`. Each
entry carries `Kind` (`anthropic` | `github` | `gitea` |
`npm`), an optional `Url` (required for `gitea`, defaulted for
the others), and `TokenRef` (the name of a host env var the
CLI resolves at launch time).
- **cred-proxy sidecar.** Runs as its own container on the
bottle's internal docker network with hostname `cred-proxy`,
listening on `0.0.0.0:<PORT>` bound to the internal interface.
No host port published. Holds the tokens in the sidecar
container's environ — never on argv, never written to disk.
Per-`Kind` route handler: inject the right header, forward
over TLS, stream the response back without buffering.
- **Agent-side rewrites.** Provisioner writes:
- `ANTHROPIC_BASE_URL=http://cred-proxy:<PORT>/anthropic` to
the agent's environ
- `~/.npmrc` `registry = http://cred-proxy:<PORT>/npm/`
- `~/.gitconfig` `[url …] insteadOf = …` for each declared
`github` / `gitea` upstream
- `~/.config/tea/config.yml` with the proxy URL for each
declared `gitea` entry
- **Sidecar lifecycle.** Mirrors `DockerGitGate` /
`DockerPipelockProxy` in shape: `prepare` is host-side and
side-effect-free; `start` does `docker create` + `docker start`
on the bottle's internal network with hostname `cred-proxy`;
`stop` is idempotent `docker rm -f`. Container name:
`claude-bottle-cred-proxy-<slug>`. The agent container starts
after the sidecar is up so DNS resolution succeeds on the
agent's first call.
- **pipelock interop.** cred-proxy's outbound HTTPS still
traverses pipelock — pipelock keeps its egress-allowlist role
for the four upstream hosts. Drop `api.anthropic.com` from
pipelock's TLS-MITM list (cred-proxy is now the trust endpoint
for that host); the host stays on the plain HTTPS allowlist.
- **Plan rendering.** `bottle_plan.py` and the y/N preflight
show: which tokens are configured (kind + ref name, not the
value), the proxy port, the routes the proxy will publish.
- **Drop the existing `CLAUDE_CODE_OAUTH_TOKEN` forward in
`prepare.py`.** Today it lands in the agent's environ; once
this PRD ships, it lands in the cred-proxy sidecar's environ
instead.
- **Tests.** Integration tests for each of the six success
criteria; unit tests for manifest parsing, route table
generation, header injection.
### Out of scope
- AWS / SigV4 (see Non-goals).
- Per-method / per-path allowlist *inside* a kind. Defer to a
follow-up once observed traffic stabilizes.
- Replacing `bottle.env` for non-token secrets. The proxy
handles the four kinds listed above; other env vars keep their
current path.
- Migrating an in-flight bottle from "token in agent env" to
"token via proxy" mid-session. Restart required.
- Audit logging. The proxy doesn't write request logs in v1.
Add only if a concrete debugging need surfaces.
## Proposed Design
### Architecture
```
┌── Host (macOS) ──────────────────────────────────────────────────┐
│ Secrets at rest (keychain / .env): │
│ CLAUDE_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
│ │ docker run -e KEY (no =VALUE on argv) │
│ ▼ │
│ ┌── per-bottle internal docker network ──────────────────────┐ │
│ │ │ │
│ │ ┌── agent container ─────────────────────────────────┐ │ │
│ │ │ claude as node (UID 1000) │ │ │
│ │ │ --dangerously-skip-permissions │ │ │
│ │ │ environ: URLs only, no plaintext tokens │ │ │
│ │ │ ANTHROPIC_BASE_URL=http://cred-proxy:PORT/an.. │ │ │
│ │ │ npm registry → http://cred-proxy:PORT/npm/ │ │ │
│ │ │ git insteadOf → http://cred-proxy:PORT/... │ │ │
│ │ │ tea --url → http://cred-proxy:PORT/gite │ │ │
│ │ └────────────┬───────────────────────────────────────┘ │ │
│ │ │ HTTP, DNS → cred-proxy │ │
│ │ ▼ │ │
│ │ ┌── cred-proxy sidecar ──────────────────────────────┐ │ │
│ │ │ distroless image, no shell, runs as root │ │ │
│ │ │ hostname: cred-proxy listens 0.0.0.0:PORT │ │ │
│ │ │ tokens live ONLY in this container's environ │ │ │
│ │ │ /anthropic → api.anthropic.com Bearer │ │ │
│ │ │ /gh-api → api.github.com Bearer │ │ │
│ │ │ /gh-git → github.com Bearer │ │ │
│ │ │ /gitea → gitea.dideric.is token │ │ │
│ │ │ /npm → registry.npmjs.org Bearer │ │ │
│ │ │ SSE pass-through, no buffering │ │ │
│ │ └────────────┬───────────────────────────────────────┘ │ │
│ │ │ HTTPS │ │
│ │ ▼ │ │
│ │ ┌── pipelock sidecar (egress allowlist) ─────────────┐ │ │
│ │ │ allow: api.anthropic.com, api.github.com, │ │ │
│ │ │ github.com, gitea.dideric.is, │ │ │
│ │ │ registry.npmjs.org │ │ │
│ │ │ block: statsig, sentry, autoupdater, * │ │ │
│ │ └────────────┬───────────────────────────────────────┘ │ │
│ └────────────────┼───────────────────────────────────────────┘ │
│ ▼ │
└────────────────────┼─────────────────────────────────────────────┘
Upstream APIs
Why the agent can't reach the sidecar's environ:
┌───────────────────────────────────────────────────────────────┐
│ Different container = different PID, mount, and network ns. │
│ The agent's /proc shows only the agent's own processes; │
│ the cred-proxy PID is not visible — no /proc/<X>/environ │
│ to read, no PID to ptrace, no shared memory. │
│ │
│ Reaching the sidecar's environ requires escaping the agent │
│ container — the same threshold pipelock and git-gate rely │
│ on. Default Docker isolation is the boundary. │
└───────────────────────────────────────────────────────────────┘
```
### New components
- **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy`
+ `CredProxyPlan` dataclass. `prepare` is host-side and
side-effect-free; renders the route table and resolves
`TokenRef`s against host env. Mirrors the existing `GitGate` /
`Pipelock` shape.
- **`claude_bottle/backend/docker/cred_proxy.py`** (new):
`DockerCredProxy` concrete subclass. `start` does
`docker create` on the bottle's internal network with hostname
`cred-proxy`, copies the route-table file into the container,
then `docker start`. `stop` is idempotent `docker rm -f`.
Container name: `claude-bottle-cred-proxy-<slug>`.
- **`claude_bottle/backend/docker/provision/cred_proxy.py`**
(new): renders `ANTHROPIC_BASE_URL`, `~/.npmrc`,
`~/.gitconfig` `insteadOf` blocks, and `~/.config/tea/config.yml`
into the agent's home for each declared kind — all pointing at
`http://cred-proxy:<PORT>/...`.
- **cred-proxy image.** Minimal base + the proxy binary, no
shell. Pinned by digest, baked at build time. Footprint sized
to match git-gate's image rather than the full agent image.
### Existing code touched
- **`claude_bottle/manifest.py`** — add `TokenEntry`,
`Bottle.tokens: tuple[TokenEntry, ...] = ()`, parse + validate
(at most one entry per `Kind` except `gitea`, which may
carry multiple Urls).
- **`claude_bottle/backend/docker/prepare.py`** — delete the
`CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN` branch
in the agent's forwarded env. The OAuth token is forwarded
into the cred-proxy sidecar's environ at sidecar `docker create`
time instead.
- **`claude_bottle/backend/docker/backend.py`** — instantiate
`DockerCredProxy` alongside `DockerPipelockProxy` and
`DockerGitGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`** — add cred-proxy
start/stop to the `ExitStack` alongside pipelock and git-gate;
the sidecar must be up before the agent container starts so
DNS resolution for `cred-proxy` succeeds on first contact.
- **`claude_bottle/backend/docker/bottle_plan.py`** — new
`CredProxyPlan` field; preflight shows kind + ref name +
port + route table.
- **`claude_bottle/pipelock.py`** — drop the `api.anthropic.com`
TLS-MITM branch; the host stays on the allowlist as a plain
HTTPS destination. Confirm the four upstream hosts are
allowlisted by default when `bottle.tokens` declares them.
- **`README.md`** — replace the architecture diagram with the
one above; document the `bottle.tokens` field.
- **`claude-bottle.example.json`** — add a `tokens` array to
one bottle showing each Kind.
- **Tests** — new unit tests for manifest parsing, route table
generation, header injection; new integration tests for the
six success criteria. Delete the bits of `prepare.py` tests
that asserted on `CLAUDE_CODE_OAUTH_TOKEN` landing in the
agent's env.
### Data model changes
```python
@dataclass(frozen=True)
class TokenEntry:
Kind: Literal["anthropic", "github", "gitea", "npm"]
TokenRef: str # name of host env var
Url: str | None = None # required for gitea; defaulted otherwise
@dataclass(frozen=True)
class Bottle:
...
tokens: tuple[TokenEntry, ...] = ()
```
Validation:
- `Kind` must be one of the four supported values.
- `TokenRef` must resolve against `os.environ` at launch (fail
fast with a clear "host env var X is unset" if missing).
- `gitea` entries require `Url`; others fall back to the
documented upstream.
- At most one entry per `Kind` except `gitea`, which may have
multiple distinct `Url`s.
- No silent overlap with `bottle.git` upstreams that already
flow through git-gate; if a `tokens[].Kind: github|gitea`
entry's `Url` collides with a `git[].Upstream`'s host, parse
fails with a "git-gate already brokers this remote, drop one"
hint. (Both paths broker credentials; doubling up is a
configuration smell, not a feature.)
### Routing table
| Kind | Proxy path | Upstream | Header |
|-----------|----------------|-------------------------|----------------------------|
| anthropic | `/anthropic/` | `api.anthropic.com` | `Authorization: Bearer …` |
| github | `/gh-api/` | `api.github.com` | `Authorization: Bearer …` |
| github | `/gh-git/` | `github.com` | `Authorization: Bearer …` |
| gitea | `/gitea/<Url>` | configured `Url` | `Authorization: token …` |
| npm | `/npm/` | `registry.npmjs.org` | `Authorization: Bearer …` |
Gitea uses `Authorization: token` rather than `Bearer` to
sidestep `go-gitea/gitea#16734`. The proxy strips any incoming
`Authorization` header before injecting its own — the agent
cannot smuggle a stolen token through this path.
### External dependencies
The proxy binary. Two real options:
- **Python (stdlib)** — `http.server` + `urllib`/`http.client`,
no new pip packages. Matches CLAUDE.md's "bash-first, low-deps"
posture. SSE pass-through is fiddly but doable.
- **Go single binary** — cleaner SSE story, smaller runtime,
one static binary in a scratch/distroless image. New build
dependency.
Default: Python in a minimal `python:3.X-slim` image (or alpine
if we want smaller). Reconsider in the implementation PR if SSE
behavior is troublesome under load.
No new Python packages. No DB. No admin API. The proxy's
configuration is a single mode-600 JSON file copied into the
sidecar at `docker create` time and read by the proxy at startup
from `/run/cred-proxy/routes.json`.
## Future work
- **AWS / SigV4.** Likely an IMDS emulator sidecar handing out
short-lived STS tokens. Different threat model (the agent
ends up holding the STS creds — the proxy just shortens
their lifetime). Separate PRD.
- **Per-method / per-path allowlist** inside a kind. Once the
set of API operations claude actually performs is observed,
reject everything else. Narrows the within-allowlist surface.
- **Short-lived token minting.** For services that support it
(GitHub Apps, GitLab project-access tokens, fine-grained
PATs with TTL), have the proxy mint a fresh per-session
child credential from a long-lived parent.
- **Smolmachines colocation.** Same packing question as
pipelock / git-gate; under a future microVM backend the
cred-proxy could share a VM with the agent (today's per-bottle
network gives it its own container, not its own VM) or sit in
its own VM (stricter isolation, an extra TCP hop). Backend
decision, not a manifest decision.
- **More kinds.** PyPI, Bun, cargo, Docker Hub. The routing
pattern generalizes; add as needed.
## Considered alternatives
### In-container proxy (root inside the agent container)
Run cred-proxy as PID 1 of the agent container, listening on
`127.0.0.1:<PORT>`, with claude exec'd as `node` (UID 1000) only
after the proxy is bound. The boundary in that shape is the
kernel's cross-UID `ptrace_may_access` check — `node` cannot read
root's `/proc/<pid>/environ` and cannot `ptrace` attach.
Pros: one less container per bottle; slightly faster bottle
startup; no extra docker create/start/stop dance.
Rejected because:
- **Weaker isolation.** The boundary collapses to UID separation
alone. Any container-root compromise inside the agent (setuid
bug in the image, accidentally mounted docker socket, a kernel
CVE, accidental `--privileged`) reads the proxy's environ via
`/proc/<pid>/environ`. The sidecar's namespace separation
cannot be bypassed from inside the agent container without a
container escape.
- **Inconsistent with the existing topology.** pipelock and
git-gate are already sidecars on the bottle's internal network.
cred-proxy slots into the same shape and reuses the same
lifecycle abstractions (`BottleBackend.prepare/start/stop`,
`ExitStack` ordering, plan rendering).
- **Coupled to the agent image.** The proxy binary, its
entrypoint, and its priv-drop logic would all live in the
agent's Dockerfile. A sidecar image evolves independently —
agents can change base, language, or tooling without touching
the proxy.
- **PID-1 babysitting.** The "proxy supervises, then `exec
setpriv → node`" entrypoint introduces a class of issues
(zombie reaping, signal forwarding, exit-code propagation) that
the sidecar shape avoids.
## Open questions
- **Field name.** `bottle.tokens` is the working name. The
research note used `bottle.forge` for the gitea/github
generalization, but "forge" doesn't fit `anthropic` or
`npm`. Alternatives: `bottle.brokered`, `bottle.upstreams`,
`bottle.cred_proxy`. Default: `bottle.tokens`.
- **Python vs Go for the proxy.** Default: Python, revisit
during implementation if SSE pass-through is unreliable.
- **Sidecar image base.** Distroless (smallest, no shell — hardest
to debug), Python slim (debuggable, larger), or scratch + a
statically-linked Go binary (smallest if Go). Default: whatever
fits the chosen language with the smallest non-shell base;
revisit if debuggability bites during implementation.
- **Belt-and-braces on outbound telemetry.** Set
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` and
`DISABLE_ERROR_REPORTING=1` in the agent's environ by
default? Default: yes — they don't route through
`ANTHROPIC_BASE_URL`, so the proxy doesn't catch them; the
flags are the only off switch.
- **`git push` over a rewritten URL vs. credential-helper
shim.** `[url "http://…"] insteadOf = "https://github.com/"`
captures push/fetch/clone/pull/ls-remote in one config knob;
a credential helper would need separate wiring. Default:
`insteadOf`.
- **Token-refresh story for the Anthropic OAuth token.** The
token is ~1-year and there's no client-side refresh, so the
proxy holds a static value. The 1-year blast radius is the
cost, documented in
[`claude-code-token-revocation.md`](../research/claude-code-token-revocation.md).
No design change here; flagged for awareness.
- **`anthropics/claude-code#36998`.** Older claude-code
versions bypassed `ANTHROPIC_BASE_URL` for some startup
calls (auth validation, org lookup). Marked closed upstream;
the implementation PR verifies with `strace -e connect`
against the pinned claude-code build before trusting the
isolation.
## References
- [`docs/research/agent-credential-proxy-landscape.md`](../research/agent-credential-proxy-landscape.md)
— landscape research; this PRD is the build path that note
recommends.
- [`docs/research/secret-minimization-over-dlp.md`](../research/secret-minimization-over-dlp.md)
— architectural framing: why moving the credential matters
more than scanning egress.
- PRD 0006: pipelock TLS interception — the
`api.anthropic.com` TLS-MITM responsibility cred-proxy takes
over.
- PRD 0008: Git gate — the credential-broker pattern this PRD
reuses (gate holds creds, agent gets a rewritten URL, gate
makes the upstream connection).
- [`anthropics/claude-code#36998`](https://github.com/anthropics/claude-code/issues/36998)
— historic `ANTHROPIC_BASE_URL` bypass.
- [`go-gitea/gitea#16734`](https://github.com/go-gitea/gitea/issues/16734)
— why Gitea uses `Authorization: token`, not `Bearer`.
- [`golang/go#28866`](https://github.com/golang/go/issues/28866)
— the `HTTPS_PROXY` loopback bug; not hit here because we're
a reverse proxy, not a forward proxy.
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Record docs/demo.gif via VHS. Runs setup, invokes `vhs docs/demo.tape`,
# always tears down. Requires `vhs` (brew install vhs).
set -euo pipefail
cd "$(dirname "$0")/.."
if ! command -v vhs >/dev/null 2>&1; then
echo "demo-record: vhs not found on PATH (brew install vhs)" >&2
exit 1
fi
if [ -z "${CLAUDE_BOTTLE_OAUTH_TOKEN:-}" ]; then
echo "demo-record: CLAUDE_BOTTLE_OAUTH_TOKEN is unset; claude inside the bottle will not auth" >&2
exit 1
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "demo-record: ffmpeg not found on PATH (brew install ffmpeg) — needed for decimation pass" >&2
exit 1
fi
bash scripts/demo-setup.sh
trap 'bash scripts/demo-teardown.sh' EXIT
vhs docs/demo.tape
# VHS records in real time, which leaves long static stretches while
# the bottle launches and commands wait for output. Run mpdecimate to
# drop duplicate consecutive frames (TUI dead time) and re-time at
# 12 fps. tpad clones the final frame for 4s so the gitleaks
# rejection on the last beat dwells long enough to read on each GIF
# loop. Re-encode through a 64-color palette to keep the file small.
tmp=$(mktemp -d)
trap 'bash scripts/demo-teardown.sh; rm -rf "$tmp"' EXIT
cp docs/demo.gif "$tmp/raw.gif"
ffmpeg -y -i "$tmp/raw.gif" \
-vf "mpdecimate,setpts=N/12/TB,tpad=stop_duration=4:stop_mode=clone,scale=960:-1:flags=lanczos,palettegen=max_colors=64" \
"$tmp/palette.png" -loglevel error
ffmpeg -y -i "$tmp/raw.gif" -i "$tmp/palette.png" \
-lavfi "mpdecimate,setpts=N/12/TB,tpad=stop_duration=4:stop_mode=clone,scale=960:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5" \
docs/demo.gif -loglevel error
echo "demo-record: wrote $(ls -lh docs/demo.gif | awk '{print $5}') ($(ffprobe -v error -show_entries stream=duration -of default=nk=1:nw=1 docs/demo.gif | cut -d. -f1)s)"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Prepare the working directory to run the recorded demo via cli.py:
# - back up any existing claude-bottle.json so the user's real config
# isn't clobbered
# - install claude-bottle.demo.json as claude-bottle.json
# - create a dummy SSH identity at the path the demo manifest expects
# - pre-warm the bottle + git-gate images quietly so the recording
# doesn't spend its first 30s in BuildKit output
set -euo pipefail
cd "$(dirname "$0")/.."
if ! docker info >/dev/null 2>&1; then
echo "demo-setup: docker daemon not reachable" >&2
exit 1
fi
# Back up an existing local manifest (untouched if absent). Stored
# alongside the manifest with a deterministic name so teardown can
# find it without state files.
if [ -f claude-bottle.json ]; then
cp claude-bottle.json claude-bottle.json.demo-backup
fi
cp claude-bottle.demo.json claude-bottle.json
# Dummy SSH identity — the git-gate validator wants a readable file at
# the IdentityFile path. Contents don't matter for the demo: the
# unreachable upstream means the gate never actually uses the key.
fake_key_dir="$HOME/.cache/claude-bottle-demo"
mkdir -p "$fake_key_dir"
chmod 700 "$fake_key_dir"
printf 'not-a-real-key\n' > "$fake_key_dir/fake-key"
chmod 600 "$fake_key_dir/fake-key"
# Build the image graph quietly so the recorded run shows only the
# bottle launch and the four `!` probes, not BuildKit progress.
docker build -q -t claude-bottle:latest . >/dev/null 2>&1 || true
docker build -q -f Dockerfile.git-gate -t claude-bottle-git-gate:latest . >/dev/null 2>&1 || true
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Undo what demo-setup.sh did. Restores any pre-existing
# claude-bottle.json, removes the dummy SSH identity. Idempotent.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -f claude-bottle.json
if [ -f claude-bottle.json.demo-backup ]; then
mv claude-bottle.json.demo-backup claude-bottle.json
fi
rm -rf "$HOME/.cache/claude-bottle-demo"
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Human-runnable demo wrapper. Stages the demo manifest and dummy
# identity (see scripts/demo-setup.sh), launches `./cli.py start demo`
# interactively, then restores prior state. The recorded GIF
# (docs/demo.gif) goes through the same flow via docs/demo.tape.
#
# Once attached to claude inside the bottle, use the `!` prefix to run
# bash directly — e.g.
# ! curl --proxy "$HTTPS_PROXY" -sw 'status=%{http_code}\n' \
# -o /dev/null http://example.com/
# returns 403 because example.com is not on the bottle's allowlist.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -z "${CLAUDE_BOTTLE_OAUTH_TOKEN:-}" ]; then
cat <<'EOF' >&2
demo: CLAUDE_BOTTLE_OAUTH_TOKEN is unset. The bottle launches claude,
which needs the token to authenticate. Set it in your shell env (e.g.
~/.zshrc) — see README §Auth — then re-run.
EOF
exit 1
fi
bash scripts/demo-setup.sh
trap 'bash scripts/demo-teardown.sh' EXIT
./cli.py start demo