PRD 0001: Per-agent egress proxy via pipelock #1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Executable
+63
@@ -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
|
||||
Executable
+74
@@ -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
|
||||
Executable
+40
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+94
@@ -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"
|
||||
Executable
+89
@@ -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
|
||||
Executable
+34
@@ -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
|
||||
Executable
+23
@@ -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
|
||||
Executable
+90
@@ -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
|
||||
Reference in New Issue
Block a user