feat(cli): wire pipelock sidecar + internal network into start flow

PRD 0001 cli.sh integration:

- Source the new lib/network.sh and lib/pipelock.sh.
- During plan resolution: generate the per-bottle pipelock YAML into
  the existing mktemp stage dir (mode 600, hostnames only) and
  resolve a one-line "<N> hosts allowed (...)" summary.
- Add the egress summary as a sub-bullet under the bottle in the y/N
  preflight, alongside the existing ssh hosts line.
- After the y/N gate (and after build_image): create the per-agent
  --internal Docker network with a slug-derived name, then start the
  pipelock sidecar attached to it.
- docker run argv: agent attaches to the internal network with
  HTTPS_PROXY / HTTP_PROXY pointing at the sidecar by service name on
  that network. NO_PROXY only covers loopback. The internal network
  has no default gateway, so any path that ignores the proxy env
  hits no-route-to-host rather than leaking.
- Exit trap: tear down the agent container, then the sidecar (so the
  network is empty), then remove the network, then run the existing
  stage cleanup. Order matters — docker refuses to remove a network
  with attached containers.
- --dry-run continues to exit before any docker network/run/cp/exec
  call; the YAML write into the mktemp dir is the only new
  side-effect inside the dry-run path.

Verified against a temp fixture: defaults-only bottle shows
"7 hosts allowed", a bottle with two extra entries shows
"9 hosts allowed (api.anthropic.com, api.openai.com, claude.ai,
+6 more)", and dry-run exits before any docker calls.

Refs: docs/prds/0001-per-agent-egress-proxy-via-pipelock.md

Assisted-by: Claude Code
This commit is contained in:
2026-05-08 01:01:20 -04:00
parent 4c51ba422e
commit e7e72c4833
+57 -2
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,11 +513,31 @@ cmd_start() {
build_image_with_cwd "$DERIVED_IMAGE" "$IMAGE" "$USER_CWD"
fi
# PRD 0001: per-agent egress topology. Create the --internal Docker
# network and start the pipelock sidecar on it BEFORE the agent
# container, so the agent's HTTPS_PROXY target exists at the moment
# the agent boots.
#
# Not declared local: needed by cleanup_all after cmd_start returns
# (same reason as MANIFEST_FILE / STAGE_DIR / CONTAINER above).
INTERNAL_NETWORK=""
PIPELOCK_CONTAINER=""
INTERNAL_NETWORK="$(network_create_internal "$SLUG")"
PIPELOCK_CONTAINER="$(pipelock_start "$SLUG" "$INTERNAL_NETWORK" "$STAGE_DIR" "$PIPELOCK_YAML_FILENAME")"
# Cleanup container on exit too. Compose with stage cleanup.
# Order matters: sidecar first, then internal network — docker
# refuses to remove a network with attached containers.
cleanup_all() {
if 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
cleanup_stage
}
# Replaces the cleanup_stage EXIT trap above; cleanup_all calls cleanup_stage internally.
@@ -508,11 +545,29 @@ cmd_start() {
# 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