PRD 0001: Per-agent egress proxy via pipelock (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-05-08 01:56:43 -04:00
parent 08597ebcf8
commit ba7616a4ae
20 changed files with 1977 additions and 12 deletions
+32
View File
@@ -37,6 +37,38 @@ The container is removed automatically when the session ends. If the script
is killed with SIGKILL the exit trap won't fire and the container may be is killed with SIGKILL the exit trap won't fire and the container may be
left running; remove it with `docker rm -f <container-name>`. left running; remove it with `docker rm -f <container-name>`.
## Egress
Agent containers route HTTP / HTTPS traffic through a per-agent
[pipelock](https://github.com/luckyPipewrench/pipelock) sidecar
attached to a Docker `--internal` network. The sidecar enforces a
hostname allowlist, runs DLP scanning (48 default credential
patterns), and detects URL-embedded high-entropy secret leaks. Without
the proxy the agent has no route off-box at all — the internal network
has no default gateway. The sidecar and network are torn down with the
agent on session exit.
The effective allowlist is the union of a baked-in default for Claude
Code's required hosts (`api.anthropic.com`, `claude.ai`, ...) and the
optional `bottles.<name>.egress.allowlist` field in
`claude-bottle.json`:
```jsonc
{
"bottles": {
"default": {
"env": { },
"ssh": [ ],
"egress": { "allowlist": ["github.com"] }
}
}
}
```
The resolved allowlist is shown in the y/N preflight before launch.
See `docs/prds/0001-per-agent-egress-proxy-via-pipelock.md` for the
design and `docs/research/pipelock-assessment.md` for the rationale.
## Auth: OAuth token, not API key ## Auth: OAuth token, not API key
claude-bottle authenticates `claude` inside the container with the same claude-bottle authenticates `claude` inside the container with the same
+83 -5
View File
@@ -34,6 +34,10 @@ REPO_DIR="$SCRIPT_DIR"
. "${SCRIPT_DIR}/lib/skills.sh" . "${SCRIPT_DIR}/lib/skills.sh"
# shellcheck source=lib/ssh.sh # shellcheck source=lib/ssh.sh
. "${SCRIPT_DIR}/lib/ssh.sh" . "${SCRIPT_DIR}/lib/ssh.sh"
# shellcheck source=lib/network.sh
. "${SCRIPT_DIR}/lib/network.sh"
# shellcheck source=lib/pipelock.sh
. "${SCRIPT_DIR}/lib/pipelock.sh"
usage() { usage() {
printf 'usage: %s <command> [args...]\n' "$(basename "$0")" >&2 printf 'usage: %s <command> [args...]\n' "$(basename "$0")" >&2
@@ -400,12 +404,15 @@ cmd_start() {
ssh_validate_entries "${SSH_ENTRIES[@]}" ssh_validate_entries "${SSH_ENTRIES[@]}"
fi fi
# Stage env-file + args-file under a mktemp dir; clean up on exit. # Stage env-file + args-file + pipelock yaml under a mktemp dir;
# clean up on exit.
# Not declared local: needed by cleanup_stage after cmd_start returns (see MANIFEST_FILE note above). # Not declared local: needed by cleanup_stage after cmd_start returns (see MANIFEST_FILE note above).
STAGE_DIR="$(mktemp -d -t claude-bottle-stage.XXXXXX)" STAGE_DIR="$(mktemp -d -t claude-bottle-stage.XXXXXX)"
local ENV_FILE="${STAGE_DIR}/agent.env" local ENV_FILE="${STAGE_DIR}/agent.env"
local ARGS_FILE="${STAGE_DIR}/docker-args" local ARGS_FILE="${STAGE_DIR}/docker-args"
local PROMPT_FILE="${STAGE_DIR}/prompt.txt" local PROMPT_FILE="${STAGE_DIR}/prompt.txt"
local PIPELOCK_YAML_FILENAME="pipelock.yaml"
local PIPELOCK_YAML="${STAGE_DIR}/${PIPELOCK_YAML_FILENAME}"
: > "$ENV_FILE" : > "$ENV_FILE"
chmod 600 "$ENV_FILE" chmod 600 "$ENV_FILE"
: > "$ARGS_FILE" : > "$ARGS_FILE"
@@ -420,6 +427,15 @@ cmd_start() {
} }
trap cleanup_stage EXIT trap cleanup_stage EXIT
# Generate the pipelock YAML config from the bottle's egress.allowlist
# union'd with the baked-in defaults. The file is mode 600 inside the
# mktemp dir; cleanup_stage removes the whole dir on exit.
pipelock_write_yaml "$MANIFEST_FILE" "$BOTTLE_NAME" "$PIPELOCK_YAML"
# Resolved one-line summary for the preflight display.
local PIPELOCK_ALLOWLIST_SUMMARY
PIPELOCK_ALLOWLIST_SUMMARY="$(pipelock_allowlist_summary "$MANIFEST_FILE" "$BOTTLE_NAME")"
# Resolve env entries: prompts secrets (silent /dev/tty), copies # Resolve env entries: prompts secrets (silent /dev/tty), copies
# interpolated host vars into this process, writes literal pairs to # interpolated host vars into this process, writes literal pairs to
# ENV_FILE. # ENV_FILE.
@@ -470,6 +486,7 @@ cmd_start() {
else else
info " ssh hosts : (none)" info " ssh hosts : (none)"
fi fi
info " egress : ${PIPELOCK_ALLOWLIST_SUMMARY}"
else else
info "bottle : (none)" info "bottle : (none)"
fi fi
@@ -496,23 +513,82 @@ cmd_start() {
build_image_with_cwd "$DERIVED_IMAGE" "$IMAGE" "$USER_CWD" build_image_with_cwd "$DERIVED_IMAGE" "$IMAGE" "$USER_CWD"
fi fi
# Cleanup container on exit too. Compose with stage cleanup. # PRD 0001: per-agent egress topology. Create the two Docker
# networks the sidecar needs, then start the pipelock sidecar on
# them BEFORE the agent container, so the agent's HTTPS_PROXY target
# exists at the moment the agent boots.
#
# The agent container itself stays on INTERNAL_NETWORK only — only
# the sidecar straddles both. The egress network is the sidecar's
# path to the upstream internet (must be a user-defined bridge so
# Docker's embedded DNS resolves api.anthropic.com et al.; the
# legacy `bridge` network has no embedded DNS and is the wrong
# answer here — see lib/network.sh).
#
# Not declared local: needed by cleanup_all after cmd_start returns
# (same reason as MANIFEST_FILE / STAGE_DIR / CONTAINER above).
INTERNAL_NETWORK=""
EGRESS_NETWORK=""
PIPELOCK_CONTAINER=""
# Define cleanup_all and INSTALL THE TRAP before any of the docker
# resources below are created. Without this, a failure in
# network_create_egress or pipelock_start (e.g. the image can't be
# pulled) would leave behind orphan networks that the previous
# cleanup_stage trap had no way to remove. cleanup_all is a no-op
# for resources whose tracking variable is empty, and the helpers
# it calls (pipelock_stop, network_remove) are idempotent against
# missing resources, so installing the trap eagerly here is safe.
#
# Order matters at teardown: sidecar first, then networks — docker
# refuses to remove a network with attached containers.
cleanup_all() { cleanup_all() {
if container_exists "$CONTAINER"; then if [ -n "${CONTAINER:-}" ] && container_exists "$CONTAINER"; then
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi fi
if [ -n "${PIPELOCK_CONTAINER:-}" ]; then
pipelock_stop "$SLUG"
fi
if [ -n "${INTERNAL_NETWORK:-}" ]; then
network_remove "$INTERNAL_NETWORK"
fi
if [ -n "${EGRESS_NETWORK:-}" ]; then
network_remove "$EGRESS_NETWORK"
fi
cleanup_stage cleanup_stage
} }
# Replaces the cleanup_stage EXIT trap above; cleanup_all calls cleanup_stage internally. # Replaces the cleanup_stage EXIT trap above; cleanup_all calls cleanup_stage internally.
trap cleanup_all EXIT INT TERM trap cleanup_all EXIT INT TERM
INTERNAL_NETWORK="$(network_create_internal "$SLUG")"
EGRESS_NETWORK="$(network_create_egress "$SLUG")"
PIPELOCK_CONTAINER="$(pipelock_start "$SLUG" "$INTERNAL_NETWORK" "$EGRESS_NETWORK" "$STAGE_DIR" "$PIPELOCK_YAML_FILENAME")"
# Assemble docker run argv: # Assemble docker run argv:
# - --rm -d --name CONTAINER # - --rm -d --name CONTAINER
# - --network INTERNAL_NETWORK so the agent's only egress route is
# the pipelock sidecar (the network is created with --internal,
# so there's no default gateway).
# - --env-file ENV_FILE (only if it has any entries) # - --env-file ENV_FILE (only if it has any entries)
# - one `-e NAME` pair per line in ARGS_FILE (secret + interpolated) # - one `-e NAME` pair per line in ARGS_FILE (secret + interpolated)
# - HTTPS_PROXY / HTTP_PROXY pointing at the sidecar by service
# name on the internal network. Belt-and-suspenders alongside
# --internal: any code path that ignores the proxy env will hit
# the no-route-to-host wall instead of leaking; any code path
# that honors it goes through pipelock.
# - IMAGE # - IMAGE
# - sleep infinity (so we can `docker exec` an interactive session) # - sleep infinity (so we can `docker exec` an interactive session)
local DOCKER_ARGS=(--rm -d --name "$CONTAINER") local PIPELOCK_PROXY_URL
PIPELOCK_PROXY_URL="$(pipelock_proxy_url "$SLUG")"
local DOCKER_ARGS=(--rm -d --name "$CONTAINER" --network "$INTERNAL_NETWORK")
DOCKER_ARGS+=(-e "HTTPS_PROXY=${PIPELOCK_PROXY_URL}")
DOCKER_ARGS+=(-e "HTTP_PROXY=${PIPELOCK_PROXY_URL}")
# NO_PROXY: leave loopback off so the agent does not bypass pipelock
# for unexpected localhost services. The deployment-recipes guide
# warns specifically against widening NO_PROXY for sidecar-on-loopback,
# but our sidecar is on a separate network, so the safe minimum here
# is just localhost / 127.0.0.1, which is what most clients honor.
DOCKER_ARGS+=(-e "NO_PROXY=localhost,127.0.0.1")
if [ -s "$ENV_FILE" ]; then if [ -s "$ENV_FILE" ]; then
DOCKER_ARGS+=(--env-file "$ENV_FILE") DOCKER_ARGS+=(--env-file "$ENV_FILE")
fi fi
@@ -574,7 +650,9 @@ cmd_start() {
# Set up SSH keys and config. # Set up SSH keys and config.
if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then
ssh_setup "$CONTAINER" "$STAGE_DIR" "${SSH_ENTRIES[@]}" local PIPELOCK_PROXY_HOST_PORT
PIPELOCK_PROXY_HOST_PORT="$(pipelock_proxy_host_port "$SLUG")"
ssh_setup "$CONTAINER" "$STAGE_DIR" "$PIPELOCK_PROXY_HOST_PORT" "${SSH_ENTRIES[@]}"
fi fi
# When --cwd is on, ship the host repo's .git directory in via `docker cp` # When --cwd is on, ship the host repo's .git directory in via `docker cp`
@@ -0,0 +1,222 @@
# PRD 0001: Per-agent egress proxy via pipelock
- **Status:** Draft
- **Author:** didericis
- **Created:** 2026-05-08
## Summary
Run pipelock as a sidecar container on each claude-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.sh 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
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.sh` 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 files under `lib/`:
- **`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-<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
`lib/docker.sh` 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`).
### 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.
- **`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).
`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.
### 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 `lib/pipelock.sh` (or a sibling `.env`-shaped constants
file) 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 `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.
- **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>
+31 -4
View File
@@ -8,7 +8,8 @@
# "bottles": { # "bottles": {
# "<bottle-name>": { # "<bottle-name>": {
# "env": { "<NAME>": <env-entry>, ... }, # "env": { "<NAME>": <env-entry>, ... },
# "ssh": [ <ssh-entry>, ... ] # "ssh": [ <ssh-entry>, ... ],
# "egress": { "allowlist": [ "<hostname>", ... ] }
# }, # },
# ... # ...
# }, # },
@@ -22,9 +23,17 @@
# } # }
# } # }
# #
# A bottle groups shared infrastructure (SSH keys, known hosts) that multiple # A bottle groups shared infrastructure (SSH keys, known hosts, egress
# agents can reference by name. The "bottle" field is required on every agent; # allowlist) that multiple agents can reference by name. The "bottle" field
# cli.sh start rejects agents that omit it. # is required on every agent; cli.sh start rejects agents that omit it.
#
# The "egress" object is added in PRD 0001. Today it carries one key:
# - allowlist: array of hostnames the agent is allowed to reach. The
# effective allowlist at launch is this list UNIONED with the
# baked-in defaults for Claude Code's required hosts (see
# lib/pipelock.sh). Bottles with no "egress" block use defaults
# only. Future keys (mode, dlp, data_budget, ...) are reserved
# under the same object; v1 ignores anything we don't recognize.
# #
# An <env-entry> is a JSON string. Mode is selected by sentinel prefix: # An <env-entry> is a JSON string. Mode is selected by sentinel prefix:
# "?<message>" → prompt for the value at runtime, displaying <message> # "?<message>" → prompt for the value at runtime, displaying <message>
@@ -225,6 +234,24 @@ manifest_bottle_ssh() {
jq -c --arg b "$bottle_name" '.bottles[$b].ssh // [] | .[]' "$manifest_file" jq -c --arg b "$bottle_name" '.bottles[$b].ssh // [] | .[]' "$manifest_file"
} }
# manifest_bottle_egress_allowlist <manifest_file> <bottle_name> — prints one
# hostname per line on stdout for the entries in
# bottles[bottle_name].egress.allowlist. Prints nothing if the field is missing
# or the array is empty. Validates only that the field, when present, is an
# array; per-element string typing is checked at use-time in lib/pipelock.sh
# so the validation lives next to the YAML generator that consumes it.
manifest_bottle_egress_allowlist() {
local manifest_file="${1:?manifest_bottle_egress_allowlist: missing manifest file}"
local bottle_name="${2:?manifest_bottle_egress_allowlist: missing bottle name}"
local field_type
field_type="$(jq -r --arg b "$bottle_name" '.bottles[$b].egress.allowlist | type' "$manifest_file" 2>/dev/null || echo "null")"
case "$field_type" in
array|null) : ;;
*) die "bottle '${bottle_name}' egress.allowlist must be an array (was ${field_type})." ;;
esac
jq -r --arg b "$bottle_name" '.bottles[$b].egress.allowlist // [] | .[]' "$manifest_file"
}
# manifest_ssh <manifest_file> <name> — prints one compact JSON object per line # manifest_ssh <manifest_file> <name> — prints one compact JSON object per line
# for each ssh entry associated with the agent. SSH entries are resolved via # for each ssh entry associated with the agent. SSH entries are resolved via
# the agent's "bottle" field: if set, entries come from bottles[bottle].ssh; if the # the agent's "bottle" field: if set, entries come from bottles[bottle].ssh; if the
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Docker network plumbing for the per-agent egress-proxy topology
# (PRD 0001).
#
# The egress design (see docs/research/pipelock-assessment.md
# §"Deployment topology") puts the agent container on a Docker
# `--internal` network — Docker omits the default gateway from
# `internal: true` networks at the iptables level inside the engine /
# LinuxKit VM, so the only address the agent can reach is the pipelock
# sidecar attached to the same network. The pipelock sidecar itself
# also needs egress to the upstream internet, so it is placed on a
# second (user-defined bridge) network as well. We deliberately do
# NOT use Docker's legacy `bridge` network for this: the legacy bridge
# has no embedded DNS resolver, so pipelock would be unable to resolve
# `api.anthropic.com` and Claude Code traffic would dead-end. Only
# user-defined bridges run Docker's built-in DNS, so we create one
# per agent.
#
# This module is the network-only half of that split: create / attach
# / teardown of both the per-agent internal network and the per-agent
# user-defined egress bridge, with no pipelock specifics. Keeping
# pipelock-agnostic helpers here means a future PRD can reuse them
# for a different sidecar (e.g. an iptables-only layer) without
# entangling the two concerns.
#
# Naming: claude-bottle-net-<slug> (internal),
# claude-bottle-egress-<slug> (egress). On conflict we append a
# numeric suffix (-2, -3, ...) to mirror the container-naming scheme
# in cli.sh, so two parallel starts of the same agent get distinct
# networks.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_NETWORK_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_NETWORK_SOURCED=1
_iso_lib_network_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_network_dir}/log.sh"
# network_name_for_slug <slug> — prints the canonical internal-network
# name for a given agent slug. No conflict resolution; that lives in
# network_create_internal.
network_name_for_slug() {
local slug="${1:?network_name_for_slug: missing slug}"
printf 'claude-bottle-net-%s' "$slug"
}
# network_egress_name_for_slug <slug> — prints the canonical egress-network
# name for a given agent slug. No conflict resolution; that lives in
# network_create_egress.
network_egress_name_for_slug() {
local slug="${1:?network_egress_name_for_slug: missing slug}"
printf 'claude-bottle-egress-%s' "$slug"
}
# network_exists <name> — returns 0 if the named docker network exists,
# else 1. Uses `docker network inspect` (not `docker network ls -f name=...`)
# because the latter does substring matching, which would falsely report
# claude-bottle-net-foo as existing when only claude-bottle-net-foo-2 was
# present.
network_exists() {
local name="${1:?network_exists: missing network name}"
docker network inspect "$name" >/dev/null 2>&1
}
# _network_create_with_prefix <prefix> <internal: 0|1>
#
# Internal helper. Creates a per-agent Docker network whose name is
# <prefix> (with -2, -3, ... appended on conflict, capped at 100).
# When <internal> is 1, the network is created with `--internal` (no
# default gateway). When 0, it's a plain user-defined bridge with
# upstream connectivity. Echoes the resolved name on stdout.
_network_create_with_prefix() {
local base="${1:?_network_create_with_prefix: missing prefix}"
local internal_flag="${2:?_network_create_with_prefix: missing internal flag}"
local name="$base"
local _suffix=2
while network_exists "$name"; do
name="${base}-${_suffix}"
_suffix=$((_suffix + 1))
if [ "$_suffix" -gt 100 ]; then
die "could not find a free network name after ${base}-99; clean up old networks with 'docker network rm <name>'"
fi
done
local kind="bridge (egress)"
local args=()
if [ "$internal_flag" = "1" ]; then
kind="internal"
args+=(--internal)
fi
info "creating ${kind} network ${name}"
# Defaults give us a bridge driver with Docker-managed addressing,
# which is what we want for both internal and egress networks.
if ! docker network create "${args[@]}" "$name" >/dev/null; then
die "docker network create ${args[*]} ${name} failed"
fi
printf '%s' "$name"
}
# network_create_internal <slug>
#
# Creates a Docker `--internal` network for the agent and prints the
# resolved network name on stdout. If the canonical name is already
# taken, appends -2, -3, ... (capped at 100, matching the
# container-name retry loop in cli.sh) until a free name is found.
#
# `--internal` is the load-bearing flag: Docker creates the bridge
# without a default route, so the agent container attached here cannot
# reach the public internet directly. The pipelock sidecar (attached
# to both this network and a per-agent egress network) is the only
# egress route.
#
# Side effect: emits one info line naming the network actually created.
network_create_internal() {
local slug="${1:?network_create_internal: missing slug}"
local base
base="$(network_name_for_slug "$slug")"
_network_create_with_prefix "$base" 1
}
# network_create_egress <slug>
#
# Creates a per-agent user-defined bridge network used by the pipelock
# sidecar for upstream egress, and prints the resolved network name on
# stdout. Conflict resolution mirrors network_create_internal.
#
# We use a user-defined bridge (NOT the legacy `bridge` network)
# because only user-defined bridges run Docker's embedded DNS resolver
# — pipelock needs DNS to resolve `api.anthropic.com` and similar
# upstream hostnames. The legacy `bridge` network would force pipelock
# onto the host's resolv.conf and fail in environments where Docker
# Desktop's NAT path is the only working DNS route.
#
# Side effect: emits one info line naming the network actually created.
network_create_egress() {
local slug="${1:?network_create_egress: missing slug}"
local base
base="$(network_egress_name_for_slug "$slug")"
_network_create_with_prefix "$base" 0
}
# network_attach <network> <container>
#
# Attaches an already-running container to the named network. Used to
# add the pipelock sidecar to a second (default-bridge) network so it
# has upstream egress, while staying reachable from the agent on the
# internal network.
#
# Note: for the agent container itself we pass `--network <name>` to
# `docker run` directly in cli.sh rather than using this function. The
# agent never touches anything except the internal network.
network_attach() {
local network="${1:?network_attach: missing network name}"
local container="${2:?network_attach: missing container name}"
if ! docker network connect "$network" "$container" >/dev/null 2>&1; then
die "docker network connect ${network} ${container} failed"
fi
}
# network_remove <name>
#
# Removes the named network. Idempotent: a missing network is treated
# as success so this can be called unconditionally from a teardown
# trap. A network that still has containers attached will fail to
# remove; the caller is expected to tear those containers down first.
network_remove() {
local name="${1:?network_remove: missing network name}"
if ! network_exists "$name"; then
return 0
fi
if ! docker network rm "$name" >/dev/null 2>&1; then
# Don't `die` here: this runs in cleanup paths where we'd rather
# warn and continue than abort and leave more orphans behind.
warn "failed to remove network ${name}; clean up with 'docker network rm ${name}'"
return 1
fi
}
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env bash
# Pipelock sidecar lifecycle for the per-agent egress topology
# (PRD 0001).
#
# Pipelock (https://github.com/luckyPipewrench/pipelock) is an HTTP
# forward proxy with hostname allowlisting + DLP scanning + URL-entropy
# checks. We run one sidecar container per agent, attached to the
# agent's --internal network (created by lib/network.sh) and to a
# per-agent user-defined bridge network for upstream egress (also
# created by lib/network.sh — see the comment in network_create_egress
# for why we don't use Docker's legacy `bridge` network). The agent's
# HTTPS_PROXY / HTTP_PROXY env vars point at the sidecar's service
# name on the internal network; combined with --internal (which omits
# the default gateway), pipelock is the only egress route the agent
# has.
#
# Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest>. The
# digest is resolved by hand against ghcr.io for tag 2.3.0 (the
# `v2.3.0` GitHub release maps to the unprefixed `2.3.0` Docker tag —
# see pipelock-assessment.md and the resolution log in PRD 0001's
# implementation thread). Bump deliberately when upgrading.
#
# YAML config we generate: minimum-viable settings to satisfy the PRD's
# observable success criteria.
# - mode: strict — only api_allowlist domains are reachable
# (per docs/configuration.md §Modes)
# - enforce: true — blocks rather than warn-only
# - api_allowlist: [...] — defaults bottle.egress.allowlist
# - forward_proxy.enabled: true — turns on the CONNECT-tunnel proxy
# the agent's HTTPS_PROXY actually uses
# (docs §Forward Proxy: this is off by
# default, restart-required to flip)
# - dlp.include_defaults: true — load all 48 built-in patterns
# (docs §DLP §Pattern Merging)
# - dlp.scan_env: true — flags URLs containing high-entropy env
# values (≥16 chars, Shannon entropy >3.0,
# checked in raw/base64/hex/base32). This
# is the documented home for pipelock's
# "subdomain entropy detection" surface
# (docs §Environment Variable Leak
# Detection); the URL-path-entropy knob
# under fetch_proxy.monitoring is for the
# /fetch?url=... helper, not the forward
# proxy we use.
# We deliberately do NOT set tls_interception (out of PRD scope), and
# do NOT carry any env-var values into the YAML — only hostnames.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_PIPELOCK_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_PIPELOCK_SOURCED=1
_iso_lib_pipelock_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_pipelock_dir}/log.sh"
# shellcheck source=./manifest.sh
. "${_iso_lib_pipelock_dir}/manifest.sh"
# shellcheck source=./network.sh
. "${_iso_lib_pipelock_dir}/network.sh"
# --- Constants -------------------------------------------------------------
# Pipelock image, pinned by digest. The digest is the multi-arch image
# index for ghcr.io/luckypipewrench/pipelock:2.3.0 (resolved 2026-05-08
# from the ghcr.io v2 manifests endpoint). Ties match the v2.3.0 GitHub
# release; the registry uses unprefixed tags so v2.3.0→2.3.0.
CLAUDE_BOTTLE_PIPELOCK_IMAGE="${CLAUDE_BOTTLE_PIPELOCK_IMAGE:-ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9}"
# Listening port for pipelock's forward proxy. Default per
# docs/configuration.md §Forward Proxy / §Fetch Proxy and the
# deployment-recipes generator. Override via env if a future image
# changes it.
CLAUDE_BOTTLE_PIPELOCK_PORT="${CLAUDE_BOTTLE_PIPELOCK_PORT:-8888}"
# Baked-in default allowlist for hosts Claude Code itself needs.
# Source: pipelock-assessment.md and the Claude Code network-config
# docs (https://code.claude.com/docs/en/network-config). The effective
# allowlist used at launch is this set unioned with whatever the
# bottle's egress.allowlist names. Kept as a newline-separated string
# because bash arrays don't survive sourcing into a function-only
# context cleanly; callers split on newlines.
CLAUDE_BOTTLE_PIPELOCK_DEFAULT_ALLOWLIST="api.anthropic.com
statsig.anthropic.com
sentry.io
claude.ai
platform.claude.com
downloads.claude.ai
raw.githubusercontent.com"
# --- Naming ----------------------------------------------------------------
# pipelock_container_name <slug> — prints the canonical sidecar
# container name for a given agent slug. The agent reaches the sidecar
# at this name as a hostname on the internal network.
pipelock_container_name() {
local slug="${1:?pipelock_container_name: missing slug}"
printf 'claude-bottle-pipelock-%s' "$slug"
}
# pipelock_proxy_url <slug> — prints http://<sidecar>:<port>, suitable
# for HTTPS_PROXY / HTTP_PROXY in the agent container.
pipelock_proxy_url() {
local slug="${1:?pipelock_proxy_url: missing slug}"
local name
name="$(pipelock_container_name "$slug")"
printf 'http://%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT"
}
# pipelock_proxy_host_port <slug> — prints <sidecar>:<port> (no scheme),
# suitable for socat's PROXY: directive in an SSH ProxyCommand. The
# agent's --internal network has no default route, so SSH (and any other
# raw TCP) must tunnel via pipelock's HTTP CONNECT.
pipelock_proxy_host_port() {
local slug="${1:?pipelock_proxy_host_port: missing slug}"
local name
name="$(pipelock_container_name "$slug")"
printf '%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT"
}
# --- Allowlist resolution --------------------------------------------------
# pipelock_bottle_allowlist <manifest_file> <bottle_name>
#
# Prints one hostname per line on stdout for the allowlist declared at
# bottles[<bottle_name>].egress.allowlist. Empty (no output) if the
# field is missing or the array is empty. Validates that each entry is
# a JSON string; dies with a clear message if any element is not.
pipelock_bottle_allowlist() {
local manifest_file="${1:?pipelock_bottle_allowlist: missing manifest file}"
local bottle_name="${2:?pipelock_bottle_allowlist: missing bottle name}"
# Validate shape first: if egress.allowlist exists, every element
# must be a string. We do this in one jq pass.
local types
types="$(jq -r --arg b "$bottle_name" '
.bottles[$b].egress.allowlist // [] | map(type) | unique[]
' "$manifest_file")"
local t
while IFS= read -r t; do
[ -z "$t" ] && continue
if [ "$t" != "string" ]; then
die "bottle '${bottle_name}' egress.allowlist must contain only strings; found a '${t}' entry."
fi
done <<< "$types"
jq -r --arg b "$bottle_name" '
.bottles[$b].egress.allowlist // [] | .[]
' "$manifest_file"
}
# pipelock_bottle_ssh_hostnames <manifest_file> <bottle_name>
#
# Prints one hostname per line for each entry in bottles[<name>].ssh[].Hostname.
# These need to reach pipelock's allowlist so the agent can tunnel SSH
# through pipelock via HTTP CONNECT (see ssh_setup's ProxyCommand
# wiring). Empty output if the bottle has no ssh entries.
pipelock_bottle_ssh_hostnames() {
local manifest_file="${1:?pipelock_bottle_ssh_hostnames: missing manifest file}"
local bottle_name="${2:?pipelock_bottle_ssh_hostnames: missing bottle name}"
jq -r --arg b "$bottle_name" '
.bottles[$b].ssh // [] | .[] | .Hostname // empty
' "$manifest_file"
}
# _pipelock_is_ipv4_literal <s> — exit 0 if <s> looks like an IPv4
# literal (four dot-separated octets). Pipelock's SSRF check fires on
# the resolved IP, so a Hostname that's already an IP literal needs
# `ssrf.ip_allowlist`, while a hostname needs `trusted_domains`.
_pipelock_is_ipv4_literal() {
local s="${1:?}"
[[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# pipelock_bottle_ssh_trusted_domains <manifest> <bottle>
#
# Hostname-shaped ssh[].Hostname entries that should bypass pipelock's
# SSRF check (so a name resolving to a private IP — e.g. internal API
# behind a VPN — is reachable). IP-literal entries are excluded;
# trusted_domains is hostname-based per pipelock's docs.
pipelock_bottle_ssh_trusted_domains() {
local manifest_file="${1:?}"
local bottle_name="${2:?}"
local h
while IFS= read -r h; do
[ -z "$h" ] && continue
_pipelock_is_ipv4_literal "$h" && continue
printf '%s\n' "$h"
done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name")
}
# pipelock_bottle_ssh_ip_cidrs <manifest> <bottle>
#
# Emits one canonical /32 CIDR per IPv4-literal ssh[].Hostname so they
# pass pipelock's SSRF IP-range check (which blocks RFC 1918, RFC 6598
# CGNAT, link-local, loopback, etc. by default). Hostnames are skipped
# — they go through trusted_domains instead.
pipelock_bottle_ssh_ip_cidrs() {
local manifest_file="${1:?}"
local bottle_name="${2:?}"
local h
while IFS= read -r h; do
[ -z "$h" ] && continue
if _pipelock_is_ipv4_literal "$h"; then
printf '%s/32\n' "$h"
fi
done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name")
}
# pipelock_effective_allowlist <manifest_file> <bottle_name>
#
# Prints the deduplicated union of: the baked-in default allowlist, the
# bottle's declared egress.allowlist, and any bottle.ssh[].Hostname
# entries (so SSH tunneling through pipelock is permitted by the same
# allowlist check that gates HTTP CONNECT). One hostname per line,
# sorted for stability. This is the single source of truth callers
# should use for both YAML generation and the preflight summary.
pipelock_effective_allowlist() {
local manifest_file="${1:?pipelock_effective_allowlist: missing manifest file}"
local bottle_name="${2:?pipelock_effective_allowlist: missing bottle name}"
{
printf '%s\n' "$CLAUDE_BOTTLE_PIPELOCK_DEFAULT_ALLOWLIST"
pipelock_bottle_allowlist "$manifest_file" "$bottle_name"
pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name"
} | awk 'NF && !seen[$0]++' | LC_ALL=C sort
}
# pipelock_allowlist_summary <manifest_file> <bottle_name>
#
# One-line summary of the effective allowlist for the y/N preflight
# display. Format:
# "<N> hosts allowed (host1, host2, host3 +M more)"
# When the allowlist has 5 or fewer entries, all are listed and the
# "+M more" suffix is omitted.
pipelock_allowlist_summary() {
local manifest_file="${1:?pipelock_allowlist_summary: missing manifest file}"
local bottle_name="${2:?pipelock_allowlist_summary: missing bottle name}"
local hosts=()
local h
while IFS= read -r h; do
[ -z "$h" ] && continue
hosts+=("$h")
done < <(pipelock_effective_allowlist "$manifest_file" "$bottle_name")
local count="${#hosts[@]}"
if [ "$count" -eq 0 ]; then
printf '0 hosts allowed (none)'
return 0
fi
local show=$count
local more=0
if [ "$count" -gt 5 ]; then
show=3
more=$((count - show))
fi
local first_n=()
local i=0
while [ "$i" -lt "$show" ]; do
first_n+=("${hosts[$i]}")
i=$((i + 1))
done
local joined=""
local h2
for h2 in "${first_n[@]}"; do
if [ -z "$joined" ]; then
joined="$h2"
else
joined="${joined}, ${h2}"
fi
done
if [ "$more" -gt 0 ]; then
printf '%s hosts allowed (%s, +%s more)' "$count" "$joined" "$more"
else
printf '%s hosts allowed (%s)' "$count" "$joined"
fi
}
# --- YAML generation -------------------------------------------------------
# pipelock_write_yaml <manifest_file> <bottle_name> <out_path>
#
# Writes a pipelock YAML config file to <out_path> (mode 600). The
# config carries only:
# - the effective allowlist (hostnames),
# - a fixed listen port (CLAUDE_BOTTLE_PIPELOCK_PORT),
# - the minimum knobs needed to satisfy PRD 0001 success criteria
# (strict mode, forward_proxy on, DLP defaults + env scanning).
#
# It deliberately contains no env values, no secrets, and no per-agent
# customization beyond the hostname list.
#
# YAML keys + defaults sourced from
# https://github.com/luckyPipewrench/pipelock/blob/main/docs/configuration.md
# (top-level fields, api_allowlist, forward_proxy, dlp).
pipelock_write_yaml() {
local manifest_file="${1:?pipelock_write_yaml: missing manifest file}"
local bottle_name="${2:?pipelock_write_yaml: missing bottle name}"
local out_path="${3:?pipelock_write_yaml: missing out_path}"
: > "$out_path"
chmod 600 "$out_path"
{
printf 'version: 1\n'
printf 'mode: strict\n'
printf 'enforce: true\n'
printf '\n'
printf '# Hostnames the agent is allowed to reach. Effective list is\n'
printf '# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).\n'
printf 'api_allowlist:\n'
local h
while IFS= read -r h; do
[ -z "$h" ] && continue
# Validate: pipelock allows hostnames + wildcards. We accept
# anything that does not contain whitespace or the YAML special
# chars that would break unquoted strings; quote on output to be
# safe.
printf ' - "%s"\n' "$h"
done < <(pipelock_effective_allowlist "$manifest_file" "$bottle_name")
printf '\n'
printf 'forward_proxy:\n'
printf ' enabled: true\n'
printf '\n'
# SSRF exemptions for declared SSH hosts. Pipelock blocks the CGNAT
# range (100.64.0.0/10, where Tailscale IPs live) and the rest of
# RFC 1918 / link-local by default. Hostname entries go to
# trusted_domains; IP-literal entries to ssrf.ip_allowlist as /32.
local trusted_count=0 ssrf_count=0
local td
while IFS= read -r td; do
[ -z "$td" ] && continue
if [ "$trusted_count" -eq 0 ]; then
printf 'trusted_domains:\n'
fi
printf ' - "%s"\n' "$td"
trusted_count=$((trusted_count + 1))
done < <(pipelock_bottle_ssh_trusted_domains "$manifest_file" "$bottle_name")
[ "$trusted_count" -gt 0 ] && printf '\n'
local cidr
while IFS= read -r cidr; do
[ -z "$cidr" ] && continue
if [ "$ssrf_count" -eq 0 ]; then
printf 'ssrf:\n'
printf ' ip_allowlist:\n'
fi
printf ' - "%s"\n' "$cidr"
ssrf_count=$((ssrf_count + 1))
done < <(pipelock_bottle_ssh_ip_cidrs "$manifest_file" "$bottle_name")
[ "$ssrf_count" -gt 0 ] && printf '\n'
printf 'dlp:\n'
printf ' include_defaults: true\n'
printf ' scan_env: true\n'
} > "$out_path"
}
# --- Sidecar lifecycle -----------------------------------------------------
# pipelock_start <slug> <internal_network> <egress_network> <yaml_dir> <yaml_filename>
#
# Boots the pipelock sidecar:
# 1. `docker run -d` on the internal network with the canonical
# service name. The image runs `pipelock` as its CMD; we override
# with `run --config <path>` and the listen address.
# 2. `docker cp` the YAML config from the host mktemp dir into the
# container at /etc/pipelock.yaml.
#
# We use docker cp rather than `-v <host>:<container>` because Docker
# Desktop bind mounts have ownership / case-sensitivity quirks on
# macOS; copying the file in sidesteps both. The host-side mktemp dir
# is the caller's responsibility to clean up.
#
# After the cp the container is restarted so pipelock picks up the
# config it boots from. Pipelock's hot-reload feature would let us
# avoid the restart, but `forward_proxy.enabled` is one of the few
# restart-required keys (per docs/configuration.md), so a restart is
# the simplest correct path on first boot.
#
# Args:
# <slug> — agent slug; sidecar name will be claude-bottle-pipelock-<slug>
# <internal_network> — name of the agent's internal docker network
# <egress_network> — name of the agent's user-defined egress
# network; the sidecar joins this so it can
# reach upstream hostnames with working DNS
# <yaml_dir> — host directory containing the YAML
# <yaml_filename> — filename within yaml_dir
#
# Echoes the container name on stdout on success.
pipelock_start() {
local slug="${1:?pipelock_start: missing slug}"
local internal_network="${2:?pipelock_start: missing internal network}"
local egress_network="${3:?pipelock_start: missing egress network}"
local yaml_dir="${4:?pipelock_start: missing yaml dir}"
local yaml_filename="${5:?pipelock_start: missing yaml filename}"
local name
name="$(pipelock_container_name "$slug")"
local host_yaml="${yaml_dir}/${yaml_filename}"
if [ ! -f "$host_yaml" ]; then
die "pipelock yaml not found at ${host_yaml}; pipelock_write_yaml must run first"
fi
# Container layout: pipelock reads its config from /etc/pipelock.yaml.
# We `docker create` the sidecar, `docker cp` the YAML into the
# writable layer, then `docker start` it — no bind mount, no shell
# shim. The image is distroless (no `sh`), and `docker cp` to a
# stopped container does NOT create intermediate parent directories,
# so the YAML lives directly under /etc rather than in a /etc/pipelock
# subdirectory.
info "starting pipelock sidecar ${name} on network ${internal_network}"
# Sidecar argv verification (PR #1 review). The pinned digest
# (CLAUDE_BOTTLE_PIPELOCK_IMAGE above) has:
# ENTRYPOINT ["/pipelock"]
# CMD ["run", "--listen", "0.0.0.0:8888"]
# `pipelock run --help` documents `-l, --listen` (default
# 127.0.0.1:8888) as the forward-proxy listen address — the
# `--mcp-listen` flag is for the separate MCP HTTP listener and is
# not what we want here. `--config` reads the YAML and hot-reloads
# on file change; values in YAML can also drive the listen address
# via `fetch_proxy.listen`, but the CLI flag takes precedence and
# is the simpler contract for our launcher. Smoke-tested 2026-05-08
# by running this exact argv against the digest and confirming the
# /health endpoint responded on :8888.
if ! docker create \
--name "$name" \
--network "$internal_network" \
"$CLAUDE_BOTTLE_PIPELOCK_IMAGE" \
run --config /etc/pipelock.yaml --listen "0.0.0.0:${CLAUDE_BOTTLE_PIPELOCK_PORT}" \
>/dev/null 2>&1; then
die "failed to create pipelock sidecar ${name}"
fi
# `docker cp` to a created-but-not-started container writes into the
# writable layer directly. The parent directory must already exist in
# the image — docker cp does NOT create missing intermediate dirs to
# a stopped container, contrary to a common assumption. The pipelock
# image is distroless (no `sh`), so we cannot prepopulate dirs with a
# shell shim either. We therefore put the config in /etc/pipelock.yaml
# (file directly under /etc) rather than /etc/pipelock/pipelock.yaml.
local cp_err
cp_err="$(docker cp "$host_yaml" "${name}:/etc/pipelock.yaml" 2>&1)" || {
docker rm -f "$name" >/dev/null 2>&1 || true
die "failed to copy pipelock yaml into ${name}: ${cp_err}"
}
# Attach to a per-agent user-defined bridge network for upstream
# egress. The internal network has no gateway by definition, so
# without a second network the sidecar can't reach the public
# internet at all. We deliberately do NOT use Docker's legacy
# `bridge` network: only user-defined bridges run Docker's embedded
# DNS resolver, which pipelock needs to resolve `api.anthropic.com`
# and similar upstream hostnames. The egress network is created by
# network_create_egress in lib/network.sh.
if ! docker network connect "$egress_network" "$name" >/dev/null 2>&1; then
docker rm -f "$name" >/dev/null 2>&1 || true
die "failed to attach pipelock sidecar ${name} to egress network ${egress_network}"
fi
if ! docker start "$name" >/dev/null 2>&1; then
docker rm -f "$name" >/dev/null 2>&1 || true
die "failed to start pipelock sidecar ${name}"
fi
printf '%s' "$name"
}
# pipelock_stop <slug>
#
# Stops and removes the sidecar by canonical name. Idempotent: a
# missing container is treated as success so this can be wired into
# cli.sh's exit trap unconditionally. Used as the first step of
# teardown — must run BEFORE the network is torn down, because docker
# refuses to remove a network that still has containers attached.
pipelock_stop() {
local slug="${1:?pipelock_stop: missing slug}"
local name
name="$(pipelock_container_name "$slug")"
if docker inspect "$name" >/dev/null 2>&1; then
docker rm -f "$name" >/dev/null 2>&1 || warn "failed to remove pipelock sidecar ${name}; clean up with 'docker rm -f ${name}'"
fi
}
+18 -3
View File
@@ -89,7 +89,12 @@ ssh_validate_entries() {
ssh_setup() { ssh_setup() {
local container="${1:?ssh_setup: missing container}" local container="${1:?ssh_setup: missing container}"
local stage_dir="${2:?ssh_setup: missing stage dir}" local stage_dir="${2:?ssh_setup: missing stage dir}"
shift 2 # proxy_host_port is the pipelock sidecar as <host>:<port> (no scheme).
# Used as socat's PROXY: argument so the agent can reach SSH hosts
# over the agent's --internal network — the only egress route is the
# pipelock CONNECT proxy. Required.
local proxy_host_port="${3:?ssh_setup: missing proxy_host_port}"
shift 3
local container_home="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}" local container_home="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}"
local container_ssh="${container_home}/.ssh" local container_ssh="${container_home}/.ssh"
@@ -140,8 +145,18 @@ ssh_setup() {
# No IdentityFile — IdentityAgent points SSH at the public (forwarded) # No IdentityFile — IdentityAgent points SSH at the public (forwarded)
# socket. Pointing at the real agent socket directly would be rejected # socket. Pointing at the real agent socket directly would be rejected
# by ssh-agent's UID-match check (see file header). # by ssh-agent's UID-match check (see file header).
printf 'Host %s\n HostName %s\n User %s\n Port %s\n IdentityAgent %s\n\n' \ #
"$name" "$hostname" "$user" "$port" "$public_socket" >> "$config_file" # ProxyCommand tunnels the SSH connection through pipelock via HTTP
# CONNECT. The agent container has no default route (--internal
# network); pipelock is the only path to anywhere. socat's PROXY:
# mode does CONNECT host:port to the proxy. %h / %p expand to this
# block's HostName / Port. The SSH host must also appear in
# pipelock's allowlist — pipelock_effective_allowlist auto-includes
# bottle.ssh[].Hostname entries so this just works for declared
# hosts.
printf 'Host %s\n HostName %s\n User %s\n Port %s\n IdentityAgent %s\n ProxyCommand socat - PROXY:%s:%%h:%%p,proxyport=%s\n\n' \
"$name" "$hostname" "$user" "$port" "$public_socket" \
"${proxy_host_port%:*}" "${proxy_host_port##*:}" >> "$config_file"
if [ -n "$known_host_key" ]; then if [ -n "$known_host_key" ]; then
# Write under both the Host alias and the Hostname so SSH finds the key # Write under both the Host alias and the Hostname so SSH finds the key
+83
View File
@@ -0,0 +1,83 @@
# Tests
Plain-bash test suite. No framework dependency — assertions are tiny
helpers in `tests/lib/assert.sh` and the runner is a shell script.
The unit tests run anywhere bash + jq are present; the integration
tests need Docker and skip cleanly otherwise.
## Layout
```
tests/
run_tests.sh # entry point
lib/
assert.sh # assert_eq, assert_contains, assert_match, ...
common.sh # sources assert + fixtures, sets REPO_ROOT
fixtures.sh # JSON manifest builders
unit/ # no docker; fast
test_pipelock_naming.sh
test_pipelock_classify.sh
test_pipelock_allowlist.sh
test_pipelock_yaml.sh
integration/ # require docker
test_pipelock_image.sh
test_pipelock_sidecar_smoke.sh
test_dry_run_plan.sh
test_orphan_cleanup.sh
```
## Running
```bash
tests/run_tests.sh # everything
tests/run_tests.sh unit # unit only
tests/run_tests.sh integration # integration only
tests/run_tests.sh tests/unit/test_pipelock_yaml.sh # one file
```
Each test file exits 0 on pass, 1 on fail. The runner aggregates and
prints a one-line summary.
## What the integration tests cover
These are versions of the smoke tests run during PR #1:
- `test_pipelock_image.sh` — the pinned digest is reachable, ENTRYPOINT
is `/pipelock`, and `CMD` includes `run`. Catches a pipelock release
that bumps the argv shape.
- `test_pipelock_sidecar_smoke.sh``docker create` + `docker cp` the
generated YAML to `/etc/pipelock.yaml` + `docker start`, then probe
`/health`. Catches the YAML-path bug we hit (the image is distroless,
so `/etc/pipelock/` does not exist) and YAML structural breakage.
- `test_dry_run_plan.sh``cli.sh start --dry-run` shows the resolved
egress allowlist and creates zero docker resources.
- `test_orphan_cleanup.sh` — when the sidecar fails to start (bogus
image digest), the EXIT trap removes both the internal and egress
networks. Catches regressions in trap-installation ordering.
## What's NOT covered
- `lib/ssh.sh` end-to-end (would need a fake SSH host inside the
container; high effort for v1).
- A live SSH-through-pipelock tunnel against a real Tailscale-style
internal IP.
- DLP false-positive measurements.
- TLS handling / cert pinning behavior.
## Adding a test
1. Pick `unit/` (no docker) or `integration/` (docker required).
2. Name it `test_<topic>.sh`. Make it executable: `chmod +x`.
3. Start with the boilerplate the existing files use:
```bash
#!/usr/bin/env bash
TEST_NAME="<topic>"
. "$(dirname "$0")/../lib/common.sh"
. "${REPO_ROOT}/lib/log.sh"
. "${REPO_ROOT}/lib/<file-under-test>.sh"
# ...assert_eq / assert_contains / ...
test_summary
```
4. For integration tests: call `skip_test_if_no_docker` after the
boilerplate and ensure your trap cleans up any docker resources you
create.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Integration: cli.sh start --dry-run renders the planned shape and
# does not create any docker resources. Confirms the preflight contract
# from PRD 0001 (allowlist line in the plan, no docker side effects).
TEST_NAME="dry_run_plan"
. "$(dirname "$0")/../lib/common.sh"
skip_test_if_no_docker
work_dir="$(mktemp -d)"
manifest="${work_dir}/claude-bottle.json"
cleanup() {
rm -rf "$work_dir"
}
trap cleanup EXIT
# Manifest with an egress.allowlist so we can grep for a known host.
cat > "$manifest" <<'JSON'
{
"bottles": {
"dev": {
"egress": { "allowlist": ["example.org"] }
}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
# Snapshot docker state before we run.
nets_before="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
ctrs_before="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
# Override HOME so the user's ~/claude-bottle.json doesn't leak in via
# manifest_resolve's home+cwd merge.
out="$(cd "$work_dir" \
&& HOME="$work_dir" CLAUDE_BOTTLE_DRY_RUN=1 \
"${REPO_ROOT}/cli.sh" start demo 2>&1 || true)"
assert_contains "$out" "egress" "preflight: egress line present"
# 7 baked defaults + 1 bottle entry = 8. The summary line shows the
# total count regardless of which entries fit in the visible
# "<a>, <b>, <c>, +N more" prefix, so this assertion is robust against
# alphabetical sort order changes.
assert_match "$out" "8 hosts allowed" "preflight: bottle entry counted in effective allowlist"
assert_contains "$out" "api.anthropic.com" "preflight: baked default shown"
assert_contains "$out" "dry-run requested" "dry-run banner present"
assert_not_contains "$out" "/dev/tty" "no /dev/tty prompt reached (dry-run exited first)"
# No docker side effects.
nets_after="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
ctrs_after="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
assert_eq "$nets_before" "$nets_after" "dry-run: no claude-bottle networks created"
assert_eq "$ctrs_before" "$ctrs_after" "dry-run: no claude-bottle containers created"
test_summary
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# Integration: the cleanup primitives the start-flow trap depends on
# are idempotent. The original orphan-network bug was a trap-ordering
# issue (cleanup_all installed AFTER networks were created); the fix
# moved the install earlier. The trap is only safe if the helpers it
# calls — network_remove, pipelock_stop — are no-ops against
# already-missing or never-existed resources. We test that here.
#
# (The full end-to-end "cli.sh dies mid-run, networks gone" flow needs
# a TTY and is documented as a manual verification step in tests/README.md.)
TEST_NAME="orphan_cleanup"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/docker.sh
. "${REPO_ROOT}/lib/docker.sh"
# shellcheck source=../../lib/network.sh
. "${REPO_ROOT}/lib/network.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
skip_test_if_no_docker
slug="cb-test-orphan-$$"
internal_name=""
egress_name=""
cleanup() {
for n in "$internal_name" "$egress_name"; do
[ -n "$n" ] && docker network rm "$n" >/dev/null 2>&1 || true
done
}
trap cleanup EXIT
# 1. network_remove against a name that doesn't exist returns 0
# (the trap can call it eagerly without crashing on the first run
# where the network was never created).
assert_exit_zero "network_remove: missing network is a no-op" \
network_remove "claude-bottle-net-${slug}-does-not-exist"
# 2. Create both networks the way cli.sh does, then remove them with
# network_remove. Both should succeed and the networks should be
# gone afterwards.
internal_name="$(network_create_internal "$slug")"
egress_name="$(network_create_egress "$slug")"
assert_match "$(docker network ls --format '{{.Name}}')" "^${internal_name}$" \
"internal network was created"
assert_match "$(docker network ls --format '{{.Name}}')" "^${egress_name}$" \
"egress network was created"
assert_exit_zero "network_remove: removes existing internal network" \
network_remove "$internal_name"
assert_exit_zero "network_remove: removes existing egress network" \
network_remove "$egress_name"
nets_after="$(docker network ls --format '{{.Name}}')"
assert_not_contains "$nets_after" "$internal_name" "internal network gone after removal"
assert_not_contains "$nets_after" "$egress_name" "egress network gone after removal"
# 3. Removing a second time is still safe — the trap may run after a
# clean exit, where the resources are already gone.
assert_exit_zero "network_remove: idempotent on already-removed internal" \
network_remove "$internal_name"
assert_exit_zero "network_remove: idempotent on already-removed egress" \
network_remove "$egress_name"
# 4. pipelock_stop against a slug whose sidecar was never started must
# also be a no-op — same reason.
assert_exit_zero "pipelock_stop: missing sidecar is a no-op" \
pipelock_stop "missing-${slug}"
test_summary
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Integration: verify the pinned pipelock image. Requires docker.
# - Pinned digest is reachable on the registry.
# - Image's ENTRYPOINT/CMD match what lib/pipelock.sh assumes
# (`/pipelock` and `run --listen 0.0.0.0:8888`).
# - The /pipelock binary actually runs (--version succeeds).
#
# This is the test that would have caught the runtime bug where the
# CMD shape diverged from what the launcher passed.
TEST_NAME="pipelock_image"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
skip_test_if_no_docker
# Pull the pinned image (cheap if already cached).
if ! docker pull "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" >/dev/null 2>&1; then
skip "could not pull ${CLAUDE_BOTTLE_PIPELOCK_IMAGE}"
exit 0
fi
# ENTRYPOINT must be the binary path lib/pipelock.sh expects.
entrypoint="$(docker image inspect "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --format '{{json .Config.Entrypoint}}')"
assert_contains "$entrypoint" "/pipelock" "entrypoint contains /pipelock"
# CMD must include `run` — the subcommand the launcher overrides via
# `docker create ... run --config ... --listen ...`. If a future image
# bumps the CMD shape, this fails loudly.
cmd="$(docker image inspect "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --format '{{json .Config.Cmd}}')"
assert_contains "$cmd" "run" "cmd contains 'run'"
# Binary actually runs.
ver="$(docker run --rm "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" --version 2>&1 || true)"
assert_match "$ver" "[Pp]ipelock|2\\.[0-9]+\\.[0-9]+" "binary --version produces version-shaped output"
test_summary
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Integration: full sidecar smoke test. Boots a pipelock container the
# same way cli.sh does (docker create + docker cp YAML + docker start),
# then probes /health. Catches regressions in:
# - the YAML-cp path (the /etc/pipelock.yaml vs /etc/pipelock/ bug)
# - argv shape (the `run --listen 0.0.0.0:N` invocation)
# - YAML structural validity (pipelock would refuse to start on a bad config)
TEST_NAME="pipelock_sidecar_smoke"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
skip_test_if_no_docker
# Use a distinct name so concurrent runs don't collide.
name="cb-test-pipelock-smoke-$$"
work_dir="$(mktemp -d)"
yaml="${work_dir}/pipelock.yaml"
cleanup() {
docker rm -f "$name" >/dev/null 2>&1 || true
rm -rf "$work_dir"
}
trap cleanup EXIT
# Generate a real config from a fixture manifest.
m="$(write_fixture fixture_minimal)"
pipelock_write_yaml "$m" dev "$yaml"
rm -f "$m"
# Same lifecycle as lib/pipelock.sh's pipelock_start, minus the
# network-attach steps (we just need a port we can curl).
docker create --name "$name" -p 0:8888 \
"$CLAUDE_BOTTLE_PIPELOCK_IMAGE" \
run --config /etc/pipelock.yaml --listen "0.0.0.0:8888" \
>/dev/null 2>&1 \
|| { _fail "docker create failed"; test_summary; }
# This is the exact cp path that broke before — guard against
# regressing to a /etc/pipelock/ subdirectory destination.
if ! docker cp "$yaml" "${name}:/etc/pipelock.yaml" >/dev/null 2>&1; then
_fail "docker cp to /etc/pipelock.yaml failed (parent dir must already exist in image)"
test_summary
fi
if ! docker start "$name" >/dev/null 2>&1; then
_fail "docker start failed; check that argv 'run --listen 0.0.0.0:8888' still matches image"
test_summary
fi
# Find the host-side port docker mapped 8888 to.
hostport="$(docker port "$name" 8888 2>/dev/null | head -1 | awk -F: '{print $NF}')"
if [ -z "$hostport" ]; then
_fail "could not determine published port" "docker port output: $(docker port "$name" 2>&1)"
test_summary
fi
# Wait up to 15 seconds for /health to come up.
healthy=0
for _ in $(seq 1 15); do
if curl -fsS "http://127.0.0.1:${hostport}/health" >/dev/null 2>&1; then
healthy=1
break
fi
sleep 1
done
if [ "$healthy" -eq 1 ]; then
_pass "sidecar /health responded"
else
_fail "sidecar /health did not respond within 15s" "logs:" "$(docker logs "$name" 2>&1 | tail -20)"
test_summary
fi
# Body should mention the version we pinned. We don't pin the exact
# version string here because the digest we test against is one
# release; the next release will change the version field but should
# keep the schema. Keep the assertion at "field is present and has
# a numeric-dotted shape".
body="$(curl -fsS "http://127.0.0.1:${hostport}/health" 2>&1)"
assert_contains "$body" '"status":"healthy"' "/health body status:healthy"
assert_match "$body" '"version":"[0-9]+\.[0-9]+\.[0-9]+"' "/health body has version field"
test_summary
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Tiny assertion helpers. No framework — each test file sources this,
# calls `assert_*` functions, and ends with `test_summary` which exits
# 0 if every assertion passed and 1 otherwise.
#
# Counters are file-local: every test process gets its own TEST_PASS /
# TEST_FAIL. run_tests.sh aggregates by exit code, not by reading these.
if [ -n "${CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED=1
TEST_PASS=0
TEST_FAIL=0
TEST_NAME="${TEST_NAME:-unnamed}"
if [ -t 1 ]; then
_C_PASS=$'\033[32m'
_C_FAIL=$'\033[31m'
_C_SKIP=$'\033[33m'
_C_RESET=$'\033[0m'
else
_C_PASS=""
_C_FAIL=""
_C_SKIP=""
_C_RESET=""
fi
_pass() {
TEST_PASS=$((TEST_PASS + 1))
printf ' %sPASS%s %s\n' "$_C_PASS" "$_C_RESET" "$1"
}
_fail() {
TEST_FAIL=$((TEST_FAIL + 1))
printf ' %sFAIL%s %s\n' "$_C_FAIL" "$_C_RESET" "$1" >&2
shift
local line
for line in "$@"; do
printf ' %s\n' "$line" >&2
done
}
assert_eq() {
local expected="$1" actual="$2" msg="${3:-equal}"
if [ "$expected" = "$actual" ]; then
_pass "$msg"
else
_fail "$msg" "expected: ${expected}" "actual: ${actual}"
fi
}
assert_contains() {
local haystack="$1" needle="$2" msg="${3:-contains}"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
_pass "$msg"
else
_fail "$msg" "expected to contain: ${needle}" "haystack: ${haystack}"
fi
}
assert_not_contains() {
local haystack="$1" needle="$2" msg="${3:-does not contain}"
if ! printf '%s' "$haystack" | grep -qF -- "$needle"; then
_pass "$msg"
else
_fail "$msg" "expected NOT to contain: ${needle}" "haystack: ${haystack}"
fi
}
assert_match() {
local haystack="$1" pattern="$2" msg="${3:-matches}"
if printf '%s' "$haystack" | grep -qE -- "$pattern"; then
_pass "$msg"
else
_fail "$msg" "expected pattern: ${pattern}" "haystack: ${haystack}"
fi
}
# assert_exit_zero <cmd...> — runs the command, fails the assertion
# if it exits non-zero. Captures stdout+stderr for the failure message.
assert_exit_zero() {
local label="$1"; shift
local out
if out="$("$@" 2>&1)"; then
_pass "$label"
else
_fail "$label" "exit non-zero" "output: ${out}"
fi
}
assert_exit_nonzero() {
local label="$1"; shift
local out
if out="$("$@" 2>&1)"; then
_fail "$label" "exit was 0; expected non-zero" "output: ${out}"
else
_pass "$label"
fi
}
skip() {
printf ' %sSKIP%s %s\n' "$_C_SKIP" "$_C_RESET" "$1"
}
skip_test_if_no_docker() {
if ! command -v docker >/dev/null 2>&1; then
printf '%sSKIP%s %s — docker not on PATH\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
exit 0
fi
if ! docker info >/dev/null 2>&1; then
printf '%sSKIP%s %s — docker daemon unreachable\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
exit 0
fi
}
test_summary() {
printf '\n%s: %d passed, %d failed\n' "$TEST_NAME" "$TEST_PASS" "$TEST_FAIL"
if [ "$TEST_FAIL" -gt 0 ]; then
exit 1
fi
exit 0
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Common scaffolding for every test file. Sources assert.sh and computes
# REPO_ROOT so tests can `. "${REPO_ROOT}/lib/<x>.sh"` to load the code
# they're exercising.
if [ -n "${CLAUDE_BOTTLE_TESTS_COMMON_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_COMMON_SOURCED=1
set -euo pipefail
_tests_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
TESTS_ROOT="$_tests_dir"
REPO_ROOT="$(CDPATH= cd -- "${TESTS_ROOT}/.." && pwd)"
# shellcheck source=./assert.sh
. "${TESTS_ROOT}/lib/assert.sh"
# shellcheck source=./fixtures.sh
. "${TESTS_ROOT}/lib/fixtures.sh"
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Manifest fixture builders. Each function prints a JSON manifest on
# stdout; callers can pipe to a temp file or pass through `write_fixture`.
if [ -n "${CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED=1
# fixture_minimal — one bottle, one agent, no env / ssh / skills.
fixture_minimal() {
cat <<'JSON'
{
"bottles": {
"dev": {}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
}
# fixture_with_egress — bottle declares an egress.allowlist.
fixture_with_egress() {
cat <<'JSON'
{
"bottles": {
"dev": {
"egress": {
"allowlist": [
"github.com",
"gitlab.com",
"registry.npmjs.org"
]
}
}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
}
# fixture_with_ssh — bottle has both an IPv4-literal SSH host (Tailscale
# CGNAT range) and a hostname SSH host, exercising both
# ssrf.ip_allowlist and trusted_domains code paths.
fixture_with_ssh() {
cat <<'JSON'
{
"bottles": {
"dev": {
"ssh": [
{
"Host": "tailscale-gitea",
"IdentityFile": "/dev/null",
"Hostname": "100.78.141.42",
"User": "git",
"Port": 30009
},
{
"Host": "github",
"IdentityFile": "/dev/null",
"Hostname": "github.com",
"User": "git",
"Port": 22
}
]
}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
}
# write_fixture <fixture_func> — write fixture to a temp file, print
# the path. Caller must rm.
write_fixture() {
local fn="${1:?write_fixture: missing fixture function}"
local f
f="$(mktemp)"
"$fn" > "$f"
printf '%s' "$f"
}
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Test runner. Iterates over test_*.sh files in unit/ and integration/
# (or just one of them when given a `unit` / `integration` argument)
# and runs each as a separate process. Aggregates exit codes and
# prints a summary.
#
# Usage:
# tests/run_tests.sh # unit + integration
# tests/run_tests.sh unit # unit only
# tests/run_tests.sh integration # integration only
# tests/run_tests.sh path/to/test_x.sh # one specific file
set -uo pipefail
_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
if [ -t 1 ]; then
C_PASS=$'\033[32m'
C_FAIL=$'\033[31m'
C_HEAD=$'\033[36m'
C_RESET=$'\033[0m'
else
C_PASS=""
C_FAIL=""
C_HEAD=""
C_RESET=""
fi
usage() {
cat <<EOF
usage: $(basename "$0") [unit|integration|path/to/test.sh]
no arg run unit + integration
unit run only tests/unit/test_*.sh
integration run only tests/integration/test_*.sh
<path> run a single test file
EOF
}
# Collect test files.
declare -a FILES=()
case "${1:-}" in
-h|--help) usage; exit 0 ;;
unit) FILES=("${_dir}"/unit/test_*.sh) ;;
integration) FILES=("${_dir}"/integration/test_*.sh) ;;
"") FILES=("${_dir}"/unit/test_*.sh "${_dir}"/integration/test_*.sh) ;;
*)
if [ -f "$1" ]; then
FILES=("$1")
else
printf 'no such file: %s\n' "$1" >&2
usage
exit 2
fi
;;
esac
# Filter out non-existent globs (no matching files).
declare -a EXISTING=()
for f in "${FILES[@]}"; do
[ -f "$f" ] && EXISTING+=("$f")
done
if [ "${#EXISTING[@]}" -eq 0 ]; then
printf 'no test files found\n' >&2
exit 2
fi
PASS_COUNT=0
FAIL_COUNT=0
declare -a FAIL_FILES=()
for f in "${EXISTING[@]}"; do
rel="${f#${_dir}/}"
printf '%s== %s ==%s\n' "$C_HEAD" "$rel" "$C_RESET"
if bash "$f"; then
PASS_COUNT=$((PASS_COUNT + 1))
else
FAIL_COUNT=$((FAIL_COUNT + 1))
FAIL_FILES+=("$rel")
fi
printf '\n'
done
# Summary.
TOTAL=$((PASS_COUNT + FAIL_COUNT))
printf '%ssummary%s: %d/%d test files passed\n' "$C_HEAD" "$C_RESET" "$PASS_COUNT" "$TOTAL"
if [ "$FAIL_COUNT" -gt 0 ]; then
printf '%sfailed%s:\n' "$C_FAIL" "$C_RESET"
for f in "${FAIL_FILES[@]}"; do
printf ' - %s\n' "$f"
done
exit 1
fi
printf '%sall tests passed%s\n' "$C_PASS" "$C_RESET"
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Unit: allowlist resolution — pipelock_bottle_allowlist,
# pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
# pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist.
TEST_NAME="pipelock_allowlist"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
# --- bottle_allowlist (egress.allowlist parsing) ---
m="$(write_fixture fixture_with_egress)"
out="$(pipelock_bottle_allowlist "$m" dev)"
assert_contains "$out" "github.com" "bottle_allowlist: github.com present"
assert_contains "$out" "gitlab.com" "bottle_allowlist: gitlab.com present"
assert_contains "$out" "registry.npmjs.org" "bottle_allowlist: npmjs present"
rm -f "$m"
m="$(write_fixture fixture_minimal)"
out="$(pipelock_bottle_allowlist "$m" dev)"
assert_eq "" "$out" "bottle_allowlist: empty when no egress block"
rm -f "$m"
# --- ssh hostnames + classification ---
m="$(write_fixture fixture_with_ssh)"
hosts="$(pipelock_bottle_ssh_hostnames "$m" dev)"
assert_contains "$hosts" "100.78.141.42" "ssh_hostnames: ipv4 included"
assert_contains "$hosts" "github.com" "ssh_hostnames: hostname included"
cidrs="$(pipelock_bottle_ssh_ip_cidrs "$m" dev)"
assert_contains "$cidrs" "100.78.141.42/32" "ssh_ip_cidrs: ipv4 emitted as /32"
assert_not_contains "$cidrs" "github.com" "ssh_ip_cidrs: hostname not in cidr list"
trusted="$(pipelock_bottle_ssh_trusted_domains "$m" dev)"
assert_contains "$trusted" "github.com" "ssh_trusted_domains: hostname present"
assert_not_contains "$trusted" "100.78.141.42" "ssh_trusted_domains: ipv4 not present"
rm -f "$m"
# --- effective_allowlist union (defaults + bottle.allowlist + ssh.Hostname) ---
# Combine egress + ssh fixtures into one manifest.
combined="$(mktemp)"
cat > "$combined" <<'JSON'
{
"bottles": {
"dev": {
"egress": { "allowlist": ["registry.npmjs.org"] },
"ssh": [
{ "Host": "ts", "IdentityFile": "/dev/null", "Hostname": "100.78.141.42", "User": "git", "Port": 30009 },
{ "Host": "gh", "IdentityFile": "/dev/null", "Hostname": "github.com", "User": "git", "Port": 22 }
]
}
},
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
}
JSON
eff="$(pipelock_effective_allowlist "$combined" dev)"
assert_contains "$eff" "api.anthropic.com" "effective: baked-in default present"
assert_contains "$eff" "registry.npmjs.org" "effective: bottle egress entry present"
assert_contains "$eff" "100.78.141.42" "effective: ssh ipv4 hostname present"
assert_contains "$eff" "github.com" "effective: ssh hostname present"
# Ensure dedup + sort: count lines, then count unique lines, expect equal.
total="$(printf '%s\n' "$eff" | wc -l | tr -d ' ')"
uniq="$(printf '%s\n' "$eff" | sort -u | wc -l | tr -d ' ')"
assert_eq "$total" "$uniq" "effective: deduplicated"
rm -f "$combined"
# --- non-string entry rejection ---
bad="$(mktemp)"
cat > "$bad" <<'JSON'
{
"bottles": { "dev": { "egress": { "allowlist": ["github.com", 42] } } },
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
}
JSON
assert_exit_nonzero "bottle_allowlist: rejects non-string entry" \
bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_bottle_allowlist "'"$bad"'" dev'
rm -f "$bad"
test_summary
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Unit: _pipelock_is_ipv4_literal — the classifier that decides
# whether bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4
# literal) or trusted_domains (hostname).
TEST_NAME="pipelock_classify"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
# Positive cases — these should be classified as IPv4 literals.
for ip in "127.0.0.1" "10.0.0.5" "100.78.141.42" "0.0.0.0" "255.255.255.255"; do
assert_exit_zero "ipv4: ${ip}" _pipelock_is_ipv4_literal "$ip"
done
# Negative cases — hostnames, partial IPs, IPv6, and edge garbage
# should NOT match.
for hn in \
"github.com" \
"gitea.dideric.is" \
"100.78.141" \
"100.78.141.42.5" \
"::1" \
"fe80::1" \
"localhost" \
"" \
"1.2.3.4.example.com"
do
assert_exit_nonzero "non-ipv4: '${hn}'" _pipelock_is_ipv4_literal "$hn"
done
test_summary
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port).
TEST_NAME="pipelock_naming"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
assert_eq "claude-bottle-pipelock-foo" "$(pipelock_container_name foo)" "container_name simple slug"
assert_eq "claude-bottle-pipelock-some-slug" "$(pipelock_container_name some-slug)" "container_name with hyphens"
# proxy_url and proxy_host_port use whatever CLAUDE_BOTTLE_PIPELOCK_PORT
# is at source time. We sourced with default (8888).
assert_eq "http://claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_url foo)" "proxy_url default port"
assert_eq "claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_host_port foo)" "proxy_host_port default port"
# Both helpers should fail loudly without a slug (the `${1:?...}` guards).
assert_exit_nonzero "container_name: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_container_name'
assert_exit_nonzero "proxy_url: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_proxy_url'
test_summary
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Unit: pipelock_write_yaml — produces a YAML config containing the
# expected top-level keys and per-bottle entries. We don't fully parse
# YAML (no yq dependency); we grep for content shape.
TEST_NAME="pipelock_yaml"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
out_dir="$(mktemp -d)"
cleanup() { rm -rf "$out_dir"; }
trap cleanup EXIT
# --- minimal bottle (no egress, no ssh): only api_allowlist defaults ---
m_min="$(write_fixture fixture_minimal)"
yaml_min="${out_dir}/min.yaml"
pipelock_write_yaml "$m_min" dev "$yaml_min"
content="$(cat "$yaml_min")"
assert_contains "$content" "mode: strict" "min: mode strict"
assert_contains "$content" "enforce: true" "min: enforce true"
assert_contains "$content" "api_allowlist:" "min: api_allowlist block"
assert_contains "$content" "api.anthropic.com" "min: anthropic baked default"
assert_contains "$content" "raw.githubusercontent.com" "min: github raw baked default"
assert_contains "$content" "forward_proxy:" "min: forward_proxy block"
assert_contains "$content" "enabled: true" "min: forward_proxy enabled"
assert_contains "$content" "dlp:" "min: dlp block"
assert_contains "$content" "include_defaults: true" "min: dlp include_defaults"
assert_contains "$content" "scan_env: true" "min: dlp scan_env"
# No ssh entries in the manifest, so neither ssrf nor trusted_domains
# blocks should be emitted.
assert_not_contains "$content" "trusted_domains:" "min: no trusted_domains"
assert_not_contains "$content" "ssrf:" "min: no ssrf block"
rm -f "$m_min"
# --- ssh bottle: trusted_domains for hostname, ssrf.ip_allowlist for ipv4 ---
m_ssh="$(write_fixture fixture_with_ssh)"
yaml_ssh="${out_dir}/ssh.yaml"
pipelock_write_yaml "$m_ssh" dev "$yaml_ssh"
content="$(cat "$yaml_ssh")"
assert_contains "$content" "trusted_domains:" "ssh: trusted_domains block emitted"
assert_contains "$content" "github.com" "ssh: hostname in trusted_domains (or allowlist)"
assert_contains "$content" "ssrf:" "ssh: ssrf block emitted"
assert_contains "$content" "ip_allowlist:" "ssh: ip_allowlist key under ssrf"
assert_contains "$content" "100.78.141.42/32" "ssh: ipv4 host emitted as /32"
# Belt-and-suspenders: the ipv4 host should also be in api_allowlist
# (strict mode requires both).
assert_contains "$content" "100.78.141.42" "ssh: ipv4 host in api_allowlist too"
rm -f "$m_ssh"
# --- secret hygiene: env values from the manifest never enter the YAML ---
m_secret="$(mktemp)"
cat > "$m_secret" <<'JSON'
{
"bottles": {
"dev": {
"env": {
"MY_SECRET": "literal-value-should-not-appear",
"ANOTHER": "?prompt-message"
},
"egress": { "allowlist": ["github.com"] }
}
},
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
}
JSON
yaml_sec="${out_dir}/secret.yaml"
pipelock_write_yaml "$m_secret" dev "$yaml_sec"
content="$(cat "$yaml_sec")"
assert_not_contains "$content" "literal-value-should-not-appear" "secret: literal env value not leaked"
assert_not_contains "$content" "MY_SECRET" "secret: env var name not leaked"
assert_not_contains "$content" "prompt-message" "secret: prompt sentinel not leaked"
rm -f "$m_secret"
# --- file mode is 600 ---
mode="$(stat -f '%p' "$yaml_min" 2>/dev/null || stat -c '%a' "$yaml_min")"
# macOS stat -f '%p' returns full mode like 100600; trim. Linux stat -c '%a' gives just 600.
mode="${mode: -3}"
assert_eq "600" "$mode" "yaml file mode is 600"
test_summary