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

This commit was merged in pull request #1.
This commit is contained in:
2026-05-08 01:56:43 -04:00
parent 08597ebcf8
commit ba7616a4ae
20 changed files with 1977 additions and 12 deletions
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Tiny assertion helpers. No framework — each test file sources this,
# calls `assert_*` functions, and ends with `test_summary` which exits
# 0 if every assertion passed and 1 otherwise.
#
# Counters are file-local: every test process gets its own TEST_PASS /
# TEST_FAIL. run_tests.sh aggregates by exit code, not by reading these.
if [ -n "${CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_ASSERT_SOURCED=1
TEST_PASS=0
TEST_FAIL=0
TEST_NAME="${TEST_NAME:-unnamed}"
if [ -t 1 ]; then
_C_PASS=$'\033[32m'
_C_FAIL=$'\033[31m'
_C_SKIP=$'\033[33m'
_C_RESET=$'\033[0m'
else
_C_PASS=""
_C_FAIL=""
_C_SKIP=""
_C_RESET=""
fi
_pass() {
TEST_PASS=$((TEST_PASS + 1))
printf ' %sPASS%s %s\n' "$_C_PASS" "$_C_RESET" "$1"
}
_fail() {
TEST_FAIL=$((TEST_FAIL + 1))
printf ' %sFAIL%s %s\n' "$_C_FAIL" "$_C_RESET" "$1" >&2
shift
local line
for line in "$@"; do
printf ' %s\n' "$line" >&2
done
}
assert_eq() {
local expected="$1" actual="$2" msg="${3:-equal}"
if [ "$expected" = "$actual" ]; then
_pass "$msg"
else
_fail "$msg" "expected: ${expected}" "actual: ${actual}"
fi
}
assert_contains() {
local haystack="$1" needle="$2" msg="${3:-contains}"
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
_pass "$msg"
else
_fail "$msg" "expected to contain: ${needle}" "haystack: ${haystack}"
fi
}
assert_not_contains() {
local haystack="$1" needle="$2" msg="${3:-does not contain}"
if ! printf '%s' "$haystack" | grep -qF -- "$needle"; then
_pass "$msg"
else
_fail "$msg" "expected NOT to contain: ${needle}" "haystack: ${haystack}"
fi
}
assert_match() {
local haystack="$1" pattern="$2" msg="${3:-matches}"
if printf '%s' "$haystack" | grep -qE -- "$pattern"; then
_pass "$msg"
else
_fail "$msg" "expected pattern: ${pattern}" "haystack: ${haystack}"
fi
}
# assert_exit_zero <cmd...> — runs the command, fails the assertion
# if it exits non-zero. Captures stdout+stderr for the failure message.
assert_exit_zero() {
local label="$1"; shift
local out
if out="$("$@" 2>&1)"; then
_pass "$label"
else
_fail "$label" "exit non-zero" "output: ${out}"
fi
}
assert_exit_nonzero() {
local label="$1"; shift
local out
if out="$("$@" 2>&1)"; then
_fail "$label" "exit was 0; expected non-zero" "output: ${out}"
else
_pass "$label"
fi
}
skip() {
printf ' %sSKIP%s %s\n' "$_C_SKIP" "$_C_RESET" "$1"
}
skip_test_if_no_docker() {
if ! command -v docker >/dev/null 2>&1; then
printf '%sSKIP%s %s — docker not on PATH\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
exit 0
fi
if ! docker info >/dev/null 2>&1; then
printf '%sSKIP%s %s — docker daemon unreachable\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME"
exit 0
fi
}
test_summary() {
printf '\n%s: %d passed, %d failed\n' "$TEST_NAME" "$TEST_PASS" "$TEST_FAIL"
if [ "$TEST_FAIL" -gt 0 ]; then
exit 1
fi
exit 0
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Common scaffolding for every test file. Sources assert.sh and computes
# REPO_ROOT so tests can `. "${REPO_ROOT}/lib/<x>.sh"` to load the code
# they're exercising.
if [ -n "${CLAUDE_BOTTLE_TESTS_COMMON_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_COMMON_SOURCED=1
set -euo pipefail
_tests_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
TESTS_ROOT="$_tests_dir"
REPO_ROOT="$(CDPATH= cd -- "${TESTS_ROOT}/.." && pwd)"
# shellcheck source=./assert.sh
. "${TESTS_ROOT}/lib/assert.sh"
# shellcheck source=./fixtures.sh
. "${TESTS_ROOT}/lib/fixtures.sh"
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Manifest fixture builders. Each function prints a JSON manifest on
# stdout; callers can pipe to a temp file or pass through `write_fixture`.
if [ -n "${CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_TESTS_FIXTURES_SOURCED=1
# fixture_minimal — one bottle, one agent, no env / ssh / skills.
fixture_minimal() {
cat <<'JSON'
{
"bottles": {
"dev": {}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
}
# fixture_with_egress — bottle declares an egress.allowlist.
fixture_with_egress() {
cat <<'JSON'
{
"bottles": {
"dev": {
"egress": {
"allowlist": [
"github.com",
"gitlab.com",
"registry.npmjs.org"
]
}
}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
}
# fixture_with_ssh — bottle has both an IPv4-literal SSH host (Tailscale
# CGNAT range) and a hostname SSH host, exercising both
# ssrf.ip_allowlist and trusted_domains code paths.
fixture_with_ssh() {
cat <<'JSON'
{
"bottles": {
"dev": {
"ssh": [
{
"Host": "tailscale-gitea",
"IdentityFile": "/dev/null",
"Hostname": "100.78.141.42",
"User": "git",
"Port": 30009
},
{
"Host": "github",
"IdentityFile": "/dev/null",
"Hostname": "github.com",
"User": "git",
"Port": 22
}
]
}
},
"agents": {
"demo": {
"skills": [],
"prompt": "",
"bottle": "dev"
}
}
}
JSON
}
# write_fixture <fixture_func> — write fixture to a temp file, print
# the path. Caller must rm.
write_fixture() {
local fn="${1:?write_fixture: missing fixture function}"
local f
f="$(mktemp)"
"$fn" > "$f"
printf '%s' "$f"
}