bot-bottle logo

# bot-bottle [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint) [![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright) **Run any coding agent like it might be compromised — and lose nothing when it is.** bot-bottle is a provider-neutral, security-first substrate for autonomous agents. Bring Claude Code, Codex, or your own harness; each one runs in an ephemeral, per-agent "bottle" it cannot modify, where every byte of egress is scanned for exfiltration and capabilities are narrowed to exactly what the task declares. **Problem:** You want to let a coding agent run unsupervised, but a prompt-injected or misbehaving agent — or a poisoned repo, MCP server, or skill — can wreck your environment or exfiltrate your secrets. Locking yourself to one vendor's cloud doesn't fix that; it just moves the blast radius. **Solution:** A neutral control plane that runs *whatever agent you choose* inside an isolation boundary the agent can't touch: TLS-bumped egress allowlisting, outbound/inbound DLP, gitleaks-gated pushes, and host secrets the agent never sees. Swap the agent; keep the guarantees. ## Why bot-bottle ### A neutral substrate — bring your own agent - **Provider-agnostic by design** — Claude and Codex ship built in; any other agent (Gemini, Aider, a local-model wrapper) is a drop-in plugin at `~/.bot-bottle/contrib//` — no fork, no PR against this repo. The manifest accepts any provider template, and the isolation, egress, and git guarantees are identical across all of them. - **One control plane, every harness** — the same bottle, egress policy, and supervise flow wrap whichever agent you run, so switching or mixing providers doesn't change your security posture. - **Composable bottles (`extends:`)** — keep provider/runtime policy in one base bottle (e.g. `claude.md`) and overlay task bottles on top. ### An isolation boundary the agent can't touch - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default. - **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Trust boundary at `$HOME`** — bottles (credentials, egress, remotes) live only under `~/.bot-bottle/bottles/`. Repos may ship agents but not bottles, so a cloned repo can't redirect an env var to an attacker host. ### Isolation that matches your host - **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. - **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network. - **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. - **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`. Per-provider auth (Claude long-lived OAuth token; Codex opt-in host device-auth forwarding) and per-provider images (`Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile) are configured on the bottle — see [Manifest](#manifest). ## Architecture On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists. On the smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS. On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box. The Docker topology looks like this: ``` host ( ./cli.py ) │ starts │ stops ▼ ┌─────────────────────────── bottle ──────────────────────────────────┐ │ │ │ ┌──────────────────┐ ┌──────────────────────┐ │ │ │ agent image │ HTTP(S) proxy │ egress image │ │ │ │ (claude-code, │ ─────────────────►│ (mitmproxy; TLS bump │ │ HTTPS to │ │ codex, etc) │ │ DLP scan, path │───┼──► allowlisted │ │ │ │ matching, auth │ │ hosts │ │ environ: proxy │ │ injection) │ │ │ │ URLs only, no │ └──────────────────────┘ │ │ │ real tokens │ │ │ │ │ git proxy ┌────────────────┐ │ SSH push/fetch │ │ │ ────────────────►│ git-gate image │──────────┼──► to bottle.git │ │ │ │ (gitleaks + │ │ upstreams │ └──────────────────┘ │ git daemon) │ │ (direct — not │ └────────────────┘ │ via egress) │ │ │ agent on internal network (no default route); egress and │ │ git-gate straddle internal + egress networks. │ │ egress is the single HTTP/HTTPS chokepoint — all agent HTTP/HTTPS │ │ traffic flows through it. git-gate's SSH egress is direct │ │ because egress is HTTP-only. │ └─────────────────────────────────────────────────────────────────────┘ ``` When the agent exits, `cli.py` tears down every sidecar and both networks; nothing about a bottle persists between runs. ## Install Install the CLI with the bootstrap script: ```sh curl -fsSL https://gitea.dideric.is/didericis/bot-bottle/raw/branch/main/install.sh | sh ``` The script checks Python 3.11+, checks Docker daemon reachability, creates the `~/.bot-bottle/` config directories, installs the Python package with `pipx` when available or `pip --user` otherwise, then runs: ```sh bot-bottle doctor ``` Python-native installers can use the package metadata directly: ```sh pipx install git+https://gitea.dideric.is/didericis/bot-bottle.git uv tool install git+https://gitea.dideric.is/didericis/bot-bottle.git ``` ## Quickstart On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. Use `BOT_BOTTLE_BACKEND=docker ./cli.py start ` on hosts where Apple Container is not installed and Docker is the desired backend. ```sh ./cli.py start # builds the image on first run, drops you into claude ``` ## Manifest Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `/.bot-bottle/agents/.md`. **Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`): ````markdown --- extends: claude # inherit the Claude provider boundary env: GIT_AUTHOR_NAME: didericis git: user: name: "Eric Bauerfeld" email: "eric+claude@dideric.is" remotes: gitea.dideric.is: Name: bot-bottle Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea KnownHostKey: ssh-ed25519 AAAA... egress: routes: - host: gitea.dideric.is auth: scheme: token # Bearer | token token_ref: BOT_BOTTLE_GITEA_TOKEN matches: # optional — restrict to specific paths/methods/headers - paths: - {type: prefix, value: /api/v1/} methods: [GET, POST, PATCH, DELETE] dlp: # optional — per-route detector overrides (default: all on) outbound_detectors: [token_patterns, known_secrets] inbound_detectors: false # disable response scanning for this host --- The `gitea-dev` bottle. Provider auth via the inherited Claude route; gitea over SSH for push, token over HTTPS for the API. ```` **Agent** (`~/.bot-bottle/agents/gitea-helper.md`): ````markdown --- bottle: gitea-dev skills: - init-prd --- You help maintain Gitea-hosted projects. ```` **Egress route fields:** | Field | Required | Description | |---|---|---| | `host` | yes | Hostname to allowlist. One entry per host. | | `role` | no | Reserved for future use. The key is recognised but any value is currently rejected at load. Provider auth routes (e.g. Claude's `api.anthropic.com`) are injected automatically from `agent_provider.auth_token`, not via `role`. | | `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. | | `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. | | `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. | | `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. | | `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. | | `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. | | `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). | | `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). | | `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). | | `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. | More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. ## Trademarks bot-bottle is an independent project and is not affiliated with, endorsed by, or sponsored by Anthropic, PBC. "Claude" and "Claude Code" are trademarks of Anthropic, PBC; the project name uses "claude" descriptively to indicate that the tool runs Claude Code inside a sandbox. ## License Copyright 2026 Eric Bauerfeld. Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full text.