3eff1e0b6e
test / run tests/run_tests.py (push) Successful in 18s
Frames the per-agent isolation story (each bottle gets only the env, skills, ssh, and egress hosts its manifest grants) and is honest about the limits of the container boundary, pointing at the new research doc for the stronger-isolation v2 question. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
121 lines
4.7 KiB
Markdown
121 lines
4.7 KiB
Markdown
# claude-bottle
|
|
|
|
[](https://gitea.dideric.is/didericis/claude-bottle/actions?workflow=test.yml)
|
|
|
|
Spins up an isolated container for running Claude Code with a curated set of skills and env vars.
|
|
|
|
## Why "claude-bottle"?
|
|
|
|
Each container is a bottle; Claude is the genie inside. The genie has
|
|
broad powers within the bottle — read, write, run anything — but it
|
|
cannot escape to the host. You uncork one bottle per agent
|
|
(`./cli.py start <agent>`), many bottles run in parallel, and each
|
|
one's powers are scoped to what the manifest grants it: a curated set
|
|
of skills, env vars, and a starting prompt. When the session ends the
|
|
bottle is destroyed and the genie does not persist.
|
|
|
|
## Goals
|
|
|
|
- Minimize risk of running claude with full permissions
|
|
- Allow me to easily spin up agent tasks in parallel
|
|
- Create isolated, well defined, easily updated, shareable agents
|
|
|
|
## Security model
|
|
|
|
Each agent runs in its own bottle: its own container, its own internal
|
|
Docker network, and its own pipelock sidecar. Bottles don't share
|
|
state, don't talk to each other, and only get the env vars, skills,
|
|
SSH identities, and egress hosts the manifest grants them — nothing
|
|
more. Any one agent only has the access it needs to do its job.
|
|
|
|
The container is the boundary against an uncoordinated agent reaching
|
|
the host: a misbehaving Claude Code session can't read files outside
|
|
the bottle, can't reach the host's network without going through
|
|
pipelock, and can't see other bottles. It is not a hardened boundary
|
|
against a determined attacker with kernel-level escape capability —
|
|
that's a v2 question (see
|
|
`docs/research/stronger-isolation-alternatives.md`). The egress proxy
|
|
and OAuth-token handling below are the load-bearing pieces of v1.
|
|
|
|
## Quickstart
|
|
|
|
Requires Docker on the host and a long-lived Claude Code OAuth token in
|
|
your shell env.
|
|
|
|
```sh
|
|
./cli.py start <agent> # builds the image on first run, drops you into claude
|
|
```
|
|
|
|
The container is removed automatically when the session ends. If the script
|
|
is killed with SIGKILL the exit trap won't fire and the container may be
|
|
left running; remove it with `docker rm -f <container-name>`.
|
|
|
|
## Egress
|
|
|
|
Agent containers route HTTP / HTTPS traffic through a per-agent
|
|
[pipelock](https://github.com/luckyPipewrench/pipelock) sidecar
|
|
attached to a Docker `--internal` network. The sidecar enforces a
|
|
hostname allowlist, runs DLP scanning (48 default credential
|
|
patterns), and detects URL-embedded high-entropy secret leaks. Without
|
|
the proxy the agent has no route off-box at all — the internal network
|
|
has no default gateway. The sidecar and network are torn down with the
|
|
agent on session exit.
|
|
|
|
The effective allowlist is the union of a baked-in default for Claude
|
|
Code's required hosts (`api.anthropic.com`, `claude.ai`, ...) and the
|
|
optional `bottles.<name>.egress.allowlist` field in
|
|
`claude-bottle.json`:
|
|
|
|
```jsonc
|
|
{
|
|
"bottles": {
|
|
"default": {
|
|
"env": { },
|
|
"ssh": [ ],
|
|
"egress": { "allowlist": ["github.com"] }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The resolved allowlist is shown in the y/N preflight before launch.
|
|
See `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` for the
|
|
design and `docs/research/pipelock-assessment.md` for the rationale.
|
|
|
|
## Auth: OAuth token, not API key
|
|
|
|
claude-bottle authenticates `claude` inside the container with the same
|
|
Pro/Max subscription you already use on the host, via a long-lived OAuth
|
|
token. No `ANTHROPIC_API_KEY` is needed.
|
|
|
|
**Why a token instead of mounting `~/.claude.json`:** on macOS, Claude
|
|
Code stores OAuth credentials in the encrypted Keychain, not in
|
|
`~/.claude.json`. Mounting that file into a Linux container does not
|
|
carry the credentials with it. Linux hosts keep credentials in
|
|
`~/.claude/.credentials.json`, but to keep the launcher portable
|
|
claude-bottle uses the env-var path on every host.
|
|
|
|
**One-time setup on the host:**
|
|
|
|
```sh
|
|
claude setup-token # browser login, prints a ~1-year OAuth token
|
|
```
|
|
|
|
Stash the token in your shell env (e.g. `~/.zshrc` or a secret manager)
|
|
as `CLAUDE_BOTTLE_OAUTH_TOKEN`:
|
|
|
|
```sh
|
|
export CLAUDE_BOTTLE_OAUTH_TOKEN="<token>"
|
|
```
|
|
|
|
`cli.py` automatically forwards it to every container as
|
|
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e` — no manifest wiring
|
|
required, and the value is never written to disk or placed on argv.
|
|
|
|
Inside the container, `claude` picks up `CLAUDE_CODE_OAUTH_TOKEN` and
|
|
authenticates against your subscription. Caveats: the token is bound
|
|
to your subscription tier (Pro/Max/Team/Enterprise), it does not work
|
|
with `claude --bare` (which only reads `ANTHROPIC_API_KEY`), and if it
|
|
leaks, regenerate via `claude setup-token` again. Reference:
|
|
<https://code.claude.com/docs/en/authentication>.
|