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
+31 -4
View File
@@ -8,7 +8,8 @@
# "bottles": {
# "<bottle-name>": {
# "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
# agents can reference by name. The "bottle" field is required on every agent;
# cli.sh start rejects agents that omit it.
# A bottle groups shared infrastructure (SSH keys, known hosts, egress
# allowlist) that multiple agents can reference by name. The "bottle" field
# 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:
# "?<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"
}
# 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
# 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
+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() {
local container="${1:?ssh_setup: missing container}"
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_ssh="${container_home}/.ssh"
@@ -140,8 +145,18 @@ ssh_setup() {
# No IdentityFile — IdentityAgent points SSH at the public (forwarded)
# socket. Pointing at the real agent socket directly would be rejected
# 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
# Write under both the Host alias and the Hostname so SSH finds the key