diff --git a/Dockerfile b/Dockerfile index 06dd7e4..06e2911 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ FROM node:22-slim # image, those features fail in surprising ways once the user does any # real work. ca-certificates is already in the slim base; listed for # clarity in case the base ever drops it. socat is the privileged -# forwarder for the in-container ssh-agent (see lib/ssh.sh): the agent +# forwarder for the in-container ssh-agent (see claude_bottle/ssh.py): the agent # runs as root and rejects non-root connections, so socat sits between # node and the agent socket. RUN apt-get update \ @@ -38,7 +38,7 @@ USER node WORKDIR /home/node # Pre-create the skills directory so PRD 0002's host->container skill -# copier (scripts/lib/skills.sh) drops files into a path owned by the +# copier (claude_bottle/skills.py) drops files into a path owned by the # `node` user. `skills_copy_into` also `mkdir -p`s defensively, but # baking it into the image avoids a permission-confusion footgun if a # future change to the launcher copies in as a different user. @@ -58,7 +58,7 @@ RUN cat > "$HOME/.claude.json" <`), many bottles run in parallel, and each +(`./cli.py start `), many bottles run in parallel, and each one's powers are scoped to what the manifest grants it: a curated set of skills, env vars, and a starting prompt. When the session ends the bottle is destroyed and the genie does not persist. @@ -97,7 +97,7 @@ as `CLAUDE_BOTTLE_OAUTH_TOKEN`: export CLAUDE_BOTTLE_OAUTH_TOKEN="" ``` -`cli.sh` automatically forwards it to every container as +`cli.py` automatically forwards it to every container as `CLAUDE_CODE_OAUTH_TOKEN` via `docker run -e` — no manifest wiring required, and the value is never written to disk or placed on argv. diff --git a/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md b/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md index 4ea1c3d..0f1c6cb 100644 --- a/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md +++ b/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md @@ -43,12 +43,12 @@ The feature works when all of the following are observable: The feature is **done** when all of the following ship: -- `cli.sh start` brings up a per-agent pipelock sidecar on a `--internal` +- `cli.py start` brings up a per-agent pipelock sidecar on a `--internal` Docker network and points the agent's `HTTPS_PROXY` at it. - A per-agent pipelock YAML config is generated from a bottle-level `egress.allowlist` field, plus baked-in defaults for Claude Code's required hosts so basic bottles work out of the box. -- The existing `cli.sh` y/N preflight shows the resolved allowlist before +- The existing `cli.py` y/N preflight shows the resolved allowlist before launch. - When the agent container exits, the pipelock sidecar and the internal network are torn down cleanly (no orphaned containers or networks). @@ -76,7 +76,7 @@ The feature is **done** when all of the following ship: - Pipelock sidecar container lifecycle (start, attach to network, receive config, stop on agent exit). - `HTTPS_PROXY` / `HTTP_PROXY` injection into the agent container. -- Preflight integration: the existing y/N plan in `cli.sh` lists the +- Preflight integration: the existing y/N plan in `cli.py` lists the resolved allowlist. ### Out of scope @@ -95,30 +95,31 @@ The feature is **done** when all of the following ship: ### New services / components -Two new files under `lib/`: +Two new modules under `claude_bottle/`: -- **`lib/pipelock.sh`** — 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. -- **`lib/network.sh`** — Docker network plumbing. Creates the per-agent - `--internal` network (named `claude-bottle-net-` 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 - `lib/docker.sh` so a future PRD can add non-pipelock network controls - without entangling them with pipelock specifics. +- **`claude_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-` 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 + network controls without entangling them with pipelock specifics. -This split mirrors the existing per-concern lib/ pattern -(`manifest.sh`, `env_resolve.sh`, `skills.sh`, `ssh.sh`). +This split mirrors the existing per-concern module pattern +(`manifest.py`, `env_resolve.py`, `skills.py`, `ssh.py`). ### Existing code touched -- **`cli.sh`** — wire the new lifecycle into `start`: 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 allowlist to the preflight y/N output. - Tear down sidecar + network in the existing exit trap. +- **`claude_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 + allowlist to the preflight y/N output. Tear down sidecar + network in + the existing exit handler. - **`README.md`** — public-facing description should mention that agent containers route HTTP egress through pipelock by default, and document the new `egress.allowlist` bottle field. @@ -128,9 +129,10 @@ This split mirrors the existing per-concern lib/ 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). -`lib/docker.sh` may grow one or two helpers if there is a clean place -for shared primitives, but the network-specific helpers live in -`lib/network.sh`. Decide during implementation; not a contract. +`claude_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 +contract. ### Data model changes @@ -174,9 +176,9 @@ bottle share the same allowlist. - **Pipelock binary** is pulled from `ghcr.io/luckypipewrench/pipelock@sha256:`. The digest is - pinned in `lib/pipelock.sh` (or a sibling `.env`-shaped constants - file) and bumped deliberately, mirroring the claude-code version - pinning pattern in `Dockerfile`. + pinned in `claude_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 external artifact. @@ -189,10 +191,10 @@ bottle share the same allowlist. which features used by this PRD are Apache-2.0-core. v1's plan (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 `lib/pipelock.sh` is - the lowest-friction option; a separate `lib/versions.sh` (or similar) - may be cleaner once there are multiple pinned dependencies. Decide - during implementation. +- **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 + 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 one agent within a bottle, revisit. Out of scope for v1. diff --git a/docs/research/host-dispatch-to-container-agents.md b/docs/research/host-dispatch-to-container-agents.md index 8e47d54..eeac349 100644 --- a/docs/research/host-dispatch-to-container-agents.md +++ b/docs/research/host-dispatch-to-container-agents.md @@ -34,7 +34,7 @@ Three pieces in combination give a 100% guarantee: - `get_status(job_id)` — check running/done - `get_output(job_id)` — read results -3. **Non-interactive container run mode** — `cli.sh run ""` passes the task to `claude --print` inside the container and captures output. Currently `cli.sh start` is interactive only; this mode does not yet exist. +3. **Non-interactive container run mode** — `cli.py run ""` passes the task to `claude --print` inside the container and captures output. Currently `cli.py start` is interactive only; this mode does not yet exist. ## Proposal @@ -42,7 +42,7 @@ Build host-dispatch-to-container in two deliverables: **Deliverable 1: Non-interactive run mode for claude-bottle** -Extend `cli.sh` with a `run ` 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. +Extend `cli.py` with a `run ` 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** diff --git a/docs/research/network-egress-guard.md b/docs/research/network-egress-guard.md index 8563dcf..58398dd 100644 --- a/docs/research/network-egress-guard.md +++ b/docs/research/network-egress-guard.md @@ -234,7 +234,7 @@ which does not work on macOS Desktop. Moderate. The script itself is well-understood and can be lifted nearly verbatim from Anthropic's devcontainer repo. The integration points in -`cli.sh` are: +`cli.py` are: 1. Pass `--cap-add NET_ADMIN --cap-add NET_RAW` in the `docker run` invocation. 2. `docker cp` an `init-firewall.sh` script into the container (alongside diff --git a/docs/research/oauth-token-exposure-to-claude.md b/docs/research/oauth-token-exposure-to-claude.md index 4ed5d93..6b82a1d 100644 --- a/docs/research/oauth-token-exposure-to-claude.md +++ b/docs/research/oauth-token-exposure-to-claude.md @@ -20,11 +20,12 @@ that does not route through `ANTHROPIC_BASE_URL` at all. ## How the token reaches claude today -1. `cli.sh:526–528` — host's `CLAUDE_BOTTLE_OAUTH_TOKEN` is exported into - the launcher process as `CLAUDE_CODE_OAUTH_TOKEN`, then forwarded with +1. `claude_bottle/cli/start.py` (around line 237–238) — host's + `CLAUDE_BOTTLE_OAUTH_TOKEN` is exported into the launcher process as + `CLAUDE_CODE_OAUTH_TOKEN`, then forwarded with `docker run -e CLAUDE_CODE_OAUTH_TOKEN` (no `=value`, so the value never lands on argv — good). -2. `cli.sh:603–605` — claude is launched via +2. `claude_bottle/cli/start.py` (around line 318–325) — claude is launched via `docker exec -it claude …`, which inherits the container PID 1's env, including the token. 3. claude runs as `node` (UID 1000) with `--dangerously-skip-permissions`. diff --git a/docs/research/pipelock-assessment.md b/docs/research/pipelock-assessment.md index 1276bcd..e547afc 100644 --- a/docs/research/pipelock-assessment.md +++ b/docs/research/pipelock-assessment.md @@ -273,7 +273,7 @@ reach, and avoids the `--best-effort` issue on macOS Docker Desktop. The claude-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.sh` changes required +`network-egress-guard.md` already sketches. The `cli.py` changes required are the same pattern: `docker network create --internal`, `docker run` for the proxy container, then `docker run` for the agent with `HTTPS_PROXY` injected. @@ -408,7 +408,7 @@ The reasoning: subdomain-entropy DNS exfil detection, MCP scanning, and request redaction. The integration shape for claude-bottle is identical: a separate container on an internal Docker network, with the agent's - `HTTPS_PROXY` pointing at it. The `cli.sh` changes are the same pattern. + `HTTPS_PROXY` pointing at it. The `cli.py` changes are the same pattern. 2. **The DLP layer is the most direct answer to the content-tripwire gap.** The `secret-exfil-tripwire-encodings.md` note concluded that no