# PRD 0001: Per-agent egress proxy via pipelock

- **Status:** Active
- **Author:** didericis
- **Created:** 2026-05-08

## Summary

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.

## Problem

Today the agent container has unrestricted internet egress, and even on
allowed channels there is no content-level inspection. Specifically:

- Containers have unrestricted internet egress; a misbehaving agent can
  POST to any host.
- Allowed channels (`api.anthropic.com`, git remotes) can still carry
  content-level exfil with no detection.
- DNS exfil via subdomain encoding is not detected anywhere in the stack.
- MCP tool calls and responses pass through unscanned.

These gaps are documented in `docs/research/network-egress-guard.md`,
`docs/research/secret-exfil-tripwire-encodings.md`, and
`docs/research/pipelock-assessment.md`. The pipelock assessment recommends
adopting pipelock as the v2 sidecar (in place of smokescreen) layered on
top of a v1 iptables+dnsmasq floor.

## Goals / Success Criteria

The feature works when all of the following are observable:

- The agent container has no default route; `curl https://example.com`
  from inside fails when `example.com` is not on the allowlist.
- The agent container can reach `api.anthropic.com` and `claude` runs
  end-to-end through the proxy.
- Pipelock blocks a known credential pattern in a request body and
  surfaces a structured log line for the block.
- The subdomain-entropy check fires on a `<base64-of-secret>.evil.com`
  request.

The feature is **done** when all of the following ship:

- `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.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).

## Non-goals

- Closing every exfil vector. SSH session content, raw TCP, UDP, ICMP,
  and TLS-SNI domain fronting all remain known gaps after this PRD ships
  and are explicitly out of scope.
- Audit logging or persistent log storage of pipelock decisions. v1 logs
  to stdout only; durable logging is a follow-up PRD.
- Replacing the v1 iptables layer. Pipelock sits above iptables, not in
  place of it (see `pipelock-assessment.md` §Recommendation).
- Multi-tenant or remote-pipelock deployments. v1 is one pipelock
  container per agent container, on the same Docker host.

## Scope

### In scope

- New manifest schema: an `egress` object on each bottle, with
  `allowlist: [hostname]` for v1.
- Generation of a per-bottle pipelock YAML config at launch.
- Per-agent Docker `--internal` network creation and teardown.
- 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.py` lists the
  resolved allowlist.

### Out of scope

- The v1 iptables + ipset + dnsmasq layer (separate PRD; see
  `network-egress-guard.md`).
- TLS interception / domain-fronting mitigation. Pipelock does not
  terminate TLS and this PRD does not introduce CA-trust injection.
- Per-bottle DLP rule customization beyond pipelock's 48 built-in
  patterns. Custom signed rule bundles are deferred.
- Mediator-signed action receipts and any other pipelock features
  potentially gated under the ELv2 enterprise subtree (see open
  question on licensing in `pipelock-assessment.md`).

## Proposed Design

### New services / components

Two new modules under `bot_bottle/`:

- **`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.
- **`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 `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
(`manifest.py`, `env_resolve.py`, `skills.py`, `ssh.py`).

### Existing code touched

- **`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
  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.

`Dockerfile` is intentionally not touched for v1 — `HTTPS_PROXY` /
`HTTP_PROXY` are injected per-launch via `docker run -e`, not baked into
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).

`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 `bot_bottle/network.py`. Decide during implementation; not a
contract.

### Data model changes

The bottle schema gains an `egress` object. The structure is designed
to allow incremental additions without a breaking rename:

```jsonc
{
  "bottles": {
    "default": {
      "env":  { "...": "..." },
      "ssh":  [],
      "egress": {
        "allowlist": [
          "api.anthropic.com",
          "github.com"
        ]
      }
    }
  }
}
```

Resolution rules:

- The effective allowlist is `<baked-in-defaults> ∪ <bottle.egress.allowlist>`.
- Baked-in defaults cover hosts Claude Code itself needs:
  `api.anthropic.com`, `statsig.anthropic.com`, `sentry.io`,
  `claude.ai`, `platform.claude.com`, `downloads.claude.ai`,
  `raw.githubusercontent.com` (per `pipelock-assessment.md` and
  Claude Code's network-config docs).
- Bottles with no `egress` block use defaults only.
- Future keys (`dlp`, `mode`, `data_budget`, etc.) are reserved under
  the same `egress` object; v1 ignores unknown keys.

The `agent` schema is unchanged. Egress is a property of the
container/sandbox, not the task — multiple agents pointing at the same
bottle share the same allowlist.

### External dependencies

- **Pipelock binary** is pulled from
  `ghcr.io/luckypipewrench/pipelock@sha256:<digest>`. The digest is
  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
  external artifact.

## Open questions

- **ELv2 licensing.** Several capabilities discussed in
  `pipelock-assessment.md` (mediator-signed action receipts, signed
  rule bundles) may live under the `enterprise/` subtree and require
  accepting Elastic License v2 terms. Before implementation, audit
  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
  `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
  one agent within a bottle, revisit. Out of scope for v1.
- **Default-allowlist drift.** Claude Code's required hostnames may
  change with new versions. v1 hardcodes the current set; a follow-up
  could derive them from the pinned claude-code version or a published
  manifest from Anthropic.
- **Sidecar log surface.** Pipelock decisions go to the sidecar's
  stdout. v1 leaves these visible only via `docker logs <sidecar>` —
  fine for inspection but not aggregated. Persistent / structured
  logging is a non-goal here, called out for the follow-up.
- **DNS resolver routing.** Pipelock's subdomain-entropy check fires
  on URLs it sees, not on raw UDP/53. Without the v1 dnsmasq layer the
  agent could still query a non-allowlisted resolver directly. Document
  the dependency on the v1 PRD (or note explicitly that v1 of this PRD
  ships with that gap if the iptables PRD lands later).

## References

- `docs/research/pipelock-assessment.md` — recommendation and rationale.
- `docs/research/network-egress-guard.md` — v1 iptables+dnsmasq baseline.
- `docs/research/secret-exfil-tripwire-encodings.md` — content-tripwire
  framing this PRD partially addresses via pipelock's DLP layer.
- Pipelock README:
  <https://github.com/luckyPipewrench/pipelock/blob/main/README.md>
- Claude Code network configuration:
  <https://code.claude.com/docs/en/network-config>
