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
+83 -5
View File
@@ -34,6 +34,10 @@ REPO_DIR="$SCRIPT_DIR"
. "${SCRIPT_DIR}/lib/skills.sh"
# shellcheck source=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() {
printf 'usage: %s <command> [args...]\n' "$(basename "$0")" >&2
@@ -400,12 +404,15 @@ cmd_start() {
ssh_validate_entries "${SSH_ENTRIES[@]}"
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).
STAGE_DIR="$(mktemp -d -t claude-bottle-stage.XXXXXX)"
local ENV_FILE="${STAGE_DIR}/agent.env"
local ARGS_FILE="${STAGE_DIR}/docker-args"
local PROMPT_FILE="${STAGE_DIR}/prompt.txt"
local PIPELOCK_YAML_FILENAME="pipelock.yaml"
local PIPELOCK_YAML="${STAGE_DIR}/${PIPELOCK_YAML_FILENAME}"
: > "$ENV_FILE"
chmod 600 "$ENV_FILE"
: > "$ARGS_FILE"
@@ -420,6 +427,15 @@ cmd_start() {
}
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
# interpolated host vars into this process, writes literal pairs to
# ENV_FILE.
@@ -470,6 +486,7 @@ cmd_start() {
else
info " ssh hosts : (none)"
fi
info " egress : ${PIPELOCK_ALLOWLIST_SUMMARY}"
else
info "bottle : (none)"
fi
@@ -496,23 +513,82 @@ cmd_start() {
build_image_with_cwd "$DERIVED_IMAGE" "$IMAGE" "$USER_CWD"
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() {
if container_exists "$CONTAINER"; then
if [ -n "${CONTAINER:-}" ] && container_exists "$CONTAINER"; then
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
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
}
# Replaces the cleanup_stage EXIT trap above; cleanup_all calls cleanup_stage internally.
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:
# - --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)
# - 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
# - 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
DOCKER_ARGS+=(--env-file "$ENV_FILE")
fi
@@ -574,7 +650,9 @@ cmd_start() {
# Set up SSH keys and config.
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
# When --cwd is on, ship the host repo's .git directory in via `docker cp`