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

Merged
didericis merged 13 commits from prd-0001-per-agent-egress-proxy-via-pipelock into main 2026-05-08 01:56:44 -04:00
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
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
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"
# 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`
@@ -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": {
# "<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
+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