refactor!: rename project to bot-bottle

Assisted-by: Codex
This commit is contained in:
2026-05-28 17:56:14 -04:00
parent 8875d8cc17
commit c08b09dc9f
200 changed files with 1271 additions and 1271 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ Type "clear"
Enter
Show
# Real cli.py invocation — what a user with claude-bottle.json in cwd
# Real cli.py invocation — what a user with bot-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
@@ -6,7 +6,7 @@
## Summary
Run pipelock as a sidecar container on each claude-bottle agent's only
Run pipelock as a sidecar container on each bot-bottle agent's only
egress route, scanning all outbound HTTP for hostname allowlist violations
and DLP matches.
@@ -95,18 +95,18 @@ The feature is **done** when all of the following ship:
### New services / components
Two new modules under `claude_bottle/`:
Two new modules under `bot_bottle/`:
- **`claude_bottle/pipelock.py`** — pipelock-specific logic. Generates
- **`bot_bottle/pipelock.py`** — pipelock-specific logic. Generates
the per-bottle YAML config from the manifest's `egress` block plus
baked-in defaults; copies the YAML into the sidecar via `docker cp`;
starts and stops the sidecar container; resolves the allowlist for
display in the preflight.
- **`claude_bottle/network.py`** — Docker network plumbing. Creates the
per-agent `--internal` network (named `claude-bottle-net-<slug>` with
- **`bot_bottle/network.py`** — Docker network plumbing. Creates the
per-agent `--internal` network (named `bot-bottle-net-<slug>` with
the same slug-and-suffix scheme used for container names), attaches
the agent and sidecar to it, removes it on teardown. Kept separate
from `claude_bottle/docker.py` so a future PRD can add non-pipelock
from `bot_bottle/docker.py` so a future PRD can add non-pipelock
network controls without entangling them with pipelock specifics.
This split mirrors the existing per-concern module pattern
@@ -114,7 +114,7 @@ This split mirrors the existing per-concern module pattern
### Existing code touched
- **`claude_bottle/cli/start.py`** — wire the new lifecycle into the
- **`bot_bottle/cli/start.py`** — wire the new lifecycle into the
`start` subcommand: create the internal network, launch the pipelock
sidecar, then launch the agent container with `HTTPS_PROXY` /
`HTTP_PROXY` set to the sidecar's service name. Add the resolved
@@ -129,9 +129,9 @@ This split mirrors the existing per-concern module pattern
the image. This keeps the image agnostic to whether a sidecar is in use
(useful if a future bottle definition opts out of the proxy for testing).
`claude_bottle/docker.py` may grow one or two helpers if there is a
`bot_bottle/docker.py` may grow one or two helpers if there is a
clean place for shared primitives, but the network-specific helpers
live in `claude_bottle/network.py`. Decide during implementation; not a
live in `bot_bottle/network.py`. Decide during implementation; not a
contract.
### Data model changes
@@ -176,7 +176,7 @@ bottle share the same allowlist.
- **Pipelock binary** is pulled from
`ghcr.io/luckypipewrench/pipelock@sha256:<digest>`. The digest is
pinned in `claude_bottle/pipelock.py` (or a sibling constants module)
pinned in `bot_bottle/pipelock.py` (or a sibling constants module)
and bumped deliberately, mirroring the claude-code version pinning
pattern in `Dockerfile`.
- No new host-side runtimes. The pipelock image is the only new
@@ -192,8 +192,8 @@ bottle share the same allowlist.
(proxy + 48 default DLP patterns + subdomain entropy + sidecar
topology) is expected to be core-only, but this should be confirmed.
- **Where to put the digest pin.** A constant in
`claude_bottle/pipelock.py` is the lowest-friction option; a separate
`claude_bottle/versions.py` (or similar) may be cleaner once there
`bot_bottle/pipelock.py` is the lowest-friction option; a separate
`bot_bottle/versions.py` (or similar) may be cleaner once there
are multiple pinned dependencies. Decide during implementation.
- **Per-agent overrides.** The PRD scopes egress to the bottle. If a
later use case calls for tightening (not loosening) the allowlist for
+24 -24
View File
@@ -14,7 +14,7 @@ second backend ships in this PRD.
## Problem
Today, "how to launch a bottle" is spread across roughly six modules
(`claude_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
(`bot_bottle/cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`,
`skills.py`, `docker.py`), each shelling out to `docker` directly via
`subprocess.run(["docker", ...])`. That coupling means:
@@ -57,22 +57,22 @@ The feature works when all of the following are observable:
The feature is **done** when all of the following ship:
- A new `claude_bottle/backend/` package exists with abstract base
- A new `bot_bottle/backend/` package exists with abstract base
classes (`BottleBackend`, `BottlePlan`, `BottleCleanupPlan`,
`Bottle`) plus a `claude_bottle/backend/docker/` subpackage
`Bottle`) plus a `bot_bottle/backend/docker/` subpackage
containing the `DockerBottleBackend` implementation.
- `DockerBottleBackend.launch(plan)` returns a context manager
yielding a `Bottle` handle exposing `exec_claude(argv, *, tty=True)`,
`cp_in(host, ctr)`, and teardown on context exit.
- Every existing `subprocess.run(["docker", ...])` call in
`cli/start.py`, `pipelock.py`, `network.py`, `ssh.py`, and
`skills.py` either moves into `claude_bottle/backend/docker/` or is
`skills.py` either moves into `bot_bottle/backend/docker/` or is
called from it. No top-level CLI code references `docker` directly.
- `bottles[].runtime` is removed from the manifest schema, the
dataclass in `manifest.py`, the example manifest, and any README /
docs references. `require_runsc()` in the old top-level
`claude_bottle/docker.py` is deleted.
- A single env var, `CLAUDE_BOTTLE_BACKEND` (default `"docker"`),
`bot_bottle/docker.py` is deleted.
- A single env var, `BOT_BOTTLE_BACKEND` (default `"docker"`),
selects the backend. Unknown values die at startup with a list of
known backends.
- The y/N preflight in `cli.py` includes the resolved Docker runtime
@@ -97,8 +97,8 @@ The feature is **done** when all of the following ship:
### In scope
- New `claude_bottle/backend/` package containing the abstract types
and the registry, plus a `claude_bottle/backend/docker/` subpackage
- New `bot_bottle/backend/` package containing the abstract types
and the registry, plus a `bot_bottle/backend/docker/` subpackage
containing the Docker implementation.
- The `Bottle`, `BottleBackend`, `BottlePlan`, and `BottleCleanupPlan`
abstract base classes; `BottleSpec` data carrier; and
@@ -136,10 +136,10 @@ The feature is **done** when all of the following ship:
### New services / components
A new package, `claude_bottle/backend/`, with an abstract base layer
A new package, `bot_bottle/backend/`, with an abstract base layer
and a Docker subpackage:
- **`claude_bottle/backend/__init__.py`** — Defines the abstract base
- **`bot_bottle/backend/__init__.py`** — Defines the abstract base
classes and the backend registry. `BottleSpec` carries the
CLI-supplied intent; the abstract `BottlePlan` and
`BottleCleanupPlan` are the prepared-but-not-launched outputs of
@@ -165,14 +165,14 @@ and a Docker subpackage:
`provision_git`); subclasses implement those four rather than
overriding `provision` itself.
Selection reads `CLAUDE_BOTTLE_BACKEND` (default `"docker"`).
Selection reads `BOT_BOTTLE_BACKEND` (default `"docker"`).
Unknown values call `die()` with the list of known backends:
```python
def get_bottle_backend() -> BottleBackend: ...
```
- **`claude_bottle/backend/docker/`** — Subpackage with the Docker
- **`bot_bottle/backend/docker/`** — Subpackage with the Docker
implementation, split into:
- `backend.py` — `DockerBottleBackend`, owning all five abstract
methods (`prepare`, `launch`, `prepare_cleanup`, `cleanup`,
@@ -196,49 +196,49 @@ and a Docker subpackage:
- `pipelock.py` — `DockerPipelockProxy` (the sidecar start/stop
lifecycle) and Docker-specific naming helpers. The backend-neutral
yaml + allowlist resolution stays in the top-level
`claude_bottle/pipelock.py`.
`bot_bottle/pipelock.py`.
- `util.py` — Docker-specific helpers (slugify, image/container
existence checks, `runsc_available`).
### Existing code touched
- **`claude_bottle/cli/start.py`** — replace the inline docker
- **`bot_bottle/cli/start.py`** — replace the inline docker
orchestration with `backend = get_bottle_backend(); plan =
backend.prepare(spec, stage_dir=...); with backend.launch(plan) as
bottle: bottle.exec_claude(...)`. The y/N preflight is rendered by
`plan.print(...)`.
- **`claude_bottle/manifest.py`** — drop the `runtime` field from the
- **`bot_bottle/manifest.py`** — drop the `runtime` field from the
Bottle dataclass and its validation. Existing manifests with
`runtime: "runsc"` produce a clear "no longer supported; gVisor is
now auto-detected by the backend; remove the 'runtime' field" error.
- **`claude_bottle/docker.py`** — module deleted. `require_runsc()`,
- **`bot_bottle/docker.py`** — module deleted. `require_runsc()`,
`slugify()`, `image_exists()`, `container_exists()`, the
`build_image` / `build_image_with_cwd` helpers, and `require_docker`
all migrate into `claude_bottle/backend/docker/util.py` (or
all migrate into `bot_bottle/backend/docker/util.py` (or
`backend.py`).
- **`claude_bottle/pipelock.py`** — keeps the allowlist resolution and
- **`bot_bottle/pipelock.py`** — keeps the allowlist resolution and
YAML generation. Becomes a thin abstract class (`PipelockProxy`)
exposing `prepare` (writes the yaml) plus abstract `start` / `stop`
methods. The Docker-specific subclass `DockerPipelockProxy` lives
under `backend/docker/pipelock.py`.
- **`claude_bottle/network.py`** — folds entirely into
- **`bot_bottle/network.py`** — folds entirely into
`backend/docker/network.py`. No top-level network module remains.
- **`claude_bottle/ssh.py`** and **`claude_bottle/skills.py`** —
- **`bot_bottle/ssh.py`** and **`bot_bottle/skills.py`** —
absorbed into `DockerBottleBackend` as `provision_ssh` and
`provision_skills`. The host-side file-tree generation stays as
private helpers on the backend class.
- **`claude_bottle/env.py`** (renamed from `env_resolve.py`) —
- **`bot_bottle/env.py`** (renamed from `env_resolve.py`) —
`resolve_env(manifest, agent) -> ResolvedEnv` returns
`forwarded: list[str]` (names whose values were exported into
`os.environ` for inheritance) and `literals: dict[str, str]` (name
→ verbatim value). The Docker backend translates the result into
`--env-file` content + `-e NAME` argv fragments.
- **`claude_bottle/util.py`** — top-level cross-backend helpers
- **`bot_bottle/util.py`** — top-level cross-backend helpers
(`expand_tilde`, `is_ipv4_literal`). Backend-specific helpers live
in their backend's `util.py`.
- **`claude-bottle.example.json`** — remove the `runtime` field from
- **`bot-bottle.example.json`** — remove the `runtime` field from
any example bottle.
- **`README.md`** — note `CLAUDE_BOTTLE_BACKEND` and the runsc
- **`README.md`** — note `BOT_BOTTLE_BACKEND` and the runsc
auto-detect; remove any mention of `runtime: "runsc"` as a manifest
field.
+10 -10
View File
@@ -6,12 +6,12 @@
## Summary
Break `claude_bottle/backend/docker/backend.py` (664 lines) apart by
Break `bot_bottle/backend/docker/backend.py` (664 lines) apart by
moving the four provisioner methods — `provision_prompt`,
`provision_skills`, `provision_ssh`, `provision_git` — out of
`DockerBottleBackend` into their own modules under
`claude_bottle/backend/docker/provision/`. The abstract base in
`claude_bottle/backend/__init__.py` keeps the same four-method
`bot_bottle/backend/docker/provision/`. The abstract base in
`bot_bottle/backend/__init__.py` keeps the same four-method
contract; only the Docker implementation changes shape.
## Problem
@@ -56,7 +56,7 @@ The feature works when all of the following are observable:
The feature is **done** when all of the following ship:
- A new `claude_bottle/backend/docker/provision/` subpackage exists
- A new `bot_bottle/backend/docker/provision/` subpackage exists
with one module per provisioner: `prompt.py`, `skills.py`, `ssh.py`,
`git.py`. Each exports a single top-level function taking
`(plan: DockerBottlePlan, target: str)` and returning the same type
@@ -66,7 +66,7 @@ The feature is **done** when all of the following ship:
`provision_ssh` / `provision_git` each become one-line delegations
to the new module functions.
- The abstract `BottleBackend.provision_*` signatures in
`claude_bottle/backend/__init__.py` are unchanged. The
`bot_bottle/backend/__init__.py` are unchanged. The
`BottleBackend.provision` orchestration in the base class is
unchanged.
- No top-level CLI code or other backend gains a direct import of the
@@ -99,7 +99,7 @@ The feature is **done** when all of the following ship:
### In scope
- New `claude_bottle/backend/docker/provision/` subpackage with
- New `bot_bottle/backend/docker/provision/` subpackage with
`__init__.py`, `prompt.py`, `skills.py`, `ssh.py`, `git.py`.
- Moving the four method bodies out of
`DockerBottleBackend` into the new modules verbatim, adjusting only
@@ -132,7 +132,7 @@ The feature is **done** when all of the following ship:
### New layout
```
claude_bottle/backend/docker/
bot_bottle/backend/docker/
backend.py # DockerBottleBackend (slimmer)
bottle.py
bottle_plan.py
@@ -199,13 +199,13 @@ take the concrete type and skip re-checking.
### Existing code touched
- **`claude_bottle/backend/docker/backend.py`** — four method
- **`bot_bottle/backend/docker/backend.py`** — four method
bodies move out; method definitions stay as one-line delegations.
Imports for `pipelock_proxy_host_port`, `expand_tilde`, etc., that
are only used by the moved bodies migrate with them.
- **`claude_bottle/backend/docker/__init__.py`** — no change. The
- **`bot_bottle/backend/docker/__init__.py`** — no change. The
public surface (`DockerBottleBackend`) is unchanged.
- **`claude_bottle/backend/__init__.py`** — no change.
- **`bot_bottle/backend/__init__.py`** — no change.
- **`tests/`** — no expected change. Existing tests exercise the
backend via `DockerBottleBackend` or the CLI surface; they don't
reach into provisioners directly. Verify after the move and only
+19 -19
View File
@@ -75,7 +75,7 @@ The feature is **done** when all of the following ship:
sidecar (read-only) so the running pipelock can read its CA.
- `BottleBackend.provision_ca` (new) copies the CA public cert
into the agent at
`/usr/local/share/ca-certificates/claude-bottle-mitm.crt`, runs
`/usr/local/share/ca-certificates/bot-bottle-mitm.crt`, runs
`update-ca-certificates`, and sets the `NODE_EXTRA_CA_CERTS` /
`SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` env trio on the agent
container's runtime env. Default no-op on the abstract base so
@@ -122,14 +122,14 @@ The feature is **done** when all of the following ship:
### In scope
- **`claude_bottle/pipelock.py`** changes:
- **`bot_bottle/pipelock.py`** changes:
- Extend `pipelock_build_config` to include
`tls_interception: { enabled: true, ca_cert: <path>, ca_key:
<path> }`. Paths are populated from the plan; the function's
signature grows a `cert_path` / `key_path` pair or reads them
off `Bottle` once they're stored.
- Extend `pipelock_render_yaml` to emit the new block.
- **`claude_bottle/backend/docker/pipelock.py`** changes:
- **`bot_bottle/backend/docker/pipelock.py`** changes:
- New helper `pipelock_tls_init(stage_dir)` runs the upstream
image as a one-shot:
`docker run --rm -v <stage>:/h -e PIPELOCK_HOME=/h pipelock tls init`,
@@ -143,31 +143,31 @@ The feature is **done** when all of the following ship:
config. If pipelock's image runs as non-root, a `docker exec
-u 0 chown pipelock:pipelock /etc/pipelock/ca*.pem` lands
between the `cp` and the `start`.
- **`claude_bottle/backend/__init__.py`**: new abstract method
- **`bot_bottle/backend/__init__.py`**: new abstract method
`provision_ca(plan, target)` on `BottleBackend`, default no-op.
`BottleBackend.provision` orchestrates `ca → prompt → skills →
ssh → git`.
- **`claude_bottle/backend/docker/provision/ca.py`** (new):
- **`bot_bottle/backend/docker/provision/ca.py`** (new):
- Reads the cert from `stage_dir` (already written by prepare).
- `docker cp` into the agent.
- `docker exec -u 0 ... chmod 644 ...` + `update-ca-certificates`.
- Computes the SHA-256 fingerprint with stdlib (`ssl` +
`hashlib`), emits one stderr log line.
- **`claude_bottle/backend/docker/launch.py`**:
- **`bot_bottle/backend/docker/launch.py`**:
- Three new `-e` flags on the agent's `docker run`:
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
`NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
`SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt`,
`REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`.
- `HTTPS_PROXY` / `HTTP_PROXY` continue to point at pipelock
(unchanged from PRD 0001 — the mitmproxy detour in PR #8 is
abandoned).
- **`claude_bottle/backend/docker/bottle_plan.py`**:
- **`bot_bottle/backend/docker/bottle_plan.py`**:
- One new `info(...)` line in `print()` noting TLS interception
is on.
- `to_dict()` gains an `egress.tls_interception: { enabled:
true, ca_fingerprint: null }` block. Reserved for future
population.
- **`claude_bottle/backend/docker/prepare.py`**: call
- **`bot_bottle/backend/docker/prepare.py`**: call
`pipelock_tls_init(stage_dir)` and write the resolved cert/key
paths onto the plan (either on the existing `proxy_plan` field
or on the parent `DockerBottlePlan`).
@@ -221,7 +221,7 @@ generated at prepare time.
the one-shot generation step. The rendered YAML references
the in-container paths.
- **Bottle install.** `provision_ca` (Docker impl) does
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/claude-bottle-mitm.crt`,
`docker cp <stage>/ca.pem agent:/usr/local/share/ca-certificates/bot-bottle-mitm.crt`,
then `update-ca-certificates`. The CA env trio is set at
`docker run -e` time (Docker propagates run-time env into
`docker exec`).
@@ -235,7 +235,7 @@ generated at prepare time.
`stage_dir`. CA dies with both, in that order, so the sidecar
is never reading a deleted mount on shutdown.
- **Fingerprint.** Computed via stdlib in `provision_ca` and
logged once to stderr (`claude-bottle: mitm ca fingerprint:
logged once to stderr (`bot-bottle: mitm ca fingerprint:
sha256:<hex>…`). The private key never appears in any log.
### Data model changes
@@ -248,18 +248,18 @@ always null at dry-run because the CA doesn't exist yet.
Surgical, all on the existing pipelock path:
- `claude_bottle/pipelock.py` — config builder + YAML renderer.
- `claude_bottle/backend/__init__.py` — abstract `provision_ca`.
- `claude_bottle/backend/docker/pipelock.py` — `tls init` helper,
- `bot_bottle/pipelock.py` — config builder + YAML renderer.
- `bot_bottle/backend/__init__.py` — abstract `provision_ca`.
- `bot_bottle/backend/docker/pipelock.py` — `tls init` helper,
sidecar volume mount.
- `claude_bottle/backend/docker/prepare.py` — CA paths on plan.
- `claude_bottle/backend/docker/launch.py` — CA env trio on agent.
- `claude_bottle/backend/docker/backend.py` — `provision_ca`
- `bot_bottle/backend/docker/prepare.py` — CA paths on plan.
- `bot_bottle/backend/docker/launch.py` — CA env trio on agent.
- `bot_bottle/backend/docker/backend.py` — `provision_ca`
dispatch + thread `self._proxy` through prepare/launch unchanged
shape.
- `claude_bottle/backend/docker/bottle_plan.py` — preflight
- `bot_bottle/backend/docker/bottle_plan.py` — preflight
rendering.
- `claude_bottle/backend/docker/provision/ca.py` (new).
- `bot_bottle/backend/docker/provision/ca.py` (new).
Net diff is meaningfully smaller than PR #8 because pipelock
already does the work — no addon, no second sidecar, no second
+11 -11
View File
@@ -95,14 +95,14 @@ back to green is the test.
Mirror the pipelock layout:
- **`claude_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
- **`bot_bottle/ssh_gate.py`** (new): abstract `SSHGate` +
`SSHGatePlan` dataclass. `prepare` is host-side / side-effect-free
on docker; renders the forwarder config under `stage_dir`.
- **`claude_bottle/backend/docker/ssh_gate.py`** (new):
- **`bot_bottle/backend/docker/ssh_gate.py`** (new):
`DockerSSHGate` concrete subclass — `start` does `docker create`
on the internal network, copies the config in, attaches the
egress network, `docker start`. `stop` is idempotent `docker rm
-f`. Container name: `claude-bottle-ssh-gate-<slug>`.
-f`. Container name: `bot-bottle-ssh-gate-<slug>`.
Forwarder image: `alpine/socat`, pinned by digest. Must be
self-sufficient at boot (no apk/apt pulls on first run) because
@@ -126,7 +126,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
### Existing code touched
- **`claude_bottle/backend/docker/provision/ssh.py`**: drop the
- **`bot_bottle/backend/docker/provision/ssh.py`**: drop the
`ProxyCommand socat - PROXY:...` plumbing and the
`pipelock_proxy_host_port` import. The rendered `~/.ssh/config`
block per entry becomes:
@@ -140,19 +140,19 @@ rejected at prepare time. One container, N listeners, N upstreams.
`known_hosts` entries are keyed off `<name>` and the new
`[<gate-container>]:<listen-port>` form so OpenSSH's strict
host-key checking still matches.
- **`claude_bottle/pipelock.py`**: delete
- **`bot_bottle/pipelock.py`**: delete
`pipelock_bottle_ssh_hostnames`, `pipelock_bottle_ssh_trusted_domains`,
`pipelock_bottle_ssh_ip_cidrs`, and the calls into them from
`pipelock_effective_allowlist` and `pipelock_build_config`. The
effective allowlist becomes baked-defaults `bottle.egress.allowlist`.
- **`claude_bottle/backend/docker/backend.py`**: instantiate
- **`bot_bottle/backend/docker/backend.py`**: instantiate
`DockerSSHGate` alongside `DockerPipelockProxy`; thread its
`prepare` / `start` / `stop` through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
stop to the `ExitStack` in the right order — gate must be up
before `provision_ssh` runs so the agent can dial it on first
boot.
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
`SSHGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-entry listen ports,
upstream `Hostname:Port` targets).
@@ -165,7 +165,7 @@ rejected at prepare time. One container, N listeners, N upstreams.
### Data model changes
None. `bottle.ssh` schema is unchanged; one new internal plan
dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
dataclass (`SSHGatePlan`) under `bot_bottle/ssh_gate.py`.
### External dependencies
@@ -202,7 +202,7 @@ dataclass (`SSHGatePlan`) under `claude_bottle/ssh_gate.py`.
- PRD 0006: pipelock native TLS interception — the change that
surfaced this regression by making pipelock incompatible with
SSH-over-CONNECT.
- `claude_bottle/backend/docker/provision/ssh.py` — current SSH
- `bot_bottle/backend/docker/provision/ssh.py` — current SSH
provisioning that this PRD rewrites.
- `claude_bottle/pipelock.py` — current pipelock config builder
- `bot_bottle/pipelock.py` — current pipelock config builder
that gains the `bottle.ssh`-derived fields this PRD removes.
+10 -10
View File
@@ -26,7 +26,7 @@ entry and pushes straight at gitea/github with ssh-gate doing dumb
L4 forwarding. There is no boundary between "the agent thinks this
commit is fine" and "the secret hits an external remote." If a
compromised or careless agent stages a `.env`, slips a token into
a fixture, or commits the `CLAUDE_BOTTLE_OAUTH_TOKEN` itself, `git
a fixture, or commits the `BOT_BOTTLE_OAUTH_TOKEN` itself, `git
push` ships it.
Host-side pre-commit / pre-push hooks are the usual defense, but
@@ -131,16 +131,16 @@ for a declared upstream:
Mirror the existing sidecar layout:
- **`claude_bottle/git_gate.py`** (new): abstract `GitGate` +
- **`bot_bottle/git_gate.py`** (new): abstract `GitGate` +
`GitGatePlan` dataclass. `prepare` is host-side / side-effect-
free on docker; renders the per-upstream config and stages the
push credentials under `stage_dir`.
- **`claude_bottle/backend/docker/git_gate.py`** (new):
- **`bot_bottle/backend/docker/git_gate.py`** (new):
`DockerGitGate` concrete subclass. `start` does `docker create`
on the internal network, copies in the bare-repo skeleton, the
hook script, and per-upstream credentials, then `docker start`.
`stop` is idempotent `docker rm -f`. Container name:
`claude-bottle-git-gate-<slug>`.
`bot-bottle-git-gate-<slug>`.
Gate image: `git-daemon` + `openssh-client` over a
`zricethezav/gitleaks` base (alpine + gitleaks), pinned by digest.
@@ -173,21 +173,21 @@ operation.
### Existing code touched
- **`claude_bottle/manifest.py`**: parse and validate the new
- **`bot_bottle/manifest.py`**: parse and validate the new
`bottle.git` block; reject `bottle.ssh` entries whose upstream
is also claimed by a `bottle.git` upstream (one path per
remote, no shadow route).
- **`claude_bottle/backend/docker/provision/git.py`** (new) or an
- **`bot_bottle/backend/docker/provision/git.py`** (new) or an
extension of the ssh provisioner: render the `insteadOf` config
and any extra `~/.gitconfig` plumbing.
- **`claude_bottle/backend/docker/backend.py`**: instantiate
- **`bot_bottle/backend/docker/backend.py`**: instantiate
`DockerGitGate` alongside `DockerPipelockProxy` and
`DockerSSHGate`; thread its `prepare` / `start` / `stop`
through `resolve_plan` / `launch`.
- **`claude_bottle/backend/docker/launch.py`**: add gate start /
- **`bot_bottle/backend/docker/launch.py`**: add gate start /
stop to the `ExitStack` so the gate is up before any
provisioner that writes the agent's `~/.gitconfig`.
- **`claude_bottle/backend/docker/bottle_plan.py`**: new
- **`bot_bottle/backend/docker/bottle_plan.py`**: new
`GitGatePlan` field on `DockerBottlePlan`; preflight rendering
surfaces the gate sidecar (name, per-upstream local paths,
upstream real URLs, which credential is in use).
@@ -249,6 +249,6 @@ exposes it as, and the credential the gate uses to push upstream
- PRD 0007: SSH egress gate — the L4 SSH forwarder this PRD
sits alongside; explicitly *not* the place to add
git-protocol awareness.
- `claude_bottle/ssh_gate.py` / `claude_bottle/pipelock.py`
- `bot_bottle/ssh_gate.py` / `bot_bottle/pipelock.py`
existing sidecar abstractions to mirror.
- gitleaks: <https://github.com/gitleaks/gitleaks>
+15 -15
View File
@@ -8,7 +8,7 @@
Delete the ssh-gate sidecar and the `bottle.ssh` manifest field.
Git-gate (PRD 0008) covers every current SSH use case in
claude-bottle: each declared upstream gets a per-bottle gate
bot-bottle: each declared upstream gets a per-bottle gate
with gitleaks scanning, an `insteadOf` rewrite that captures
push / fetch / clone / pull / ls-remote, and credential
isolation from the agent. ssh-gate is now redundant L4
@@ -76,11 +76,11 @@ the unused path.
`_validate_no_shadow_route`. Add an explicit branch in
`Bottle.from_dict` that dies on a `ssh` key with a one-line
"move this to `bottle.git` (see PRD 0008)" hint.
- **Sidecar.** Delete `claude_bottle/ssh_gate.py` and
`claude_bottle/backend/docker/ssh_gate.py`. Drop the socat
- **Sidecar.** Delete `bot_bottle/ssh_gate.py` and
`bot_bottle/backend/docker/ssh_gate.py`. Drop the socat
image build path.
- **Provisioner.** Delete
`claude_bottle/backend/docker/provision/ssh.py` and its
`bot_bottle/backend/docker/provision/ssh.py` and its
`~/.ssh/config` render.
- **Docker backend wiring.** Drop `DockerSSHGate` from
`backend.py`; drop its start / stop from `launch.py`'s
@@ -98,7 +98,7 @@ the unused path.
- **README.** Drop the socat / ssh image box from the
architecture diagram and its bullet; drop `ssh:` from the
manifest example.
- **Example manifest.** Drop `ssh:` from `claude-bottle.example.json`.
- **Example manifest.** Drop `ssh:` from `bot-bottle.example.json`.
- **PRD 0007.** Add a `Status: Superseded by PRD 0009` header
at the top of the document. Do not delete the file; the
history of intent matters for the audit trail.
@@ -138,19 +138,19 @@ the seams between ssh-gate and the rest of the system:
### Existing code touched
- `claude_bottle/manifest.py` — delete `SshEntry`,
- `bot_bottle/manifest.py` — delete `SshEntry`,
`Bottle.ssh`, `_validate_no_shadow_route`; add the
parse-fail branch.
- `claude_bottle/ssh_gate.py` — delete.
- `claude_bottle/backend/docker/ssh_gate.py` — delete.
- `claude_bottle/backend/docker/provision/ssh.py` — delete.
- `claude_bottle/backend/docker/backend.py` — drop
- `bot_bottle/ssh_gate.py` — delete.
- `bot_bottle/backend/docker/ssh_gate.py` — delete.
- `bot_bottle/backend/docker/provision/ssh.py` — delete.
- `bot_bottle/backend/docker/backend.py` — drop
`DockerSSHGate` instantiation.
- `claude_bottle/backend/docker/launch.py` — drop the
- `bot_bottle/backend/docker/launch.py` — drop the
ssh-gate start / stop from the `ExitStack`.
- `claude_bottle/backend/docker/bottle_plan.py` — drop the
- `bot_bottle/backend/docker/bottle_plan.py` — drop the
ssh-gate plan field.
- `claude_bottle/pipelock.py` — drop the `bottle.ssh`-derived
- `bot_bottle/pipelock.py` — drop the `bottle.ssh`-derived
branch in the allowlist render.
- `tests/unit/test_ssh_gate.py` — delete.
- `tests/integration/` — delete any ssh-gate-specific tests.
@@ -160,7 +160,7 @@ the seams between ssh-gate and the rest of the system:
helper.
- `README.md` — drop the socat image box from the diagram and
the matching bullet; drop `ssh:` from the manifest example.
- `claude-bottle.example.json` — drop the `ssh` field.
- `bot-bottle.example.json` — drop the `ssh` field.
- `docs/prds/0007-ssh-egress-gate.md` — add a
`Status: Superseded by PRD 0009` header at the top.
@@ -173,7 +173,7 @@ the seams between ssh-gate and the rest of the system:
### External dependencies
Nothing added. The `alpine/socat` image is no longer pulled
by claude-bottle; the cleanup of any existing local image is
by bot-bottle; the cleanup of any existing local image is
the user's choice (a single `docker image rm` if they care).
## Future work
+16 -16
View File
@@ -51,7 +51,7 @@ 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
bot-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.
@@ -118,7 +118,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
- **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.
the OAuth token. Not in bot-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.
@@ -175,7 +175,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
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
`bot-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 traverses
@@ -230,7 +230,7 @@ common upstreams (Anthropic, GitHub, Gitea, npm) as
```
┌── Host (macOS) ──────────────────────────────────────────────────┐
│ Secrets at rest (keychain / .env): │
CLAUDE_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
BOT_BOTTLE_OAUTH_TOKEN, GITHUB_TOKEN, │
│ GITEA_SERVER_TOKEN, NPM_TOKEN │
│ │ docker run -e KEY (no =VALUE on argv) │
│ ▼ │
@@ -288,18 +288,18 @@ Why the agent can't reach the sidecar's environ:
### New components
- **`claude_bottle/cred_proxy.py`** (new): abstract `CredProxy`
- **`bot_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):
- **`bot_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`**
Container name: `bot-bottle-cred-proxy-<slug>`.
- **`bot_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
@@ -310,12 +310,12 @@ Why the agent can't reach the sidecar's environ:
### Existing code touched
- **`claude_bottle/manifest.py`** — add `CredProxyRoute`,
- **`bot_bottle/manifest.py`** — add `CredProxyRoute`,
`CredProxyConfig`, `Bottle.cred_proxy: CredProxyConfig`. Parse
+ validate route shape, role enum, path uniqueness, singleton-
role constraints.
- **`claude_bottle/backend/docker/prepare.py`** — drop the
legacy `CLAUDE_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN`
- **`bot_bottle/backend/docker/prepare.py`** — drop the
legacy `BOT_BOTTLE_OAUTH_TOKEN``CLAUDE_CODE_OAUTH_TOKEN`
forward entirely. cred-proxy is the only path the Anthropic
OAuth token reaches the bottle. When a route claims the
`anthropic-base-url` role, write `ANTHROPIC_BASE_URL`
@@ -324,27 +324,27 @@ Why the agent can't reach the sidecar's environ:
otherwise; the proxy strips & replaces on every request).
Bottles that need claude-code to authenticate must declare
the route; there is no fallback.
- **`claude_bottle/backend/docker/backend.py`** — instantiate
- **`bot_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
- **`bot_bottle/backend/docker/launch.py`** — add cred-proxy
start/stop to the `ExitStack` after pipelock and before the
agent; populate `pipelock_proxy_url` + `pipelock_ca_host_path`
on the cred-proxy plan so its outbound HTTPS routes through
pipelock.
- **`claude_bottle/backend/docker/bottle_plan.py`** — new
- **`bot_bottle/backend/docker/bottle_plan.py`** — new
`cred_proxy_plan` field; preflight shows route count + token
refs + a path→upstream line per route; `to_dict` emits a
`cred_proxy` array of `{path, upstream, auth_scheme, token_ref,
roles}`.
- **`claude_bottle/pipelock.py`** — `pipelock_token_hosts` derives
- **`bot_bottle/pipelock.py`** — `pipelock_token_hosts` derives
from each route's `UpstreamHost` (not a hardcoded Kind→hosts
map). Allowlist auto-includes them; passthrough does not (the
proxy trusts pipelock's CA so MITM works).
- **`README.md`** — architecture diagram includes the cred-proxy
lane; manifest section documents `bottle.cred_proxy.routes`.
- **`claude-bottle.example.json`** — one bottle demonstrates the
- **`bot-bottle.example.json`** — one bottle demonstrates the
four common routes (Anthropic, GitHub, Gitea, npm).
- **Tests** — manifest parsing/validation, route lift + token-env
slot assignment, role-based dispatch in the provisioner,
+42 -42
View File
@@ -6,11 +6,11 @@
## Summary
Replace the single-file `claude-bottle.json` manifest with a
Replace the single-file `bot-bottle.json` manifest with a
per-file Markdown-with-YAML-frontmatter layout. Bottles live as
`$HOME/.claude-bottle/bottles/<name>.md`; agents live as
`$HOME/.claude-bottle/agents/<name>.md` (home-resident) and
`$CWD/.claude-bottle/agents/<name>.md` (repo-supplied). Each file
`$HOME/.bot-bottle/bottles/<name>.md`; agents live as
`$HOME/.bot-bottle/agents/<name>.md` (home-resident) and
`$CWD/.bot-bottle/agents/<name>.md` (repo-supplied). Each file
carries its structured config in YAML frontmatter and (for agents)
its system prompt in the Markdown body.
@@ -28,7 +28,7 @@ PyYAML dependency. The project's "low deps by default" stance
## Problem
`claude-bottle.json` works fine at one bottle and one agent. The
`bot-bottle.json` works fine at one bottle and one agent. The
project is heading for many of both, and the single-JSON shape
starts to fray:
@@ -60,22 +60,22 @@ axes (grouping × format) and lands on this design.
Each test runs against a temporary `$HOME` and a temporary `$CWD`:
1. **A bottle file under `$HOME/.claude-bottle/bottles/`
1. **A bottle file under `$HOME/.bot-bottle/bottles/`
parses.** A `dev.md` file with YAML frontmatter declaring
`cred_proxy.routes`, `git`, `env`, `egress` produces a Bottle
dataclass equivalent to the current JSON shape.
2. **An agent file under `$HOME/.claude-bottle/agents/` parses.**
2. **An agent file under `$HOME/.bot-bottle/agents/` parses.**
`implementer.md` with frontmatter that names `bottle:`,
`skills:`, and other fields, with the body as the system
prompt, produces an Agent dataclass.
3. **An agent file under `$CWD/.claude-bottle/agents/` parses
3. **An agent file under `$CWD/.bot-bottle/agents/` parses
and overrides home-resident agents of the same name.** The
cwd agent's frontmatter and body win; the home bottle it
references stays intact.
4. **A bottle file under `$CWD/.claude-bottle/bottles/` is
4. **A bottle file under `$CWD/.bot-bottle/bottles/` is
ignored.** The directory does not contribute to the
manifest; if a user accidentally creates one, the launcher
emits a `warn`-level log naming the offending files and
@@ -83,7 +83,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
is a usability nicety, not a security gate.
5. **No third-party Python dependencies introduced.** A fresh
clone with only stdlib + claude-bottle's own code runs every
clone with only stdlib + bot-bottle's own code runs every
parser test. Frontmatter parsing is hand-rolled against the
declared YAML subset.
@@ -97,30 +97,30 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
`name`, `description`, `model`, `color`, and `memory` fields
from Claude Code's existing subagent spec are accepted in
our frontmatter alongside our own fields. Copying an agent
file from `$HOME/.claude-bottle/agents/` to
file from `$HOME/.bot-bottle/agents/` to
`~/.claude/agents/` produces a working Claude Code subagent
(subject to Claude Code's tolerance for the extra `bottle:`
and `claude_bottle:` fields — see Open Questions).
and `bot_bottle:` fields — see Open Questions).
## Non-goals
- **A general YAML implementation.** The parser handles the
subset claude-bottle's frontmatter actually uses; documents
subset bot-bottle's frontmatter actually uses; documents
that exceed the subset (anchors, multi-line block scalars,
tags, implicit type coercion, flow style, etc.) die with a
pointer at the spec. We are not building a YAML library.
- **Compatibility with the old JSON layout at runtime.** The
resolver no longer reads `claude-bottle.json` files. This is
resolver no longer reads `bot-bottle.json` files. This is
a breaking change; existing users hand-rewrite their JSON
into the new per-file layout (claude-bottle has a single
into the new per-file layout (bot-bottle has a single
primary user today, so the migration is one person rewriting
one file). Documented as part of the README rewrite.
- **`$HOME/.claude/agents/` integration on the input side.** We
don't read agent files out of Claude Code's directory. Our
files can be copied into Claude Code's tree by the user if
they want, but the input path for claude-bottle is its own
they want, but the input path for bot-bottle is its own
directory.
- **A signed-manifest scheme.** Out of scope per the
@@ -139,14 +139,14 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
### In scope
- **Directory layout.**
- `$HOME/.claude-bottle/bottles/<name>.md` — bottle
- `$HOME/.bot-bottle/bottles/<name>.md` — bottle
definitions (full schema; one Bottle per file).
- `$HOME/.claude-bottle/agents/<name>.md` — home-resident
- `$HOME/.bot-bottle/agents/<name>.md` — home-resident
agents.
- `$CWD/.claude-bottle/agents/<name>.md` — cwd-resident
- `$CWD/.bot-bottle/agents/<name>.md` — cwd-resident
agents; same schema as home agents, but bottle names must
resolve against the home set.
- `$CWD/.claude-bottle/bottles/` — ignored with a warn-level
- `$CWD/.bot-bottle/bottles/` — ignored with a warn-level
log (see SC #4). Does not contribute to the manifest.
- `<name>` is the file basename without `.md`. Filenames must
match `[a-z][a-z0-9-]*` (kebab-case, ASCII-only).
@@ -162,7 +162,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
- `skills: [<name>, ...]` (optional) — host-side skills under
`~/.claude/skills/`.
- `name`, `description`, `model`, `color`, `memory` — accepted
but treated as Claude Code passthrough; claude-bottle
but treated as Claude Code passthrough; bot-bottle
ignores them at launch but doesn't reject. Lets the same
file double as a Claude Code subagent.
- Unknown top-level keys die with a hint listing accepted
@@ -191,17 +191,17 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
the required-keys check — same diagnostic as malformed).
- **Manifest assembly.** New resolver:
1. Walk `$HOME/.claude-bottle/bottles/*.md` → Bottle dict
1. Walk `$HOME/.bot-bottle/bottles/*.md` → Bottle dict
keyed by filename.
2. Walk `$HOME/.claude-bottle/agents/*.md` → Agent dict.
3. Walk `$CWD/.claude-bottle/agents/*.md` → Agent dict; merge
2. Walk `$HOME/.bot-bottle/agents/*.md` → Agent dict.
3. Walk `$CWD/.bot-bottle/agents/*.md` → Agent dict; merge
into the home agent dict, cwd wins on name collision.
4. Validate every agent's `bottle:` against the bottle dict.
5. Warn if `$CWD/.claude-bottle/bottles/` exists with files.
5. Warn if `$CWD/.bot-bottle/bottles/` exists with files.
6. Return Manifest dataclass — same shape as today.
- **Docs.** README's manifest section rewrites against the new
layout. `claude-bottle.example.json` becomes
layout. `bot-bottle.example.json` becomes
`examples/bottles/dev.md` + `examples/agents/implementer.md`.
The PRD 0010 example block in its own document gets a
follow-up commit noting the new layout (out of scope for
@@ -233,7 +233,7 @@ Each test runs against a temporary `$HOME` and a temporary `$CWD`:
### File layout
```
$HOME/.claude-bottle/
$HOME/.bot-bottle/
├── bottles/
│ ├── dev.md
│ ├── gitea-dev.md
@@ -243,7 +243,7 @@ $HOME/.claude-bottle/
├── researcher.md
└── ...
$CWD/.claude-bottle/
$CWD/.bot-bottle/
└── agents/
└── <repo-specific>.md
```
@@ -261,7 +261,7 @@ cred_proxy:
- path: /anthropic/
upstream: https://api.anthropic.com
auth_scheme: Bearer
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
token_ref: BOT_BOTTLE_OAUTH_TOKEN
role: anthropic-base-url
- path: /gitea/dideric/
upstream: https://gitea.dideric.is
@@ -271,8 +271,8 @@ cred_proxy:
git:
remotes:
gitea.dideric.is:
Name: claude-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git
Name: bot-bottle
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
IdentityFile: ~/.ssh/gitea-delos-2.pem
ExtraHosts:
gitea.dideric.is: 100.78.141.42
@@ -302,7 +302,7 @@ skills:
---
You are a feature-implementation agent running inside an
ephemeral claude-bottle sandbox...
ephemeral bot-bottle sandbox...
```
Drop the same file into `~/.claude/agents/implementer.md` and
@@ -336,7 +336,7 @@ Notable rejections (each dies with a specific error):
be ambiguous, quote it.
- Flow style mappings nested more than one level deep.
Parser lives at `claude_bottle/yaml_subset.py`, ~300 lines.
Parser lives at `bot_bottle/yaml_subset.py`, ~300 lines.
Public API:
```python
@@ -348,14 +348,14 @@ def parse_frontmatter(text: str) -> tuple[dict[str, object], str]:
### Existing code touched
- **`claude_bottle/manifest.py`** — `Manifest.resolve` rewritten
- **`bot_bottle/manifest.py`** — `Manifest.resolve` rewritten
to walk the new directories. `Manifest.from_json_obj` kept as
a programmatic entry point (used by tests). New
`Manifest.from_md_dirs(home_dir, cwd_dir)` for the loader.
- **`claude_bottle/yaml_subset.py`** — new. The parser.
- **`bot_bottle/yaml_subset.py`** — new. The parser.
- **`README.md`** — manifest section rewritten against the new
layout.
- **`claude-bottle.example.json`** — removed; replaced by an
- **`bot-bottle.example.json`** — removed; replaced by an
`examples/` directory with one bottle file + one agent file.
- **Tests** — new parser tests + new loader tests; existing
manifest tests adapt to either build via `from_json_obj`
@@ -368,12 +368,12 @@ etc. all stay the same shape. Only the loader changes.
### Backward compatibility
This is a breaking change for v1 users. claude-bottle has a
This is a breaking change for v1 users. bot-bottle has a
single primary user today, so migration is one person rewriting
one file — no automated migration command is in scope.
If `claude-bottle.json` exists in `$HOME` or `$CWD` *and* the
new `.claude-bottle/` directory does not exist, the resolver
If `bot-bottle.json` exists in `$HOME` or `$CWD` *and* the
new `.bot-bottle/` directory does not exist, the resolver
dies with a clear pointer at the README's manifest section —
not silently merging formats, not silently dropping the JSON
content.
@@ -384,11 +384,11 @@ content.
empirically before settling: drop a file with `bottle: dev`
in `~/.claude/agents/` and see whether Claude Code warns,
ignores, or breaks. If it warns, namespace the field
(`claude-bottle-bottle:` or a nested `claude_bottle:` block).
- **Hidden directory vs visible.** Default `.claude-bottle/`
(`bot-bottle-bottle:` or a nested `bot_bottle:` block).
- **Hidden directory vs visible.** Default `.bot-bottle/`
(hidden — matches `.config/`, `.ssh/`, `.docker/`). If users
routinely want to navigate to it from the file manager,
switch to `claude-bottle/`. Lean hidden.
switch to `bot-bottle/`. Lean hidden.
- **`description:` for bottles.** Should bottle frontmatter
carry a `description:` field for the y/N preflight? Default
no — bottle names are kebab-case and self-describing, and
+2 -2
View File
@@ -6,7 +6,7 @@
## Summary
When an agent running inside a claude-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
When an agent running inside a bot-bottle container gets blocked, it invokes one of three MCP tool calls — `cred-proxy-block`, `pipelock-block`, or `capability-block` — passing a *proposed* config change (modified `routes.json`, modified pipelock allowlist, or modified agent Dockerfile) plus text describing why the change is justified. The supervisor sees the proposal in a host-side TUI, approves / modifies / rejects it, and the corresponding remediation runs: SIGHUP-reload cred-proxy with the new routes; restart pipelock with the new allowlist; rebuild the bottle from the new Dockerfile on the same branch. The agent's tool call blocks until the operator acts. The supervisor never opens a live channel into a running bottle; all signal flow goes through a per-bottle MCP sidecar on the existing internal network.
This PRD is the overview. Implementation is split across four follow-on PRDs (00130016); see *Implementation chunks* below.
@@ -29,7 +29,7 @@ A real stuck agent recovers end-to-end in each of the three categories: a **cred
Three named categories, each with its own MCP tool. Ordered by remediation cost:
- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/claude-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
- **cred-proxy block.** Tool: `cred-proxy-block`. The agent's request was refused by cred-proxy — missing route, expired token, wrong scope. The agent reads the current `routes.json` from `/etc/bot-bottle/current-config/`, composes a modified version, and calls the tool with `{routes: <new file>, justification: "..."}`. The operator reviews the diff in the TUI; on approval, the supervisor writes the new `routes.json` and cred-proxy SIGHUP-reloads. In-flight connections are not dropped. The tool returns `{status: "approved", notes: "..."}` and the agent retries. Implementation: PRD 0014.
- **pipelock block.** Tool: `pipelock-block`. The agent's outbound request was refused by pipelock — host not in the allowlist, protocol not permitted. The agent reads the current allowlist, composes a modified version, and calls the tool with `{allowlist: <new file>, justification: "..."}`. On approval, the supervisor writes the new allowlist and restarts pipelock; in-flight outbound calls may drop and rely on retry. The tool returns the same approve/reject shape. Implementation: PRD 0015.
- **capability block.** Tool: `capability-block`. The bottle is missing a tool, skill, permission, or env var the agent needs — something that lives in the agent Dockerfile rather than in routes or the pipelock allowlist. The agent reads the current Dockerfile, composes a modified version, and calls the tool with `{dockerfile: <new file>, justification: "..."}`. On approval, the rebuild orchestrator tears down the bottle, builds from the new Dockerfile, and starts a replacement bottle on the same branch via the state-preservation helper. Because the current agent is about to be replaced, the tool's return is best-effort — the replacement agent inherits the approval record via the preserved transcript. Implementation: PRD 0016.
+9 -9
View File
@@ -7,7 +7,7 @@
## Summary
The shared infrastructure that PRDs 00140016 build on. Adds a per-bottle MCP sidecar that exposes three tools (`cred-proxy-block`, `pipelock-block`, `capability-block`) to the agent; a read-only `/etc/claude-bottle/current-config/` mount in the agent container that exposes the current `routes.json`, pipelock allowlist, and Dockerfile; a host-mounted proposal queue; a minimal TUI dashboard that lists pending proposals and supports approve / modify / reject; and the audit log format. After this PRD, an operator can see proposals and approve/reject them — but the approval handlers are no-ops. The remediation engines that actually act on approvals land in 0014, 0015, and 0016.
The shared infrastructure that PRDs 00140016 build on. Adds a per-bottle MCP sidecar that exposes three tools (`cred-proxy-block`, `pipelock-block`, `capability-block`) to the agent; a read-only `/etc/bot-bottle/current-config/` mount in the agent container that exposes the current `routes.json`, pipelock allowlist, and Dockerfile; a host-mounted proposal queue; a minimal TUI dashboard that lists pending proposals and supports approve / modify / reject; and the audit log format. After this PRD, an operator can see proposals and approve/reject them — but the approval handlers are no-ops. The remediation engines that actually act on approvals land in 0014, 0015, and 0016.
## Problem
@@ -33,10 +33,10 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
- A per-bottle MCP sidecar container on the bottle's internal network.
- MCP tool definitions for `cred-proxy-block`, `pipelock-block`, `capability-block` (input schemas as defined in PRD 0012 *Stuck categories*).
- Tool output: `{status: "approved"|"modified"|"rejected", notes: "..."}`.
- A read-only mount at `/etc/claude-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
- A host-mounted per-bottle proposal queue at `~/.claude-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content).
- A `claude-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
- Audit log files at `~/.claude-bottle/audit/cred-proxy-<slug>.log` and `~/.claude-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
- A read-only mount at `/etc/bot-bottle/current-config/` in the agent container exposing the current `routes.json`, pipelock allowlist, and Dockerfile.
- A host-mounted per-bottle proposal queue at `~/.bot-bottle/queue/<slug>/` (file-per-proposal, with metadata and proposed file content).
- A `bot-bottle dashboard` (or similarly named) TUI that lists running bottles and pending proposals across all of them; supports approve, modify-then-approve, and reject-with-reason for each pending proposal.
- Audit log files at `~/.bot-bottle/audit/cred-proxy-<slug>.log` and `~/.bot-bottle/audit/pipelock-<slug>.log` with the agreed-upon format (timestamp, diff before/after, justification text, operator action with notes). Entries are written by the supervisor on each approve/modify/reject decision. (capability-block has no separate audit log — capability changes are captured by the bottle's rebuild record / git history.)
- Bottle lifecycle script changes to launch the MCP sidecar alongside the other sidecars and mount the read-only current-config directory.
### Out of scope
@@ -49,15 +49,15 @@ See PRD 0012 for the broader stuck-agent problem. This PRD specifically addresse
### New services / components
- **MCP sidecar.** New per-bottle container on the bottle's internal network. Exposes the three tools to the agent. On a tool call: validates the proposed file syntactically (valid JSON for `routes.json`, parseable Dockerfile, etc.), writes the proposal to the queue, and holds the tool-call connection open until the supervisor responds. Returns `{status, notes}` to the agent on response.
- **Read-only current-config mount.** `/etc/claude-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
- **Proposal queue.** Per-bottle directory under `~/.claude-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
- **Read-only current-config mount.** `/etc/bot-bottle/current-config/` in the agent container exposes `routes.json`, the pipelock allowlist, and the agent Dockerfile from the host. Read-only — the agent proposes changes via the tool call, never by writing the file directly.
- **Proposal queue.** Per-bottle directory under `~/.bot-bottle/queue/<slug>/` on the host. One file per pending proposal with `{id, tool, proposed_file, justification, arrival_timestamp, current_file_hash, bottle_slug}`.
- **Minimal TUI dashboard.** Lists running bottles and pending proposals. For each proposal: shows current vs. proposed diff and justification. Operator actions: approve / modify-then-approve / reject-with-reason. Stdlib only (curses) unless that proves painful.
- **Audit log format.** Append-only files at `~/.claude-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
- **Audit log format.** Append-only files at `~/.bot-bottle/audit/<component>-<slug>.log`. Each entry: timestamp, diff before/after, agent justification (if from a tool call), operator action + notes. Defines the format; the per-component PRDs (0014, 0015) fill in real entries.
- **No-op approval handlers.** Each tool's approve path in 0013 writes an audit entry and returns `{status: "approved"}` to the agent but doesn't actually change any config. 0014 / 0015 / 0016 replace these with real handlers.
### Existing code touched
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/claude-bottle/current-config/` read-only into the agent container.
- **Bottle lifecycle scripts** — launch the MCP sidecar alongside other sidecars; mount `/etc/bot-bottle/current-config/` read-only into the agent container.
- **`cli.py`** — adds the dashboard subcommand.
### Data model changes
+3 -3
View File
@@ -106,7 +106,7 @@ delivery.
apply path. SIGHUP reload semantics carry over to egress-proxy.
- PRD 0013 (supervise plane) `cred-proxy-block` MCP tool stays;
its proposed file format updates per the new route shape.
- Removal of the old cred-proxy code: `claude_bottle/cred_proxy.py`,
- Removal of the old cred-proxy code: `bot_bottle/cred_proxy.py`,
`cred_proxy_server.py`, `backend/docker/cred_proxy.py`,
`provision/cred_proxy.py`, the `Dockerfile.cred-proxy`. Tests
updated.
@@ -254,8 +254,8 @@ manifest load:
`path``host`, drop the agent-side URL prefix).
- `cred_proxy_routes` field on existing dataclasses removed.
- `Dockerfile.cred-proxy` deleted.
- `claude_bottle/cred_proxy*.py` deleted.
- `claude_bottle/backend/docker/cred_proxy*.py` consolidated into
- `bot_bottle/cred_proxy*.py` deleted.
- `bot_bottle/backend/docker/cred_proxy*.py` consolidated into
`egress_proxy*.py`.
- Provisioner files renamed.
- PRDs 0010 (cred-proxy), 0014 (cred-proxy-block remediation)
+29 -29
View File
@@ -15,7 +15,7 @@ down`. Logs come from `docker compose logs` and land in a single file
per instance, so reading what happened in a session is one `less`
away.
State for each instance (`~/.claude-bottle/state/<slug>/`) becomes a
State for each instance (`~/.bot-bottle/state/<slug>/`) becomes a
self-describing folder:
```
@@ -34,7 +34,7 @@ together fully describe the container topology.
Today `start` builds each sidecar (`pipelock`, `egress`, `git-gate`,
`supervise`) and the agent container with a chain of individual SDK
calls in `claude_bottle/backend/docker/launch.py`:
calls in `bot_bottle/backend/docker/launch.py`:
- A per-sidecar `Docker{Sidecar}.start()` method does
`docker create``docker cp` (stage files) → `docker network
@@ -50,7 +50,7 @@ This is fine, but it has three rough edges:
2. **Logs are scattered.** Each container's logs sit in Docker's per-
container journal. To debug a session post-mortem you have to
remember to run `docker logs claude-bottle-pipelock-<slug>` etc.
remember to run `docker logs bot-bottle-pipelock-<slug>` etc.
before the containers age out, and there's no merged view.
3. **Teardown is bespoke.** Each sidecar's `stop()` is its own
@@ -62,14 +62,14 @@ project name per environment, merged logs, atomic up/down.
## Goals / Success Criteria
1. `claude-bottle start <agent>` writes
`~/.claude-bottle/state/<slug>/docker-compose.yml` and brings the
1. `bot-bottle start <agent>` writes
`~/.bot-bottle/state/<slug>/docker-compose.yml` and brings the
project up with `docker compose -p <project> up`.
2. The compose file is the source of truth for the container
topology — every sidecar that runs is declared as a `services:`
entry, every network is a `networks:` entry, every bind mount is
a `volumes:` entry.
3. `~/.claude-bottle/state/<slug>/compose.log` contains the full
3. `~/.bot-bottle/state/<slug>/compose.log` contains the full
merged stdout/stderr of every service for the session, in
`docker compose logs --no-color` format.
4. `metadata.json` records the compose project name alongside the
@@ -79,7 +79,7 @@ project name per environment, merged logs, atomic up/down.
5. Session teardown is `docker compose -p <project> down`. The
existing per-sidecar `stop()` lifecycle methods come out.
6. The `cleanup` CLI uses `docker compose ls` (filtered to
`claude-bottle-*` projects) instead of name-prefix scans across
`bot-bottle-*` projects) instead of name-prefix scans across
`docker ps -a` and `docker network ls`.
7. The existing remediation flows (`pipelock-block`,
`egress-block`, `capability-block`) keep working without
@@ -95,7 +95,7 @@ project name per environment, merged logs, atomic up/down.
implementation detail of the Docker backend.
- **Replacing the backend abstraction (PRD 0003).** `Backend` stays
abstract; only the Docker implementation changes.
- **A long-lived "claude-bottle daemon."** Each `start` invocation
- **A long-lived "bot-bottle daemon."** Each `start` invocation
still owns a single compose project for the lifetime of the
session. No persistent service.
- **Image pre-building.** Compose's `build:` directive triggers
@@ -109,7 +109,7 @@ project name per environment, merged logs, atomic up/down.
### In scope
- New module `claude_bottle/backend/docker/compose.py` that renders a
- New module `bot_bottle/backend/docker/compose.py` that renders a
compose dict from a `BottlePlan` and writes it to
`state/<slug>/docker-compose.yml`.
- `DockerBackend.start` rewritten to:
@@ -118,7 +118,7 @@ project name per environment, merged logs, atomic up/down.
into host paths under `state/<slug>/`.
3. Render + write the compose file.
4. Exec `docker compose -p <project> up -d`.
5. `docker attach claude-bottle-<slug>` for the agent's TTY.
5. `docker attach bot-bottle-<slug>` for the agent's TTY.
6. On exit: `docker compose -p <project> logs --no-color`
`state/<slug>/compose.log`, then `docker compose -p
<project> down --volumes`.
@@ -134,12 +134,12 @@ project name per environment, merged logs, atomic up/down.
### Out of scope
- Changing the manifest layer (`claude_bottle/manifest.py`,
- Changing the manifest layer (`bot_bottle/manifest.py`,
`egress.py`'s plan dataclasses, `pipelock.py`'s plan dataclasses).
- Changing the agent's runtime contract (proxy env vars, CA bundle
paths, current-config mount path).
- Changing audit-log shape or location (
`~/.claude-bottle/audit/<component>-<slug>.log` stays).
`~/.bot-bottle/audit/<component>-<slug>.log` stays).
- Changing the MCP server's tool list or wire format.
- Dropping the `--rm` semantics for the agent: the agent container
is still ephemeral; compose's `down --volumes` handles cleanup.
@@ -148,7 +148,7 @@ project name per environment, merged logs, atomic up/down.
### Project name
`compose_project = f"claude-bottle-{slug}"`. The slug stays the
`compose_project = f"bot-bottle-{slug}"`. The slug stays the
existing `slugify(agent_name)-<5-char-random-base36>` from
`bottle_state.py`. Compose adds its own prefix to networks
(`<project>_<network>`) and to default container names — which is
@@ -163,29 +163,29 @@ an explicit `container_name:` matching today's pattern:
```yaml
services:
pipelock:
container_name: claude-bottle-pipelock-<slug>
container_name: bot-bottle-pipelock-<slug>
egress:
container_name: claude-bottle-egress-<slug>
container_name: bot-bottle-egress-<slug>
# ...
```
This keeps the dashboard's container-discovery output stable for
operators who've memorized the names. The compose project name
(`claude-bottle-<slug>`) is the only new identifier.
(`bot-bottle-<slug>`) is the only new identifier.
### Networks
The two existing networks (`claude-bottle-net-<slug>` internal +
`claude-bottle-egress-<slug>` upstream-bridge) become compose
The two existing networks (`bot-bottle-net-<slug>` internal +
`bot-bottle-egress-<slug>` upstream-bridge) become compose
networks:
```yaml
networks:
internal:
name: claude-bottle-net-<slug>
name: bot-bottle-net-<slug>
internal: true
egress:
name: claude-bottle-egress-<slug>
name: bot-bottle-egress-<slug>
```
Each service's `networks:` list mirrors today's wiring.
@@ -238,7 +238,7 @@ sidecars that exist.
### Logging
`docker compose up -d` starts everything detached. The agent is
attached for the user's TTY via `docker attach claude-bottle-
attached for the user's TTY via `docker attach bot-bottle-
<slug>`. Sidecars stream into Docker's per-container journals
during the session, exactly as today, and `docker compose logs -f`
gives a merged tail if the user wants it (the dashboard can shell
@@ -265,7 +265,7 @@ Add one field; everything else is unchanged.
"agent_name": "implementer",
"cwd": "/Users/.../some-project",
"started_at": "2026-05-25T20:13:04Z",
"compose_project": "claude-bottle-implementer-a7k3f"
"compose_project": "bot-bottle-implementer-a7k3f"
}
```
@@ -291,13 +291,13 @@ After this PRD:
### Cleanup CLI
`./cli.py cleanup` switches from "list every container with prefix
`claude-bottle-` and every network with prefix `claude-bottle-net-`
or `claude-bottle-egress-`" to:
`bot-bottle-` and every network with prefix `bot-bottle-net-`
or `bot-bottle-egress-`" to:
1. `docker compose ls --all --format json` → filter to projects
whose name starts with `claude-bottle-`.
whose name starts with `bot-bottle-`.
2. For each: `docker compose -p <project> down --volumes`.
3. Reap any state dirs under `~/.claude-bottle/state/` whose
3. Reap any state dirs under `~/.bot-bottle/state/` whose
`compose_project` no longer appears in `compose ls`.
Strays from pre-compose code-paths can be mopped up by keeping the
@@ -313,7 +313,7 @@ existing prefix scan as a fallback for one release.
2. **How does `claude` reach the agent's TTY?** Decided: keep
today's `docker exec -it` model. Agent runs `sleep infinity`
under compose; `DockerBottle.exec_claude` runs
`docker exec -it claude-bottle-<slug> claude ...` exactly like
`docker exec -it bot-bottle-<slug> claude ...` exactly like
today. Compose owns the lifecycle (so `compose logs` includes
the agent's stdout, `compose down` tears it down), but the
user-facing exec model is unchanged. Rejected `docker attach`
@@ -332,8 +332,8 @@ existing prefix scan as a fallback for one release.
5. **Image build caching.** `build:` in compose rebuilds on first
`up` unless the image is already tagged. The per-sidecar images
(`claude-bottle-pipelock`, `claude-bottle-egress`,
`claude-bottle-git-gate`, `claude-bottle-supervise`) should
(`bot-bottle-pipelock`, `bot-bottle-egress`,
`bot-bottle-git-gate`, `bot-bottle-supervise`) should
stay tagged on the daemon between runs so we don't rebuild on
every start. Verify compose's behavior matches.
+1 -1
View File
@@ -128,7 +128,7 @@ the "operator wants to make an unprompted change" case.
### Layout
```
claude-bottle dashboard (3 pending, 2 active)
bot-bottle dashboard (3 pending, 2 active)
─────────────────────────────────────────────────────────
proposals:
03:14:22 [implementer-cy7a6] egress-block abc123…
@@ -63,7 +63,7 @@ captures full-merged logs per bottle (PRD 0018). It already
→ restore`, matching the existing editor-flow pattern.
3. On launch success, the dashboard performs a handoff (option
1 from the research doc): `curses.endwin()` → `docker exec
-it claude-bottle-<slug> claude --dangerously-skip-permissions`
-it bot-bottle-<slug> claude --dangerously-skip-permissions`
→ on exit, `stdscr.refresh()` and re-render with the new
bottle in the agents pane.
4. The bottle's lifetime is owned by the dashboard process, NOT
@@ -268,7 +268,7 @@ dashboard started this session, the dashboard holds the
`bottle.exec_claude(...)`. For an agent it discovered via
`list_active_slugs` (previous-dashboard or external start),
the dashboard synthesizes a one-shot `DockerBottle` from the
slug — container name is `claude-bottle-<slug>`, no prompt
slug — container name is `bot-bottle-<slug>`, no prompt
path because the agent's claude config already has `--append-
system-prompt-file` baked in from the original launch —
and runs the same exec. Either way, Enter drops to
@@ -284,7 +284,7 @@ agents pane.
`x` on a non-owned agent (discovered via `list_active_slugs`
but not in `bottles` dict): no-op with status hint pointing
at `./cli.py cleanup` (the existing path that tears down
ANY claude-bottle compose project plus reaps state dirs).
ANY bot-bottle compose project plus reaps state dirs).
### Dashboard quit
@@ -392,5 +392,5 @@ Sized for one PR each.
- `docs/research/claude-code-pane-in-dashboard.md` — option 1
(handoff) is what `attach_claude` implements here; options 2
/ 3 are out of scope for this PRD
- `claude_bottle/cli/start.py:_launch_bottle` — the function
- `bot_bottle/cli/start.py:_launch_bottle` — the function
chunk 1 extracts the prepare + attach pieces out of
+1 -1
View File
@@ -46,7 +46,7 @@ window, two panes, no terminal handoff.
two-pane layout: dashboard in the left pane, an initially-
empty right pane reserved for claude sessions.
2. Pressing Enter on a focused agent row spawns / respawns the
right pane with `docker exec -it claude-bottle-<slug> claude
right pane with `docker exec -it bot-bottle-<slug> claude
--continue --dangerously-skip-permissions`. The right pane's
prior content (if any) is replaced.
3. Pressing `n` to start a new agent (the existing chunk-2 flow
@@ -26,7 +26,7 @@ The suite is the backend-agnostic gate the smolmachines work
has to pass before that backend can be considered a viable
substitute for Docker. Today's Docker backend is the
backend-under-test; the suite runs against whatever
`CLAUDE_BOTTLE_BACKEND` resolves to.
`BOT_BOTTLE_BACKEND` resolves to.
## Problem
@@ -121,7 +121,7 @@ destination outside the bottle.
suite is written against the abstract `BottleBackend` API
via the existing `get_bottle_backend()` selector; future
smolmachines work flips
`CLAUDE_BOTTLE_BACKEND=smolmachines` and reruns. No
`BOT_BOTTLE_BACKEND=smolmachines` and reruns. No
smolmachines-specific code here.
## Scope
@@ -212,7 +212,7 @@ scan happens first.
### Per-attack scaffolding
Each test calls `bottle.exec(script)` (the existing
`Bottle.exec` from `claude_bottle.backend.Bottle`) and
`Bottle.exec` from `bot_bottle.backend.Bottle`) and
asserts on the returncode + stdout + stderr.
The agent container has `curl`, `git`, `dig`, etc. pre-
@@ -331,7 +331,7 @@ def test_5_readme_push_blocked(self):
f'echo "[click](https://attacker.example.com/?leak={env_ref})" > README.md && '
'git add . && git commit -m "leak" && '
'git remote add origin '
'git://claude-bottle-git-gate-<slug>/throwaway.git && '
'git://bot-bottle-git-gate-<slug>/throwaway.git && '
'git push origin master'
)
self.assertNotEqual(0, result.returncode)
@@ -460,7 +460,7 @@ Sized small.
already dies with a clear message naming the unknown
backend; the test surfaces that as a hard error
rather than a skip. Forces the developer to set
`CLAUDE_BOTTLE_BACKEND` to a real implementation —
`BOT_BOTTLE_BACKEND` to a real implementation —
surprise-skips on smolmachines branches that forgot to
set the env var are worse than a loud failure.
+22 -22
View File
@@ -8,7 +8,7 @@
Ship a second concrete `BottleBackend`
`SmolmachinesBottleBackend`, selected via
`CLAUDE_BOTTLE_BACKEND=smolmachines` — that runs each bottle inside
`BOT_BOTTLE_BACKEND=smolmachines` — that runs each bottle inside
a per-agent libkrun microVM via `smolvm`. Egress is enforced by
libkrun's TSI ("Transport Socket Interface") allowlist set to a
**single /32** — the docker IP of the per-bottle sidecar bundle
@@ -28,7 +28,7 @@ port-granular.
The Docker backend ships unchanged; this is opt-in via the existing
env-var selector. The acceptance gate is PRD 0022's
`tests/integration/test_sandbox_escape.py` running green against
`CLAUDE_BOTTLE_BACKEND=smolmachines`.
`BOT_BOTTLE_BACKEND=smolmachines`.
### Design pivot from the first draft
@@ -63,7 +63,7 @@ with significantly less code.
container-based bottles on macOS; `smolmachines-as-vm-backend.md`
evaluates smolmachines as the lifecycle wrapper. Today, the only
backend in the registry is Docker
(`claude_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}`),
(`bot_bottle/backend/__init__.py:_BACKENDS = {"docker": ...}`),
and four things motivate a second one now:
- **Network reach beyond pipelock.** The threat model is a malicious
@@ -85,7 +85,7 @@ and four things motivate a second one now:
enforced by the CPU's MMU instead of namespace bookkeeping.
- **PRD 0022 is backend-agnostic by design** but currently only
exercises the Docker backend. The suite was written with
`CLAUDE_BOTTLE_BACKEND` selection in mind precisely so the
`BOT_BOTTLE_BACKEND` selection in mind precisely so the
smolmachines path could be validated against the same five
attacks. Until a second backend exists, the abstraction is
unproven.
@@ -143,7 +143,7 @@ virtio-net carve-out smolvm doesn't expose anyway).
The feature works when all of the following are observable on a
macOS host with smolmachines installed:
- `CLAUDE_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent>`
- `BOT_BOTTLE_BACKEND=smolmachines python3 cli.py start <agent>`
brings up a microVM, runs claude-code inside it, and tears it
down on exit. Same y/N preflight UX as Docker — only the
resolved-runtime line differs.
@@ -160,14 +160,14 @@ macOS host with smolmachines installed:
The feature is **done** when all of the following ship:
- A new `claude_bottle/backend/smolmachines/` subpackage exists,
mirroring the layout of `claude_bottle/backend/docker/`
- A new `bot_bottle/backend/smolmachines/` subpackage exists,
mirroring the layout of `bot_bottle/backend/docker/`
(`backend.py`, `bottle.py`, `bottle_plan.py`,
`bottle_cleanup_plan.py`, `prepare.py`, `launch.py`,
`cleanup.py`, `util.py`, and a `provision/` subpackage for the
five `provision_*` methods).
- `SmolmachinesBottleBackend` registered under the
`"smolmachines"` key in `claude_bottle/backend/__init__.py:_BACKENDS`.
`"smolmachines"` key in `bot_bottle/backend/__init__.py:_BACKENDS`.
- Per-bottle Smolfile generation: a runtime-rendered TOML written
to the bottle's stage dir using smolvm 0.8.0's actual schema
(`image`, `entrypoint`, `cmd`, `env = ["K=V", …]`, `[network]
@@ -197,7 +197,7 @@ The feature is **done** when all of the following ship:
step is part of `prepare`, analogous to
`docker_mod.build_image`.
- The PRD 0022 sandbox-escape suite, run with
`CLAUDE_BOTTLE_BACKEND=smolmachines`, passes locally on a
`BOT_BOTTLE_BACKEND=smolmachines`, passes locally on a
smolmachines-capable host. The suite is updated to skip cleanly
on hosts that can't reach smolmachines (same shape as the
existing `GITEA_ACTIONS == "true"` skip), not to fail.
@@ -215,7 +215,7 @@ The feature is **done** when all of the following ship:
side. Selection stays env-driven; the manifest does not gain a
`backend` field.
- **No default-backend change.** `docker` remains the default
value of `CLAUDE_BOTTLE_BACKEND`; smolmachines is strictly
value of `BOT_BOTTLE_BACKEND`; smolmachines is strictly
opt-in until it has been load-bearing on at least one operator's
workflow for a release cycle.
- **No `--outbound-localhost-only`.** That TSI flag opens the
@@ -251,7 +251,7 @@ The feature is **done** when all of the following ship:
### In scope
- New `claude_bottle/backend/smolmachines/` subpackage with the
- New `bot_bottle/backend/smolmachines/` subpackage with the
full set of `BottleBackend` overrides.
- Smolfile generator (TOML) emitting the smolvm 0.8.0 schema:
top-level `image`, `entrypoint`, `cmd`, `env = [...]`,
@@ -267,7 +267,7 @@ The feature is **done** when all of the following ship:
- Per-bottle CA install path: the bundle's CA cert lands inside
the microVM via `smolvm machine exec` after start
(analogous to the existing `provision_ca` for Docker).
- Per-bottle docker bridge: a `claude-bottle-bundle-<slug>`
- Per-bottle docker bridge: a `bot-bottle-bundle-<slug>`
network with a /24 subnet derived from the slug hash; the
bundle gets a pinned IP at `.2` (gateway is `.1`). Pinning the
IP at start time avoids a race between the bundle's IP being
@@ -314,7 +314,7 @@ The feature is **done** when all of the following ship:
### Backend layout
```
claude_bottle/backend/smolmachines/
bot_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle (exec_claude / exec / cp_in / close)
@@ -339,7 +339,7 @@ design needs neither.
```
┌── macOS host ─────────────────────────────────────────────────────┐
│ │
│ ┌── per-bottle docker bridge claude-bottle-bundle-<slug> ──┐ │
│ ┌── per-bottle docker bridge bot-bottle-bundle-<slug> ──┐ │
│ │ subnet: 192.168.X.0/24 (X = hash(slug) mod 254) │ │
│ │ │ │
│ │ ┌── bundle container (pinned --ip 192.168.X.2) ────────┐ │ │
@@ -401,9 +401,9 @@ Three changes vs. the Docker backend:
transport tested. The conversion path is a registry hop: bring
up an ephemeral `registry:2.8.3` container bound to
`127.0.0.1:<random>`, `docker tag` + `docker push` into it,
`smolvm pack create --image localhost:<port>/claude-bottle:<id>`,
`smolvm pack create --image localhost:<port>/bot-bottle:<id>`,
tear down the registry. The `.smolmachine` is cached under
`~/.cache/claude-bottle/smolmachines/` keyed by the docker
`~/.cache/bot-bottle/smolmachines/` keyed by the docker
image ID, so Dockerfile changes invalidate the cache and
unchanged rebuilds skip the whole pipeline.
4. Render the per-bottle Smolfile to `stage_dir/smolfile.toml`
@@ -424,7 +424,7 @@ Three changes vs. the Docker backend:
`SmolmachinesBottleBackend.launch(plan)`:
1. Create the per-bottle docker bridge network
(`claude-bottle-bundle-<slug>` with the resolved subnet) and
(`bot-bottle-bundle-<slug>` with the resolved subnet) and
start the sidecar bundle container with `docker run --network
... --ip <bundle-ip> ...`. Wait for its daemons to bind:
pipelock on 8888, git-gate on 9418 (conditional), supervise
@@ -457,7 +457,7 @@ The `BottleSpec` dataclass and the `Bottle` ABC do not change.
### Selection wiring
In `claude_bottle/backend/__init__.py`:
In `bot_bottle/backend/__init__.py`:
```python
from .docker import DockerBottleBackend
@@ -508,7 +508,7 @@ The existing "unknown backend" `die()` path stays as-is.
(egress's port) is refused — confirming the bundle-internal
bind of egress to `127.0.0.1` works as the port-granularity
layer TSI doesn't provide.
- **PRD 0022 re-run:** with `CLAUDE_BOTTLE_BACKEND=smolmachines`,
- **PRD 0022 re-run:** with `BOT_BOTTLE_BACKEND=smolmachines`,
all five attack categories return sandbox-block markers and the
suite passes. The test code does not change beyond the env-var
flip — that's the contract the PRD 0022 abstraction was
@@ -517,7 +517,7 @@ The existing "unknown backend" `die()` path stays as-is.
## Sizing — into chunks
PRD 0024's bundle image is a prerequisite — this PRD assumes
`claude-bottle-sidecars:<pinned>` is available when chunk 3 lands.
`bot-bottle-sidecars:<pinned>` is available when chunk 3 lands.
1. **Backend skeleton + selection + Smolfile/gvproxy renderers.**
*Shipped (PR #62), but under the now-rejected gvproxy design.*
@@ -598,7 +598,7 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
enumerate active bottles (`list_active` queries the daemon).
The microVM enumeration story is `smolvm machine ls --json`;
the plan is to filter on a deterministic name prefix
`claude-bottle-<slug>` + cross-reference with on-disk metadata
`bot-bottle-<slug>` + cross-reference with on-disk metadata
under `state/<slug>/`.
8. **Loopback scoping (Docker Desktop pivot).** The original
design pinned the bundle at a docker bridge IP and set TSI's
@@ -667,5 +667,5 @@ PRD 0024's bundle image is a prerequisite — this PRD assumes
needed to exercise the smolmachines path.
- PRD 0024
(`docs/prds/0024-consolidate-sidecar-bundle.md`) — defines the
single bundle image (`claude-bottle-sidecars`) this PRD
single bundle image (`bot-bottle-sidecars`) this PRD
consumes. Prerequisite for chunk 3 of this PRD.
+30 -30
View File
@@ -8,7 +8,7 @@
Replace the four per-bottle sidecar containers in the Docker
backend (pipelock, egress, git-gate, supervise) with a single
container image — `claude-bottle-sidecars` — that runs all four
container image — `bot-bottle-sidecars` — that runs all four
daemons under a small stdlib-Python init supervisor. Same
per-bottle lifetime, same scope, fewer containers per bottle,
one Dockerfile to maintain instead of three. Outcome: the
@@ -64,7 +64,7 @@ The feature works when all of the following are observable:
speak the same protocols on the same well-known in-container
ports as before; only the container hostname changes.
- The sandbox-escape suite from PRD 0022 stays green.
- `docker logs claude-bottle-sidecars-<slug>` shows interleaved
- `docker logs bot-bottle-sidecars-<slug>` shows interleaved
output from all four daemons, prefixed by the supervisor with
the daemon name. Each daemon's exit propagates through the
supervisor to the container's exit code.
@@ -77,18 +77,18 @@ The feature is **done** when all of the following ship:
- A new `Dockerfile.sidecars` (multi-stage) that:
- Copies the `pipelock` binary from the upstream pipelock
image (currently `ghcr.io/luckypipewrench/pipelock` pinned
by digest in `claude_bottle/backend/docker/pipelock.py`).
by digest in `bot_bottle/backend/docker/pipelock.py`).
- Copies the `gitleaks` binary from `zricethezav/gitleaks`
(currently pinned by digest in `Dockerfile.git-gate`).
- Installs `mitmdump` (via `pip install mitmproxy==<pinned>`).
- Installs the system deps `git-daemon` + `openssh-client`
that git-gate needs.
- Copies the existing addon + server Python from
`claude_bottle/egress_addon.py`, `egress_addon_core.py`,
`bot_bottle/egress_addon.py`, `egress_addon_core.py`,
`yaml_subset.py`, `supervise.py`, `supervise_server.py`.
- Drops in a new `claude_bottle/sidecar_init.py` (stdlib
- Drops in a new `bot_bottle/sidecar_init.py` (stdlib
Python) as the container's `ENTRYPOINT`.
- A new `claude_bottle/sidecar_init.py` — a small Python init
- A new `bot_bottle/sidecar_init.py` — a small Python init
supervisor that:
- Reads which daemons to run from env (defaults: all four).
- Spawns each as a `subprocess.Popen` with prefixed
@@ -99,13 +99,13 @@ The feature is **done** when all of the following ship:
- Exits with code 0 only if every child exited 0; otherwise
exits 1. (Or: any-child-died → tear down the rest and exit
that child's code — see open question 2.)
- `claude_bottle/backend/docker/compose.py` renderer updated to
- `bot_bottle/backend/docker/compose.py` renderer updated to
emit one `sidecars` service in place of the four. The four
in-container ports (8888 / 9099 / 9418 / 9100, today) all
land on the same container; the agent-facing ports
(HTTPS_PROXY, git-gate-SSH, supervise-MCP) are published as
before, just from one container instead of three.
- `claude_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.py`
- `bot_bottle/backend/docker/{pipelock,egress,git_gate,supervise}.py`
collapsed: the platform-neutral pieces stay
(`PipelockProxy`, `Egress`, `GitGate`, `Supervise` ABCs and
their plans), the docker-specific subclasses lose their
@@ -161,7 +161,7 @@ The feature is **done** when all of the following ship:
- New `Dockerfile.sidecars` (multi-stage) bringing pipelock,
mitmproxy, gitleaks, git-daemon, openssh-client, and the
project's addon + server Python into one image.
- New `claude_bottle/sidecar_init.py` supervising the four
- New `bot_bottle/sidecar_init.py` supervising the four
daemons.
- `backend/docker/compose.py` renderer collapse (five services
→ two).
@@ -217,12 +217,12 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
# Drop in the project's Python addon + server code
COPY claude_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY claude_bottle/egress_addon.py /app/egress_addon.py
COPY claude_bottle/yaml_subset.py /app/yaml_subset.py
COPY claude_bottle/supervise.py /app/supervise.py
COPY claude_bottle/supervise_server.py /app/supervise_server.py
COPY claude_bottle/sidecar_init.py /app/sidecar_init.py
COPY bot_bottle/egress_addon_core.py /app/egress_addon_core.py
COPY bot_bottle/egress_addon.py /app/egress_addon.py
COPY bot_bottle/yaml_subset.py /app/yaml_subset.py
COPY bot_bottle/supervise.py /app/supervise.py
COPY bot_bottle/supervise_server.py /app/supervise_server.py
COPY bot_bottle/sidecar_init.py /app/sidecar_init.py
# Pull the standalone binaries into the final stage
COPY --from=pipelock-src /usr/local/bin/pipelock /usr/local/bin/pipelock
@@ -252,7 +252,7 @@ existing Dockerfiles.
### Init supervisor
`claude_bottle/sidecar_init.py` (sketch — actual code lands as
`bot_bottle/sidecar_init.py` (sketch — actual code lands as
part of implementation):
```python
@@ -287,7 +287,7 @@ of the four. The service inherits the union of the four's
existing bind mounts; environment variables get prefixed by
daemon name where they clash (none clash today, but the renderer
becomes the central place to enforce that). Container hostname
becomes `sidecars` (or `claude-bottle-sidecars-<slug>` for the
becomes `sidecars` (or `bot-bottle-sidecars-<slug>` for the
externally-visible name). The agent service's HTTPS_PROXY and
git-gate URL move from per-sidecar hostnames to the single
`sidecars` hostname:
@@ -301,9 +301,9 @@ services:
GIT_GATE_URL: "git://git-gate:9418/repo"
MCP_SUPERVISE_URL: "http://supervise:9100"
pipelock: { image: ghcr.io/luckypipewrench/pipelock:... }
egress: { image: claude-bottle-egress:latest }
git-gate: { image: claude-bottle-git-gate:latest }
supervise:{ image: claude-bottle-supervise:latest }
egress: { image: bot-bottle-egress:latest }
git-gate: { image: bot-bottle-git-gate:latest }
supervise:{ image: bot-bottle-supervise:latest }
# After (two services)
services:
@@ -313,7 +313,7 @@ services:
GIT_GATE_URL: "git://sidecars:9418/repo"
MCP_SUPERVISE_URL: "http://sidecars:9100"
sidecars:
image: claude-bottle-sidecars:<pinned>
image: bot-bottle-sidecars:<pinned>
# union of the four prior services' volumes / env / ports
```
@@ -321,7 +321,7 @@ services:
### Backend Python collapse
The four `claude_bottle/backend/docker/<sidecar>.py` files keep
The four `bot_bottle/backend/docker/<sidecar>.py` files keep
their platform-neutral abstractions (proxy/plan classes) but
shed the docker-container-lifecycle code that compose-up
already owns. Container-name helpers consolidate:
@@ -335,7 +335,7 @@ def supervise_container_name(slug): ...
# becomes:
def sidecar_bundle_container_name(slug: str) -> str:
return f"claude-bottle-sidecars-{slug}"
return f"bot-bottle-sidecars-{slug}"
```
Per-daemon "is the container up?" helpers used by orphan
@@ -356,7 +356,7 @@ This PRD's change is large but mechanical. A pre-merge dry-run:
and the resulting container runs all four daemons.
2. Switch the renderer to emit the two-service shape behind an
env-var feature flag (e.g.
`CLAUDE_BOTTLE_SIDECAR_BUNDLE=1`).
`BOT_BOTTLE_SIDECAR_BUNDLE=1`).
3. Update integration tests in-place; flip the default once
green; delete the flag and the old Dockerfiles in a
follow-up commit on the same branch.
@@ -379,7 +379,7 @@ rewrite.
3. **Backend Python collapse.** Drop the vestigial per-container
`.start()` / `.stop()` methods from `DockerPipelockProxy`,
`DockerEgress`, `DockerGitGate`, `DockerSupervise` (and from
the ABCs in `claude_bottle/{pipelock,egress,git_gate,supervise}.py`).
the ABCs in `bot_bottle/{pipelock,egress,git_gate,supervise}.py`).
These were already documented as vestigial in PRD 0018 ch3.
Strip vestigial sidecar-instance parameters from
`launch.launch()` and `prepare.resolve_plan()`. Delete the
@@ -419,10 +419,10 @@ rewrite.
is signal-killed (negative returncode) so the max is 0; a
crashed-before-signal daemon's nonzero code wins and reaches
the operator on container exit.
3. **Image pin policy.** Pin `claude-bottle-sidecars` by tag
3. **Image pin policy.** Pin `bot-bottle-sidecars` by tag
(`:latest` rebuilt per-release) or by digest written into a
`CLAUDE_BOTTLE_SIDECAR_IMAGE` env var like the existing
`CLAUDE_BOTTLE_PIPELOCK_IMAGE`? Default to env-var override
`BOT_BOTTLE_SIDECAR_IMAGE` env var like the existing
`BOT_BOTTLE_PIPELOCK_IMAGE`? Default to env-var override
+ a documented tag; digest pinning is an operator opt-in.
4. **Healthcheck aggregation.** Today each sidecar service has
its own compose healthcheck and `agent.depends_on:
@@ -448,9 +448,9 @@ rewrite.
- `Dockerfile.egress`, `Dockerfile.git-gate`,
`Dockerfile.supervise` — the three Dockerfiles this PRD
collapses into `Dockerfile.sidecars`.
- `claude_bottle/backend/docker/compose.py` — the renderer this
- `bot_bottle/backend/docker/compose.py` — the renderer this
PRD slims down.
- `claude_bottle/backend/docker/pipelock.py` — current home of
- `bot_bottle/backend/docker/pipelock.py` — current home of
`PIPELOCK_IMAGE` and the pinned digest the bundle's first
stage reuses.
- PRD 0017
+3 -3
View File
@@ -28,7 +28,7 @@ silently absent from `staging` until someone notices.
Issue #88 proposed inlining a `bottle_config:` block in agent files
that would merge with (and override) the referenced bottle. That
design lets a `$CWD/.claude-bottle/agents/<name>.md` file from a
design lets a `$CWD/.bot-bottle/agents/<name>.md` file from a
cloned repo redeclare egress routes, env mappings, and git remotes
— breaking the existing security model where bottles are
`$HOME`-only specifically so cloned repos can't influence them
@@ -93,7 +93,7 @@ egress:
`extends:` is a string — the name of another bottle (without the
`.md`). Required to be one of the bottles loaded from
`$HOME/.claude-bottle/bottles/`. Self-reference (`extends: self`
`$HOME/.bot-bottle/bottles/`. Self-reference (`extends: self`
in `self.md`) and longer cycles die at parse.
### Merge rules
@@ -163,7 +163,7 @@ the full chain so operators can find the offending file.
### Trust boundary preservation
Bottles continue to be loaded from `$HOME/.claude-bottle/bottles/`
Bottles continue to be loaded from `$HOME/.bot-bottle/bottles/`
only (`Manifest.from_md_dirs` is unchanged). The `extends:` field
references another file in that same directory. No cwd-readable
file gains the ability to declare or modify bottle config — the
+3 -3
View File
@@ -10,7 +10,7 @@ Support Claude and Codex agent providers while keeping agent files provider-agno
## Problem
Today claude-bottle is hard-wired around Claude Code assumptions. When Claude runs out or is otherwise unavailable, the operator cannot spin up an equivalent Codex-backed bottle from the dashboard or `start` path. Agent files should remain purpose/guidance documents, while bottle files define security boundaries and provider/runtime choices.
Today bot-bottle is hard-wired around Claude Code assumptions. When Claude runs out or is otherwise unavailable, the operator cannot spin up an equivalent Codex-backed bottle from the dashboard or `start` path. Agent files should remain purpose/guidance documents, while bottle files define security boundaries and provider/runtime choices.
## Goals / Success Criteria
@@ -24,7 +24,7 @@ Today claude-bottle is hard-wired around Claude Code assumptions. When Claude ru
- Do not implement support for providers beyond Claude and Codex.
- Do not move security boundaries into agent files.
- Do not allow custom Dockerfiles to remove or bypass required claude-bottle infrastructure.
- Do not allow custom Dockerfiles to remove or bypass required bot-bottle infrastructure.
- Do not add new runtime dependencies unless the existing Docker/Codex tooling cannot satisfy the minimum cut.
## Scope
@@ -63,7 +63,7 @@ agent_provider:
### Existing code touched
- `claude_bottle/manifest.py` for provider schema and role validation.
- `bot_bottle/manifest.py` for provider schema and role validation.
- Docker and smolmachines prepare/launch/provision paths for provider-specific image, command, auth, and state behavior.
- Dashboard/start display paths so the selected provider is visible and usable.
- README and PRD docs for provider/template configuration.
@@ -3,7 +3,7 @@
Consolidated research on running an auth-header-injecting proxy in
front of an AI agent so API tokens stay out of the agent's process
space. Folds in the per-service mechanics for the Anthropic OAuth
token and the Gitea PAT — the two cases claude-bottle hits first —
token and the Gitea PAT — the two cases bot-bottle hits first —
and surveys existing tools as of May 2026.
Companion to
@@ -15,7 +15,7 @@ the biggest credential risk).
## Summary
Today every claude-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and
Today every bot-bottle agent gets `CLAUDE_CODE_OAUTH_TOKEN` (and
any `bottle.env` secrets like a Gitea PAT) injected as env vars,
which means the agent process can read them with `printenv` or
`/proc/self/environ`. A prompt-injected or hijacked agent can ship
@@ -28,11 +28,11 @@ level via `ptrace_may_access`; a future smolmachines backend
enforces it harder, at the VM line.
Several existing tools implement this pattern, but none of them are
a clean drop-in for claude-bottle today: the most architecturally
a clean drop-in for bot-bottle today: the most architecturally
aligned (nono) is alpha; the most mature open-source
(Infisical Agent Vault) requires TLS MITM and would double up on
pipelock's TLS-interception stack. For the Anthropic-token slice, a
small claude-bottle-specific reverse proxy modeled on the
small bot-bottle-specific reverse proxy modeled on the
phantom-token shape is probably the right call. For Gitea / GitHub /
GitLab, the same proxy generalizes by config.
@@ -49,7 +49,7 @@ the caller's UID/GID don't match the target's and the caller lacks
`CAP_SYS_PTRACE` or `CAP_PERFMON`. A `node`-uid claude attempting to
read a root-owned proxy's environ gets `EACCES`. Escape hatches
(`--cap-add=SYS_PTRACE`, `--cap-add=PERFMON`, `--privileged`) are
not used by claude-bottle. Yama `ptrace_scope` is irrelevant — it
not used by bot-bottle. Yama `ptrace_scope` is irrelevant — it
only relaxes the *same-UID* relationship check; the cross-UID
match requirement still blocks the read. On a smolmachines backend
the boundary becomes the VM line; same property, harder.
@@ -77,8 +77,8 @@ The remaining credible designs reduce to three:
### Anthropic / Claude Code
**Today's wiring** (`claude_bottle/cli/start.py`): the host's
`CLAUDE_BOTTLE_OAUTH_TOKEN` is forwarded into the bottle as
**Today's wiring** (`bot_bottle/cli/start.py`): the host's
`BOT_BOTTLE_OAUTH_TOKEN` is forwarded into the bottle as
`CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e CLAUDE_CODE_OAUTH_TOKEN`
(no `=value`, so the value never lands on argv — good). Inside the
bottle, claude runs as `node` (UID 1000) with
@@ -128,7 +128,7 @@ never the token.
A hijacked claude could exfil the captured token (or any other
data) through any of these even with the proxy in place. Pair
the proxy with an explicit egress allowlist for the full benefit
(claude-bottle does this via pipelock).
(bot-bottle does this via pipelock).
- **Token refresh**: `claude setup-token` issues a ~1-year OAuth
token with no client-side refresh, so a static proxy value is
fine. The flip side is a one-year blast radius if the token leaks
@@ -138,7 +138,7 @@ never the token.
rewriting is safe.
- **`--bare` mode** reads only `ANTHROPIC_API_KEY`, not
`CLAUDE_CODE_OAUTH_TOKEN`. Not relevant to the interactive flow
claude-bottle ships, but worth noting if `--bare` is ever wired in.
bot-bottle ships, but worth noting if `--bare` is ever wired in.
### Gitea (`tea` + git HTTPS)
@@ -191,7 +191,7 @@ mitigation. Either composes cleanly with the same proxy.
## Proxy architectures
Four shapes worth comparing. The first is the lowest-friction
match for claude-bottle today.
match for bot-bottle today.
| Shape | Pros | Cons |
|---|---|---|
@@ -200,7 +200,7 @@ match for claude-bottle today.
| **Host-side proxy** | Token stays entirely outside the Linux VM. This is the Docker AI Sandbox shape. | A host daemon to maintain; the published port is reachable by any container on the host unless firewalled. UDS-across-VM doesn't work on Docker Desktop on macOS (no AF_UNIX `connect()` over the VM), but `host.docker.internal:<port>` over TCP works fine. |
| **Sidecar container** | Clean isolation; portable across hosts. Matches the existing pipelock / ssh-gate / git-gate topology. | Another container to orchestrate per agent; the token is in another container's env, which is a lateral move unless the sidecar runs with stricter isolation than the agent container does. |
For claude-bottle today — local Docker, per-agent containers, the
For bot-bottle today — local Docker, per-agent containers, the
root-owned-helper pattern already established by the SSH agent —
the **in-container reverse proxy** is the lowest-friction option
that gives the desired property. The sidecar-container shape is
@@ -214,7 +214,7 @@ Two categories:
- **A. Generic LLM / API gateways** that happen to support credential
injection as a side feature.
- **B. Purpose-built agent credential brokers** — newer, closer to
what claude-bottle wants.
what bot-bottle wants.
| Tool | Category | License | Topology | Injection mechanism | `ANTHROPIC_BASE_URL` compatible | Per-route allowlist | Maturity |
|---|---|---|---|---|---|---|---|
@@ -235,7 +235,7 @@ Two categories:
### Cluster commentary
- **The phantom-token pattern** (nono) is the cleanest architectural
fit for claude-bottle. The agent receives a per-session
fit for bot-bottle. The agent receives a per-session
cryptographically random token scoped to the localhost proxy;
the proxy validates and swaps for the real upstream credential.
No TLS interception, no CA trust setup, works directly with
@@ -275,7 +275,7 @@ is a bet on the project rather than a buy-vs-build win.
**Most mature OSS purpose-built:** Infisical Agent Vault. MIT,
v0.19.0 active, v0.17.0 added a containerized agent mode that
maps directly to claude-bottle. Friction is the TLS-MITM topology
maps directly to bot-bottle. Friction is the TLS-MITM topology
— another container-local CA, the Go-loopback workaround,
duplication with pipelock's existing TLS interception layer.
+8 -8
View File
@@ -4,13 +4,13 @@ A broader survey than [`landscape-containerized-claude.md`](landscape-containeri
which focused on Claude-Code-specific containerizers. This one covers
general AI-agent sandbox / containment projects — some Claude-specific,
some agent-agnostic, some hosted SaaS — and contrasts them with
claude-bottle's design.
bot-bottle's design.
Research conducted 2026-05-11.
## Summary
Eight projects surveyed. None duplicate claude-bottle's combination of
Eight projects surveyed. None duplicate bot-bottle's combination of
local Docker, declarative JSON manifest, per-agent egress allowlist via
pipelock, and bottle/agent split. Two clusters stand out:
@@ -157,7 +157,7 @@ plausible without a heavy stack.
## Comparison table
| Axis | claude-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines |
| Axis | bot-bottle | endo-familiar | litterbox | agent-safehouse | matchlock | tilde.run | boxlite | microsandbox | smolmachines |
|---|---|---|---|---|---|---|---|---|---|
| Isolation | Docker + internal net + pipelock; gVisor if present | Object-capability (no OS isolation) | Podman + opt. Landlock | macOS `sandbox-exec` | MicroVM (Firecracker / Virt.fw) | Hosted container (unverified) | MicroVM (KVM / Hypervisor.fw) | MicroVM (libkrun) | MicroVM (libkrun / KVM) |
| Local vs hosted | Local | Local | Local (Linux) | Local (macOS) | Local | Hosted SaaS | Local | Local | Local |
@@ -171,9 +171,9 @@ plausible without a heavy stack.
## What's closest, what's different
**Closest in design and scope.** agent-safehouse and litterbox sit
nearest claude-bottle: local, single-user, thin wrappers over an
nearest bot-bottle: local, single-user, thin wrappers over an
existing OS primitive, low-dep. The split is the isolation primitive —
claude-bottle uses Docker + pipelock egress (plus gVisor where
bot-bottle uses Docker + pipelock egress (plus gVisor where
available); agent-safehouse uses `sandbox-exec`; litterbox uses Podman +
Landlock. matchlock and smolmachines are spiritually close on the
*policy* side (default-deny net, per-host allowlist) but use microVMs
@@ -181,16 +181,16 @@ instead of containers.
**Solving a different problem.** tilde.run is hosted SaaS for team /
production agent pipelines with data-versioned rollback — explicitly
opposite to claude-bottle's "infrastructure I control" goal. boxlite and
opposite to bot-bottle's "infrastructure I control" goal. boxlite and
microsandbox are infrastructure libraries aimed at platform builders
embedding sandboxes into agent frameworks; they would be a *backend*
claude-bottle could call, not a competitor to its manifest layer.
bot-bottle could call, not a competitor to its manifest layer.
endo-familiar is in a different paradigm entirely: capability passing
rather than kernel boundaries.
## Borrowable ideas
What claude-bottle already has that the survey suggested as
What bot-bottle already has that the survey suggested as
differentiators:
- Default-deny egress with a per-agent allowlist (pipelock).
- DLP scanning of outbound traffic.
+14 -14
View File
@@ -24,26 +24,26 @@ which version you want before starting.
## Current Docker surface area
The places claude-bottle shells out to `docker` today:
The places bot-bottle shells out to `docker` today:
- `build` — base image plus a per-cwd derived image
(`claude_bottle/docker.py:67-103`).
(`bot_bottle/docker.py:67-103`).
- `run` — with `--runtime`, `--env-file`, `-e`, `--name`, `--network`,
and volume mounts (`claude_bottle/cli/start.py:217-261`).
and volume mounts (`bot_bottle/cli/start.py:217-261`).
- `exec -it` / `exec -u 0` — for `claude` itself, file-ownership fixups,
and SSH provisioning (`claude_bottle/ssh.py`, `claude_bottle/skills.py`,
`claude_bottle/cli/start.py`).
and SSH provisioning (`bot_bottle/ssh.py`, `bot_bottle/skills.py`,
`bot_bottle/cli/start.py`).
- `cp` — skills, SSH keys, the prompt file, the workspace `.git`,
and the pipelock config
(`claude_bottle/skills.py:73`, `claude_bottle/ssh.py:106`,
`claude_bottle/cli/start.py:279`, `claude_bottle/pipelock.py:218`).
(`bot_bottle/skills.py:73`, `bot_bottle/ssh.py:106`,
`bot_bottle/cli/start.py:279`, `bot_bottle/pipelock.py:218`).
- `network create` / `connect` / `inspect` / `rm` — bottle network plus
multi-network attach for the pipelock sidecar
(`claude_bottle/network.py`, `claude_bottle/pipelock.py:227`).
(`bot_bottle/network.py`, `bot_bottle/pipelock.py:227`).
- `create` / `start` / `rm -f` — pipelock sidecar lifecycle
(`claude_bottle/pipelock.py:207-258`).
(`bot_bottle/pipelock.py:207-258`).
- Misc preflight: `image inspect`, `ps -a -f name=^...$`, `info` for
registered runtimes (`claude_bottle/docker.py`).
registered runtimes (`bot_bottle/docker.py`).
## Mapping to Apple's `container`
@@ -60,10 +60,10 @@ The places claude-bottle shells out to `docker` today:
Roughly two weeks for one person, split as:
1. **Backend abstraction (12 days).** `claude_bottle/docker.py` is
already a partial seam, but `claude_bottle/network.py`,
`claude_bottle/pipelock.py`, `claude_bottle/ssh.py`,
`claude_bottle/skills.py`, and `claude_bottle/cli/start.py` all call
1. **Backend abstraction (12 days).** `bot_bottle/docker.py` is
already a partial seam, but `bot_bottle/network.py`,
`bot_bottle/pipelock.py`, `bot_bottle/ssh.py`,
`bot_bottle/skills.py`, and `bot_bottle/cli/start.py` all call
`subprocess.run(["docker", ...])` directly. Define a `Backend`
protocol — `run`, `exec`, `cp`, `build`, `network_create`,
`network_connect`, `inspect`, `rm` — route every call through it,
+4 -4
View File
@@ -1,6 +1,6 @@
# Implementation language: bash vs. Python vs. Go
Research into which runtime claude-bottle should be implemented in, given
Research into which runtime bot-bottle should be implemented in, given
where the project is today (~1250 lines, Python, mostly orchestration of
`docker` / `flyctl` / `ssh`). The project started in bash and was rewritten
to Python; this note evaluates whether either of the other two options
@@ -10,7 +10,7 @@ would be a better fit going forward.
Stay on Python. Switch to Go if and when distribution friction becomes the
dominant pain — i.e., when bug reports about Python interpreter / venv
behavior start outweighing bug reports about claude-bottle itself. Bash is
behavior start outweighing bug reports about bot-bottle itself. Bash is
not the right tool at the project's current size; reverting would be a
regression.
@@ -54,7 +54,7 @@ The relevant criteria, in roughly the order they bite:
## Bash
Right tool *if the project stays under ~500 lines*. claude-bottle has
Right tool *if the project stays under ~500 lines*. bot-bottle has
already crossed that threshold (~1250 lines), and the orchestration is no
longer "stitch CLIs together" — it has manifest validation, env-var
resolution, network and sidecar lifecycle, and SSH provisioning. Bash
@@ -119,7 +119,7 @@ Costs:
Stay on Python. The signal to watch for, before reconsidering, is bug
reports about Python interpreter or venv behavior outnumbering bug reports
about claude-bottle's actual logic. Until that pattern shows up, the Go
about bot-bottle's actual logic. Until that pattern shows up, the Go
rewrite isn't paying for itself.
Independent of language: invest in the backend abstraction now. A clean
+5 -5
View File
@@ -2,13 +2,13 @@
## Question
Can claude-bottle grow a built-in supervisor — TUI inventory plus PR-feedback routing — without breaking the per-bottle isolation model, and without departing from the Python-stdlib-first, low-dependency posture?
Can bot-bottle grow a built-in supervisor — TUI inventory plus PR-feedback routing — without breaking the per-bottle isolation model, and without departing from the Python-stdlib-first, low-dependency posture?
## Context
claude-bottle today is a fleet *executor*: `./cli.py start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand.
bot-bottle today is a fleet *executor*: `./cli.py start <agent>` brings up one bottle (agent container + pipelock + optional git-gate + optional cred-proxy on a per-bottle internal network), and `cli.py` tears it down when the session ends. There is no inventory view, no idle-detection, no automated reaction to PR or CI events. In parallel use, a human is the supervisor — opening one terminal per bottle, switching between them, and watching upstream PR state by hand.
A separate survey of the broader ecosystem ([agent control dashboards research, mid-2026](https://gitea.dideric.is/didericis/consilium-research/src/branch/main/developer-workflow/agent-control-dashboards-2026-05-24.md)) sorts dashboards into five tiers (session managers, parallel runners, Kanban boards, mission-control SPAs, observability backends). The earlier first-pass conclusion was that a full SPA tier conflicts with claude-bottle's isolation model. This doc reconsiders the smaller question: a TUI supervisor in the existing Python CLI.
A separate survey of the broader ecosystem ([agent control dashboards research, mid-2026](https://gitea.dideric.is/didericis/consilium-research/src/branch/main/developer-workflow/agent-control-dashboards-2026-05-24.md)) sorts dashboards into five tiers (session managers, parallel runners, Kanban boards, mission-control SPAs, observability backends). The earlier first-pass conclusion was that a full SPA tier conflicts with bot-bottle's isolation model. This doc reconsiders the smaller question: a TUI supervisor in the existing Python CLI.
## What I got wrong the first time
@@ -75,7 +75,7 @@ A few design defaults worth holding:
- **No auto-respawn.** The supervisor surfaces PR feedback to a human, never to the bottle's next prompt. The autonomous flow (review-comment → tear down → relaunch with the comment prepended) was considered and rejected: in a public-ish repo, any commenter could inject content that the next launch would treat as system instructions, with the agent's full bottle privileges. Available mitigations — commenter allowlists, prompt-injection regex screens, private-repo defaults — are all soft. The load-bearing defense is to keep the human between the review comment and any agent prompt. Notify-only is the only mode.
- **Idle detection is harder than it looks.** Last-log-line-age works ~80% of the time. Codeman's Ralph Loop tracker (watching for `<promise>` tags) is more accurate but adds complexity and tooling-coupling. Start with the dumb version; add heuristics only when actual confusion arises.
- **No web UI.** A browser UI reintroduces the privileged-channel problem — the browser talks to a server that talks to all bottles. TUI sidesteps it because the supervisor runs in the user's own shell context, not as a long-running daemon serving multiple consumers.
- **State file in `~/.claude-bottle/`, not inside any bottle.** The mapping of bottle → PR → status lives next to the manifest. Nothing about the supervisor's bookkeeping enters a bottle.
- **State file in `~/.bot-bottle/`, not inside any bottle.** The mapping of bottle → PR → status lives next to the manifest. Nothing about the supervisor's bookkeeping enters a bottle.
- **No new credentials on bottles.** PR-watch is a host-side concern. A bottle's manifest *names* the upstream/branch to watch; it does not grant the bottle the ability to read PR state itself.
## Trust-model edge cases worth flagging
@@ -91,6 +91,6 @@ Phased: `status` first (purely additive, no design decisions), then `watch` (the
## Conclusion
A supervisor that respects the bottle wall is a small natural extension of what claude-bottle already is, not a category shift toward Mission Control / Codeman / Composio AO. The mistake in earlier framing was treating "supervisor" as synonymous with "dashboard SPA." The trust-model question that disqualifies the SPA tier (privileged channel into every bottle) does not apply to a TUI that reads host-side signals and shells out to the existing CLI.
A supervisor that respects the bottle wall is a small natural extension of what bot-bottle already is, not a category shift toward Mission Control / Codeman / Composio AO. The mistake in earlier framing was treating "supervisor" as synonymous with "dashboard SPA." The trust-model question that disqualifies the SPA tier (privileged channel into every bottle) does not apply to a TUI that reads host-side signals and shells out to the existing CLI.
Recommendation: build `status` and `watch` opportunistically when the pain is felt; treat `supervise` as a separate PRD before implementation, scoped to notify-only (no autonomous loop from review comment to next agent prompt — see "Where to be conservative").
@@ -15,7 +15,7 @@ What's the cheapest path to that, and where does it bottom out?
Today the flow is bimodal. `./cli.py start <agent>` brings the
bottle up and immediately drops you into an interactive
`docker exec -it claude-bottle-<slug> claude ...` — claude-code
`docker exec -it bot-bottle-<slug> claude ...` — claude-code
owns the whole terminal until you Ctrl-D out, at which point the
bottle tears down. The dashboard (`./cli.py dashboard`) is a
*separate* invocation that watches across bottles but never
@@ -68,7 +68,7 @@ Below are the actual costs.
The dashboard sees a key (say Enter on a selected agent in the
agents pane). It calls `curses.endwin()`, then `subprocess.run(
["docker", "exec", "-it", "claude-bottle-<slug>", "claude",
["docker", "exec", "-it", "bot-bottle-<slug>", "claude",
"--dangerously-skip-permissions"])`. claude-code takes the
terminal full-screen. When the operator exits claude-code
(Ctrl-D, `/exit`), the subprocess returns; the dashboard calls
@@ -192,7 +192,7 @@ want one focused session at a time with proposals visible.
## Option 3: External multiplexer
The dashboard binds a key (e.g. `Enter` on agent) to
`tmux split-window -h 'docker exec -it claude-bottle-<slug>
`tmux split-window -h 'docker exec -it bot-bottle-<slug>
claude'` when run inside a tmux session, or to `osascript`-
driven iTerm pane spawning on macOS, or to `wezterm cli
spawn` if the user is on wezterm.
@@ -278,7 +278,7 @@ interface; the multiplexer is convenience for power users.
- PRD 0018 chunk 3 — agent container runs `sleep infinity`;
claude is invoked via `docker exec -it` (the
attachment-point this doc is layering against)
- `claude_bottle/cli/dashboard.py:_operator_edit_flow` — the
- `bot_bottle/cli/dashboard.py:_operator_edit_flow` — the
existing `curses.endwin` → shell out → `stdscr.refresh()`
pattern Option 1 would clone
- pyte: <https://pyte.readthedocs.io/> — the candidate
@@ -2,7 +2,7 @@
Research into how to revoke a long-lived `CLAUDE_CODE_OAUTH_TOKEN` (the kind
`claude setup-token` mints), prompted by needing to rotate a token baked into a
claude-bottle container.
bot-bottle container.
## Summary
@@ -63,7 +63,7 @@ For a known-leaked or suspected-leaked token:
1. Revoke the entry at `claude.ai/settings/claude-code`.
2. Run "Log out all sessions" under Settings → Account → Active Sessions.
3. Run `claude setup-token` to mint a replacement, and rotate it into
`CLAUDE_BOTTLE_OAUTH_TOKEN` immediately.
`BOT_BOTTLE_OAUTH_TOKEN` immediately.
4. Email Anthropic support at `support.anthropic.com`. Security issues
sometimes get attention that GitHub issues do not.
+9 -9
View File
@@ -13,7 +13,7 @@ wrong in the user-intent sense, and there is no way to say so.
## Summary
No off-the-shelf dashboard fits the shape claude-bottle needs
No off-the-shelf dashboard fits the shape bot-bottle needs
(per-bottle, host-local, integrated into a pre-receive rejection
with approval feeding back into the gate's own decision). Gitleaks
itself is a CLI with no UI and was declared **feature-complete** in
@@ -49,9 +49,9 @@ baseline), and recommends a direction.
## Question 1: Existing dashboards and control surfaces
### Inside claude-bottle today
### Inside bot-bottle today
`claude_bottle/cli/` has `_common, cleanup, edit, info, init, list,
`bot_bottle/cli/` has `_common, cleanup, edit, info, init, list,
start` — nothing gate-specific. The gate appears only as a sidecar
in `bottle_plan.py`'s preflight rendering. Rejections are written
to the pre-receive hook's stderr (`echo "git-gate: gitleaks
@@ -76,14 +76,14 @@ TOML allowlist, and a roadmap that includes LLM-assisted
classification and automatic secret revocation via provider APIs.
Still CLI-shaped — no dashboard either.
Relevant to claude-bottle in two ways:
Relevant to bot-bottle in two ways:
- The upstream direction of travel is *toward* agent-driven
scanners, which makes "the bottle invokes a scanner and reports
findings up" a supported pattern rather than a hack.
- CEL is a richer expression language for filter entries than
gitleaks's selector struct, which loosens the design space for
Option B (below). If claude-bottle ever swaps gitleaks for
Option B (below). If bot-bottle ever swaps gitleaks for
Betterleaks, the approval-flow design should be expressible in
both.
@@ -107,7 +107,7 @@ false-positive in its UI, and tracks remediation state. Designed
for org-scale: one DefectDojo instance covers many repos and
scanners.
Shape mismatch for claude-bottle:
Shape mismatch for bot-bottle:
- DefectDojo's review state is *informational* — marking a finding
as accepted in DefectDojo does not write to gitleaks's allowlist
@@ -137,7 +137,7 @@ premise is sandbox isolation.
### Bottom line
No off-the-shelf dashboard fits claude-bottle's shape: per-bottle,
No off-the-shelf dashboard fits bot-bottle's shape: per-bottle,
host-local, integrated into a pre-receive rejection with the
approval feeding back into the gate's own decision-making. The
nearest open-source analogue (DefectDojo) is post-hoc and
@@ -334,7 +334,7 @@ project, and the vendor-side benchmark numbers (98.6% recall vs
gitleaks's 70.4% on CredData) have not been independently
reproduced in published sources.
### What Betterleaks would add for claude-bottle
### What Betterleaks would add for bot-bottle
- **Detection coverage on encoded secrets.** Native handling of
doubly- and triply-encoded matches. This matters in the
@@ -434,6 +434,6 @@ redesign.
- [AWS example access key (`AKIAIOSFODNN7EXAMPLE`)](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_iam-quotas.html)
— documented placeholder safe to use in examples without
triggering most secret scanners.
- `claude_bottle/git_gate.py` — pre-receive hook implementation.
- `bot_bottle/git_gate.py` — pre-receive hook implementation.
Today: `gitleaks git --log-opts="$log_opts" --no-banner
--redact`; no `--config`, no `--baseline-path`.
@@ -1,6 +1,6 @@
# Git secret scanning as further hardening
Research into whether claude-bottle should add a secret-scanning step to
Research into whether bot-bottle should add a secret-scanning step to
its git workflow — both on the host repo and (potentially) inside
bottles — and what tools exist for it. Motivated by the threat model
below: a secret accidentally `git push`ed to a public remote is
@@ -14,7 +14,7 @@ of defense-in-depth that doesn't replace any existing control
(`.gitignore`, environment-variable hygiene, network egress guards) but
catches the one case where everything else fails: a credential ending
up in a tracked file or commit message and being pushed to a public
remote. For claude-bottle specifically, `gitleaks` is the clearest fit
remote. For bot-bottle specifically, `gitleaks` is the clearest fit
— Go binary, MIT, scans full history including commit messages, runs
fully offline, and integrates with the existing `.githooks/` directory
without adding a new runtime.
@@ -83,12 +83,12 @@ suspicious, let me close without merging," the bytes that mattered are
already on the attacker's box. Detection has to be at *commit* time
(or *push* time at the latest), not at review time.
### Why this matters for claude-bottle
### Why this matters for bot-bottle
Two surfaces are exposed:
1. **The claude-bottle repo itself.** Development happens on a host
with `CLAUDE_BOTTLE_OAUTH_TOKEN`, Gitea tokens, and other
1. **The bot-bottle repo itself.** Development happens on a host
with `BOT_BOTTLE_OAUTH_TOKEN`, Gitea tokens, and other
credentials in the environment. A fixture, test snapshot, log
capture, or pasted-in debug output could carry one of them into a
tracked file. The repo's Gitea remote is private, but mirrors or
@@ -209,7 +209,7 @@ it with a separate message-scanning step.
## Recommended path forward
In priority order, for the host claude-bottle repo:
In priority order, for the host bot-bottle repo:
1. **One-time retro scan** with gitleaks:
`gitleaks detect --source . --log-opts="--all" --redact`.
@@ -2,7 +2,7 @@
## Question
Can host Claude decide which claude-bottle container to spin up for a task, while guaranteeing the work executes in the container and not on the host?
Can host Claude decide which bot-bottle container to spin up for a task, while guaranteeing the work executes in the container and not on the host?
## Claude Code Agent Mechanisms
@@ -16,7 +16,7 @@ Claude Code provides two mechanisms for defining reusable agent behavior:
## The Reliability Problem
The previous approach used an MCP server to bridge host Claude and claude-bottle containers. It failed because host Claude had both work-capable tools (Edit, Write, Bash) and MCP dispatch tools. Claude could choose to do the work itself rather than dispatch, with no enforcement mechanism to prevent it.
The previous approach used an MCP server to bridge host Claude and bot-bottle containers. It failed because host Claude had both work-capable tools (Edit, Write, Bash) and MCP dispatch tools. Claude could choose to do the work itself rather than dispatch, with no enforcement mechanism to prevent it.
## Why Tool Restriction Solves It
@@ -26,7 +26,7 @@ Claude Code's subagent `tools:` allowlist is architecturally enforced — not a
Three pieces in combination give a 100% guarantee:
1. **Restricted host subagent** — a `.claude/agents/claude-bottle-dispatch.md` with `tools:` limited to MCP container tools and git-read operations. No Edit, Write, or arbitrary Bash.
1. **Restricted host subagent** — a `.claude/agents/bot-bottle-dispatch.md` with `tools:` limited to MCP container tools and git-read operations. No Edit, Write, or arbitrary Bash.
2. **MCP server** — exposes tools the restricted host can call:
- `list_agents()` — available agents from the manifest (host Claude decides which to use)
@@ -40,11 +40,11 @@ Three pieces in combination give a 100% guarantee:
Build host-dispatch-to-container in two deliverables:
**Deliverable 1: Non-interactive run mode for claude-bottle**
**Deliverable 1: Non-interactive run mode for bot-bottle**
Extend `cli.py` with a `run <agent> <task>` subcommand. Starts the container, writes the task prompt to a file inside it (same `docker cp` pattern used for `--append-system-prompt-file`), invokes `claude --print` with the prompt, streams stdout back to the host, and exits when Claude finishes. Results committed and pushed from inside the container as usual.
**Deliverable 2: MCP server wrapping claude-bottle**
**Deliverable 2: MCP server wrapping bot-bottle**
A minimal MCP server (bash or node) exposing `list_agents`, `run_agent`, `get_status`, `get_output`. Registered in the host Claude Code settings so a restricted dispatch subagent can call it.
@@ -52,7 +52,7 @@ The combination enforces the container boundary at the tool layer, not the promp
**Critical:** the tool restriction only applies within the dispatch agent's context. A normal Claude session has its full toolset and may never invoke the dispatch agent regardless of its description. The dispatch agent must be the *entry point* for the session, not an optional subagent a full-tool host might call. Two ways to enforce this:
- Launch with `claude --agent claude-bottle-dispatch` — makes the dispatch agent the primary agent for the session.
- Set `agent: claude-bottle-dispatch` in the project `.claude/settings.json` — same effect automatically for any `claude` invocation in that directory.
- Launch with `claude --agent bot-bottle-dispatch` — makes the dispatch agent the primary agent for the session.
- Set `agent: bot-bottle-dispatch` in the project `.claude/settings.json` — same effect automatically for any `claude` invocation in that directory.
Without one of these, the guarantee does not hold.
@@ -1,11 +1,11 @@
# Landscape: containerized Claude Code agent tools
Research into whether claude-bottle is redundant with existing projects, and
Research into whether bot-bottle is redundant with existing projects, and
whether it's worth publishing.
## Summary
The "Claude Code in Docker" space is active but not saturated. claude-bottle
The "Claude Code in Docker" space is active but not saturated. bot-bottle
occupies a distinct position: no surveyed project combines all five of its
defining features. Publishing is likely worthwhile, with the main risk being
claudebox expanding to absorb the same niche.
@@ -38,7 +38,7 @@ manifest merge.
## Adjacent (different model)
- **dagger/container-use** (mid-2025) — exposes an MCP server so the *agent*
spins up its own containers with Git worktrees. Inverted model vs. claude-bottle
spins up its own containers with Git worktrees. Inverted model vs. bot-bottle
(agent controls container rather than being launched into one by a manifest).
Still marked early-development.
- **E2B, Northflank, Cloudflare Sandbox SDK** — cloud-hosted SaaS sandbox
@@ -2,7 +2,7 @@
Research notes on when to run containerized Claude Code agents on a remote machine
outside the local network versus inside it, focusing on security and privacy concerns.
Relevant to a potential claude-bottle extension for remote agent execution.
Relevant to a potential bot-bottle extension for remote agent execution.
---
@@ -16,7 +16,7 @@ escapes**, and **whether credentials are short-lived and scoped**.
## Threat landscape by topology
### Local (current claude-bottle model)
### Local (current bot-bottle model)
- Container escape → developer laptop → `~/.ssh`, `~/.aws`, browser cookies, Keychain, everything
- Outbound: Docker containers have full internet access by default; no egress monitoring on most home networks
@@ -99,7 +99,7 @@ Key insight: once a container is compromised via prompt injection, the blast rad
## Credentials and secrets
### Local topology (current claude-bottle)
### Local topology (current bot-bottle)
- Secrets live in the host environment or are prompted from `/dev/tty`
- Forwarded to containers via `-e NAME` (not `=value`), never on argv, never in env-files for secrets
@@ -125,10 +125,10 @@ An 8,640x reduction in abuse window comes from switching from 90-day keys to 15-
### Local topology
- Monitoring: whatever the home/office router supports — usually minimal
- Containment: `--network none` + a proxy socket provides the strongest containment; claude-bottle does not currently do this
- Containment: `--network none` + a proxy socket provides the strongest containment; bot-bottle does not currently do this
- DLP: essentially none unless specifically deployed on the LAN
- Domain fronting risk: even allowlisted-domain proxies can be bypassed via domain fronting — an agent that can reach `api.anthropic.com` could relay data to an attacker-controlled backend through that domain
- **claude-bottle today: containers have full outbound internet access. No egress restrictions.**
- **bot-bottle today: containers have full outbound internet access. No egress restrictions.**
### Remote topology (cloud VM)
@@ -177,7 +177,7 @@ Strongest exfiltration controls for either topology:
---
## Concrete recommendations if extending claude-bottle for remote
## Concrete recommendations if extending bot-bottle for remote
1. **Never build the VPN-pivot pattern.** A remote agent connected back to the LAN via VPN is the worst of both worlds. If a remote agent needs LAN resources, expose those through a narrow API, not a VPN.
@@ -199,7 +199,7 @@ Strongest exfiltration controls for either topology:
## Bottom line
For the current claude-bottle use case (developer feature implementation, no regulated data,
For the current bot-bottle use case (developer feature implementation, no regulated data,
single developer), local execution is the right default. The biggest unaddressed risk
right now isn't topology — it's that containers have unrestricted outbound internet access.
Adding `--network none` + a proxy socket would be higher-leverage than any topology change.
+36 -36
View File
@@ -1,6 +1,6 @@
# Manifest format and grouping
Two open questions for claude-bottle's manifest layer after PRD 0011:
Two open questions for bot-bottle's manifest layer after PRD 0011:
1. **Grouping.** Keep bottles and agents in the same manifest file
(today's shape), or split them — one file per bottle and one
@@ -8,7 +8,7 @@ Two open questions for claude-bottle's manifest layer after PRD 0011:
2. **Format.** Stay on JSON, switch to YAML, or move to a Markdown
spec with YAML frontmatter. The Markdown option splits into two
sub-flavors: reuse Claude Code's existing subagent format with
bottle-specific extensions, or invent a claude-bottle-owned
bottle-specific extensions, or invent a bot-bottle-owned
Markdown spec used for both agents and bottles.
The trust boundary from PRD 0011 — bottle infrastructure lives in
@@ -19,8 +19,8 @@ will be once a user has 5+ bottles and 10+ agents.
## Why this matters
Current shape: one JSON file at `$HOME/claude-bottle.json` (and
optionally `$CWD/claude-bottle.json` for cwd-defined agents). After
Current shape: one JSON file at `$HOME/bot-bottle.json` (and
optionally `$CWD/bot-bottle.json` for cwd-defined agents). After
PRD 0011, the home file owns bottles + home agents; the cwd file is
agents-only.
@@ -48,7 +48,7 @@ the inflection point has been reached.
### Option A: one file for both (current)
`$HOME/claude-bottle.json` contains `bottles:` and `agents:`. Cwd
`$HOME/bot-bottle.json` contains `bottles:` and `agents:`. Cwd
file (optional) contains `agents:` only.
**Pros**
@@ -74,16 +74,16 @@ file (optional) contains `agents:` only.
### Option B: file per thing
Bottles live as `$HOME/.claude-bottle/bottles/<name>.<ext>`. Agents
live as `$HOME/.claude-bottle/agents/<name>.<ext>` (home agents)
and `$CWD/.claude-bottle/agents/<name>.<ext>` (cwd agents). The
Bottles live as `$HOME/.bot-bottle/bottles/<name>.<ext>`. Agents
live as `$HOME/.bot-bottle/agents/<name>.<ext>` (home agents)
and `$CWD/.bot-bottle/agents/<name>.<ext>` (cwd agents). The
resolver globs each directory.
**Pros**
- Scales to N bottles + N agents without any single file growing.
- Trust boundary expresses on disk: `$HOME/.claude-bottle/bottles/`
is the only place bottles can come from. `$CWD/.claude-bottle/`
- Trust boundary expresses on disk: `$HOME/.bot-bottle/bottles/`
is the only place bottles can come from. `$CWD/.bot-bottle/`
can only contribute agents. No resolver logic needed to enforce
it — the file paths are the enforcement.
- Aligns with Claude Code's existing model: each subagent already
@@ -147,7 +147,7 @@ preserves this format.
### Option 2: full YAML
`$HOME/claude-bottle.yaml` (or `.yml`). Parser pulls in PyYAML (or
`$HOME/bot-bottle.yaml` (or `.yml`). Parser pulls in PyYAML (or
ruamel.yaml).
**Pros**
@@ -172,14 +172,14 @@ ruamel.yaml).
users will reach for one (Jinja, Helm-style) and then we're in
yaml-as-template-language territory.
### Option 3: reuse Claude Code's subagent spec (Markdown + YAML frontmatter), with claude-bottle extensions
### Option 3: reuse Claude Code's subagent spec (Markdown + YAML frontmatter), with bot-bottle extensions
Claude Code already stores subagents at `~/.claude/agents/<name>.md`
with YAML frontmatter and a Markdown body. Frontmatter today
carries fields like `name`, `description`, `model`, `color`,
`memory`; the body is the system prompt. Adding fields like
`bottle: dev` and a `claude_bottle:` sub-block to the same
frontmatter would make each claude-bottle agent a drop-in addition
`bottle: dev` and a `bot_bottle:` sub-block to the same
frontmatter would make each bot-bottle agent a drop-in addition
to Claude Code's agent directory.
```markdown
@@ -188,12 +188,12 @@ name: implementer
description: Implements features against PRDs in this repo
model: opus
bottle: dev
claude_bottle:
bot_bottle:
skills: [init-prd]
---
You are a feature-implementation agent running inside an
ephemeral claude-bottle sandbox. The host has copied the user's
ephemeral bot-bottle sandbox. The host has copied the user's
project into /home/node/workspace...
```
@@ -202,7 +202,7 @@ infrastructure, not behavior. Either:
- (3a) Bottles stay JSON / YAML; only agents adopt the
MD+frontmatter format. Mixed-format manifest.
- (3b) Bottles adopt MD+frontmatter too, using a claude-bottle-only
- (3b) Bottles adopt MD+frontmatter too, using a bot-bottle-only
schema. Then we're really doing option 4 for bottles + option 3
for agents. Two formats but one parser.
@@ -214,7 +214,7 @@ infrastructure, not behavior. Either:
- Each agent's prompt lives naturally as Markdown body — long
prompts read well, can use headings/lists/code blocks.
- File-per-thing falls out automatically (one MD per agent).
- Claude Code may eventually consume claude-bottle's agent files
- Claude Code may eventually consume bot-bottle's agent files
directly, doubling their utility.
**Cons**
@@ -222,7 +222,7 @@ infrastructure, not behavior. Either:
- **Coupling to Claude Code's spec.** Anthropic owns that schema;
field names and semantics can change. Today's `model` /
`description` / `memory` are stable, but tomorrow's may not be.
Our `bottle:` / `claude_bottle:` extensions could collide with
Our `bottle:` / `bot_bottle:` extensions could collide with
future official fields.
- The agent file's frontmatter starts to carry two unrelated
schemas: Claude Code's (model, description) and ours (bottle,
@@ -233,28 +233,28 @@ infrastructure, not behavior. Either:
frontmatter library (python-frontmatter) or hand-parse the
`---` block and feed it to PyYAML. Either way, a new dep.
### Option 4: invent a claude-bottle MD spec, used for both agents and bottles
### Option 4: invent a bot-bottle MD spec, used for both agents and bottles
```markdown
---
# $HOME/.claude-bottle/agents/implementer.md
# $HOME/.bot-bottle/agents/implementer.md
bottle: dev
skills: [init-prd]
---
You are a feature-implementation agent running inside an
ephemeral claude-bottle sandbox...
ephemeral bot-bottle sandbox...
```
```markdown
---
# $HOME/.claude-bottle/bottles/dev.md
# $HOME/.bot-bottle/bottles/dev.md
cred_proxy:
routes:
- path: /anthropic/
upstream: https://api.anthropic.com
auth_scheme: Bearer
token_ref: CLAUDE_BOTTLE_OAUTH_TOKEN
token_ref: BOT_BOTTLE_OAUTH_TOKEN
role: anthropic-base-url
egress:
allowlist: [example.com]
@@ -273,8 +273,8 @@ for publishing scoped packages.
documentation (why does this bottle exist? what tokens does it
hold? who owns the keys?).
- Not coupled to Claude Code's schema; we own the spec.
- Trust boundary on disk: `$HOME/.claude-bottle/bottles/` is the
only place bottles can come from; `$CWD/.claude-bottle/agents/`
- Trust boundary on disk: `$HOME/.bot-bottle/bottles/` is the
only place bottles can come from; `$CWD/.bot-bottle/agents/`
is the only thing cwd contributes.
- Agent files in this spec are *almost* compatible with Claude
Code's subagent format. If we keep the `name` / `description`
@@ -308,14 +308,14 @@ grouping fits how users iterate on agents (write a prompt, save,
launch).
Between option 3 (reuse CC spec) and option 4 (new spec): the
appealing middle ground is "claude-bottle agents follow the CC
appealing middle ground is "bot-bottle agents follow the CC
subagent shape closely (name / description / model + bottle and
skills extensions) so they drop into `~/.claude/agents/` as a
side effect, while bottles use the same MD+frontmatter shape but
with claude-bottle's own schema and live in a dedicated directory."
with bot-bottle's own schema and live in a dedicated directory."
This:
- gives agents both a claude-bottle launch story AND a Claude Code
- gives agents both a bot-bottle launch story AND a Claude Code
invocation story from the same file;
- keeps bottles entirely under our schema (no Anthropic dependency
for the security-load-bearing config);
@@ -343,18 +343,18 @@ per-file grouping (which is the bigger UX win), and the per-file
shape is what makes the trust boundary self-documenting on disk.
The dependency cost (PyYAML) is the main thing that needs an
explicit yes from the user — claude-bottle today has zero
explicit yes from the user — bot-bottle today has zero
third-party Python deps for production code, and adopting one
crosses a clean architectural line. If "low deps" stays a hard
constraint, the alternative is to hand-parse the frontmatter block
and feed it to a minimal YAML subset parser (the keys
claude-bottle uses are all flat string/list/dict — no anchors, no
bot-bottle uses are all flat string/list/dict — no anchors, no
multi-line block scalars, no implicit type coercion).
If we don't want to commit to the move yet, the next-cheapest
option is keeping JSON but splitting into per-file (option B ×
option 1): `$HOME/.claude-bottle/bottles/<name>.json` +
`$HOME/.claude-bottle/agents/<name>.json`. Most of the scaling
option 1): `$HOME/.bot-bottle/bottles/<name>.json` +
`$HOME/.bot-bottle/agents/<name>.json`. Most of the scaling
wins; none of the body-prose or dependency story.
## Open questions
@@ -362,7 +362,7 @@ wins; none of the body-prose or dependency story.
- **Does Claude Code object to extra frontmatter fields?** Test:
drop a file with `bottle:` in `~/.claude/agents/` and see if CC
warns / ignores / breaks. If it warns, we'd want a different
field name (e.g. `claude-bottle-bottle`) or a namespaced block.
field name (e.g. `bot-bottle-bottle`) or a namespaced block.
- **Migration story.** Is the project willing to ship a one-shot
`./cli.py migrate-manifest` command that does the JSON → MD
conversion? Or do users just rewrite by hand from the new docs?
@@ -370,8 +370,8 @@ wins; none of the body-prose or dependency story.
empty body, is the MD-with-frontmatter format still warranted?
An alternative is YAML for bottles only (no body, but with
comments) and MD+frontmatter for agents.
- **Dotfiles vs not.** `$HOME/.claude-bottle/` or
`$HOME/claude-bottle/`? The hidden dotfile shape matches dev
- **Dotfiles vs not.** `$HOME/.bot-bottle/` or
`$HOME/bot-bottle/`? The hidden dotfile shape matches dev
conventions (`.config/`, `.ssh/`); the visible shape signals
"this is a real thing you own."
- **PyYAML hard dep, or minimal subset parser?** Trade-off between
+4 -4
View File
@@ -1,4 +1,4 @@
# Network egress guard for claude-bottle containers
# Network egress guard for bot-bottle containers
Research into preventing data exfiltration from Docker containers running
Claude Code (`--dangerously-skip-permissions`), with a focus on approaches
@@ -358,7 +358,7 @@ services:
- agent-net
claude-agent:
image: claude-bottle:latest
image: bot-bottle-claude:latest
environment:
HTTPS_PROXY: "http://proxy:4750"
HTTP_PROXY: "http://proxy:4750"
@@ -387,7 +387,7 @@ docker run -d --name "$container_name" \
--network agent-net-"$slug" \
-e HTTPS_PROXY=http://"$proxy_name":4750 \
-e HTTP_PROXY=http://"$proxy_name":4750 \
claude-bottle:latest
bot-bottle-claude:latest
```
The `--internal` flag on the network prevents containers from reaching
@@ -639,7 +639,7 @@ this is not relevant — the binary uses the Linux certificate store.
Justified only if the threat model includes sophisticated actors deliberately
crafting domain-fronting payloads. The extra complexity and CA-trust-management
overhead is not worth it for v1. Keep in view for v2 if the claude-bottle use
overhead is not worth it for v1. Keep in view for v2 if the bot-bottle use
case expands to high-value agent deployments.
---
+31 -31
View File
@@ -1,4 +1,4 @@
# Pipelock assessment for claude-bottle egress control
# Pipelock assessment for bot-bottle egress control
Research into whether pipelock — an open-source AI agent firewall —
is a suitable replacement for, or complement to, the egress-control
@@ -10,7 +10,7 @@ tripwire approach sketched in `secret-exfil-tripwire-encodings.md`.
- Pipelock (`luckyPipewrench/pipelock`) is an open-source AI agent
firewall implemented as a single Go binary. It sits inline as an HTTP
forward proxy and, optionally, applies OS-level process containment. It
is the most directly relevant tool found for claude-bottle's egress /
is the most directly relevant tool found for bot-bottle's egress /
data-exfiltration concerns.
- Its strongest differentiator over the v1 iptables recommendation is
**content-aware DLP**: it matches 48 credential patterns across
@@ -238,12 +238,12 @@ The following threat-model items from `network-egress-guard.md` are
---
## Fit for claude-bottle
## Fit for bot-bottle
### Deployment topology
Pipelock explicitly supports two deployment shapes relevant to
claude-bottle:
bot-bottle:
**Sidecar proxy.** A separate container running pipelock on an
`--internal` Docker network, with the agent container's only route to the
@@ -269,12 +269,12 @@ its own scanner. This avoids a second container but introduces the
`--best-effort` degradation problem described below and means the agent and
the proxy run in the same failure domain.
The sidecar topology is recommended for claude-bottle because it matches
The sidecar topology is recommended for bot-bottle because it matches
the existing Python-orchestrated multi-container model (the SSH key agent
already uses a separate process), keeps pipelock outside the agent's kill
reach, and avoids the `--best-effort` issue on macOS Docker Desktop.
The claude-bottle manifest model would need one new piece of plumbing: a
The bot-bottle manifest model would need one new piece of plumbing: a
per-agent pipelock ACL YAML generated from the manifest's `allowlist`
and `ssh` entries, analogous to what the smokescreen section of
`network-egress-guard.md` already sketches. The `cli.py` changes required
@@ -341,7 +341,7 @@ generated with `pipelock generate config --preset balanced > pipelock.yaml`.
The config watcher picks up changes at runtime (100ms debounce on SIGHUP or
file events), so per-agent ACL updates do not require a container restart.
For claude-bottle, the relevant per-agent configuration is the domain
For bot-bottle, the relevant per-agent configuration is the domain
allowlist. The manifest already captures the necessary inputs: the `ssh`
array has target hostnames, and an `allowlist` key is planned for the v2
egress work (per `network-egress-guard.md`). Generating a per-agent pipelock
@@ -353,14 +353,14 @@ The YAML format is more expressive than smokescreen's YAML ACL: it also
carries DLP sensitivity settings, per-domain data budgets, and rate limits.
For a first integration pass, only the `api_allowlist` section needs
per-agent population; the rest of the defaults are appropriate for the
claude-bottle threat model.
bot-bottle threat model.
### Runtime footprint
A single Go binary, ~1220 MB (sources report slightly different figures; the
GitHub description says "~20 MB" and the randomcpu.com writeup says "~12 MB").
Zero runtime dependencies; the Go standard library is statically linked. This
is consistent with claude-bottle's low-dependency principle. Adding Go as a
is consistent with bot-bottle's low-dependency principle. Adding Go as a
host build dependency is not required — the binary is fetched from a Docker
image or Homebrew.
@@ -410,7 +410,7 @@ The reasoning:
everything smokescreen covers (CONNECT-based hostname allowlisting,
RFC 1918 blocking, Docker `--internal` network isolation) and adds DLP,
subdomain-entropy DNS exfil detection, MCP scanning, and request
redaction. The integration shape for claude-bottle is identical: a
redaction. The integration shape for bot-bottle is identical: a
separate container on an internal Docker network, with the agent's
`HTTPS_PROXY` pointing at it. The `cli.py` changes are the same pattern.
@@ -454,7 +454,7 @@ The reasoning:
hostname-based allowlisting, content DLP, subdomain entropy analysis, and
MCP scanning on top of the v1 IP layer. Implementation effort is comparable
to the smokescreen plan; capabilities are a strict superset for the
claude-bottle threat model.
bot-bottle threat model.
- **DIY tripwire script (deferred):** the `secret-exfil-tripwire-encodings.md`
DIY sketch can be deferred entirely if pipelock's DLP patterns cover the
secrets in use. Custom patterns (for secrets not matching pipelock's 48
@@ -463,17 +463,17 @@ The reasoning:
---
## Does pipelock make claude-bottle redundant?
## Does pipelock make bot-bottle redundant?
Pipelock is itself an AI-agent firewall with an in-process sandbox mode,
which raises a fair question: if pipelock can already wrap an agent process
with Landlock + seccomp + namespaces (or `sandbox-exec` on macOS), is the
Docker-container layer that claude-bottle provides still doing useful work?
Docker-container layer that bot-bottle provides still doing useful work?
The short answer: **no, pipelock does not make claude-bottle redundant**.
The short answer: **no, pipelock does not make bot-bottle redundant**.
The two operate at different layers and the overlap is narrow.
### Where pipelock substitutes for parts of claude-bottle
### Where pipelock substitutes for parts of bot-bottle
For a single-agent use case on Linux with full unprivileged-userns support,
`pipelock sandbox -- claude` could replace the Docker container with a
@@ -483,7 +483,7 @@ a real isolation primitive, not a fig leaf. A user whose only concern is
"don't let one agent's bug touch my home directory or exfil my keys" could
plausibly run pipelock on the host and skip Docker entirely.
### Where claude-bottle does work pipelock does not
### Where bot-bottle does work pipelock does not
The redundancy argument breaks down once the actual goals from
`CLAUDE.md` are enumerated:
@@ -499,7 +499,7 @@ The redundancy argument breaks down once the actual goals from
filesystem.
2. **Parallel agents.** A primary stated goal is "Allow me to easily spin
up agent tasks in parallel". claude-bottle launches one container per
up agent tasks in parallel". bot-bottle launches one container per
agent invocation with a slug-derived name and a numeric suffix on
conflict. Pipelock has no equivalent fleet-management concept; it is a
per-process wrapper. Running `pipelock sandbox -- claude` four times in
@@ -508,7 +508,7 @@ The redundancy argument breaks down once the actual goals from
keychain. That is not the same property as four containers each with
its own ephemeral filesystem.
3. **The manifest model.** claude-bottle's `claude-bottle.json` carries
3. **The manifest model.** bot-bottle's `bot-bottle.json` carries
per-agent `env`, `skills`, `prompt`, and `ssh` configuration with
precise resolution semantics (prompt-at-launch secrets, host-env
forwarding, literal env-file values, host-key fingerprint pinning).
@@ -530,43 +530,43 @@ The redundancy argument breaks down once the actual goals from
(no UDS forwarding from host agent into the VM) and gives the property
that the `node` user can sign with the key but cannot read its bytes.
Pipelock does not address SSH at all, which is one of its documented
gaps. claude-bottle's solution remains relevant under either deployment.
gaps. bot-bottle's solution remains relevant under either deployment.
6. **Skill-directory injection per agent.** The `skills` array copies named
directories from `~/.claude/skills/` into the container at launch. There
is no analogous concept in pipelock; the skill set claude-bottle exposes
is no analogous concept in pipelock; the skill set bot-bottle exposes
is part of the per-agent isolation model, not just a configuration.
7. **Shareability of agent definitions.** A `claude-bottle.json` file can
7. **Shareability of agent definitions.** A `bot-bottle.json` file can
be checked into a project repo, and a third party can run the same
agent with the same env-resolution rules. Pipelock configurations are
per-installation; they do not encode "this is an agent named X".
### The opposite question
Does claude-bottle make pipelock redundant? Equally no. Docker container
Does bot-bottle make pipelock redundant? Equally no. Docker container
isolation does nothing about content-level exfil over an allowed channel.
A misbehaving agent inside a claude-bottle container with HTTPS access to
A misbehaving agent inside a bot-bottle container with HTTPS access to
`api.anthropic.com` can still attempt to exfiltrate via DNS subdomain
encoding, prompt-injection responses from MCP servers, or covert HTTP
parameters. Those are exactly the threats pipelock is designed to detect.
The containment argument for claude-bottle and the content-inspection
The containment argument for bot-bottle and the content-inspection
argument for pipelock do not overlap.
### Net conclusion
Pipelock and claude-bottle are layered defenses, not alternatives.
claude-bottle provides filesystem isolation, per-agent state ephemerality,
Pipelock and bot-bottle are layered defenses, not alternatives.
bot-bottle provides filesystem isolation, per-agent state ephemerality,
fleet management, manifest-driven configuration, and the SSH-agent-without-
key-leak property. Pipelock provides hostname allowlisting, content-aware
DLP, MCP scanning, and subdomain-entropy DNS exfil detection at the network
boundary. The strongest deployment is both: pipelock as a sidecar on the
container's only egress route, claude-bottle as the per-agent container
container's only egress route, bot-bottle as the per-agent container
orchestrator. Removing either layer leaves a real and named threat
uncovered.
The one scenario in which adopting pipelock could justify retiring
claude-bottle is a single-user, single-agent, host-resident deployment
bot-bottle is a single-user, single-agent, host-resident deployment
where the user is willing to give up the parallel-agent goal, accept
Landlock-level filesystem restriction in place of mount-namespace
isolation, and re-implement env / skill / SSH-key / prompt management
@@ -587,7 +587,7 @@ some other way. That is not the use case the project was built for.
from an unvetted source. Pinning by digest (as the CLAUDE.md recommends
for supply-chain hygiene) and building from source are both options.
3. **What is the actual DLP false-positive rate for the secrets claude-bottle
3. **What is the actual DLP false-positive rate for the secrets bot-bottle
agents use?** The 48 patterns cover well-known credential formats. Custom
patterns can be added but the mechanism (signed rule bundles) is not
documented in detail in public search results. Before v2, testing against
@@ -606,12 +606,12 @@ some other way. That is not the use case the project was built for.
integration work, audit which capabilities cited above are in the
Apache 2.0 core and which require accepting ELv2 terms (which permit
internal use and modification but prohibit offering pipelock as a
managed service). For claude-bottle's local-Docker single-user use case,
managed service). For bot-bottle's local-Docker single-user use case,
ELv2 is likely acceptable, but the determination should be explicit.
6. **Can pipelock's YAML config be generated per-agent from the manifest in
a way that handles the `ssh` array correctly?** The `ssh` array in
`claude-bottle.json` contains hostnames, ports, and `KnownHostKey` entries.
`bot-bottle.json` contains hostnames, ports, and `KnownHostKey` entries.
These need to be mapped to pipelock's `api_allowlist` (for HTTP) and
potentially to a separate bypass for the SSH socket. SSH is opaque to the
HTTP proxy and does not go through `HTTPS_PROXY`; the allowlist entry is
+7 -7
View File
@@ -1,7 +1,7 @@
# Closing the maturity gap: polish priorities
Research into what would close the perceived maturity gap between
claude-bottle and a "polished" comparable like claudebox. Motivated
bot-bottle and a "polished" comparable like claudebox. Motivated
by adopter feedback citing first-run friction, manifest authoring,
and distribution as the dominant obstacles to recommending the tool
to others.
@@ -33,11 +33,11 @@ on top of working onboarding.
### Onboarding friction
A first-time user today goes through five steps: install Docker,
install `uv`, set `CLAUDE_BOTTLE_OAUTH_TOKEN`, write
`claude-bottle.json`, run `./cli.py start`. One of those is
install `uv`, set `BOT_BOTTLE_OAUTH_TOKEN`, write
`bot-bottle.json`, run `./cli.py start`. One of those is
"author a JSON manifest." Polished tools in this category let
users skip that step on day one. The fix is an `init` subcommand
that drops a working `claude-bottle.json` with a default `coder`
that drops a working `bot-bottle.json` with a default `coder`
bottle/agent into the user's home directory and prints the next
command to run.
@@ -45,14 +45,14 @@ command to run.
Missing Docker, missing OAuth token, manifest typo, image build
failure — each should print a one-line fix rather than a stack
trace. claudebox handles this well; claude-bottle currently exits
trace. claudebox handles this well; bot-bottle currently exits
on `die()` calls that vary in helpfulness. A focused pass over
every `die()` site, ensuring each message says what failed *and*
what to do, is cheap and compounds across every user interaction.
### Distribution
`brew install claude-bottle` or `curl | sh`, not "clone the repo,
`brew install bot-bottle` or `curl | sh`, not "clone the repo,
install Python deps, `chmod cli.py`." The single highest-leverage
polish item, and the one that interacts with the language choice
covered in `bash-vs-python-vs-go.md`. Staying on Python means a
@@ -69,7 +69,7 @@ small; the signal value is large.
### Schema
A JSON schema for `claude-bottle.json` published with a `$schema`
A JSON schema for `bot-bottle.json` published with a `$schema`
URL gives VSCode and Cursor users autocomplete and inline
validation. ~½ day to author the schema, plus a few hours to
publish it where editors can fetch it.
+15 -15
View File
@@ -1,7 +1,7 @@
# Remote Docker VM as an isolation upgrade for claude-bottle
# Remote Docker VM as an isolation upgrade for bot-bottle
Note on the cheapest practical path to stronger isolation than local
Docker: run claude-bottle unchanged on a remote Linux VM that has
Docker: run bot-bottle unchanged on a remote Linux VM that has
dockerd. Complements `stronger-isolation-alternatives.md` (which
surveys runtime swaps like gVisor, Kata, Firecracker, Apple Container)
and `local-vs-remote-agent-execution.md` (which surveys the
@@ -10,7 +10,7 @@ local-vs-remote decision broadly).
## Summary
If the goal is "stronger isolation than Docker-on-my-laptop without
rewriting the runtime," the cleanest answer is to keep claude-bottle
rewriting the runtime," the cleanest answer is to keep bot-bottle
exactly as it is and run it on a remote Linux VM where you can install
dockerd. The v1 design — pipelock as a separate container on a
`--internal` network, ephemeral agent containers, OAuth-token
@@ -91,7 +91,7 @@ work:
may not allow installing dockerd depending on tier; Fly Machines,
EC2, GCE, Hetzner, Linode, and self-hosted hypervisors give you full
control.
- Enough disk + RAM to host the claude-bottle image, the agent
- Enough disk + RAM to host the bot-bottle image, the agent
container, and the pipelock sidecar. Headroom of ~24 GB RAM and
~5 GB disk is comfortable; less works for short sessions.
- An interactive reach path. SSH is fine. The launcher uses
@@ -102,7 +102,7 @@ work:
- **Typing latency.** Interactive Claude sessions over SSH have visible
per-keystroke latency; usually fine on wired/fiber, less fine on
Wi-Fi-to-cloud. Mosh helps if it's bothersome.
- **Token shipping.** `CLAUDE_BOTTLE_OAUTH_TOKEN` has to live on the
- **Token shipping.** `BOT_BOTTLE_OAUTH_TOKEN` has to live on the
remote box for the launcher to forward it into containers. Use the
provider's secret-injection path (cloud-init user-data,
`flyctl secrets`, Tailscale-served local file, etc.). Never echo the
@@ -122,15 +122,15 @@ work:
## Operational shape
The minimum-viable workflow, no claude-bottle code changes:
The minimum-viable workflow, no bot-bottle code changes:
1. `terraform apply` / `flyctl machine run` / `gcloud compute
instances create` — provision a fresh Linux VM.
2. Install dockerd via the provider's image or a one-liner
(`curl -fsSL https://get.docker.com | sh`).
3. SSH in.
4. `git clone` claude-bottle on the VM, drop a manifest in place,
inject `CLAUDE_BOTTLE_OAUTH_TOKEN` via the provider's secrets path.
4. `git clone` bot-bottle on the VM, drop a manifest in place,
inject `BOT_BOTTLE_OAUTH_TOKEN` via the provider's secrets path.
5. `./cli.py start <agent>` — the existing launcher handles the rest.
6. On exit: destroy the VM. No host artifacts persist.
@@ -150,14 +150,14 @@ gotchas the abstract pattern leaves implicit.
Build a custom OCI image `FROM docker:dind` that bakes in:
- The claude-bottle repository checkout.
- A pre-built `claude-bottle:latest` image, saved via `docker save` on
- The bot-bottle repository checkout.
- A pre-built `bot-bottle-claude:latest` image, saved via `docker save` on
your laptop and loaded in at image-build time
(`RUN docker load < claude-bottle.tar`) or pushed as a layer into
(`RUN docker load < bot-bottle.tar`) or pushed as a layer into
the dind storage. Without this step, the first in-VM `docker build`
runs `apt-get` and a global `npm install -g
@anthropic-ai/claude-code`, which adds 3090 s to every cold start.
- A `flyctl secrets`-injected `CLAUDE_BOTTLE_OAUTH_TOKEN`, exposed to
- A `flyctl secrets`-injected `BOT_BOTTLE_OAUTH_TOKEN`, exposed to
the VM's PID 1 as an env var.
- An entrypoint that starts dockerd, waits for it to be healthy, then
either drops into a shell or directly runs `cli.py start <agent>`.
@@ -166,7 +166,7 @@ Deploy with `flyctl deploy` or `flyctl machine run --image …`.
### Boot-to-first-prompt timing
Three scenarios, all assuming the custom image above (claude-bottle
Three scenarios, all assuming the custom image above (bot-bottle
image baked in, token injected, no in-VM rebuild):
| Phase | Cold (image not cached on Fly host) | Warm (image cached, `machine run` fresh) | Hot (`machine stop`ped, `machine start`) |
@@ -186,7 +186,7 @@ is mostly about cost, not speed.
### Cost of standby vs. create-per-session
Stopped Fly Machines stop billing CPU/RAM but continue to bill for
storage and any allocated IPv4. A reasonable claude-bottle Machine
storage and any allocated IPv4. A reasonable bot-bottle Machine
size (2 vCPU / 2 GB / ~3 GB rootfs) costs roughly:
| Item | While stopped | Monthly |
@@ -275,7 +275,7 @@ overhead in any given setting.
If we wanted to land this as a real project direction:
1. Add a short "Running claude-bottle on a remote Docker VM" section
1. Add a short "Running bot-bottle on a remote Docker VM" section
to the README pointing at this doc.
2. Optionally: prototype the `--remote=user@host` launcher subcommand.
3. Update `stronger-isolation-alternatives.md` to mention the remote
@@ -35,7 +35,7 @@ which naive encoding a misbehaving agent uses.
explicitly does not decode base64 or ROT13 in all inspection paths due
to processing overhead concerns.
- Rolling this in ~100 lines of Python is feasible and is probably the right
path for claude-bottle v1. The limiting factor is not the encoding logic
path for bot-bottle v1. The limiting factor is not the encoding logic
— that is straightforward — but the false-positive rate from common
base64 alphabet collisions and the zero coverage against any re-encoding
that involves a key (encryption) or destroys byte boundaries (packet
@@ -385,7 +385,7 @@ involves:
- Steganography (hiding data in image pixels, audio samples, whitespace),
...will produce output with no detectable relationship to the input plaintext.
For claude-bottle's threat model — an untrusted but not adversarially
For bot-bottle's threat model — an untrusted but not adversarially
sophisticated agent that might accidentally or naively encode secrets before
logging or posting them — naive-encoding detection covers the realistic concern.
A deliberately adversarial agent with knowledge of its own monitoring is a
@@ -393,9 +393,9 @@ different threat model entirely.
---
## Integration point in claude-bottle
## Integration point in bot-bottle
The natural choke point for encoding-aware content inspection in claude-bottle
The natural choke point for encoding-aware content inspection in bot-bottle
is the body of outbound HTTP/HTTPS requests, visible at the egress proxy layer.
The egress-guard note documents smokescreen (Stripe,
[github.com/stripe/smokescreen](https://github.com/stripe/smokescreen))
@@ -450,7 +450,7 @@ and pre-computable. The following techniques defeat it entirely:
intercepted by an HTTP proxy at all. The egress-guard note covers DNS
separately via a controlled resolver.
In the claude-bottle context, the primary realistic concern is an agent
In the bot-bottle context, the primary realistic concern is an agent
that naively embeds a secret in a log line, a curl argument, a JSON body,
or a shell heredoc without specifically intending to obfuscate. All of the
above bypass techniques require deliberate, adversarially-motivated engineering
@@ -13,7 +13,7 @@ existing tools in that space).
## Summary
claude-bottle's v1 egress story is: pipelock allowlists hostnames,
bot-bottle's v1 egress story is: pipelock allowlists hostnames,
intercepts TLS, body-scans every request against 48 builtin DLP
patterns, and blocks on hit. Gitleaks does the analog on `git push`.
Both are signature-based. Against a *determined* compromised or
@@ -79,7 +79,7 @@ The agent's conversation channel is therefore wide open as an exfil
path. A prompt-injected agent that has been told a secret can ship
it to Anthropic as conversation text, formatted however it likes,
and pipelock sees only `CONNECT api.anthropic.com:443`. The
`CLAUDE_BOTTLE_OAUTH_TOKEN` itself rides this exact path.
`BOT_BOTTLE_OAUTH_TOKEN` itself rides this exact path.
### 3. Out-of-band channels exist regardless
@@ -134,7 +134,7 @@ per-bottle gate that:
Two concrete instances worth implementing:
**Anthropic-API gate.** Holds `CLAUDE_BOTTLE_OAUTH_TOKEN`. Agent's
**Anthropic-API gate.** Holds `BOT_BOTTLE_OAUTH_TOKEN`. Agent's
`ANTHROPIC_BASE_URL` points at the gate; gate injects
`Authorization: Bearer …` and forwards to api.anthropic.com. The
token is no longer in the bottle's env. Once the token is out,
+1 -1
View File
@@ -1,4 +1,4 @@
# smolmachines as a VM backend for claude-bottle
# smolmachines as a VM backend for bot-bottle
Evaluation of whether [smolmachines](https://smolmachines.com/) would
simplify the macOS agent-VM-isolation work spelled out in
@@ -1,7 +1,7 @@
# Stronger isolation alternatives: gVisor, Kata, Firecracker, Apple Container
Research into what it would take to replace or augment Docker (with `runc`)
as the agent runtime in claude-bottle, and what each option would actually
as the agent runtime in bot-bottle, and what each option would actually
buy in security terms vs. cost in launcher rewrite.
## Summary
@@ -17,7 +17,7 @@ There is a ladder, not a menu. Three realistic rungs, ordered by effort:
A fourth option, **Apple Container**, is the right macOS-native answer to
"I want Kata's isolation model without giving up MacBooks as the dev
target." Probably the right v2 if claude-bottle keeps macOS in scope.
target." Probably the right v2 if bot-bottle keeps macOS in scope.
The pipelock egress design is portable across all four: every option can
provide a network primitive that means "no default route except through
@@ -54,11 +54,11 @@ forwarded to the host kernel.
### What changes in this codebase
- `claude_bottle/cli/start.py` (where `docker run` is assembled): add
- `bot_bottle/cli/start.py` (where `docker run` is assembled): add
`--runtime=runsc` to the container args when the bottle requests it.
Make it configurable: `bottles.<name>.runtime: "runsc" | "runc"`,
default `runc`.
- `claude_bottle/docker.py`: add a `require_runsc()` check that runs
- `bot_bottle/docker.py`: add a `require_runsc()` check that runs
`docker info --format '{{.Runtimes}}'` once and dies with an install
pointer if `runsc` isn't registered.
- `network.py`, `pipelock.py`, `skills.py`, `ssh.py`: **no changes**.
@@ -111,7 +111,7 @@ Docker network.
- Slower cold start (hundreds of ms vs. tens). For interactive Claude
this is fine; for ephemeral batch agents you would notice.
- Not natively supported on macOS at all — needs a Linux host or a Linux
VM you control. **This is the moment claude-bottle stops being "works
VM you control. **This is the moment bot-bottle stops being "works
on a Mac dev laptop with Docker Desktop."**
### When this is the right rung
@@ -138,18 +138,18 @@ replacing Docker, not adding to it.
### Files in this repo that would change
- `claude_bottle/docker.py` → replaced by a new `claude_bottle/firecracker.py`
- `bot_bottle/docker.py` → replaced by a new `bot_bottle/firecracker.py`
that POSTs to the Firecracker API socket per microVM (`/boot-source`,
`/drives`, `/network-interfaces`, `/actions`).
- `claude_bottle/network.py` → a host-side networking module that creates
- `bot_bottle/network.py` → a host-side networking module that creates
a Linux bridge per agent, two TAPs (agent-side, pipelock-side), and
either iptables rules or no host route at all so the agent VM
literally cannot reach anything except pipelock.
- `claude_bottle/pipelock.py` → instead of a sidecar container, run
- `bot_bottle/pipelock.py` → instead of a sidecar container, run
pipelock as its own microVM (or on the host pinned to the bridge).
The hostname-allowlist semantics carry over; the implementation is
different.
- `claude_bottle/skills.py`, `ssh.py` → can no longer use `docker cp`.
- `bot_bottle/skills.py`, `ssh.py` → can no longer use `docker cp`.
Bake skills into the rootfs at build time, or mount a virtiofs share
read-only.
- `Dockerfile` → replaced by a rootfs builder. Realistically this means
@@ -221,7 +221,7 @@ scope and the manifest example carries `/Users/didericis` paths:
and look at Apple Container. Smaller launcher rewrite than
Firecracker; Linux stays on the gVisor / Kata path. Probably the
right v2.
3. **Firecracker only if** claude-bottle's deployment target settles on
3. **Firecracker only if** bot-bottle's deployment target settles on
self-hosted Linux, not laptops — at which point the "non-goal:
self-hosted VMs" line in `CLAUDE.md` flips and the project's
identity changes.