refactor: convert project from bash to Python

Replaces cli.sh + lib/*.sh with a claude_bottle/ Python package and a
cli.py entry point. No external dependencies — uses only Python's
stdlib (json, subprocess, getpass, tempfile, argparse, re, etc.).

- claude_bottle/{log,docker,manifest,env_resolve,network,pipelock,
  skills,ssh,cli}.py mirror the previous lib/*.sh modules.
- Tests converted to unittest under tests/test_*.py with a stdlib
  runner at tests/run_tests.py (unit | integration | path).
- .githooks/commit-msg ported to Python; same Conventional Commits rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit was merged in pull request #2.
This commit is contained in:
2026-05-08 15:26:58 +00:00
parent b94b6904ae
commit 399ed93dc8
47 changed files with 2706 additions and 3586 deletions
+50 -54
View File
@@ -1,83 +1,79 @@
# 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
Plain-Python test suite using stdlib `unittest`. No external
dependencies. Unit tests run anywhere Python 3 is present; 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
run_tests.py # entry point
fixtures.py # JSON manifest builders
_docker.py # docker-availability skip helper
test_pipelock_naming.py # unit
test_pipelock_classify.py # unit
test_pipelock_allowlist.py # unit
test_pipelock_yaml.py # unit
test_pipelock_image.py # integration
test_pipelock_sidecar_smoke.py # integration
test_dry_run_plan.py # integration
test_orphan_cleanup.py # integration
```
## 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
tests/run_tests.py # everything
tests/run_tests.py unit # unit only
tests/run_tests.py integration # integration only
tests/run_tests.py tests/test_pipelock_yaml.py # one file
```
Each test file exits 0 on pass, 1 on fail. The runner aggregates and
prints a one-line summary.
You can also run via `python -m unittest`:
```bash
python -m unittest discover -s tests
python -m unittest tests.test_pipelock_yaml
```
## 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
- `test_pipelock_image.py` — the pinned digest is reachable, ENTRYPOINT
is `/pipelock`, and `CMD` includes `run`.
- `test_pipelock_sidecar_smoke.py``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
`/health`.
- `test_dry_run_plan.py``cli.py 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.
- `test_orphan_cleanup.py`network_remove and pipelock_stop are
idempotent against missing resources, so the EXIT trap can call them
unconditionally.
## 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.
- `claude_bottle/ssh.py` end-to-end (would need a fake SSH host inside
the container).
- A live SSH-through-pipelock tunnel against a real Tailscale-style 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
1. Pick a filename: `test_<topic>.py`. Add it to `INTEGRATION_NAMES`
in `run_tests.py` if it needs Docker.
2. Boilerplate:
```python
import unittest
from claude_bottle.<module> import <symbol>
class TestThing(unittest.TestCase):
def test_x(self):
...
if __name__ == "__main__":
unittest.main()
```
4. For integration tests: call `skip_test_if_no_docker` after the
boilerplate and ensure your trap cleans up any docker resources you
create.
3. For Docker-dependent tests, decorate the class with
`@skip_unless_docker()` from `tests._docker`.
View File
+24
View File
@@ -0,0 +1,24 @@
"""Docker availability check used by integration tests."""
from __future__ import annotations
import shutil
import subprocess
import unittest
def docker_available() -> bool:
if shutil.which("docker") is None:
return False
return (
subprocess.run(
["docker", "info"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
def skip_unless_docker(reason: str = "docker unreachable"):
return unittest.skipUnless(docker_available(), reason)
+70
View File
@@ -0,0 +1,70 @@
"""Manifest fixtures for the test suite."""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from typing import Any
def fixture_minimal() -> dict[str, Any]:
"""One bottle, one agent, no env / ssh / skills."""
return {
"bottles": {"dev": {}},
"agents": {
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
},
}
def fixture_with_egress() -> dict[str, Any]:
"""Bottle declares an egress.allowlist."""
return {
"bottles": {
"dev": {
"egress": {
"allowlist": ["github.com", "gitlab.com", "registry.npmjs.org"]
}
}
},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}
def fixture_with_ssh() -> dict[str, Any]:
"""Bottle has both an IPv4-literal SSH host (CGNAT) and a hostname host,
exercising both ssrf.ip_allowlist and trusted_domains code paths."""
return {
"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"}},
}
def write_fixture(fn) -> Path:
"""Write fixture dict to a temp file; return the path. Caller must rm."""
f = tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False, encoding="utf-8"
)
json.dump(fn(), f)
f.close()
return Path(f.name)
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# Integration: cli.sh start --dry-run renders the planned shape and
# does not create any docker resources. Confirms the preflight contract
# from PRD 0001 (allowlist line in the plan, no docker side effects).
TEST_NAME="dry_run_plan"
. "$(dirname "$0")/../lib/common.sh"
skip_test_if_no_docker
work_dir="$(mktemp -d)"
manifest="${work_dir}/claude-bottle.json"
cleanup() {
rm -rf "$work_dir"
}
trap cleanup EXIT
# Manifest with an egress.allowlist so we can grep for a known host.
cat > "$manifest" <<'JSON'
{
"bottles": {
"dev": {
"egress": { "allowlist": ["example.org"] }
}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
# Snapshot docker state before we run.
nets_before="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
ctrs_before="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
# Override HOME so the user's ~/claude-bottle.json doesn't leak in via
# manifest_resolve's home+cwd merge.
out="$(cd "$work_dir" \
&& HOME="$work_dir" CLAUDE_BOTTLE_DRY_RUN=1 \
"${REPO_ROOT}/cli.sh" start demo 2>&1 || true)"
assert_contains "$out" "egress" "preflight: egress line present"
# 7 baked defaults + 1 bottle entry = 8. The summary line shows the
# total count regardless of which entries fit in the visible
# "<a>, <b>, <c>, +N more" prefix, so this assertion is robust against
# alphabetical sort order changes.
assert_match "$out" "8 hosts allowed" "preflight: bottle entry counted in effective allowlist"
assert_contains "$out" "api.anthropic.com" "preflight: baked default shown"
assert_contains "$out" "dry-run requested" "dry-run banner present"
assert_not_contains "$out" "/dev/tty" "no /dev/tty prompt reached (dry-run exited first)"
# No docker side effects.
nets_after="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)"
ctrs_after="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)"
assert_eq "$nets_before" "$nets_after" "dry-run: no claude-bottle networks created"
assert_eq "$ctrs_before" "$ctrs_after" "dry-run: no claude-bottle containers created"
test_summary
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env bash
# Integration: the cleanup primitives the start-flow trap depends on
# are idempotent. The original orphan-network bug was a trap-ordering
# issue (cleanup_all installed AFTER networks were created); the fix
# moved the install earlier. The trap is only safe if the helpers it
# calls — network_remove, pipelock_stop — are no-ops against
# already-missing or never-existed resources. We test that here.
#
# (The full end-to-end "cli.sh dies mid-run, networks gone" flow needs
# a TTY and is documented as a manual verification step in tests/README.md.)
TEST_NAME="orphan_cleanup"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/docker.sh
. "${REPO_ROOT}/lib/docker.sh"
# shellcheck source=../../lib/network.sh
. "${REPO_ROOT}/lib/network.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
skip_test_if_no_docker
slug="cb-test-orphan-$$"
internal_name=""
egress_name=""
cleanup() {
for n in "$internal_name" "$egress_name"; do
[ -n "$n" ] && docker network rm "$n" >/dev/null 2>&1 || true
done
}
trap cleanup EXIT
# 1. network_remove against a name that doesn't exist returns 0
# (the trap can call it eagerly without crashing on the first run
# where the network was never created).
assert_exit_zero "network_remove: missing network is a no-op" \
network_remove "claude-bottle-net-${slug}-does-not-exist"
# 2. Create both networks the way cli.sh does, then remove them with
# network_remove. Both should succeed and the networks should be
# gone afterwards.
internal_name="$(network_create_internal "$slug")"
egress_name="$(network_create_egress "$slug")"
assert_match "$(docker network ls --format '{{.Name}}')" "^${internal_name}$" \
"internal network was created"
assert_match "$(docker network ls --format '{{.Name}}')" "^${egress_name}$" \
"egress network was created"
assert_exit_zero "network_remove: removes existing internal network" \
network_remove "$internal_name"
assert_exit_zero "network_remove: removes existing egress network" \
network_remove "$egress_name"
nets_after="$(docker network ls --format '{{.Name}}')"
assert_not_contains "$nets_after" "$internal_name" "internal network gone after removal"
assert_not_contains "$nets_after" "$egress_name" "egress network gone after removal"
# 3. Removing a second time is still safe — the trap may run after a
# clean exit, where the resources are already gone.
assert_exit_zero "network_remove: idempotent on already-removed internal" \
network_remove "$internal_name"
assert_exit_zero "network_remove: idempotent on already-removed egress" \
network_remove "$egress_name"
# 4. pipelock_stop against a slug whose sidecar was never started must
# also be a no-op — same reason.
assert_exit_zero "pipelock_stop: missing sidecar is a no-op" \
pipelock_stop "missing-${slug}"
test_summary
-40
View File
@@ -1,40 +0,0 @@
#!/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
@@ -1,87 +0,0 @@
#!/usr/bin/env bash
# Integration: full sidecar smoke test. Boots a pipelock container the
# same way cli.sh does (docker create + docker cp YAML + docker start),
# then probes /health. Catches regressions in:
# - the YAML-cp path (the /etc/pipelock.yaml vs /etc/pipelock/ bug)
# - argv shape (the `run --listen 0.0.0.0:N` invocation)
# - YAML structural validity (pipelock would refuse to start on a bad config)
TEST_NAME="pipelock_sidecar_smoke"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
skip_test_if_no_docker
# Use a distinct name so concurrent runs don't collide.
name="cb-test-pipelock-smoke-$$"
work_dir="$(mktemp -d)"
yaml="${work_dir}/pipelock.yaml"
cleanup() {
docker rm -f "$name" >/dev/null 2>&1 || true
rm -rf "$work_dir"
}
trap cleanup EXIT
# Generate a real config from a fixture manifest.
m="$(write_fixture fixture_minimal)"
pipelock_write_yaml "$m" dev "$yaml"
rm -f "$m"
# Same lifecycle as lib/pipelock.sh's pipelock_start, minus the
# network-attach steps (we just need a port we can curl).
docker create --name "$name" -p 0:8888 \
"$CLAUDE_BOTTLE_PIPELOCK_IMAGE" \
run --config /etc/pipelock.yaml --listen "0.0.0.0:8888" \
>/dev/null 2>&1 \
|| { _fail "docker create failed"; test_summary; }
# This is the exact cp path that broke before — guard against
# regressing to a /etc/pipelock/ subdirectory destination.
if ! docker cp "$yaml" "${name}:/etc/pipelock.yaml" >/dev/null 2>&1; then
_fail "docker cp to /etc/pipelock.yaml failed (parent dir must already exist in image)"
test_summary
fi
if ! docker start "$name" >/dev/null 2>&1; then
_fail "docker start failed; check that argv 'run --listen 0.0.0.0:8888' still matches image"
test_summary
fi
# Find the host-side port docker mapped 8888 to.
hostport="$(docker port "$name" 8888 2>/dev/null | head -1 | awk -F: '{print $NF}')"
if [ -z "$hostport" ]; then
_fail "could not determine published port" "docker port output: $(docker port "$name" 2>&1)"
test_summary
fi
# Wait up to 15 seconds for /health to come up.
healthy=0
for _ in $(seq 1 15); do
if curl -fsS "http://127.0.0.1:${hostport}/health" >/dev/null 2>&1; then
healthy=1
break
fi
sleep 1
done
if [ "$healthy" -eq 1 ]; then
_pass "sidecar /health responded"
else
_fail "sidecar /health did not respond within 15s" "logs:" "$(docker logs "$name" 2>&1 | tail -20)"
test_summary
fi
# Body should mention the version we pinned. We don't pin the exact
# version string here because the digest we test against is one
# release; the next release will change the version field but should
# keep the schema. Keep the assertion at "field is present and has
# a numeric-dotted shape".
body="$(curl -fsS "http://127.0.0.1:${hostport}/health" 2>&1)"
assert_contains "$body" '"status":"healthy"' "/health body status:healthy"
assert_match "$body" '"version":"[0-9]+\.[0-9]+\.[0-9]+"' "/health body has version field"
test_summary
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env bash
# Tiny assertion helpers. No framework — each test file sources this,
# calls `assert_*` functions, and ends with `test_summary` which exits
# 0 if every assertion passed and 1 otherwise.
#
# Counters are file-local: every test process gets its own TEST_PASS /
# TEST_FAIL. run_tests.sh aggregates by exit code, not by reading these.
if [ -n "${CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED=1
TEST_PASS=0
TEST_FAIL=0
TEST_NAME="${TEST_NAME:-unnamed}"
if [ -t 1 ]; then
_C_PASS=$'\033[32m'
_C_FAIL=$'\033[31m'
_C_SKIP=$'\033[33m'
_C_RESET=$'\033[0m'
else
_C_PASS=""
_C_FAIL=""
_C_SKIP=""
_C_RESET=""
fi
_pass() {
TEST_PASS=$((TEST_PASS + 1))
printf ' %sPASS%s %s\n' "$_C_PASS" "$_C_RESET" "$1"
}
_fail() {
TEST_FAIL=$((TEST_FAIL + 1))
printf ' %sFAIL%s %s\n' "$_C_FAIL" "$_C_RESET" "$1" >&2
shift
local line
for line in "$@"; do
printf ' %s\n' "$line" >&2
done
}
assert_eq() {
local expected="$1" actual="$2" msg="${3:-equal}"
if [ "$expected" = "$actual" ]; then
_pass "$msg"
else
_fail "$msg" "expected: ${expected}" "actual: ${actual}"
fi
}
assert_contains() {
local haystack="$1" needle="$2" msg="${3:-contains}"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
_pass "$msg"
else
_fail "$msg" "expected to contain: ${needle}" "haystack: ${haystack}"
fi
}
assert_not_contains() {
local haystack="$1" needle="$2" msg="${3:-does not contain}"
if ! printf '%s' "$haystack" | grep -qF -- "$needle"; then
_pass "$msg"
else
_fail "$msg" "expected NOT to contain: ${needle}" "haystack: ${haystack}"
fi
}
assert_match() {
local haystack="$1" pattern="$2" msg="${3:-matches}"
if printf '%s' "$haystack" | grep -qE -- "$pattern"; then
_pass "$msg"
else
_fail "$msg" "expected pattern: ${pattern}" "haystack: ${haystack}"
fi
}
# assert_exit_zero <cmd...> — runs the command, fails the assertion
# if it exits non-zero. Captures stdout+stderr for the failure message.
assert_exit_zero() {
local label="$1"; shift
local out
if out="$("$@" 2>&1)"; then
_pass "$label"
else
_fail "$label" "exit non-zero" "output: ${out}"
fi
}
assert_exit_nonzero() {
local label="$1"; shift
local out
if out="$("$@" 2>&1)"; then
_fail "$label" "exit was 0; expected non-zero" "output: ${out}"
else
_pass "$label"
fi
}
skip() {
printf ' %sSKIP%s %s\n' "$_C_SKIP" "$_C_RESET" "$1"
}
skip_test_if_no_docker() {
if ! command -v docker >/dev/null 2>&1; then
printf '%sSKIP%s %s — docker not on PATH\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
exit 0
fi
if ! docker info >/dev/null 2>&1; then
printf '%sSKIP%s %s — docker daemon unreachable\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
exit 0
fi
}
test_summary() {
printf '\n%s: %d passed, %d failed\n' "$TEST_NAME" "$TEST_PASS" "$TEST_FAIL"
if [ "$TEST_FAIL" -gt 0 ]; then
exit 1
fi
exit 0
}
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# Common scaffolding for every test file. Sources assert.sh and computes
# REPO_ROOT so tests can `. "${REPO_ROOT}/lib/<x>.sh"` to load the code
# they're exercising.
if [ -n "${CLAUDE_BOTTLE_TESTS_COMMON_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_COMMON_SOURCED=1
set -euo pipefail
_tests_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
TESTS_ROOT="$_tests_dir"
REPO_ROOT="$(CDPATH= cd -- "${TESTS_ROOT}/.." && pwd)"
# shellcheck source=./assert.sh
. "${TESTS_ROOT}/lib/assert.sh"
# shellcheck source=./fixtures.sh
. "${TESTS_ROOT}/lib/fixtures.sh"
-99
View File
@@ -1,99 +0,0 @@
#!/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"
}
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Test runner. Wraps unittest's discovery so we can split unit /
integration the same way the bash runner did.
Usage:
tests/run_tests.py # unit + integration
tests/run_tests.py unit # unit only (no docker)
tests/run_tests.py integration # integration only (need docker)
tests/run_tests.py tests/test_x.py # one specific file (or path)
Tests are auto-classified as integration when their filename matches
one of INTEGRATION_NAMES below; everything else is a unit test.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
TESTS_DIR = REPO_ROOT / "tests"
INTEGRATION_NAMES = {
"test_dry_run_plan.py",
"test_orphan_cleanup.py",
"test_pipelock_image.py",
"test_pipelock_sidecar_smoke.py",
}
def _all_test_files() -> list[Path]:
return sorted(TESTS_DIR.glob("test_*.py"))
def _classify(path: Path) -> str:
return "integration" if path.name in INTEGRATION_NAMES else "unit"
def _modname(path: Path) -> str:
return f"tests.{path.stem}"
def _build_suite(files: list[Path]) -> unittest.TestSuite:
loader = unittest.TestLoader()
suite = unittest.TestSuite()
for f in files:
suite.addTests(loader.loadTestsFromName(_modname(f)))
return suite
def usage() -> None:
sys.stderr.write(
"usage: tests/run_tests.py [unit|integration|path/to/test.py]\n"
)
def main(argv: list[str]) -> int:
sys.path.insert(0, str(REPO_ROOT))
if not argv:
files = _all_test_files()
else:
arg = argv[0]
if arg in ("-h", "--help"):
usage()
return 0
if arg == "unit":
files = [f for f in _all_test_files() if _classify(f) == "unit"]
elif arg == "integration":
files = [f for f in _all_test_files() if _classify(f) == "integration"]
else:
p = Path(arg).resolve()
if not p.is_file():
sys.stderr.write(f"no such file: {arg}\n")
usage()
return 2
files = [p]
if not files:
sys.stderr.write("no test files found\n")
return 2
suite = _build_suite(files)
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
return 0 if result.wasSuccessful() else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-94
View File
@@ -1,94 +0,0 @@
#!/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"
+80
View File
@@ -0,0 +1,80 @@
"""Integration: cli.py 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)."""
import json
import os
import re
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from tests._docker import skip_unless_docker
REPO_ROOT = Path(__file__).resolve().parent.parent
@skip_unless_docker()
class TestDryRunPlan(unittest.TestCase):
def test_dry_run(self):
work_dir = Path(tempfile.mkdtemp())
try:
manifest = work_dir / "claude-bottle.json"
manifest.write_text(json.dumps({
"bottles": {"dev": {"egress": {"allowlist": ["example.org"]}}},
"agents": {
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
},
}))
nets_before = self._count_claude_bottle_networks()
ctrs_before = self._count_claude_bottle_containers()
env = os.environ.copy()
env["HOME"] = str(work_dir)
env["CLAUDE_BOTTLE_DRY_RUN"] = "1"
result = subprocess.run(
[sys.executable, str(REPO_ROOT / "cli.py"), "start", "demo"],
cwd=work_dir,
env=env,
capture_output=True,
text=True,
)
out = result.stdout + result.stderr
self.assertIn("egress", out, "preflight: egress line present")
# 7 baked defaults + 1 bottle entry = 8.
self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted")
self.assertIn("api.anthropic.com", out, "preflight: baked default shown")
self.assertIn("dry-run requested", out, "dry-run banner present")
self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt")
self.assertEqual(nets_before, self._count_claude_bottle_networks(),
"no networks created")
self.assertEqual(ctrs_before, self._count_claude_bottle_containers(),
"no containers created")
finally:
import shutil
shutil.rmtree(work_dir, ignore_errors=True)
def _count_claude_bottle_networks(self) -> int:
result = subprocess.run(
["docker", "network", "ls", "--format", "{{.Name}}"],
capture_output=True,
text=True,
)
return sum(1 for n in result.stdout.splitlines() if n.startswith("claude-bottle"))
def _count_claude_bottle_containers(self) -> int:
result = subprocess.run(
["docker", "ps", "-a", "--format", "{{.Names}}"],
capture_output=True,
text=True,
)
return sum(1 for n in result.stdout.splitlines() if n.startswith("claude-bottle"))
if __name__ == "__main__":
unittest.main()
+70
View File
@@ -0,0 +1,70 @@
"""Integration: the cleanup primitives the start-flow trap depends on
are idempotent. The original orphan-network bug was a trap-ordering
issue; the fix moved the install earlier. The trap is only safe if
network_remove and pipelock_stop are no-ops against missing resources."""
import os
import subprocess
import unittest
from claude_bottle.network import (
network_create_egress,
network_create_internal,
network_remove,
)
from claude_bottle.pipelock import pipelock_stop
from tests._docker import skip_unless_docker
@skip_unless_docker()
class TestOrphanCleanup(unittest.TestCase):
def setUp(self):
self.slug = f"cb-test-orphan-{os.getpid()}"
self.internal_name = ""
self.egress_name = ""
def tearDown(self):
for n in (self.internal_name, self.egress_name):
if n:
subprocess.run(
["docker", "network", "rm", n],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def test_remove_missing_is_noop(self):
# Returning True == idempotent success.
self.assertTrue(network_remove(f"claude-bottle-net-{self.slug}-does-not-exist"))
def test_create_and_remove(self):
self.internal_name = network_create_internal(self.slug)
self.egress_name = network_create_egress(self.slug)
nets = subprocess.run(
["docker", "network", "ls", "--format", "{{.Name}}"],
capture_output=True, text=True,
).stdout.splitlines()
self.assertIn(self.internal_name, nets)
self.assertIn(self.egress_name, nets)
self.assertTrue(network_remove(self.internal_name))
self.assertTrue(network_remove(self.egress_name))
nets_after = subprocess.run(
["docker", "network", "ls", "--format", "{{.Name}}"],
capture_output=True, text=True,
).stdout.splitlines()
self.assertNotIn(self.internal_name, nets_after)
self.assertNotIn(self.egress_name, nets_after)
# Idempotent on already-removed.
self.assertTrue(network_remove(self.internal_name))
self.assertTrue(network_remove(self.egress_name))
def test_pipelock_stop_missing_sidecar(self):
# Should not raise.
pipelock_stop(f"missing-{self.slug}")
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
"""Unit: allowlist resolution — pipelock_bottle_allowlist,
pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist."""
import unittest
from claude_bottle.log import Die
from claude_bottle.pipelock import (
pipelock_bottle_allowlist,
pipelock_bottle_ssh_hostnames,
pipelock_bottle_ssh_ip_cidrs,
pipelock_bottle_ssh_trusted_domains,
pipelock_effective_allowlist,
)
from tests.fixtures import fixture_minimal, fixture_with_egress, fixture_with_ssh
class TestBottleAllowlist(unittest.TestCase):
def test_egress_allowlist_present(self):
out = pipelock_bottle_allowlist(fixture_with_egress(), "dev")
self.assertIn("github.com", out)
self.assertIn("gitlab.com", out)
self.assertIn("registry.npmjs.org", out)
def test_empty_when_no_egress_block(self):
out = pipelock_bottle_allowlist(fixture_minimal(), "dev")
self.assertEqual([], out)
def test_rejects_non_string_entry(self):
bad = {
"bottles": {"dev": {"egress": {"allowlist": ["github.com", 42]}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}
with self.assertRaises(Die):
pipelock_bottle_allowlist(bad, "dev")
class TestSSHHostnames(unittest.TestCase):
def test_hostnames_include_both(self):
hosts = pipelock_bottle_ssh_hostnames(fixture_with_ssh(), "dev")
self.assertIn("100.78.141.42", hosts)
self.assertIn("github.com", hosts)
def test_ip_cidrs_only_ipv4(self):
cidrs = pipelock_bottle_ssh_ip_cidrs(fixture_with_ssh(), "dev")
self.assertIn("100.78.141.42/32", cidrs)
self.assertNotIn("github.com", cidrs)
def test_trusted_domains_only_hostnames(self):
trusted = pipelock_bottle_ssh_trusted_domains(fixture_with_ssh(), "dev")
self.assertIn("github.com", trusted)
self.assertNotIn("100.78.141.42", trusted)
class TestEffectiveAllowlist(unittest.TestCase):
def test_union_and_dedup(self):
manifest = {
"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"}},
}
eff = pipelock_effective_allowlist(manifest, "dev")
self.assertIn("api.anthropic.com", eff)
self.assertIn("registry.npmjs.org", eff)
self.assertIn("100.78.141.42", eff)
self.assertIn("github.com", eff)
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
self.assertEqual(eff, sorted(eff), "sorted")
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
"""Unit: is_ipv4_literal — the classifier that decides whether
bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4 literal) or
trusted_domains (hostname)."""
import unittest
from claude_bottle.pipelock import is_ipv4_literal
class TestIPv4Classify(unittest.TestCase):
def test_positive(self):
for ip in ("127.0.0.1", "10.0.0.5", "100.78.141.42", "0.0.0.0", "255.255.255.255"):
with self.subTest(ip=ip):
self.assertTrue(is_ipv4_literal(ip), ip)
def test_negative(self):
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",
):
with self.subTest(hn=hn):
self.assertFalse(is_ipv4_literal(hn), hn)
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
"""Integration: verify the pinned pipelock image. Requires docker.
- Pinned digest is reachable on the registry.
- Image's ENTRYPOINT/CMD match what claude_bottle.pipelock assumes
(`/pipelock` and `run --listen 0.0.0.0:8888`).
- The /pipelock binary actually runs (--version succeeds)."""
import json
import re
import subprocess
import unittest
from claude_bottle.pipelock import PIPELOCK_IMAGE
from tests._docker import skip_unless_docker
@skip_unless_docker()
class TestPipelockImage(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Pull the pinned image (cheap if cached).
result = subprocess.run(
["docker", "pull", PIPELOCK_IMAGE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
raise unittest.SkipTest(f"could not pull {PIPELOCK_IMAGE}")
def test_entrypoint_contains_pipelock(self):
result = subprocess.run(
["docker", "image", "inspect", PIPELOCK_IMAGE,
"--format", "{{json .Config.Entrypoint}}"],
capture_output=True, text=True,
)
self.assertIn("/pipelock", result.stdout)
def test_cmd_contains_run(self):
result = subprocess.run(
["docker", "image", "inspect", PIPELOCK_IMAGE,
"--format", "{{json .Config.Cmd}}"],
capture_output=True, text=True,
)
self.assertIn("run", result.stdout)
def test_binary_runs(self):
result = subprocess.run(
["docker", "run", "--rm", PIPELOCK_IMAGE, "--version"],
capture_output=True, text=True,
)
out = result.stdout + result.stderr
self.assertRegex(out, r"[Pp]ipelock|2\.[0-9]+\.[0-9]+")
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
"""Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port)."""
import unittest
from claude_bottle.pipelock import (
pipelock_container_name,
pipelock_proxy_host_port,
pipelock_proxy_url,
)
class TestPipelockNaming(unittest.TestCase):
def test_container_name_simple(self):
self.assertEqual("claude-bottle-pipelock-foo", pipelock_container_name("foo"))
def test_container_name_with_hyphens(self):
self.assertEqual(
"claude-bottle-pipelock-some-slug", pipelock_container_name("some-slug")
)
def test_proxy_url_default_port(self):
self.assertEqual(
"http://claude-bottle-pipelock-foo:8888", pipelock_proxy_url("foo")
)
def test_proxy_host_port_default_port(self):
self.assertEqual(
"claude-bottle-pipelock-foo:8888", pipelock_proxy_host_port("foo")
)
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
"""Integration: full sidecar smoke test. Boots a pipelock container the
same way cli.py does (docker create + docker cp YAML + docker start),
then probes /health."""
import os
import re
import shutil
import subprocess
import tempfile
import time
import unittest
import urllib.request
from pathlib import Path
from claude_bottle.pipelock import PIPELOCK_IMAGE, pipelock_write_yaml
from tests._docker import skip_unless_docker
from tests.fixtures import fixture_minimal
@skip_unless_docker()
class TestPipelockSidecarSmoke(unittest.TestCase):
def setUp(self):
self.name = f"cb-test-pipelock-smoke-{os.getpid()}"
self.work_dir = Path(tempfile.mkdtemp())
def tearDown(self):
subprocess.run(
["docker", "rm", "-f", self.name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
shutil.rmtree(self.work_dir, ignore_errors=True)
def test_smoke(self):
yaml_path = self.work_dir / "pipelock.yaml"
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
create = subprocess.run(
[
"docker", "create",
"--name", self.name,
"-p", "0:8888",
PIPELOCK_IMAGE,
"run", "--config", "/etc/pipelock.yaml",
"--listen", "0.0.0.0:8888",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual(0, create.returncode, f"docker create failed: {create.stderr}")
# Guard against /etc/pipelock/ regressions: the path must be
# /etc/pipelock.yaml, since the image is distroless.
cp = subprocess.run(
["docker", "cp", str(yaml_path), f"{self.name}:/etc/pipelock.yaml"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual(0, cp.returncode, f"docker cp failed: {cp.stderr}")
start = subprocess.run(
["docker", "start", self.name],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
self.assertEqual(0, start.returncode,
f"docker start failed; check argv 'run --listen 0.0.0.0:8888'")
port_result = subprocess.run(
["docker", "port", self.name, "8888"],
capture_output=True, text=True,
)
first_line = (port_result.stdout or "").splitlines()[0] if port_result.stdout else ""
host_port = first_line.rsplit(":", 1)[-1] if first_line else ""
self.assertTrue(host_port, "could not determine published port")
health_url = f"http://127.0.0.1:{host_port}/health"
body = ""
for _ in range(15):
try:
with urllib.request.urlopen(health_url, timeout=2) as resp:
body = resp.read().decode("utf-8")
break
except (urllib.error.URLError, urllib.error.HTTPError, ConnectionError):
time.sleep(1)
self.assertIn('"status":"healthy"', body, "health body status:healthy")
self.assertRegex(body, r'"version":"[0-9]+\.[0-9]+\.[0-9]+"',
"health body has version field")
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -0,0 +1,80 @@
"""Unit: pipelock_write_yaml — produces a YAML config containing the
expected top-level keys and per-bottle entries. We don't fully parse
YAML; we grep for content shape."""
import os
import tempfile
import unittest
from pathlib import Path
from claude_bottle.pipelock import pipelock_write_yaml
from tests.fixtures import fixture_minimal, fixture_with_ssh
class TestPipelockYaml(unittest.TestCase):
def setUp(self):
self.out_dir = Path(tempfile.mkdtemp())
def tearDown(self):
import shutil
shutil.rmtree(self.out_dir, ignore_errors=True)
def test_minimal(self):
yaml_path = self.out_dir / "min.yaml"
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
content = yaml_path.read_text()
self.assertIn("mode: strict", content)
self.assertIn("enforce: true", content)
self.assertIn("api_allowlist:", content)
self.assertIn("api.anthropic.com", content)
self.assertIn("raw.githubusercontent.com", content)
self.assertIn("forward_proxy:", content)
self.assertIn("enabled: true", content)
self.assertIn("dlp:", content)
self.assertIn("include_defaults: true", content)
self.assertIn("scan_env: true", content)
# No ssh entries → no trusted_domains nor ssrf block.
self.assertNotIn("trusted_domains:", content)
self.assertNotIn("ssrf:", content)
def test_ssh_blocks(self):
yaml_path = self.out_dir / "ssh.yaml"
pipelock_write_yaml(fixture_with_ssh(), "dev", yaml_path)
content = yaml_path.read_text()
self.assertIn("trusted_domains:", content)
self.assertIn("github.com", content)
self.assertIn("ssrf:", content)
self.assertIn("ip_allowlist:", content)
self.assertIn("100.78.141.42/32", content)
# ipv4 host should also be in api_allowlist (strict mode requires both).
self.assertIn("100.78.141.42", content)
def test_secret_hygiene(self):
manifest = {
"bottles": {
"dev": {
"env": {
"MY_SECRET": "literal-value-should-not-appear",
"ANOTHER": "?prompt-message",
},
"egress": {"allowlist": ["github.com"]},
}
},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}
yaml_path = self.out_dir / "secret.yaml"
pipelock_write_yaml(manifest, "dev", yaml_path)
content = yaml_path.read_text()
self.assertNotIn("literal-value-should-not-appear", content)
self.assertNotIn("MY_SECRET", content)
self.assertNotIn("prompt-message", content)
def test_file_mode_is_600(self):
yaml_path = self.out_dir / "min.yaml"
pipelock_write_yaml(fixture_minimal(), "dev", yaml_path)
mode = os.stat(yaml_path).st_mode & 0o777
self.assertEqual(0o600, mode)
if __name__ == "__main__":
unittest.main()
-89
View File
@@ -1,89 +0,0 @@
#!/usr/bin/env bash
# Unit: allowlist resolution — pipelock_bottle_allowlist,
# pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
# pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist.
TEST_NAME="pipelock_allowlist"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
# --- bottle_allowlist (egress.allowlist parsing) ---
m="$(write_fixture fixture_with_egress)"
out="$(pipelock_bottle_allowlist "$m" dev)"
assert_contains "$out" "github.com" "bottle_allowlist: github.com present"
assert_contains "$out" "gitlab.com" "bottle_allowlist: gitlab.com present"
assert_contains "$out" "registry.npmjs.org" "bottle_allowlist: npmjs present"
rm -f "$m"
m="$(write_fixture fixture_minimal)"
out="$(pipelock_bottle_allowlist "$m" dev)"
assert_eq "" "$out" "bottle_allowlist: empty when no egress block"
rm -f "$m"
# --- ssh hostnames + classification ---
m="$(write_fixture fixture_with_ssh)"
hosts="$(pipelock_bottle_ssh_hostnames "$m" dev)"
assert_contains "$hosts" "100.78.141.42" "ssh_hostnames: ipv4 included"
assert_contains "$hosts" "github.com" "ssh_hostnames: hostname included"
cidrs="$(pipelock_bottle_ssh_ip_cidrs "$m" dev)"
assert_contains "$cidrs" "100.78.141.42/32" "ssh_ip_cidrs: ipv4 emitted as /32"
assert_not_contains "$cidrs" "github.com" "ssh_ip_cidrs: hostname not in cidr list"
trusted="$(pipelock_bottle_ssh_trusted_domains "$m" dev)"
assert_contains "$trusted" "github.com" "ssh_trusted_domains: hostname present"
assert_not_contains "$trusted" "100.78.141.42" "ssh_trusted_domains: ipv4 not present"
rm -f "$m"
# --- effective_allowlist union (defaults + bottle.allowlist + ssh.Hostname) ---
# Combine egress + ssh fixtures into one manifest.
combined="$(mktemp)"
cat > "$combined" <<'JSON'
{
"bottles": {
"dev": {
"egress": { "allowlist": ["registry.npmjs.org"] },
"ssh": [
{ "Host": "ts", "IdentityFile": "/dev/null", "Hostname": "100.78.141.42", "User": "git", "Port": 30009 },
{ "Host": "gh", "IdentityFile": "/dev/null", "Hostname": "github.com", "User": "git", "Port": 22 }
]
}
},
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
}
JSON
eff="$(pipelock_effective_allowlist "$combined" dev)"
assert_contains "$eff" "api.anthropic.com" "effective: baked-in default present"
assert_contains "$eff" "registry.npmjs.org" "effective: bottle egress entry present"
assert_contains "$eff" "100.78.141.42" "effective: ssh ipv4 hostname present"
assert_contains "$eff" "github.com" "effective: ssh hostname present"
# Ensure dedup + sort: count lines, then count unique lines, expect equal.
total="$(printf '%s\n' "$eff" | wc -l | tr -d ' ')"
uniq="$(printf '%s\n' "$eff" | sort -u | wc -l | tr -d ' ')"
assert_eq "$total" "$uniq" "effective: deduplicated"
rm -f "$combined"
# --- non-string entry rejection ---
bad="$(mktemp)"
cat > "$bad" <<'JSON'
{
"bottles": { "dev": { "egress": { "allowlist": ["github.com", 42] } } },
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
}
JSON
assert_exit_nonzero "bottle_allowlist: rejects non-string entry" \
bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_bottle_allowlist "'"$bad"'" dev'
rm -f "$bad"
test_summary
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Unit: _pipelock_is_ipv4_literal — the classifier that decides
# whether bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4
# literal) or trusted_domains (hostname).
TEST_NAME="pipelock_classify"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
# Positive cases — these should be classified as IPv4 literals.
for ip in "127.0.0.1" "10.0.0.5" "100.78.141.42" "0.0.0.0" "255.255.255.255"; do
assert_exit_zero "ipv4: ${ip}" _pipelock_is_ipv4_literal "$ip"
done
# Negative cases — hostnames, partial IPs, IPv6, and edge garbage
# should NOT match.
for hn in \
"github.com" \
"gitea.dideric.is" \
"100.78.141" \
"100.78.141.42.5" \
"::1" \
"fe80::1" \
"localhost" \
"" \
"1.2.3.4.example.com"
do
assert_exit_nonzero "non-ipv4: '${hn}'" _pipelock_is_ipv4_literal "$hn"
done
test_summary
-23
View File
@@ -1,23 +0,0 @@
#!/usr/bin/env bash
# Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port).
TEST_NAME="pipelock_naming"
. "$(dirname "$0")/../lib/common.sh"
# shellcheck source=../../lib/log.sh
. "${REPO_ROOT}/lib/log.sh"
# shellcheck source=../../lib/pipelock.sh
. "${REPO_ROOT}/lib/pipelock.sh"
assert_eq "claude-bottle-pipelock-foo" "$(pipelock_container_name foo)" "container_name simple slug"
assert_eq "claude-bottle-pipelock-some-slug" "$(pipelock_container_name some-slug)" "container_name with hyphens"
# proxy_url and proxy_host_port use whatever CLAUDE_BOTTLE_PIPELOCK_PORT
# is at source time. We sourced with default (8888).
assert_eq "http://claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_url foo)" "proxy_url default port"
assert_eq "claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_host_port foo)" "proxy_host_port default port"
# Both helpers should fail loudly without a slug (the `${1:?...}` guards).
assert_exit_nonzero "container_name: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_container_name'
assert_exit_nonzero "proxy_url: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_proxy_url'
test_summary
-90
View File
@@ -1,90 +0,0 @@
#!/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