From ba7616a4aedd3d5da4cd692287d83e427cf90819 Mon Sep 17 00:00:00 2001 From: didericis Date: Fri, 8 May 2026 01:56:43 -0400 Subject: [PATCH] PRD 0001: Per-agent egress proxy via pipelock (#1) --- README.md | 32 ++ cli.sh | 88 +++- ...001-per-agent-egress-proxy-via-pipelock.md | 222 ++++++++ lib/manifest.sh | 35 +- lib/network.sh | 182 +++++++ lib/pipelock.sh | 489 ++++++++++++++++++ lib/ssh.sh | 21 +- tests/README.md | 83 +++ tests/integration/test_dry_run_plan.sh | 63 +++ tests/integration/test_orphan_cleanup.sh | 74 +++ tests/integration/test_pipelock_image.sh | 40 ++ .../test_pipelock_sidecar_smoke.sh | 87 ++++ tests/lib/assert.sh | 124 +++++ tests/lib/common.sh | 20 + tests/lib/fixtures.sh | 99 ++++ tests/run_tests.sh | 94 ++++ tests/unit/test_pipelock_allowlist.sh | 89 ++++ tests/unit/test_pipelock_classify.sh | 34 ++ tests/unit/test_pipelock_naming.sh | 23 + tests/unit/test_pipelock_yaml.sh | 90 ++++ 20 files changed, 1977 insertions(+), 12 deletions(-) create mode 100644 docs/prds/0001-per-agent-egress-proxy-via-pipelock.md create mode 100644 lib/network.sh create mode 100644 lib/pipelock.sh create mode 100644 tests/README.md create mode 100755 tests/integration/test_dry_run_plan.sh create mode 100755 tests/integration/test_orphan_cleanup.sh create mode 100755 tests/integration/test_pipelock_image.sh create mode 100755 tests/integration/test_pipelock_sidecar_smoke.sh create mode 100644 tests/lib/assert.sh create mode 100644 tests/lib/common.sh create mode 100644 tests/lib/fixtures.sh create mode 100755 tests/run_tests.sh create mode 100755 tests/unit/test_pipelock_allowlist.sh create mode 100755 tests/unit/test_pipelock_classify.sh create mode 100755 tests/unit/test_pipelock_naming.sh create mode 100755 tests/unit/test_pipelock_yaml.sh diff --git a/README.md b/README.md index 6b9f9f6..51bf9ae 100644 --- a/README.md +++ b/README.md @@ -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 `. +## 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..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 diff --git a/cli.sh b/cli.sh index b5acbbb..78c7611 100755 --- a/cli.sh +++ b/cli.sh @@ -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 [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` diff --git a/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md b/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md new file mode 100644 index 0000000..4ea1c3d --- /dev/null +++ b/docs/prds/0001-per-agent-egress-proxy-via-pipelock.md @@ -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 `.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-` 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 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:`. 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 ` — + 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: + +- Claude Code network configuration: + diff --git a/lib/manifest.sh b/lib/manifest.sh index 6dc496b..d232ad4 100644 --- a/lib/manifest.sh +++ b/lib/manifest.sh @@ -8,7 +8,8 @@ # "bottles": { # "": { # "env": { "": , ... }, -# "ssh": [ , ... ] +# "ssh": [ , ... ], +# "egress": { "allowlist": [ "", ... ] } # }, # ... # }, @@ -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 is a JSON string. Mode is selected by sentinel prefix: # "?" → prompt for the value at runtime, displaying @@ -225,6 +234,24 @@ manifest_bottle_ssh() { jq -c --arg b "$bottle_name" '.bottles[$b].ssh // [] | .[]' "$manifest_file" } +# manifest_bottle_egress_allowlist — 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 — 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 diff --git a/lib/network.sh b/lib/network.sh new file mode 100644 index 0000000..ad0a819 --- /dev/null +++ b/lib/network.sh @@ -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- (internal), +# claude-bottle-egress- (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 — 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 — 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 — 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 +# +# Internal helper. Creates a per-agent Docker network whose name is +# (with -2, -3, ... appended on conflict, capped at 100). +# When 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 '" + 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 +# +# 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 +# +# 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 +# +# 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 ` 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 +# +# 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 +} diff --git a/lib/pipelock.sh b/lib/pipelock.sh new file mode 100644 index 0000000..187175f --- /dev/null +++ b/lib/pipelock.sh @@ -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:. 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 — 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 — prints http://:, 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 — prints : (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 +# +# Prints one hostname per line on stdout for the allowlist declared at +# bottles[].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 +# +# Prints one hostname per line for each entry in bottles[].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 — exit 0 if 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 +# +# 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 +# +# 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 +# +# 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 +# +# One-line summary of the effective allowlist for the y/N preflight +# display. Format: +# " 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 +# +# Writes a pipelock YAML config file to (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 +# +# 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 ` 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 :` 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: +# — agent slug; sidecar name will be claude-bottle-pipelock- +# — name of the agent's internal docker network +# — name of the agent's user-defined egress +# network; the sidecar joins this so it can +# reach upstream hostnames with working DNS +# — host directory containing the YAML +# — 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 +# +# 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 +} diff --git a/lib/ssh.sh b/lib/ssh.sh index 5ac4fa2..81bd9be 100644 --- a/lib/ssh.sh +++ b/lib/ssh.sh @@ -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 : (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 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..2ea901f --- /dev/null +++ b/tests/README.md @@ -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_.sh`. Make it executable: `chmod +x`. +3. Start with the boilerplate the existing files use: + ```bash + #!/usr/bin/env bash + TEST_NAME="" + . "$(dirname "$0")/../lib/common.sh" + . "${REPO_ROOT}/lib/log.sh" + . "${REPO_ROOT}/lib/.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. diff --git a/tests/integration/test_dry_run_plan.sh b/tests/integration/test_dry_run_plan.sh new file mode 100755 index 0000000..c1ba8df --- /dev/null +++ b/tests/integration/test_dry_run_plan.sh @@ -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 +# ", , , +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 diff --git a/tests/integration/test_orphan_cleanup.sh b/tests/integration/test_orphan_cleanup.sh new file mode 100755 index 0000000..41b814e --- /dev/null +++ b/tests/integration/test_orphan_cleanup.sh @@ -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 diff --git a/tests/integration/test_pipelock_image.sh b/tests/integration/test_pipelock_image.sh new file mode 100755 index 0000000..afff10e --- /dev/null +++ b/tests/integration/test_pipelock_image.sh @@ -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 diff --git a/tests/integration/test_pipelock_sidecar_smoke.sh b/tests/integration/test_pipelock_sidecar_smoke.sh new file mode 100755 index 0000000..5340441 --- /dev/null +++ b/tests/integration/test_pipelock_sidecar_smoke.sh @@ -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 diff --git a/tests/lib/assert.sh b/tests/lib/assert.sh new file mode 100644 index 0000000..9d92ab2 --- /dev/null +++ b/tests/lib/assert.sh @@ -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 — 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 +} diff --git a/tests/lib/common.sh b/tests/lib/common.sh new file mode 100644 index 0000000..152107f --- /dev/null +++ b/tests/lib/common.sh @@ -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/.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" diff --git a/tests/lib/fixtures.sh b/tests/lib/fixtures.sh new file mode 100644 index 0000000..e001c39 --- /dev/null +++ b/tests/lib/fixtures.sh @@ -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 — 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" +} diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..d3825c0 --- /dev/null +++ b/tests/run_tests.sh @@ -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 < 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" diff --git a/tests/unit/test_pipelock_allowlist.sh b/tests/unit/test_pipelock_allowlist.sh new file mode 100755 index 0000000..6c2e059 --- /dev/null +++ b/tests/unit/test_pipelock_allowlist.sh @@ -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 diff --git a/tests/unit/test_pipelock_classify.sh b/tests/unit/test_pipelock_classify.sh new file mode 100755 index 0000000..513bfc8 --- /dev/null +++ b/tests/unit/test_pipelock_classify.sh @@ -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 diff --git a/tests/unit/test_pipelock_naming.sh b/tests/unit/test_pipelock_naming.sh new file mode 100755 index 0000000..4a39055 --- /dev/null +++ b/tests/unit/test_pipelock_naming.sh @@ -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 diff --git a/tests/unit/test_pipelock_yaml.sh b/tests/unit/test_pipelock_yaml.sh new file mode 100755 index 0000000..e1c2e5e --- /dev/null +++ b/tests/unit/test_pipelock_yaml.sh @@ -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