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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user