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
+40 -20
View File
@@ -1,25 +1,45 @@
#!/usr/bin/env bash
# Enforce Conventional Commits on the first line of the commit message.
# https://www.conventionalcommits.org/en/v1.0.0/
#
# Activate per clone with:
# git config core.hooksPath .githooks
#!/usr/bin/env python3
"""Enforce Conventional Commits on the first line of the commit message.
https://www.conventionalcommits.org/en/v1.0.0/
set -euo pipefail
Activate per clone with:
git config core.hooksPath .githooks
"""
msg_file="${1:?commit-msg: missing message file path}"
first_line="$(awk 'NR==1{print; exit}' "$msg_file")"
from __future__ import annotations
case "$first_line" in
"Merge "*|"Revert "*|"fixup! "*|"squash! "*|"amend! "*) exit 0 ;;
esac
import re
import sys
from pathlib import Path
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._-]+\))?!?: .+'
ALLOWED_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ")
PATTERN = re.compile(
r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
r"(\([a-z0-9._-]+\))?!?: .+"
)
if ! printf '%s' "$first_line" | grep -qE "$pattern"; then
printf 'commit-msg: aborting — message does not follow Conventional Commits.\n' >&2
printf ' expected: <type>[(<scope>)][!]: <description>\n' >&2
printf ' types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert\n' >&2
printf ' got: %s\n' "$first_line" >&2
exit 1
fi
def main(argv: list[str]) -> int:
if len(argv) < 1:
print("commit-msg: missing message file path", file=sys.stderr)
return 1
msg_file = Path(argv[0])
text = msg_file.read_text(encoding="utf-8", errors="replace")
first_line = text.splitlines()[0] if text else ""
if any(first_line.startswith(p) for p in ALLOWED_PREFIXES):
return 0
if not PATTERN.match(first_line):
sys.stderr.write("commit-msg: aborting — message does not follow Conventional Commits.\n")
sys.stderr.write(" expected: <type>[(<scope>)][!]: <description>\n")
sys.stderr.write(" types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert\n")
sys.stderr.write(f" got: {first_line}\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+1
View File
@@ -0,0 +1 @@
"""claude-bottle: Python implementation of the agent container launcher."""
+758
View File
@@ -0,0 +1,758 @@
"""Main CLI dispatcher.
Commands: build, cleanup, edit, info, init, list, start
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
from . import docker as docker_mod
from . import network as network_mod
from . import pipelock
from . import skills as skills_mod
from . import ssh as ssh_mod
from .env_resolve import env_resolve
from .log import Die, die, info, warn
from .manifest import (
Manifest,
manifest_agent_bottle,
manifest_env_names,
manifest_prompt,
manifest_require_agent,
manifest_require_bottle,
manifest_resolve,
manifest_skills,
manifest_ssh,
)
PROG = "cli.py"
USER_CWD = os.getcwd()
REPO_DIR = str(Path(__file__).resolve().parent.parent)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def cmd_build(_argv: list[str]) -> int:
docker_mod.require_docker()
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
docker_mod.build_image(image, REPO_DIR)
return 0
def cmd_info(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} info", add_help=True)
parser.add_argument("name", help="agent name defined in claude-bottle.json")
args = parser.parse_args(argv)
manifest = manifest_resolve(USER_CWD)
manifest_require_agent(manifest, args.name)
env_names = manifest_env_names(manifest, args.name)
skill_names = manifest_skills(manifest, args.name)
prompt_content = manifest_prompt(manifest, args.name)
prompt_first_line = prompt_content.splitlines()[0] if prompt_content else ""
bottle_name = manifest_agent_bottle(manifest, args.name)
ssh_entries = manifest_ssh(manifest, args.name)
print()
info(f"agent : {args.name}")
info(f"env (names only): {', '.join(env_names) if env_names else '(none)'}")
info(f"skills : {' '.join(skill_names) if skill_names else '(none)'}")
info(
f"prompt : {len(prompt_content)} chars; "
f"first line: {prompt_first_line or '(empty)'}"
)
if bottle_name:
info(f"bottle : {bottle_name}")
if ssh_entries:
for e in ssh_entries:
info(
f" ssh host : {e.get('Host')} "
f"(Hostname={e.get('Hostname')}, User={e.get('User')}, "
f"Port={e.get('Port')}, IdentityFile={e.get('IdentityFile')})"
)
if e.get("KnownHostKey"):
info(f" KnownHostKey: {e['KnownHostKey']}")
else:
info(" ssh hosts : (none)")
else:
info("bottle : (none)")
print()
return 0
def cmd_list(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} list", add_help=True)
parser.add_argument("scope", choices=["available", "active"])
args = parser.parse_args(argv)
if args.scope == "available":
manifest = manifest_resolve(USER_CWD)
for name in (manifest.get("agents") or {}).keys():
print(name)
return 0
docker_mod.require_docker()
result = subprocess.run(
[
"docker", "ps",
"--filter", "name=^claude-bottle-",
"--format", "{{.Names}}\t{{.Status}}",
],
capture_output=True,
text=True,
)
containers = (result.stdout or "").strip()
if not containers:
info("no active claude-bottle containers")
return 0
print()
for line in containers.splitlines():
name, _, status = line.partition("\t")
info(f"container: {name} status: {status}")
print()
return 0
def cmd_cleanup(_argv: list[str]) -> int:
docker_mod.require_docker()
result = subprocess.run(
["docker", "ps", "--filter", "name=^claude-bottle-", "--format", "{{.Names}}"],
capture_output=True,
text=True,
)
containers = (result.stdout or "").strip()
if not containers:
info("no active claude-bottle containers")
return 0
print(file=sys.stderr)
for name in containers.splitlines():
info(f"found: {name}")
print(file=sys.stderr)
sys.stderr.write("claude-bottle: remove all of the above? [y/N] ")
sys.stderr.flush()
reply = _read_tty_line()
if reply not in ("y", "Y", "yes", "YES"):
info("aborted")
return 0
for name in containers.splitlines():
info(f"removing {name}")
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
info("done")
return 0
def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
parser.add_argument("--remote-control", action="store_true")
parser.add_argument("name", help="agent name defined in claude-bottle.json")
args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1"
name = args.name
slug = docker_mod.slugify(name)
image = os.environ.get("CLAUDE_BOTTLE_IMAGE", "claude-bottle:latest")
default_container = f"claude-bottle-{slug}"
pinned_container = os.environ.get("CLAUDE_BOTTLE_CONTAINER", "")
runtime_image = image
derived_image = ""
if args.cwd:
derived_image = os.environ.get("CLAUDE_BOTTLE_DERIVED_IMAGE", f"claude-bottle:cwd-{slug}")
runtime_image = derived_image
docker_mod.require_docker()
manifest = manifest_resolve(USER_CWD)
manifest_require_agent(manifest, name)
container = pinned_container or default_container
suffix = 2
if pinned_container:
if docker_mod.container_exists(container):
die(
f"container '{container}' already exists "
f"(pinned via CLAUDE_BOTTLE_CONTAINER). "
f"Remove it with 'docker rm -f {container}' or unset the override."
)
else:
while docker_mod.container_exists(container):
container = f"{default_container}-{suffix}"
suffix += 1
if suffix > 100:
die(
f"could not find a free container name after "
f"{default_container}-99; clean up old containers with "
f"'docker rm -f <name>'"
)
# --- Plan resolution (host-only, no container yet) ---
env_names = manifest_env_names(manifest, name)
# CLAUDE_BOTTLE_OAUTH_TOKEN → CLAUDE_CODE_OAUTH_TOKEN forwarding.
# Host-side token is always forwarded so every container can authenticate.
forward_oauth_token = bool(os.environ.get("CLAUDE_BOTTLE_OAUTH_TOKEN"))
display_env_names = list(env_names)
if forward_oauth_token:
display_env_names.append("CLAUDE_CODE_OAUTH_TOKEN")
skill_names = manifest_skills(manifest, name)
if skill_names:
skills_mod.skills_validate_all(skill_names)
bottle_name = manifest_agent_bottle(manifest, name)
if not bottle_name:
die(
f"agent '{name}' has no 'bottle' field. "
f"Add a bottle association to this agent in claude-bottle.json."
)
manifest_require_bottle(manifest, bottle_name)
ssh_entries = manifest_ssh(manifest, name)
if ssh_entries:
ssh_mod.ssh_validate_entries(ssh_entries)
stage_dir = Path(tempfile.mkdtemp(prefix="claude-bottle-stage."))
env_file = stage_dir / "agent.env"
args_file = stage_dir / "docker-args"
prompt_file = stage_dir / "prompt.txt"
pipelock_yaml_filename = "pipelock.yaml"
pipelock_yaml = stage_dir / pipelock_yaml_filename
env_file.write_text("")
env_file.chmod(0o600)
args_file.write_text("")
prompt_file.write_text("")
prompt_file.chmod(0o600)
# cleanup state — populated as resources come up.
state: dict[str, str] = {
"container": "",
"pipelock": "",
"internal_network": "",
"egress_network": "",
}
def cleanup_all() -> None:
try:
if state["container"] and docker_mod.container_exists(state["container"]):
subprocess.run(
["docker", "rm", "-f", state["container"]],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if state["pipelock"]:
pipelock.pipelock_stop(slug)
if state["internal_network"]:
network_mod.network_remove(state["internal_network"])
if state["egress_network"]:
network_mod.network_remove(state["egress_network"])
finally:
shutil.rmtree(stage_dir, ignore_errors=True)
try:
pipelock.pipelock_write_yaml(manifest, bottle_name, pipelock_yaml)
allowlist_summary = pipelock.pipelock_allowlist_summary(manifest, bottle_name)
env_resolve(manifest, name, env_file, args_file)
prompt_content = manifest_prompt(manifest, name)
prompt_file.write_text(prompt_content)
prompt_first_line = prompt_content.splitlines()[0] if prompt_content else ""
# --- Plan + confirm ---
print(file=sys.stderr)
info(f"agent : {name}")
info(f"image : {image}")
if derived_image:
info(f"cwd : {USER_CWD} -> /home/node/workspace (derived: {derived_image})")
info(f"container : {container}")
info(f"stage dir : {stage_dir}")
info(
"env (names only): "
+ (", ".join(display_env_names) if display_env_names else "(none)")
)
info("skills : " + (" ".join(skill_names) if skill_names else "(none)"))
info(f"bottle : {bottle_name}")
if ssh_entries:
ssh_names = ", ".join(e.get("Host", "") for e in ssh_entries)
info(f" ssh hosts : {ssh_names}")
else:
info(" ssh hosts : (none)")
info(f" egress : {allowlist_summary}")
info(
f"prompt : {len(prompt_content)} chars; "
f"first line: {prompt_first_line or '(empty)'}"
)
info("remote-control : " + ("enabled" if args.remote_control else "disabled"))
print(file=sys.stderr)
if dry_run:
info("dry-run requested; not starting container.")
cleanup_all()
return 0
sys.stderr.write("claude-bottle: launch this agent? [y/N] ")
sys.stderr.flush()
reply = _read_tty_line()
if reply not in ("y", "Y", "yes", "YES"):
info("aborted by user")
cleanup_all()
return 0
# --- Build & launch ---
docker_mod.build_image(image, REPO_DIR)
if derived_image:
docker_mod.build_image_with_cwd(derived_image, image, USER_CWD)
state["internal_network"] = network_mod.network_create_internal(slug)
state["egress_network"] = network_mod.network_create_egress(slug)
state["pipelock"] = pipelock.pipelock_start(
slug,
state["internal_network"],
state["egress_network"],
stage_dir,
pipelock_yaml_filename,
)
proxy_url = pipelock.pipelock_proxy_url(slug)
docker_args: list[str] = [
"--rm", "-d",
"--name", container,
"--network", state["internal_network"],
"-e", f"HTTPS_PROXY={proxy_url}",
"-e", f"HTTP_PROXY={proxy_url}",
"-e", "NO_PROXY=localhost,127.0.0.1",
]
if env_file.stat().st_size > 0:
docker_args.extend(["--env-file", str(env_file)])
# ARGS_FILE pairs (-e, NAME) line-by-line.
args_lines = args_file.read_text().splitlines()
i = 0
while i < len(args_lines):
flag = args_lines[i]
i += 1
if not flag:
continue
if i >= len(args_lines):
break
vname = args_lines[i]
i += 1
docker_args.extend([flag, vname])
if forward_oauth_token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = os.environ["CLAUDE_BOTTLE_OAUTH_TOKEN"]
docker_args.extend(["-e", "CLAUDE_CODE_OAUTH_TOKEN"])
docker_args.extend([runtime_image, "sleep", "infinity"])
info(f"starting container {container} from {runtime_image}")
# Retry-on-name-conflict loop to mirror the bash version.
while True:
full_argv = ["docker", "run", *docker_args]
run_result = subprocess.run(full_argv, capture_output=True, text=True)
if run_result.returncode == 0:
state["container"] = container
break
err_text = run_result.stderr
if pinned_container or "is already in use" not in err_text:
sys.stderr.write(err_text + "\n")
die(f"docker run failed for container '{container}'")
if suffix > 100:
die(
f"could not find a free container name after "
f"{default_container}-99 retries; clean up old containers"
)
container = f"{default_container}-{suffix}"
suffix += 1
# Replace --name slot in docker_args.
name_idx = docker_args.index("--name") + 1
docker_args[name_idx] = container
info(f"name conflict; retrying as {container}")
container_prompt_path = (
os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
+ "/.claude-bottle-prompt.txt"
)
subprocess.run(
["docker", "cp", str(prompt_file), f"{container}:{container_prompt_path}"],
stdout=subprocess.DEVNULL,
check=True,
)
# `docker cp` preserves host UID; re-own/mode as root in the container
# so node can read its own mode-600 prompt regardless of host UID.
subprocess.run(
["docker", "exec", "-u", "0", container, "chown", "node:node", container_prompt_path],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "exec", "-u", "0", container, "chmod", "600", container_prompt_path],
stdout=subprocess.DEVNULL,
check=True,
)
if skill_names:
skills_mod.skills_copy_into(container, skill_names)
if ssh_entries:
proxy_host_port = pipelock.pipelock_proxy_host_port(slug)
ssh_mod.ssh_setup(container, stage_dir, proxy_host_port, ssh_entries)
if args.cwd and Path(USER_CWD, ".git").is_dir():
info(f"copying {USER_CWD}/.git -> {container}:/home/node/workspace/.git")
subprocess.run(
["docker", "cp", f"{USER_CWD}/.git", f"{container}:/home/node/workspace/.git"],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "exec", "-u", "0", container, "chown", "-R", "node:node", "/home/node/workspace/.git"],
stdout=subprocess.DEVNULL,
check=True,
)
info(
"attaching interactive claude session "
"(Ctrl-D or 'exit' to leave; container will be removed)"
)
claude_args = ["--dangerously-skip-permissions"]
if args.remote_control:
claude_args.append("--remote-control")
if prompt_content:
subprocess.run(
[
"docker", "exec", "-it", container, "claude",
*claude_args,
"--append-system-prompt-file", container_prompt_path,
]
)
else:
subprocess.run(
["docker", "exec", "-it", container, "claude", *claude_args]
)
info(f"session ended; container {container} will be removed")
return 0
finally:
cleanup_all()
# ---------------------------------------------------------------------------
# init / edit
# ---------------------------------------------------------------------------
def cmd_init(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} init", add_help=True)
parser.add_argument("scope", choices=["user", "project"])
args = parser.parse_args(argv)
if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
else:
target_file = Path(USER_CWD) / "claude-bottle.json"
print(file=sys.stderr)
info(f"claude-bottle init — adding a new agent to {target_file}")
print(file=sys.stderr)
# Agent name
agent_name = ""
while not agent_name:
sys.stderr.write("Agent name: ")
sys.stderr.flush()
agent_name = _read_tty_line().replace(" ", "-")
if not agent_name:
warn("agent name cannot be empty")
if not re.match(r"^[a-z0-9][a-z0-9-]*$", agent_name):
warn(
f"agent name '{agent_name}' contains non-slug characters; "
f"it will still work but may cause container naming issues"
)
existing: dict[str, Any] = {}
if target_file.is_file():
try:
existing = json.loads(target_file.read_text())
except json.JSONDecodeError:
die(f"{target_file} exists but is not valid JSON; fix or remove it first")
if agent_name in (existing.get("agents") or {}):
sys.stderr.write(
f'claude-bottle: agent "{agent_name}" already exists in {target_file}. Overwrite? [y/N] '
)
sys.stderr.flush()
ow = _read_tty_line()
if ow not in ("y", "Y", "yes", "YES"):
info("aborted")
return 0
# Skills
print(file=sys.stderr)
sys.stderr.write("Skills (space or comma separated, or Enter for none): ")
sys.stderr.flush()
skills_input = _read_tty_line()
skill_list: list[str] = []
if skills_input:
cleaned = skills_input.replace(",", " ")
skill_list = [s for s in cleaned.split() if s]
# Prompt
print(file=sys.stderr)
info("System prompt — enter text, then a lone '.' on its own line to finish (just '.' to leave empty):")
prompt_lines: list[str] = []
while True:
line = _read_tty_line()
if line == ".":
break
prompt_lines.append(line)
prompt_content = "\n".join(prompt_lines)
# Bottle association
print(file=sys.stderr)
sys.stderr.write("Associate this agent with a bottle? [y/N] ")
sys.stderr.flush()
bottle_yn = _read_tty_line()
bottle_name = ""
bottle_env: dict[str, str] = {}
bottle_ssh: list[dict[str, Any]] = []
bottle_exists_already = False
if bottle_yn in ("y", "Y", "yes", "YES"):
while not bottle_name:
sys.stderr.write(" Bottle name: ")
sys.stderr.flush()
bottle_name = _read_tty_line().replace(" ", "-")
if not bottle_name:
warn("bottle name cannot be empty")
if bottle_name in (existing.get("bottles") or {}):
bottle_exists_already = True
info(f"Bottle '{bottle_name}' already exists in {target_file}; agent will reference it.")
else:
info(f"Creating new bottle '{bottle_name}'.")
bottle_env = _prompt_for_env_vars()
sys.stderr.write(" Add SSH host entries to this bottle? [y/N] ")
sys.stderr.flush()
ssh_yn = _read_tty_line()
if ssh_yn in ("y", "Y", "yes", "YES"):
bottle_ssh = _prompt_for_ssh_entries()
# Build agent JSON
agent_def: dict[str, Any] = {"skills": skill_list, "prompt": prompt_content}
if bottle_name:
agent_def["bottle"] = bottle_name
merged: dict[str, Any] = {
"bottles": dict(existing.get("bottles") or {}),
"agents": dict(existing.get("agents") or {}),
}
if bottle_name and not bottle_exists_already:
merged["bottles"][bottle_name] = {"env": bottle_env, "ssh": bottle_ssh}
merged["agents"][agent_name] = agent_def
target_file.write_text(json.dumps(merged, indent=2) + "\n")
info(f"Agent '{agent_name}' written to {target_file}.")
info(f"Run '{PROG} info {agent_name}' to verify.")
print(file=sys.stderr)
return 0
def _prompt_for_env_vars() -> dict[str, str]:
print(file=sys.stderr)
info("Env vars — enter each var name then its mode. Press Enter with no name to finish.")
info(" Modes: secret (prompt at runtime) | interpolated (read from host env) | literal (hardcoded value)")
out: dict[str, str] = {}
while True:
print(file=sys.stderr)
sys.stderr.write(" Var name (or Enter to finish): ")
sys.stderr.flush()
vname = _read_tty_line()
if not vname:
break
sys.stderr.write(" Mode [secret/interpolated/literal] (default: secret): ")
sys.stderr.flush()
vmode = _read_tty_line() or "secret"
if vmode == "secret":
sys.stderr.write(f' Prompt message shown to user (default: "enter {vname}"): ')
sys.stderr.flush()
smsg = _read_tty_line()
value = f"?{smsg}" if smsg else "?"
elif vmode == "interpolated":
sys.stderr.write(f" Host env var to read from (default: {vname}): ")
sys.stderr.flush()
hvar = _read_tty_line() or vname
value = "${" + hvar + "}"
elif vmode == "literal":
sys.stderr.write(" Value: ")
sys.stderr.flush()
value = _read_tty_line()
else:
warn(f"unknown mode '{vmode}'; using secret")
value = "?"
out[vname] = value
return out
def _prompt_for_ssh_entries() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
while True:
print(file=sys.stderr)
sys.stderr.write(" SSH Host alias (or Enter to finish): ")
sys.stderr.flush()
shost = _read_tty_line()
if not shost:
break
sys.stderr.write(" Hostname (actual hostname or IP): ")
sys.stderr.flush()
shostname = _read_tty_line()
sys.stderr.write(" User: ")
sys.stderr.flush()
suser = _read_tty_line()
sys.stderr.write(" Port (default: 22): ")
sys.stderr.flush()
sport_raw = _read_tty_line() or "22"
if not sport_raw.isdigit():
warn("port must be a number; defaulting to 22")
sport_raw = "22"
sport = int(sport_raw)
sys.stderr.write(" IdentityFile (path to private key on host): ")
sys.stderr.flush()
sidentity = _read_tty_line()
sys.stderr.write(" KnownHostKey (optional, Enter to skip): ")
sys.stderr.flush()
skhk = _read_tty_line()
entry: dict[str, Any] = {
"Host": shost,
"Hostname": shostname,
"User": suser,
"Port": sport,
"IdentityFile": sidentity,
}
if skhk:
entry["KnownHostKey"] = skhk
out.append(entry)
return out
def cmd_edit(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} edit", add_help=True)
parser.add_argument("scope", choices=["user", "project"])
parser.add_argument("name")
args = parser.parse_args(argv)
if args.scope == "user":
target_file = Path(os.environ["HOME"]) / "claude-bottle.json"
else:
target_file = Path(USER_CWD) / "claude-bottle.json"
if not target_file.is_file():
die(f"{target_file} does not exist")
try:
doc = json.loads(target_file.read_text())
except json.JSONDecodeError:
die(f"{target_file} is not valid JSON")
if args.name not in (doc.get("agents") or {}):
die(f"agent '{args.name}' not found in {target_file}")
line = 1
text = target_file.read_text().splitlines()
needle = f'"{args.name}"'
for idx, l in enumerate(text, start=1):
if needle in l:
line = idx
break
os.execvp("vim", ["vim", f"+{line}", str(target_file)])
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _read_tty_line() -> str:
"""Mirror `IFS= read -r REPLY </dev/tty`. Falls back to stdin."""
try:
with open("/dev/tty", "r") as tty:
return tty.readline().rstrip("\n")
except OSError:
return sys.stdin.readline().rstrip("\n")
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
COMMANDS = {
"build": cmd_build,
"cleanup": cmd_cleanup,
"edit": cmd_edit,
"info": cmd_info,
"init": cmd_init,
"list": cmd_list,
"start": cmd_start,
}
def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
sys.stderr.write("Commands:\n")
sys.stderr.write(" build build (or rebuild) the claude-bottle Docker image\n")
sys.stderr.write(" cleanup stop and remove all active claude-bottle containers\n")
sys.stderr.write(" edit open an agent in vim for editing\n")
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
sys.stderr.write(" init interactively create a new agent and add it to claude-bottle.json\n")
sys.stderr.write(" list list available agents or active containers\n")
sys.stderr.write(" start boot a container for a named agent and attach an interactive session\n\n")
sys.stderr.write(f"Run '{PROG} <command> --help' for command-specific usage.\n")
def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
if not argv:
usage()
return 2
command = argv[0]
rest = argv[1:]
if command in ("-h", "--help"):
usage()
return 0
handler = COMMANDS.get(command)
if handler is None:
usage()
die(f"unknown command: {command}")
try:
return handler(rest) or 0
except Die as e:
return e.code if isinstance(e.code, int) else 1
except KeyboardInterrupt:
return 130
if __name__ == "__main__":
sys.exit(main())
+95
View File
@@ -0,0 +1,95 @@
"""Docker helpers. Build/inspect primitives shared by the CLI."""
from __future__ import annotations
import re
import shutil
import subprocess
from typing import Iterable
from .log import die, info
def require_docker() -> None:
"""Fail with an install pointer if `docker` is not on PATH."""
if shutil.which("docker") is None:
info("Docker is required but was not found on PATH.")
info("macOS: install Docker Desktop https://docs.docker.com/desktop/install/mac-install/")
info("Linux: install Docker Engine https://docs.docker.com/engine/install/")
die("docker not found")
def image_exists(ref: str) -> bool:
return _silent_run(["docker", "image", "inspect", ref]) == 0
def container_exists(name: str) -> bool:
"""Returns True if a container (running or stopped) with the given
name exists. Uses `docker ps -a -q -f name=^<name>$` so substring
matches don't false-positive."""
result = subprocess.run(
["docker", "ps", "-a", "-q", "-f", f"name=^{name}$"],
capture_output=True,
text=True,
)
return bool(result.stdout.strip())
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def slugify(name: str) -> str:
"""Lowercase, non-alnum runs → '-', trimmed. Dies on empty result."""
if not name:
die("slugify: missing name")
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
if not slug:
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
return slug
def build_image(ref: str, context: str) -> None:
"""Invokes `docker build` every call. Layer cache makes no-change
rebuilds cheap; running every time means Dockerfile edits land
without manual `docker rmi`."""
info(f"building image {ref} from {context} (layer cache keeps repeat builds fast)")
subprocess.run(["docker", "build", "-t", ref, context], check=True)
_TRUST_DIALOG_NODE_SCRIPT = (
'const fs=require("fs"),p=process.env.HOME+"/.claude.json",'
'c=JSON.parse(fs.readFileSync(p,"utf8"));'
'c.projects=c.projects||{};'
'c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};'
'fs.writeFileSync(p,JSON.stringify(c,null,2));'
)
def build_image_with_cwd(derived: str, base: str, cwd: str) -> None:
"""Build a thin derived image that copies <cwd> into
/home/node/workspace and adds a trust-dialog entry for it."""
import os
if not os.path.isdir(cwd):
die(f"cwd not found at {cwd}")
info(f"building image {derived} from {base} with {cwd} -> /home/node/workspace")
dockerfile = (
f"FROM {base}\n"
f"COPY --chown=node:node . /home/node/workspace\n"
f"RUN node -e '{_TRUST_DIALOG_NODE_SCRIPT}'\n"
f"WORKDIR /home/node/workspace\n"
)
subprocess.run(
["docker", "build", "-t", derived, "-f", "-", cwd],
input=dockerfile,
text=True,
check=True,
)
def _silent_run(cmd: Iterable[str]) -> int:
return subprocess.run(
list(cmd),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
+140
View File
@@ -0,0 +1,140 @@
"""Env resolver. Walks the env entries for one agent and produces:
1. The list of `docker run` arg fragments needed to forward each var.
Both `secret` and `interpolated` entries become `-e NAME` (no
`=value`) so Docker inherits the value from this process env
without rendering it on argv or persisting it to disk.
Only `literal` entries are written to a host-disk env-file.
2. The export side-effect of populating this process's env with
secret values prompted from the user, and with interpolated
values copied from the matching host var, so `-e NAME` actually
has something to inherit.
Each env entry is a string. Mode is selected by sentinel prefix:
"?" → secret (prompt at runtime). Bare "?" uses default prompt;
"?<message>" uses <message> as the prompt body.
"${HOST_VAR}" → interpolated from $HOST_VAR in the host process env
any other str → literal (the string is the value verbatim)
Critical rules:
- NEVER echo, log, or interpolate the value of a secret or
interpolated env var. Both are treated as potentially sensitive:
nothing about their value (other than presence) ever lands on
disk, in a log line, or on argv.
- The env-file written for literals lives under mktemp -d with mode
600, removed by the caller's cleanup.
- Errors mention only the variable NAME, never any portion of the value.
"""
from __future__ import annotations
import getpass
import os
import re
import sys
from pathlib import Path
from .log import die
from .manifest import Manifest, manifest_env_entry, manifest_env_names
_INTERPOLATED_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
def env_entry_kind(raw: str) -> str:
"""Returns 'secret', 'interpolated', or 'literal'."""
if raw.startswith("?"):
return "secret"
if _INTERPOLATED_RE.match(raw):
return "interpolated"
return "literal"
def env_entry_secret_prompt(raw: str) -> str:
"""For a secret entry, the prompt body (after the leading '?').
Empty for bare '?', meaning use default."""
if raw.startswith("?"):
return raw[1:]
return ""
def env_entry_interpolated_from(raw: str) -> str:
"""For an interpolated entry, the host var name between '${' and '}'."""
m = _INTERPOLATED_RE.match(raw)
if not m:
return ""
return m.group(1)
def _read_secret_silent(name: str, prompt_body: str) -> str:
"""Read a secret value from the controlling tty without echoing.
The "(input hidden): " tail is always appended; manifest authors
write only the message text."""
if not (sys.stdin.isatty() or sys.stderr.isatty()):
# Fall back to /dev/tty so this still works when stdin is a pipe.
try:
tty = open("/dev/tty", "r+")
except OSError:
die(
f"cannot prompt for secret '{name}': no tty available. "
f"Run from an interactive shell."
)
prompt = (
f"{prompt_body} (input hidden): "
if prompt_body
else f"claude-bottle: secret value for {name} (input hidden): "
)
value = getpass.getpass(prompt, stream=tty)
tty.close()
else:
prompt = (
f"{prompt_body} (input hidden): "
if prompt_body
else f"claude-bottle: secret value for {name} (input hidden): "
)
value = getpass.getpass(prompt)
if not value:
die(f"empty value provided for secret '{name}'. Re-run and supply a value.")
return value
def env_resolve(
manifest: Manifest,
agent: str,
env_file: Path,
out_args: Path,
) -> None:
"""Iterate the agent's env entries:
- secret: always prompt; export into this process; append `-e NAME` to out_args
- interpolated: copy host value; export under target name; append `-e NAME`
- literal: append `NAME=VALUE` to env_file
"""
for name in manifest_env_names(manifest, agent):
if not name:
continue
raw = manifest_env_entry(manifest, agent, name)
kind = env_entry_kind(raw)
if kind == "secret":
prompt_body = env_entry_secret_prompt(raw)
value = _read_secret_silent(name, prompt_body)
os.environ[name] = value
with out_args.open("a") as f:
f.write(f"-e\n{name}\n")
elif kind == "interpolated":
host_var = env_entry_interpolated_from(raw)
host_value = os.environ.get(host_var, "")
if not host_value:
die(
f"env entry {name} is interpolated from ${host_var}, "
f"but ${host_var} is unset or empty in the host environment."
)
os.environ[name] = host_value
with out_args.open("a") as f:
f.write(f"-e\n{name}\n")
else: # literal
if "\n" in raw:
die(
f"env entry {name} (literal) contains a newline; "
f"docker --env-file cannot represent multi-line values."
)
with env_file.open("a") as f:
f.write(f"{name}={raw}\n")
+23
View File
@@ -0,0 +1,23 @@
"""Tiny logging wrappers. All output goes to stderr."""
from __future__ import annotations
import sys
def info(msg: str) -> None:
print(f"claude-bottle: {msg}", file=sys.stderr)
def warn(msg: str) -> None:
print(f"claude-bottle: warning: {msg}", file=sys.stderr)
class Die(SystemExit):
"""Raised by die() so callers (and tests) can distinguish a deliberate
fatal exit from an unrelated SystemExit."""
def die(msg: str) -> "Die":
print(f"claude-bottle: error: {msg}", file=sys.stderr)
raise Die(1)
+195
View File
@@ -0,0 +1,195 @@
"""Manifest helpers. Read claude-bottle.json and pull the definition for a
named agent.
Schema (see CLAUDE.md "Intended design"):
{
"bottles": {
"<bottle-name>": {
"env": { "<NAME>": <env-entry>, ... },
"ssh": [ <ssh-entry>, ... ],
"egress": { "allowlist": [ "<hostname>", ... ] }
}
},
"agents": {
"<agent-name>": {
"skills": [ "<skill-name>", ... ],
"prompt": "<string>",
"bottle": "<bottle-name>"
}
}
}
Bottles group shared infrastructure (SSH keys, known hosts, egress allowlist)
that multiple agents can reference. Every agent must reference a bottle.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from .log import die
Manifest = dict[str, Any]
def manifest_resolve(cwd: str) -> Manifest:
"""Look for claude-bottle.json in <cwd> and in $HOME, deep-merge
them (cwd entries override home entries on key conflict for both
bottles and agents). Dies if neither file is found or either is
invalid JSON."""
cwd_file = Path(cwd) / "claude-bottle.json"
home_file = Path(os.environ["HOME"]) / "claude-bottle.json"
cwd_doc = _load_json_or_die(cwd_file) if cwd_file.is_file() else None
home_doc = _load_json_or_die(home_file) if home_file.is_file() else None
if cwd_doc is None and home_doc is None:
die(f"no claude-bottle.json found in {cwd} or {os.environ['HOME']}")
if cwd_doc is None:
return home_doc # type: ignore[return-value]
if home_doc is None:
return cwd_doc
return {
"bottles": {**(home_doc.get("bottles") or {}), **(cwd_doc.get("bottles") or {})},
"agents": {**(home_doc.get("agents") or {}), **(cwd_doc.get("agents") or {})},
}
def _load_json_or_die(path: Path) -> Manifest:
try:
with path.open() as f:
doc = json.load(f)
except json.JSONDecodeError:
die(f"claude-bottle.json at {path} is not valid JSON")
if not isinstance(doc, dict):
die(f"claude-bottle.json at {path} must be a JSON object")
return doc
def manifest_has_agent(manifest: Manifest, name: str) -> bool:
return name in (manifest.get("agents") or {})
def manifest_require_agent(manifest: Manifest, name: str) -> None:
"""Like has_agent but dies with the available agent names listed."""
if manifest_has_agent(manifest, name):
return
available = ", ".join((manifest.get("agents") or {}).keys())
if available:
die(f"agent '{name}' not defined in claude-bottle.json. Available: {available}")
else:
die(f"agent '{name}' not defined in claude-bottle.json (manifest is empty).")
def manifest_env_names(manifest: Manifest, name: str) -> list[str]:
"""Names (not values) of bottles[agent.bottle].env, in declaration
order. Empty list if the agent has no bottle or the bottle has no env."""
agent = (manifest.get("agents") or {}).get(name) or {}
bottle_name = agent.get("bottle") or ""
if not bottle_name:
return []
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
return list((bottle.get("env") or {}).keys())
def manifest_env_entry(manifest: Manifest, agent: str, var: str) -> str:
"""Raw string value of one env entry. Used by env_resolve, which
classifies the result by sentinel. Dies if the agent has no bottle,
or the entry is not a string."""
agent_def = (manifest.get("agents") or {}).get(agent) or {}
bottle_name = agent_def.get("bottle") or ""
if not bottle_name:
die(f"env entry {var} for agent {agent}: agent has no 'bottle' field")
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
env = bottle.get("env") or {}
value = env.get(var)
if not isinstance(value, str):
actual = _json_type(value)
die(
f"env entry {var} for agent {agent} must be a JSON string "
f"(was {actual}). Use \"?<message>\" for prompt-at-runtime."
)
return value
def manifest_skills(manifest: Manifest, name: str) -> list[str]:
agent = (manifest.get("agents") or {}).get(name) or {}
return list(agent.get("skills") or [])
def manifest_prompt(manifest: Manifest, name: str) -> str:
agent = (manifest.get("agents") or {}).get(name) or {}
return agent.get("prompt") or ""
def manifest_agent_bottle(manifest: Manifest, name: str) -> str:
agent = (manifest.get("agents") or {}).get(name) or {}
return agent.get("bottle") or ""
def manifest_has_bottle(manifest: Manifest, bottle_name: str) -> bool:
return bottle_name in (manifest.get("bottles") or {})
def manifest_require_bottle(manifest: Manifest, bottle_name: str) -> None:
if manifest_has_bottle(manifest, bottle_name):
return
available = ", ".join((manifest.get("bottles") or {}).keys())
if available:
die(
f"bottle '{bottle_name}' not defined in claude-bottle.json. "
f"Available bottles: {available}"
)
else:
die(f"bottle '{bottle_name}' not defined in claude-bottle.json (no bottles defined).")
def manifest_bottle_ssh(manifest: Manifest, bottle_name: str) -> list[dict[str, Any]]:
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
return list(bottle.get("ssh") or [])
def manifest_bottle_egress_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
"""Hostnames in bottles[bottle_name].egress.allowlist. Dies if the
field is present but not an array. Per-element string typing is
re-checked at use-time in pipelock."""
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
allowlist = (bottle.get("egress") or {}).get("allowlist")
if allowlist is None:
return []
if not isinstance(allowlist, list):
die(
f"bottle '{bottle_name}' egress.allowlist must be an array "
f"(was {_json_type(allowlist)})."
)
return list(allowlist)
def manifest_ssh(manifest: Manifest, agent_name: str) -> list[dict[str, Any]]:
"""SSH entries resolved via the agent's "bottle" field; empty if no bottle set."""
bottle_name = manifest_agent_bottle(manifest, agent_name)
if not bottle_name:
return []
return manifest_bottle_ssh(manifest, bottle_name)
def _json_type(value: Any) -> str:
"""Mirror jq's type names for parity with the bash error messages."""
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
return type(value).__name__
+107
View File
@@ -0,0 +1,107 @@
"""Docker network plumbing for the per-agent egress-proxy topology.
The agent container sits on a Docker `--internal` network (no default
gateway). Pipelock straddles that network and a per-agent user-defined
bridge for upstream egress. We deliberately do NOT use Docker's legacy
`bridge` network because only user-defined bridges run Docker's
embedded DNS resolver, which pipelock needs to resolve api.anthropic.com
and similar upstream hostnames.
Naming: claude-bottle-net-<slug> (internal),
claude-bottle-egress-<slug> (egress). Numeric suffix on conflict
(-2, -3, ..., capped at 100).
"""
from __future__ import annotations
import subprocess
from .log import die, info, warn
def network_name_for_slug(slug: str) -> str:
return f"claude-bottle-net-{slug}"
def network_egress_name_for_slug(slug: str) -> str:
return f"claude-bottle-egress-{slug}"
def network_exists(name: str) -> bool:
"""Uses `docker network inspect`, not `docker network ls -f name=...`,
because the latter does substring matching."""
return (
subprocess.run(
["docker", "network", "inspect", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
def _network_create_with_prefix(base: str, internal: bool) -> str:
"""Create a per-agent Docker network whose name is <base> (with
-2, -3, ... appended on conflict, capped at 100). Returns the
resolved name."""
name = base
suffix = 2
while network_exists(name):
name = f"{base}-{suffix}"
suffix += 1
if suffix > 100:
die(
f"could not find a free network name after {base}-99; "
f"clean up old networks with 'docker network rm <name>'"
)
kind = "internal" if internal else "bridge (egress)"
args = ["docker", "network", "create"]
if internal:
args.append("--internal")
args.append(name)
info(f"creating {kind} network {name}")
result = subprocess.run(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
if result.returncode != 0:
flag = " --internal" if internal else ""
die(f"docker network create{flag} {name} failed")
return name
def network_create_internal(slug: str) -> str:
"""Create a Docker `--internal` network for the agent. Returns the
resolved name."""
return _network_create_with_prefix(network_name_for_slug(slug), internal=True)
def network_create_egress(slug: str) -> str:
"""Create a per-agent user-defined bridge (NOT the legacy `bridge`)
so the pipelock sidecar has working DNS for upstream hostnames."""
return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
def network_attach(network: str, container: str) -> None:
result = subprocess.run(
["docker", "network", "connect", network, container],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
die(f"docker network connect {network} {container} failed")
def network_remove(name: str) -> bool:
"""Idempotent: a missing network is treated as success so this can
be called from a teardown trap. Returns True if removal succeeded
(or the network was already gone)."""
if not network_exists(name):
return True
result = subprocess.run(
["docker", "network", "rm", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
warn(f"failed to remove network {name}; clean up with 'docker network rm {name}'")
return False
return True
+274
View File
@@ -0,0 +1,274 @@
"""Pipelock sidecar lifecycle for the per-agent egress topology.
Pipelock (https://github.com/luckyPipewrench/pipelock) is an HTTP
forward proxy with hostname allowlisting + DLP scanning + URL-entropy
checks. One sidecar per agent, attached to the agent's --internal
network and a per-agent user-defined egress bridge. Combined with
HTTPS_PROXY/HTTP_PROXY pointing at the sidecar's service name, pipelock
is the only egress route the agent has.
Image pin: ghcr.io/luckypipewrench/pipelock@sha256:<digest> for tag 2.3.0.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from .log import die, info, warn
from .manifest import Manifest, manifest_bottle_egress_allowlist, manifest_bottle_ssh
# Pipelock image, pinned by digest. The digest is the multi-arch image
# index for ghcr.io/luckypipewrench/pipelock:2.3.0.
PIPELOCK_IMAGE = os.environ.get(
"CLAUDE_BOTTLE_PIPELOCK_IMAGE",
"ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9",
)
# Listening port for pipelock's forward proxy.
PIPELOCK_PORT = os.environ.get("CLAUDE_BOTTLE_PIPELOCK_PORT", "8888")
# Baked-in default allowlist for hosts Claude Code itself needs.
DEFAULT_ALLOWLIST: tuple[str, ...] = (
"api.anthropic.com",
"statsig.anthropic.com",
"sentry.io",
"claude.ai",
"platform.claude.com",
"downloads.claude.ai",
"raw.githubusercontent.com",
)
def pipelock_container_name(slug: str) -> str:
return f"claude-bottle-pipelock-{slug}"
def pipelock_proxy_url(slug: str) -> str:
return f"http://{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
def pipelock_proxy_host_port(slug: str) -> str:
return f"{pipelock_container_name(slug)}:{PIPELOCK_PORT}"
# --- Allowlist resolution --------------------------------------------------
def pipelock_bottle_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
"""Hostnames in bottles[<bottle_name>].egress.allowlist. Validates
that each entry is a string."""
raw = manifest_bottle_egress_allowlist(manifest, bottle_name)
for entry in raw:
if not isinstance(entry, str):
t = _json_type(entry)
die(f"bottle '{bottle_name}' egress.allowlist must contain only strings; found a '{t}' entry.")
return list(raw)
def pipelock_bottle_ssh_hostnames(manifest: Manifest, bottle_name: str) -> list[str]:
out: list[str] = []
for entry in manifest_bottle_ssh(manifest, bottle_name):
h = entry.get("Hostname") or ""
if h:
out.append(h)
return out
_IPV4_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$")
def is_ipv4_literal(s: str) -> bool:
"""Pipelock's SSRF check fires on resolved IP, so an IP-literal
Hostname goes to ssrf.ip_allowlist while a hostname goes to
trusted_domains."""
if not s:
return False
return bool(_IPV4_RE.match(s))
def pipelock_bottle_ssh_trusted_domains(manifest: Manifest, bottle_name: str) -> list[str]:
return [h for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name) if not is_ipv4_literal(h)]
def pipelock_bottle_ssh_ip_cidrs(manifest: Manifest, bottle_name: str) -> list[str]:
return [f"{h}/32" for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name) if is_ipv4_literal(h)]
def pipelock_effective_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
"""Deduplicated union of: baked-in defaults, bottle.egress.allowlist,
bottle.ssh[].Hostname. Sorted for stability."""
seen: dict[str, None] = {}
for h in DEFAULT_ALLOWLIST:
seen.setdefault(h, None)
for h in pipelock_bottle_allowlist(manifest, bottle_name):
if h:
seen.setdefault(h, None)
for h in pipelock_bottle_ssh_hostnames(manifest, bottle_name):
if h:
seen.setdefault(h, None)
return sorted(seen.keys())
def pipelock_allowlist_summary(manifest: Manifest, bottle_name: str) -> str:
"""One-line summary for the y/N preflight display:
"<N> hosts allowed (host1, host2, host3, +M more)"."""
hosts = pipelock_effective_allowlist(manifest, bottle_name)
count = len(hosts)
if count == 0:
return "0 hosts allowed (none)"
show = count
more = 0
if count > 5:
show = 3
more = count - show
joined = ", ".join(hosts[:show])
if more > 0:
return f"{count} hosts allowed ({joined}, +{more} more)"
return f"{count} hosts allowed ({joined})"
# --- YAML generation -------------------------------------------------------
def pipelock_write_yaml(manifest: Manifest, bottle_name: str, out_path: Path) -> None:
"""Write a pipelock YAML config (mode 600) carrying:
- the effective allowlist (hostnames),
- a fixed listen port,
- strict mode + forward_proxy.enabled + DLP defaults + scan_env.
Deliberately contains no env values, no secrets, no per-agent
customization beyond the hostname list."""
allowlist = pipelock_effective_allowlist(manifest, bottle_name)
trusted = pipelock_bottle_ssh_trusted_domains(manifest, bottle_name)
ip_cidrs = pipelock_bottle_ssh_ip_cidrs(manifest, bottle_name)
lines: list[str] = []
lines.append("version: 1")
lines.append("mode: strict")
lines.append("enforce: true")
lines.append("")
lines.append("# Hostnames the agent is allowed to reach. Effective list is")
lines.append("# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).")
lines.append("api_allowlist:")
for h in allowlist:
lines.append(f' - "{h}"')
lines.append("")
lines.append("forward_proxy:")
lines.append(" enabled: true")
lines.append("")
if trusted:
lines.append("trusted_domains:")
for td in trusted:
lines.append(f' - "{td}"')
lines.append("")
if ip_cidrs:
lines.append("ssrf:")
lines.append(" ip_allowlist:")
for cidr in ip_cidrs:
lines.append(f' - "{cidr}"')
lines.append("")
lines.append("dlp:")
lines.append(" include_defaults: true")
lines.append(" scan_env: true")
out_path.write_text("\n".join(lines) + "\n")
out_path.chmod(0o600)
# --- Sidecar lifecycle -----------------------------------------------------
def pipelock_start(
slug: str,
internal_network: str,
egress_network: str,
yaml_dir: Path,
yaml_filename: str,
) -> str:
"""Boot the pipelock sidecar:
1. `docker create` on the internal network with the canonical name
and argv `run --config /etc/pipelock.yaml --listen 0.0.0.0:<port>`.
2. `docker cp` the YAML config to /etc/pipelock.yaml in the
writable layer (parent dir must already exist; image is distroless).
3. Attach to the per-agent egress network.
4. `docker start`.
Returns the container name."""
name = pipelock_container_name(slug)
host_yaml = yaml_dir / yaml_filename
if not host_yaml.is_file():
die(f"pipelock yaml not found at {host_yaml}; pipelock_write_yaml must run first")
info(f"starting pipelock sidecar {name} on network {internal_network}")
create_args = [
"docker", "create",
"--name", name,
"--network", internal_network,
PIPELOCK_IMAGE,
"run", "--config", "/etc/pipelock.yaml",
"--listen", f"0.0.0.0:{PIPELOCK_PORT}",
]
if subprocess.run(create_args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0:
die(f"failed to create pipelock sidecar {name}")
cp_result = subprocess.run(
["docker", "cp", str(host_yaml), f"{name}:/etc/pipelock.yaml"],
capture_output=True,
text=True,
)
if cp_result.returncode != 0:
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
die(f"failed to copy pipelock yaml into {name}: {cp_result.stderr.strip()}")
if subprocess.run(
["docker", "network", "connect", egress_network, name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode != 0:
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
die(f"failed to attach pipelock sidecar {name} to egress network {egress_network}")
if subprocess.run(
["docker", "start", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode != 0:
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
die(f"failed to start pipelock sidecar {name}")
return name
def pipelock_stop(slug: str) -> None:
"""Idempotent: missing container is success."""
name = pipelock_container_name(slug)
if subprocess.run(
["docker", "inspect", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0:
if subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode != 0:
warn(f"failed to remove pipelock sidecar {name}; clean up with 'docker rm -f {name}'")
def _json_type(value: object) -> str:
if value is None:
return "null"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
if isinstance(value, str):
return "string"
if isinstance(value, list):
return "array"
if isinstance(value, dict):
return "object"
return type(value).__name__
+76
View File
@@ -0,0 +1,76 @@
"""Skill copier: host's ~/.claude/skills/<name>/ -> container's
~/.claude/skills/<name>/, preserving directory structure."""
from __future__ import annotations
import os
import subprocess
from .log import die, info
CONTAINER_HOME = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
CONTAINER_SKILLS_DIR = os.environ.get(
"CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR", f"{CONTAINER_HOME}/.claude/skills"
)
def host_skill_dir(name: str) -> str:
home = os.environ.get("HOME")
if not home:
die("HOME not set")
return f"{home}/.claude/skills/{name}"
def host_skill_exists(name: str) -> bool:
return os.path.isdir(host_skill_dir(name))
def require_host_skill(name: str) -> None:
if not host_skill_exists(name):
die(
f"skill '{name}' not found on host at {host_skill_dir(name)}. "
f"Create it under ~/.claude/skills/, then re-run."
)
def skills_validate_all(names: list[str]) -> None:
"""Use BEFORE the y/N so the user does not get asked about a plan
that's already known to fail."""
for n in names:
require_host_skill(n)
def skills_copy_into(container: str, names: list[str]) -> None:
"""For each named skill, ensure the parent dir exists, wipe any
prior copy, then `docker cp <host>/. <container>:<dst>/` so the
contents are copied into a freshly-created destination dir."""
if not names:
return
subprocess.run(
["docker", "exec", container, "mkdir", "-p", CONTAINER_SKILLS_DIR],
stdout=subprocess.DEVNULL,
check=True,
)
for n in names:
src = host_skill_dir(n)
if not os.path.isdir(src):
die(f"skill '{n}' disappeared from host between validation and copy at {src}.")
dst = f"{CONTAINER_SKILLS_DIR}/{n}"
info(f"copying skill {n} into {container}:{dst}")
subprocess.run(
["docker", "exec", container, "rm", "-rf", dst],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "exec", container, "mkdir", "-p", dst],
stdout=subprocess.DEVNULL,
check=True,
)
subprocess.run(
["docker", "cp", f"{src}/.", f"{container}:{dst}/"],
stdout=subprocess.DEVNULL,
check=True,
)
+209
View File
@@ -0,0 +1,209 @@
"""SSH helpers. Validates ssh entries from claude-bottle.json, then sets
up SSH inside the container via a root-owned ssh-agent so the `node`
user can use the keys for SSH but cannot read the key bytes.
Why an in-container agent (not bind-mounted from host): Docker Desktop
on macOS does not forward Unix-domain socket connect() across the VM
boundary connect() returns ENOTSUP. Running ssh-agent inside the
container sidesteps that entirely.
Isolation:
- Keys live at /root/.claude-bottle-keys/ (mode 700, root-owned).
/root is mode 700 in node:22-slim, so node (uid 1000) can't even
traverse in.
- ssh-agent runs as root, listening on /run/claude-bottle-agent.sock.
Each key is loaded with ssh-add, then deleted; the bytes now live
only in the agent process's memory.
- ssh-agent's SO_PEERCRED-based UID match rejects every connection
whose peer euid is neither 0 nor the agent's. To bridge that, a
root-owned socat forwarder listens on
/run/claude-bottle-agent-public.sock (mode 666) and proxies bytes
to the real agent socket.
- node can't ptrace root-owned agent or socat, so /proc/<pid>/mem is
off-limits and key bytes never leave root-owned memory.
- ~/.ssh/config in node's home points each Host at the public socket
via IdentityAgent.
Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty
for passphrases, but our docker exec has no TTY.
Each ssh entry has keys: Host, IdentityFile, Hostname, User, Port
(required); KnownHostKey (optional).
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
from typing import Any
from .log import die, info
def ssh_validate_entries(entries: list[dict[str, Any]]) -> None:
"""Each entry must have Host + IdentityFile, and the IdentityFile
must exist on the host (after expanding leading ~)."""
for entry in entries:
name = entry.get("Host", "")
key = entry.get("IdentityFile", "")
if not name:
die(f"ssh entry missing required field 'Host': {entry}")
if not key:
die(f"ssh entry '{name}' missing required field 'IdentityFile'")
key = _expand_tilde(key)
if not os.path.isfile(key):
die(f"ssh key file not found for host '{name}': {key}")
def ssh_setup(
container: str,
stage_dir: Path,
proxy_host_port: str,
entries: list[dict[str, Any]],
) -> None:
"""Set up SSH in the container so node can authenticate using each
entry's key without the key file being readable by node."""
container_home = os.environ.get("CLAUDE_BOTTLE_CONTAINER_HOME", "/home/node")
container_ssh = f"{container_home}/.ssh"
agent_socket = "/run/claude-bottle-agent.sock"
public_socket = "/run/claude-bottle-agent-public.sock"
keys_dir = "/root/.claude-bottle-keys"
# ~/.ssh for node (700, owned by node).
_docker_exec_root(container, ["mkdir", "-p", container_ssh])
_docker_exec_root(container, ["chown", "node:node", container_ssh])
_docker_exec_root(container, ["chmod", "700", container_ssh])
# /root/.claude-bottle-keys for root (700, root-owned).
_docker_exec_root(container, ["mkdir", "-p", keys_dir])
_docker_exec_root(container, ["chown", "root:root", keys_dir])
_docker_exec_root(container, ["chmod", "700", keys_dir])
config_file = stage_dir / "ssh_config"
known_hosts_file = stage_dir / "ssh_known_hosts"
config_file.write_text("")
config_file.chmod(0o600)
known_hosts_file.write_text("")
known_hosts_file.chmod(0o600)
proxy_host, _, proxy_port = proxy_host_port.partition(":")
container_key_paths: list[str] = []
for entry in entries:
name = entry["Host"]
key = _expand_tilde(entry["IdentityFile"])
hostname = entry["Hostname"]
user = entry["User"]
port = str(entry["Port"])
known_host_key = entry.get("KnownHostKey", "")
key_basename = os.path.basename(key)
container_key_path = f"{keys_dir}/{key_basename}"
info(f"copying ssh key for '{name}' -> {container} (root-only staging)")
subprocess.run(
["docker", "cp", key, f"{container}:{container_key_path}"],
stdout=subprocess.DEVNULL,
check=True,
)
_docker_exec_root(container, ["chown", "root:root", container_key_path])
_docker_exec_root(container, ["chmod", "600", container_key_path])
container_key_paths.append(container_key_path)
# ProxyCommand tunnels SSH through pipelock via HTTP CONNECT.
# %h / %p expand to this block's HostName / Port. socat's
# PROXY: mode does CONNECT host:port to the proxy.
block = (
f"Host {name}\n"
f" HostName {hostname}\n"
f" User {user}\n"
f" Port {port}\n"
f" IdentityAgent {public_socket}\n"
f" ProxyCommand socat - PROXY:{proxy_host}:%h:%p,proxyport={proxy_port}\n"
f"\n"
)
with config_file.open("a") as f:
f.write(block)
if known_host_key:
entries_to_write: list[str] = []
if port == "22":
entries_to_write.append(f"{name} {known_host_key}\n")
if hostname != name:
entries_to_write.append(f"{hostname} {known_host_key}\n")
else:
entries_to_write.append(f"[{name}]:{port} {known_host_key}\n")
if hostname != name:
entries_to_write.append(f"[{hostname}]:{port} {known_host_key}\n")
with known_hosts_file.open("a") as f:
for e in entries_to_write:
f.write(e)
# Boot the agent, load each key, delete the key files, then start
# the root-owned socat forwarder. One docker exec so the whole
# sequence is atomic.
info(f"starting in-container ssh-agent at {agent_socket} (forwarded via {public_socket})")
setup_lines = [
"set -eu",
f"ssh-agent -a {agent_socket} >/dev/null",
]
for kp in container_key_paths:
setup_lines.append(f"SSH_AUTH_SOCK={agent_socket} ssh-add {kp}")
setup_lines.append(f"rm -f {kp}")
setup_lines.append(f"rmdir {keys_dir} 2>/dev/null || true")
# Forwarder: socat (uid 0) connects to the agent on node's behalf.
setup_lines.append(
f"nohup socat UNIX-LISTEN:{public_socket},fork,reuseaddr,mode=666 "
f"UNIX-CONNECT:{agent_socket} </dev/null >/dev/null 2>&1 &"
)
# Wait briefly for the forwarder to bind.
setup_lines.extend([
"i=0",
"while [ $i -lt 20 ]; do",
f" [ -S {public_socket} ] && break",
" i=$((i + 1))",
" sleep 0.1",
"done",
f"[ -S {public_socket} ] || {{ echo 'claude-bottle: socat forwarder failed to bind {public_socket}' >&2; exit 1; }}",
])
setup_script = "\n".join(setup_lines) + "\n"
subprocess.run(
["docker", "exec", "-u", "0", container, "sh", "-c", setup_script],
check=True,
)
info(f"writing {container_ssh}/config")
subprocess.run(
["docker", "cp", str(config_file), f"{container}:{container_ssh}/config"],
stdout=subprocess.DEVNULL,
check=True,
)
_docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/config"])
_docker_exec_root(container, ["chmod", "600", f"{container_ssh}/config"])
if known_hosts_file.stat().st_size > 0:
info(f"writing {container_ssh}/known_hosts")
subprocess.run(
["docker", "cp", str(known_hosts_file), f"{container}:{container_ssh}/known_hosts"],
stdout=subprocess.DEVNULL,
check=True,
)
_docker_exec_root(container, ["chown", "node:node", f"{container_ssh}/known_hosts"])
_docker_exec_root(container, ["chmod", "600", f"{container_ssh}/known_hosts"])
def _docker_exec_root(container: str, argv: list[str]) -> None:
subprocess.run(
["docker", "exec", "-u", "0", container, *argv],
stdout=subprocess.DEVNULL,
check=True,
)
def _expand_tilde(path: str) -> str:
if path.startswith("~"):
home = os.environ.get("HOME", "")
return home + path[1:]
return path
Executable
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""cli.py — manage claude-bottle containers.
usage: cli.py <command> [args...]
Commands:
build build (or rebuild) the claude-bottle Docker image.
cleanup stop and remove all active claude-bottle containers.
edit open an agent in vim for editing.
info print env, skills, and prompt details for a named agent.
init interactively create a new agent and add it to claude-bottle.json.
list list available agents or active containers.
start boot a sandboxed container for a named agent and attach an
interactive claude-code session. The container is torn down
when the session ends.
"""
from __future__ import annotations
import sys
from claude_bottle.cli import main
if __name__ == "__main__":
sys.exit(main())
-1055
View File
File diff suppressed because it is too large Load Diff
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env bash
# Docker helpers. Build/inspect primitives shared by cli.sh
# (and reusable by future skill-sync / secret-injection scripts).
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_DOCKER_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_DOCKER_SOURCED=1
_iso_lib_docker_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_docker_dir}/log.sh"
# require_docker — fails with an install pointer if `docker` is not on PATH.
require_docker() {
if ! command -v docker >/dev/null 2>&1; then
info "Docker is required but was not found on PATH."
info "macOS: install Docker Desktop https://docs.docker.com/desktop/install/mac-install/"
info "Linux: install Docker Engine https://docs.docker.com/engine/install/"
die "docker not found"
fi
}
# image_exists <ref> — returns 0 if the named local image exists, else 1.
image_exists() {
local ref="${1:?image_exists: missing image reference}"
docker image inspect "$ref" >/dev/null 2>&1
}
# container_exists <name> — returns 0 if a container (running or stopped)
# with the given name exists, else 1.
container_exists() {
local name="${1:?container_exists: missing container name}"
# `docker ps -a -q -f name=^<name>$` prints the container id if it exists.
local id
id="$(docker ps -a -q -f "name=^${name}$" 2>/dev/null || true)"
[ -n "$id" ]
}
# slugify <name> — prints a DNS-safe slug (lowercase, non-alnum runs → '-',
# trimmed) on stdout. Exits non-zero if the result is empty.
slugify() {
local input="${1:?slugify: missing name}"
local slug
slug="$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
if [ -z "$slug" ]; then
die "name '${input}' produced an empty slug; use alphanumeric characters"
fi
printf '%s' "$slug"
}
# build_image <ref> <context_dir> — invokes `docker build` every call. The
# layer cache makes no-change rebuilds cheap (typically <1s); always running
# the build means edits to the Dockerfile (or anything COPY'd in) take
# effect on the next cli.sh without the user having to manually `docker
# rmi` first.
build_image() {
local ref="${1:?build_image: missing image reference}"
local context="${2:?build_image: missing build context directory}"
info "building image ${ref} from ${context} (layer cache keeps repeat builds fast)"
docker build -t "$ref" "$context"
}
# build_image_with_cwd <derived_ref> <base_ref> <cwd>
#
# Builds a thin derived image that copies the contents of <cwd> into
# /home/node/workspace (owned by node:node) and sets WORKDIR there, so
# the launched claude session starts inside the user's project.
#
# The Dockerfile is piped via stdin (`-f -`) so no file is written into
# <cwd> — only the build context is read from there. Any .dockerignore
# already in <cwd> is honored automatically by docker build.
#
# A trust-dialog entry for /home/node/workspace is added to
# ~/.claude.json during the build, because the baked-in entry in the
# base image only covers /home/node and claude's "trust this folder"
# prompt is keyed on cwd.
build_image_with_cwd() {
local derived="${1:?build_image_with_cwd: missing derived ref}"
local base="${2:?build_image_with_cwd: missing base ref}"
local cwd="${3:?build_image_with_cwd: missing cwd}"
if [ ! -d "$cwd" ]; then
die "cwd not found at ${cwd}"
fi
info "building image ${derived} from ${base} with ${cwd} -> /home/node/workspace"
docker build -t "$derived" -f - "$cwd" <<DOCKERFILE
FROM ${base}
COPY --chown=node:node . /home/node/workspace
RUN node -e 'const fs=require("fs"),p=process.env.HOME+"/.claude.json",c=JSON.parse(fs.readFileSync(p,"utf8"));c.projects=c.projects||{};c.projects[process.env.HOME+"/workspace"]={hasTrustDialogAccepted:true};fs.writeFileSync(p,JSON.stringify(c,null,2));'
WORKDIR /home/node/workspace
DOCKERFILE
}
-33
View File
@@ -1,33 +0,0 @@
#!/usr/bin/env bash
# Env-var helpers. Set/unset checks only — never echo a secret value.
# See CLAUDE.md "Checking env vars safely" for the rule this enforces.
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_ENV_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_ENV_SOURCED=1
# Resolve sibling helpers regardless of caller's cwd.
_iso_lib_env_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_env_dir}/log.sh"
# require_env <NAME> — fails with a clear message if the named env var is
# unset or empty. Crucially does NOT print the value, the length, or any
# substring; only the variable name is echoed.
#
# Usage:
# require_env ANTHROPIC_API_KEY
require_env() {
local name="${1:-}"
if [ -z "$name" ]; then
die "require_env: missing variable name argument"
fi
# Indirect expansion to read the named variable without naming it twice.
local value="${!name-}"
if [ -z "$value" ]; then
die "required env var ${name} is not set. Export it in your shell and re-run."
fi
}
-205
View File
@@ -1,205 +0,0 @@
#!/usr/bin/env bash
# Env resolver. Walks the env entries for one agent in claude-bottle.json
# and produces:
# 1. The list of `docker run` arg fragments needed to forward each var.
# Both `secret` and `interpolated` entries become `-e NAME` (no
# `=value`) so Docker inherits the value from this process env
# without rendering it on argv or persisting it to disk.
# Only `literal` entries are written to a host-disk env-file and
# forwarded with `--env-file <path>`.
# 2. The export side-effect of populating this process's env with
# secret values prompted from the user, and with interpolated
# values copied from the matching host var, so `-e NAME` actually
# has something to inherit.
#
# Each env entry is a JSON string. Mode is selected by sentinel prefix:
# "?" → secret (prompt at runtime). Bare "?" uses a default
# prompt; "?<message>" uses <message> as the prompt body.
# "${HOST_VAR}" → interpolated from $HOST_VAR in the host process env
# any other str → literal (the JSON string is the value verbatim)
# A literal whose text starts with "?" or matches "${IDENT}" is not
# representable in v1 — pick a different value or change the convention.
#
# Critical rules (re-read CLAUDE.md "Checking env vars safely"):
# - NEVER echo, log, or interpolate the value of a secret or
# interpolated env var. Both modes are treated as potentially
# sensitive: nothing about their value (other than presence /
# length) ever lands on disk, in a log line, or on argv.
# - The env-file written for literal values lives under `mktemp -d`
# with mode 600 and is removed on script exit by the caller's trap.
# Secrets and interpolated values never go to this file.
# - Errors mention only the variable NAME, never any portion of the value.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_ENV_RESOLVE_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_ENV_RESOLVE_SOURCED=1
_iso_lib_env_resolve_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_env_resolve_dir}/log.sh"
# shellcheck source=./manifest.sh
. "${_iso_lib_env_resolve_dir}/manifest.sh"
# env_entry_kind <raw-string> — prints "secret", "interpolated", or
# "literal" based on the sentinel form of the entry. Never echoes the
# value of an interpolated entry — only its host-var NAME via the
# captured submatch. Secret-mode prompt text (everything after the
# leading "?") is extracted by env_entry_secret_prompt, not here.
env_entry_kind() {
local raw="${1-}"
case "$raw" in
\?*)
printf 'secret'
return 0
;;
esac
if [[ "$raw" =~ ^\$\{[A-Za-z_][A-Za-z0-9_]*\}$ ]]; then
printf 'interpolated'
return 0
fi
printf 'literal'
}
# env_entry_secret_prompt <raw-string> — for a secret entry (one whose
# raw value starts with "?"), prints the prompt body (everything after
# the leading "?"). Empty for a bare "?", which signals "use default
# prompt." Caller is responsible for falling back to a default.
env_entry_secret_prompt() {
local raw="${1-}"
printf '%s' "${raw#\?}"
}
# env_entry_interpolated_from <raw-string> — for an interpolated entry,
# prints the host var name (the identifier between `${` and `}`).
env_entry_interpolated_from() {
local raw="${1-}"
local inner="${raw#\$\{}"
inner="${inner%\}}"
printf '%s' "$inner"
}
# _read_secret_silent <NAME> [<prompt-body>] — prompt the user for a
# secret value on the tty without echoing the keystrokes. Stores the
# value in the global variable named by $1 via printf -v. Stdin
# redirection from /dev/tty so this still works under `<(...)` and
# other non-tty stdin situations.
#
# If <prompt-body> is provided and non-empty, the prompt rendered to
# the tty is "<prompt-body> (input hidden): "; otherwise it falls back
# to "claude-bottle: secret value for <NAME> (input hidden): ". The "(input
# hidden): " tail is always appended by this function — manifest
# authors write the message text only.
#
# We never `echo "$VALUE"` or interpolate it elsewhere; the only consumer
# is `export "$NAME=$VALUE"` immediately below.
_read_secret_silent() {
local target="${1:?_read_secret_silent: missing target var name}"
local prompt_body="${2-}"
local value=""
# Use the controlling tty for both the prompt and the read so this is
# robust even if stdin is a pipe.
if [ ! -t 0 ] && [ ! -t 2 ]; then
die "cannot prompt for secret '${target}': no tty available. Run from an interactive shell."
fi
# `printf` to /dev/tty for the prompt, `read -s` from /dev/tty for the value.
if [ -n "$prompt_body" ]; then
printf '%s (input hidden): ' "$prompt_body" >/dev/tty
else
printf 'claude-bottle: secret value for %s (input hidden): ' "$target" >/dev/tty
fi
# IFS= read -rs to read one line, raw, silent.
IFS= read -rs value </dev/tty
printf '\n' >/dev/tty
if [ -z "$value" ]; then
die "empty value provided for secret '${target}'. Re-run and supply a value."
fi
# Indirect assignment — never expose value via expansion in a string we
# log or pass anywhere else.
printf -v "$target" '%s' "$value"
# Scrub our local copy.
value=""
}
# env_resolve <manifest_file> <agent_name> <env_file_path> <out_args_path>
#
# Iterates the agent's env entries. For each entry:
# - secret → ALWAYS prompt for the value (even if already set in
# this process env), export it into this process, and
# append `-e NAME` to <out_args_path> (one arg per
# line; a NAME with no `=value`).
# - interpolated→ read the host process env value of the named host var;
# if unset, die with the host-var name. Copy into this
# process under the target name and append `-e NAME` to
# <out_args_path>. Never written to disk.
# - literal → append `NAME=VALUE` to <env_file_path>; the resolver
# does NOT add anything to <out_args_path> for this entry
# (the caller adds a single `--env-file <env_file_path>`
# if the file is non-empty).
#
# The caller is responsible for:
# - creating <env_file_path> as an empty file with mode 600 under a
# mktemp dir,
# - creating <out_args_path> as an empty file,
# - cleaning both up on exit (trap),
# - reading <out_args_path> line-by-line into the docker-run argv.
#
# Returns 0 on success, dies on any error.
env_resolve() {
local manifest_file="${1:?env_resolve: missing manifest file}"
local agent="${2:?env_resolve: missing agent name}"
local env_file="${3:?env_resolve: missing env_file path}"
local out_args="${4:?env_resolve: missing out_args path}"
local name raw kind from prompt_body
while IFS= read -r name; do
[ -z "$name" ] && continue
raw="$(manifest_env_entry "$manifest_file" "$agent" "$name")"
kind="$(env_entry_kind "$raw")"
case "$kind" in
secret)
# Always prompt — never trust an already-exported host value.
# A "?"-prefixed entry in the manifest is the user's signal
# that this variable must be supplied interactively at launch
# time, even if a same-named var is already in the parent shell.
prompt_body="$(env_entry_secret_prompt "$raw")"
_read_secret_silent "$name" "$prompt_body"
# Export so child processes (docker run) inherit. `-e NAME` (no
# value) on docker run picks up from the parent process env.
export "${name?}"
printf -- '-e\n%s\n' "$name" >>"$out_args"
;;
interpolated)
from="$(env_entry_interpolated_from "$raw")"
# Treat interpolated values as potentially sensitive: never write
# them to disk and never put them on argv. Instead, copy the host
# var into THIS process under the target name (so Docker can
# inherit it via `-e NAME`), and emit `-e NAME` in the args file.
# The check below uses indirect expansion only to determine
# presence — no expansion of the value lands in any output.
if [ -z "${!from-}" ]; then
die "env entry ${name} is interpolated from \$${from}, but \$${from} is unset or empty in the host environment."
fi
# Copy via printf -v + indirect read. We use a brief local then
# immediately export under $name and scrub the local.
local _interp_val
_interp_val="${!from}"
printf -v "${name?}" '%s' "$_interp_val"
_interp_val=""
export "${name?}"
printf -- '-e\n%s\n' "$name" >>"$out_args"
;;
literal)
# Multi-line literal values are not supported by docker --env-file,
# so reject them up front rather than letting docker fail with a
# confusing message.
case "$raw" in
*$'\n'*) die "env entry ${name} (literal) contains a newline; docker --env-file cannot represent multi-line values." ;;
esac
printf '%s=%s\n' "$name" "$raw" >>"$env_file"
;;
esac
done < <(manifest_env_names "$manifest_file" "$agent")
}
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Tiny logging wrappers. Sourced by entry-point scripts.
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_LOG_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_LOG_SOURCED=1
# info <msg...> — informational message to stderr.
info() {
printf 'claude-bottle: %s\n' "$*" >&2
}
# warn <msg...> — warning to stderr.
warn() {
printf 'claude-bottle: warning: %s\n' "$*" >&2
}
# die <msg...> — error to stderr, exit 1.
die() {
printf 'claude-bottle: error: %s\n' "$*" >&2
exit 1
}
-270
View File
@@ -1,270 +0,0 @@
#!/usr/bin/env bash
# Manifest helpers. Read claude-bottle.json and pull the definition for a named
# agent.
#
# The manifest schema is documented in CLAUDE.md "Intended design". In
# short:
# {
# "bottles": {
# "<bottle-name>": {
# "env": { "<NAME>": <env-entry>, ... },
# "ssh": [ <ssh-entry>, ... ],
# "egress": { "allowlist": [ "<hostname>", ... ] }
# },
# ...
# },
# "agents": {
# "<agent-name>": {
# "skills": [ "<skill-name>", ... ],
# "prompt": "<string>",
# "bottle": "<bottle-name>"
# },
# ...
# }
# }
#
# 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>
# (bare "?" is allowed; uses a default prompt)
# "${HOST_VAR}" → interpolate from $HOST_VAR in the host process env
# any other str → literal (the JSON string is the value verbatim)
# The classification lives in env_resolve.sh (env_entry_kind); this
# module only fetches the raw string and validates that it is a string.
#
# Manifest parsing happens on the host with `jq`, never inside the
# container. We never echo env *values* here — only names. For literal
# entries the "name" and the value happen to be the same shape (both
# are JSON strings), so callers must take care not to log the result of
# manifest_env_entry.
#
# All functions (except manifest_resolve) take a manifest_file argument —
# the path to a resolved JSON file, typically produced by manifest_resolve.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_MANIFEST_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_MANIFEST_SOURCED=1
_iso_lib_manifest_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_manifest_dir}/log.sh"
# require_jq — fails with an install pointer if `jq` is not on PATH.
require_jq() {
if ! command -v jq >/dev/null 2>&1; then
info "jq is required on the host for claude-bottle manifest parsing."
info "macOS: brew install jq"
info "Linux: apt-get install jq (or your distro equivalent)"
die "jq not found"
fi
}
# manifest_resolve <cwd> — looks for claude-bottle.json in <cwd> and in $HOME,
# merges the two (cwd entries override home entries for the same agent name),
# and prints the merged JSON to stdout. Dies if neither file is found or if
# either found file is not valid JSON.
manifest_resolve() {
local cwd="${1:?manifest_resolve: missing cwd}"
local cwd_file="${cwd}/claude-bottle.json"
local home_file="${HOME}/claude-bottle.json"
local has_cwd=0 has_home=0
if [ -f "$cwd_file" ]; then
if ! jq -e . "$cwd_file" >/dev/null 2>&1; then
die "claude-bottle.json at ${cwd_file} is not valid JSON"
fi
has_cwd=1
fi
if [ -f "$home_file" ]; then
if ! jq -e . "$home_file" >/dev/null 2>&1; then
die "claude-bottle.json at ${home_file} is not valid JSON"
fi
has_home=1
fi
if [ "$has_cwd" = "0" ] && [ "$has_home" = "0" ]; then
die "no claude-bottle.json found in ${cwd} or ${HOME}"
elif [ "$has_cwd" = "1" ] && [ "$has_home" = "0" ]; then
cat "$cwd_file"
elif [ "$has_cwd" = "0" ] && [ "$has_home" = "1" ]; then
cat "$home_file"
else
# Merge: home is the base, cwd overrides on name conflict for both bottles and agents.
jq -s '{
"bottles": ((.[0].bottles // {}) * (.[1].bottles // {})),
"agents": ((.[0].agents // {}) * (.[1].agents // {}))
}' "$home_file" "$cwd_file"
fi
}
# manifest_has_agent <manifest_file> <name> — returns 0 if the agent key
# exists in the manifest, else 1.
manifest_has_agent() {
local manifest_file="${1:?manifest_has_agent: missing manifest file}"
local name="${2:?manifest_has_agent: missing agent name}"
jq -e --arg n "$name" '.agents | has($n)' "$manifest_file" >/dev/null 2>&1
}
# manifest_require_agent <manifest_file> <name> — like manifest_has_agent but
# dies with a useful message (and prints the available agent names) if the
# named agent is not defined.
manifest_require_agent() {
local manifest_file="${1:?manifest_require_agent: missing manifest file}"
local name="${2:?manifest_require_agent: missing agent name}"
if ! manifest_has_agent "$manifest_file" "$name"; then
local available
available="$(jq -r '.agents | keys_unsorted | join(", ")' "$manifest_file" 2>/dev/null || echo "")"
if [ -n "$available" ]; then
die "agent '${name}' not defined in claude-bottle.json. Available: ${available}"
else
die "agent '${name}' not defined in claude-bottle.json (manifest is empty)."
fi
fi
}
# manifest_env_names <manifest_file> <name> — prints one env-var name per line
# on stdout (the keys of bottles[agent.bottle].env, in declaration order). No values.
# Prints nothing if the agent has no bottle or the bottle has no env.
manifest_env_names() {
local manifest_file="${1:?manifest_env_names: missing manifest file}"
local name="${2:?manifest_env_names: missing agent name}"
jq -r --arg n "$name" '
.agents[$n].bottle as $bottle |
if ($bottle == null or $bottle == "") then empty
else (.bottles[$bottle].env // {} | keys_unsorted[])
end
' "$manifest_file"
}
# manifest_env_entry <manifest_file> <agent> <env_name> — prints the raw
# string value of a single env entry on stdout (no quoting, no JSON
# encoding). Env entries live on the agent's bottle (bottles[agent.bottle].env).
# Used by env_resolve.sh, which classifies the result by sentinel. Dies
# if the agent has no bottle, or the entry is not a JSON string; the
# prompt-at-runtime form is "?<message>", not JSON null.
manifest_env_entry() {
local manifest_file="${1:?manifest_env_entry: missing manifest file}"
local agent="${2:?manifest_env_entry: missing agent name}"
local var="${3:?manifest_env_entry: missing env var name}"
local bottle
bottle="$(jq -r --arg a "$agent" '.agents[$a].bottle // ""' "$manifest_file")"
if [ -z "$bottle" ]; then
die "env entry ${var} for agent ${agent}: agent has no 'bottle' field"
fi
local entry_type
entry_type="$(jq -r --arg b "$bottle" --arg v "$var" '.bottles[$b].env[$v] | type' "$manifest_file")"
if [ "$entry_type" != "string" ]; then
die "env entry ${var} for agent ${agent} must be a JSON string (was ${entry_type}). Use \"?<message>\" for prompt-at-runtime."
fi
jq -r --arg b "$bottle" --arg v "$var" '.bottles[$b].env[$v]' "$manifest_file"
}
# manifest_skills <manifest_file> <name> — prints one skill name per line on
# stdout (the elements of agent.skills, in order).
manifest_skills() {
local manifest_file="${1:?manifest_skills: missing manifest file}"
local name="${2:?manifest_skills: missing agent name}"
jq -r --arg n "$name" '.agents[$n].skills // [] | .[]' "$manifest_file"
}
# manifest_prompt <manifest_file> <name> — prints the prompt string on stdout
# (no trailing newline manipulation; the raw value goes out). Empty string
# if not set.
manifest_prompt() {
local manifest_file="${1:?manifest_prompt: missing manifest file}"
local name="${2:?manifest_prompt: missing agent name}"
jq -r --arg n "$name" '.agents[$n].prompt // ""' "$manifest_file"
}
# manifest_agent_bottle <manifest_file> <name> — prints the bottle name referenced
# by the agent on stdout, or an empty string if the agent has no "bottle" field.
manifest_agent_bottle() {
local manifest_file="${1:?manifest_agent_bottle: missing manifest file}"
local name="${2:?manifest_agent_bottle: missing agent name}"
jq -r --arg n "$name" '.agents[$n].bottle // ""' "$manifest_file"
}
# manifest_has_bottle <manifest_file> <bottle_name> — returns 0 if the named bottle
# exists in the manifest, else 1.
manifest_has_bottle() {
local manifest_file="${1:?manifest_has_bottle: missing manifest file}"
local bottle_name="${2:?manifest_has_bottle: missing bottle name}"
jq -e --arg b "$bottle_name" '.bottles | has($b)' "$manifest_file" >/dev/null 2>&1
}
# manifest_require_bottle <manifest_file> <bottle_name> — like manifest_has_bottle but
# dies with a useful message (and prints available bottle names) if the bottle is
# not defined.
manifest_require_bottle() {
local manifest_file="${1:?manifest_require_bottle: missing manifest file}"
local bottle_name="${2:?manifest_require_bottle: missing bottle name}"
if ! manifest_has_bottle "$manifest_file" "$bottle_name"; then
local available
available="$(jq -r '.bottles // {} | keys_unsorted | join(", ")' "$manifest_file" 2>/dev/null || echo "")"
if [ -n "$available" ]; then
die "bottle '${bottle_name}' not defined in claude-bottle.json. Available bottles: ${available}"
else
die "bottle '${bottle_name}' not defined in claude-bottle.json (no bottles defined)."
fi
fi
}
# manifest_bottle_ssh <manifest_file> <bottle_name> — prints one compact JSON object
# per line for each ssh entry in bottles[bottle_name].ssh. Prints nothing if the
# bottle has no ssh array or it is empty.
manifest_bottle_ssh() {
local manifest_file="${1:?manifest_bottle_ssh: missing manifest file}"
local bottle_name="${2:?manifest_bottle_ssh: missing bottle name}"
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
# agent has no "bottle" field, prints nothing.
# Each object has: Host, IdentityFile, Hostname, User, Port (required);
# KnownHostKey (optional).
manifest_ssh() {
local manifest_file="${1:?manifest_ssh: missing manifest file}"
local name="${2:?manifest_ssh: missing agent name}"
local bottle
bottle="$(jq -r --arg n "$name" '.agents[$n].bottle // ""' "$manifest_file")"
if [ -z "$bottle" ]; then
return 0
fi
jq -c --arg b "$bottle" '.bottles[$b].ssh // [] | .[]' "$manifest_file"
}
-182
View File
@@ -1,182 +0,0 @@
#!/usr/bin/env bash
# Docker network plumbing for the per-agent egress-proxy topology
# (PRD 0001).
#
# The egress design (see docs/research/pipelock-assessment.md
# §"Deployment topology") puts the agent container on a Docker
# `--internal` network — Docker omits the default gateway from
# `internal: true` networks at the iptables level inside the engine /
# LinuxKit VM, so the only address the agent can reach is the pipelock
# sidecar attached to the same network. The pipelock sidecar itself
# also needs egress to the upstream internet, so it is placed on a
# second (user-defined bridge) network as well. We deliberately do
# NOT use Docker's legacy `bridge` network for this: the legacy bridge
# has no embedded DNS resolver, so pipelock would be unable to resolve
# `api.anthropic.com` and Claude Code traffic would dead-end. Only
# user-defined bridges run Docker's built-in DNS, so we create one
# per agent.
#
# This module is the network-only half of that split: create / attach
# / teardown of both the per-agent internal network and the per-agent
# user-defined egress bridge, with no pipelock specifics. Keeping
# pipelock-agnostic helpers here means a future PRD can reuse them
# for a different sidecar (e.g. an iptables-only layer) without
# entangling the two concerns.
#
# Naming: claude-bottle-net-<slug> (internal),
# claude-bottle-egress-<slug> (egress). On conflict we append a
# numeric suffix (-2, -3, ...) to mirror the container-naming scheme
# in cli.sh, so two parallel starts of the same agent get distinct
# networks.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_NETWORK_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_NETWORK_SOURCED=1
_iso_lib_network_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_network_dir}/log.sh"
# network_name_for_slug <slug> — prints the canonical internal-network
# name for a given agent slug. No conflict resolution; that lives in
# network_create_internal.
network_name_for_slug() {
local slug="${1:?network_name_for_slug: missing slug}"
printf 'claude-bottle-net-%s' "$slug"
}
# network_egress_name_for_slug <slug> — prints the canonical egress-network
# name for a given agent slug. No conflict resolution; that lives in
# network_create_egress.
network_egress_name_for_slug() {
local slug="${1:?network_egress_name_for_slug: missing slug}"
printf 'claude-bottle-egress-%s' "$slug"
}
# network_exists <name> — returns 0 if the named docker network exists,
# else 1. Uses `docker network inspect` (not `docker network ls -f name=...`)
# because the latter does substring matching, which would falsely report
# claude-bottle-net-foo as existing when only claude-bottle-net-foo-2 was
# present.
network_exists() {
local name="${1:?network_exists: missing network name}"
docker network inspect "$name" >/dev/null 2>&1
}
# _network_create_with_prefix <prefix> <internal: 0|1>
#
# Internal helper. Creates a per-agent Docker network whose name is
# <prefix> (with -2, -3, ... appended on conflict, capped at 100).
# When <internal> is 1, the network is created with `--internal` (no
# default gateway). When 0, it's a plain user-defined bridge with
# upstream connectivity. Echoes the resolved name on stdout.
_network_create_with_prefix() {
local base="${1:?_network_create_with_prefix: missing prefix}"
local internal_flag="${2:?_network_create_with_prefix: missing internal flag}"
local name="$base"
local _suffix=2
while network_exists "$name"; do
name="${base}-${_suffix}"
_suffix=$((_suffix + 1))
if [ "$_suffix" -gt 100 ]; then
die "could not find a free network name after ${base}-99; clean up old networks with 'docker network rm <name>'"
fi
done
local kind="bridge (egress)"
local args=()
if [ "$internal_flag" = "1" ]; then
kind="internal"
args+=(--internal)
fi
info "creating ${kind} network ${name}"
# Defaults give us a bridge driver with Docker-managed addressing,
# which is what we want for both internal and egress networks.
if ! docker network create "${args[@]}" "$name" >/dev/null; then
die "docker network create ${args[*]} ${name} failed"
fi
printf '%s' "$name"
}
# network_create_internal <slug>
#
# Creates a Docker `--internal` network for the agent and prints the
# resolved network name on stdout. If the canonical name is already
# taken, appends -2, -3, ... (capped at 100, matching the
# container-name retry loop in cli.sh) until a free name is found.
#
# `--internal` is the load-bearing flag: Docker creates the bridge
# without a default route, so the agent container attached here cannot
# reach the public internet directly. The pipelock sidecar (attached
# to both this network and a per-agent egress network) is the only
# egress route.
#
# Side effect: emits one info line naming the network actually created.
network_create_internal() {
local slug="${1:?network_create_internal: missing slug}"
local base
base="$(network_name_for_slug "$slug")"
_network_create_with_prefix "$base" 1
}
# network_create_egress <slug>
#
# Creates a per-agent user-defined bridge network used by the pipelock
# sidecar for upstream egress, and prints the resolved network name on
# stdout. Conflict resolution mirrors network_create_internal.
#
# We use a user-defined bridge (NOT the legacy `bridge` network)
# because only user-defined bridges run Docker's embedded DNS resolver
# — pipelock needs DNS to resolve `api.anthropic.com` and similar
# upstream hostnames. The legacy `bridge` network would force pipelock
# onto the host's resolv.conf and fail in environments where Docker
# Desktop's NAT path is the only working DNS route.
#
# Side effect: emits one info line naming the network actually created.
network_create_egress() {
local slug="${1:?network_create_egress: missing slug}"
local base
base="$(network_egress_name_for_slug "$slug")"
_network_create_with_prefix "$base" 0
}
# network_attach <network> <container>
#
# Attaches an already-running container to the named network. Used to
# add the pipelock sidecar to a second (default-bridge) network so it
# has upstream egress, while staying reachable from the agent on the
# internal network.
#
# Note: for the agent container itself we pass `--network <name>` to
# `docker run` directly in cli.sh rather than using this function. The
# agent never touches anything except the internal network.
network_attach() {
local network="${1:?network_attach: missing network name}"
local container="${2:?network_attach: missing container name}"
if ! docker network connect "$network" "$container" >/dev/null 2>&1; then
die "docker network connect ${network} ${container} failed"
fi
}
# network_remove <name>
#
# Removes the named network. Idempotent: a missing network is treated
# as success so this can be called unconditionally from a teardown
# trap. A network that still has containers attached will fail to
# remove; the caller is expected to tear those containers down first.
network_remove() {
local name="${1:?network_remove: missing network name}"
if ! network_exists "$name"; then
return 0
fi
if ! docker network rm "$name" >/dev/null 2>&1; then
# Don't `die` here: this runs in cleanup paths where we'd rather
# warn and continue than abort and leave more orphans behind.
warn "failed to remove network ${name}; clean up with 'docker network rm ${name}'"
return 1
fi
}
-489
View File
@@ -1,489 +0,0 @@
#!/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
}
-101
View File
@@ -1,101 +0,0 @@
#!/usr/bin/env bash
# Skill copier. Copies named skills from the host's ~/.claude/skills/<name>/
# into the running container's ~/.claude/skills/<name>/, preserving
# directory structure (no flattening, no archives), per CLAUDE.md
# "Intended design".
#
# Scope of THIS file (matches PRD 0002 "Open question 3" resolution):
# - host → container only.
# - if a referenced skill is missing on the host, fail with a clear
# message naming the skill. No silent skipping. The repo-side
# `skills/<name>/` snapshot and host↔repo diff prompt described in
# CLAUDE.md "Intended design" are deferred.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_SKILLS_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_SKILLS_SOURCED=1
_iso_lib_skills_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_skills_dir}/log.sh"
# Container-side home/skills paths. The Dockerfile sets the user to `node`
# (uid 1000) with home /home/node, so this is where claude-code looks.
CLAUDE_BOTTLE_CONTAINER_HOME="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}"
CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR="${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR:-${CLAUDE_BOTTLE_CONTAINER_HOME}/.claude/skills}"
# host_skill_dir <name> — prints the absolute host path for a skill.
host_skill_dir() {
local name="${1:?host_skill_dir: missing skill name}"
printf '%s/.claude/skills/%s' "${HOME:?HOME not set}" "$name"
}
# host_skill_exists <name> — returns 0 if the host has a skill directory
# at ~/.claude/skills/<name>/, else 1.
host_skill_exists() {
local name="${1:?host_skill_exists: missing skill name}"
[ -d "$(host_skill_dir "$name")" ]
}
# require_host_skill <name> — dies with a clear message if the named
# skill is missing on the host. The error names the skill and the path
# checked.
require_host_skill() {
local name="${1:?require_host_skill: missing skill name}"
if ! host_skill_exists "$name"; then
die "skill '${name}' not found on host at $(host_skill_dir "$name"). Create it under ~/.claude/skills/, then re-run."
fi
}
# skills_validate_all <name1> [<name2> ...] — checks every named skill
# exists on the host, dies on the first one that does not. No copy yet.
# Use this BEFORE the confirmation prompt so the user does not get
# asked y/N for a plan that's already known to fail.
skills_validate_all() {
local n
for n in "$@"; do
require_host_skill "$n"
done
}
# skills_copy_into <container> <name1> [<name2> ...]
#
# For each named skill:
# 1. ensure ~/.claude/skills/ exists in the container (mkdir -p)
# 2. `docker cp <host_skill_dir>/. <container>:<container_skills>/<name>/`
# — the trailing `/.` on the source preserves directory structure
# and copies the contents into a freshly-created destination dir,
# avoiding the docker-cp quirk where copying `dir` (no slash) into
# an existing `dest/` would nest as `dest/dir/`.
#
# The destination directory is removed first if it already exists, so
# repeated calls produce a deterministic state.
skills_copy_into() {
local container="${1:?skills_copy_into: missing container name}"
shift
if [ "$#" -eq 0 ]; then
return 0
fi
# Ensure the target parent dir exists in the container. This is a
# no-op if the Dockerfile already created it, but cheap and defensive.
docker exec "$container" mkdir -p "${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR}" >/dev/null
local n src dst
for n in "$@"; do
src="$(host_skill_dir "$n")"
if [ ! -d "$src" ]; then
die "skill '${n}' disappeared from host between validation and copy at ${src}."
fi
dst="${CLAUDE_BOTTLE_CONTAINER_SKILLS_DIR}/${n}"
info "copying skill ${n} into ${container}:${dst}"
# Wipe any prior copy so we're deterministic, then create empty dst
# and copy contents-of-src into it via the `/.` source-suffix trick.
docker exec "$container" rm -rf "$dst" >/dev/null
docker exec "$container" mkdir -p "$dst" >/dev/null
docker cp "${src}/." "${container}:${dst}/" >/dev/null
done
}
-220
View File
@@ -1,220 +0,0 @@
#!/usr/bin/env bash
# SSH helpers. Validates ssh entries from claude-bottle.json, then sets up SSH
# inside the container via a root-owned ssh-agent so the `node` user (Claude)
# can use the keys for SSH operations but cannot read the key bytes.
#
# Why an in-container agent (not bind-mounted from host): Docker Desktop on
# macOS does not forward Unix-domain socket connect() across the macOS↔Linux
# VM boundary — connect() returns ENOTSUP. Running ssh-agent inside the
# container sidesteps that entirely and keeps the same isolation guarantee.
#
# How the isolation works:
# - Keys are docker cp'd to /root/.claude-bottle-keys/ (mode 700, root-owned).
# /root itself is mode 700 in the node:22-slim base image, so node (uid
# 1000) cannot even traverse into it.
# - ssh-agent runs as root, listening on /run/claude-bottle-agent.sock. Each
# key is loaded with ssh-add, then the key file is deleted. The bytes
# now live only in the agent process's memory.
# - The agent socket stays root-only. OpenSSH's ssh-agent enforces a
# SO_PEERCRED-based UID match: it rejects every connection whose peer
# euid is neither 0 nor the agent's own uid. chmod'ing the socket open
# does *not* defeat this — the kernel-level check still rejects node.
# - To bridge that, a root-owned socat forwarder listens on
# /run/claude-bottle-agent-public.sock (mode 666) and proxies bytes to the
# real agent socket. From the agent's view, socat (uid 0) is the peer
# and passes the UID check. From node's view, the public socket is the
# accessible endpoint.
# - node cannot ptrace the root-owned agent or socat (no CAP_SYS_PTRACE in
# a default container), so /proc/<pid>/mem is off-limits and the key
# bytes never leave root-owned memory.
# - ~/.ssh/config in node's home points each Host at the public socket via
# IdentityAgent, so SSH always reaches the forwarder regardless of
# SSH_AUTH_SOCK.
#
# Limitation: keys must be passphrase-less. ssh-add prompts on /dev/tty for
# passphrases, but our docker exec has no TTY. Adding SSH_ASKPASS support is
# possible but not implemented in v1.
#
# Each ssh entry is a JSON object (jq -c) with keys:
# Host SSH Host alias
# IdentityFile absolute path to the private key file on the host
# Hostname the actual hostname or IP
# User SSH username
# Port SSH port (number)
# KnownHostKey (optional) host public key — written to known_hosts under
# both the Host alias and the Hostname so the lookup works
# whether SSH connects via the alias or the raw IP/host.
#
# Idempotent: safe to source multiple times.
if [ -n "${CLAUDE_BOTTLE_LIB_SSH_SOURCED:-}" ]; then
return 0
fi
CLAUDE_BOTTLE_LIB_SSH_SOURCED=1
_iso_lib_ssh_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./log.sh
. "${_iso_lib_ssh_dir}/log.sh"
# ssh_validate_entries <json_object>... — checks that each entry has the
# required fields and that its IdentityFile exists on the host. Dies on the
# first problem.
ssh_validate_entries() {
local entry name key
for entry in "$@"; do
name="$(printf '%s' "$entry" | jq -r '.Host // empty')"
key="$(printf '%s' "$entry" | jq -r '.IdentityFile // empty')"
[ -n "$name" ] || die "ssh entry missing required field 'Host': ${entry}"
[ -n "$key" ] || die "ssh entry '${name}' missing required field 'IdentityFile'"
# Expand a leading ~ so callers can use ~/... paths.
key="${key/#\~/$HOME}"
[ -f "$key" ] || die "ssh key file not found for host '${name}': ${key}"
done
}
# ssh_setup <container> <stage_dir> <json_object>... — sets up SSH in the
# container so node (Claude) can authenticate using each entry's key without
# the key file being readable by node.
#
# Lifecycle:
# 1. Create ~/.ssh (700) for node and /root/.claude-bottle-keys (700) for root.
# 2. docker cp each key into /root/.claude-bottle-keys/, chown root, chmod 600.
# 3. Boot ssh-agent at /run/claude-bottle-agent.sock (root-only), ssh-add each
# key, delete the key file, rmdir the keys staging dir.
# 4. Boot a root-owned socat forwarder on /run/claude-bottle-agent-public.sock
# (mode 666) proxying to the agent socket. Bridges the UID-match check
# that would otherwise reject node's connections (see file header).
# 5. Install ~/.ssh/config (IdentityAgent → public socket) and
# ~/.ssh/known_hosts under node's home.
ssh_setup() {
local container="${1:?ssh_setup: missing container}"
local stage_dir="${2:?ssh_setup: missing stage dir}"
# 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"
local agent_socket="/run/claude-bottle-agent.sock"
local public_socket="/run/claude-bottle-agent-public.sock"
local keys_dir="/root/.claude-bottle-keys"
# ~/.ssh for node (700, owned by node).
docker exec -u 0 "$container" mkdir -p "$container_ssh" >/dev/null
docker exec -u 0 "$container" chown node:node "$container_ssh" >/dev/null
docker exec -u 0 "$container" chmod 700 "$container_ssh" >/dev/null
# /root/.claude-bottle-keys for root (700, root-owned). /root is already 700
# in node:22-slim, so node can't traverse here either way; setting both
# layers keeps the intent explicit.
docker exec -u 0 "$container" mkdir -p "$keys_dir" >/dev/null
docker exec -u 0 "$container" chown root:root "$keys_dir" >/dev/null
docker exec -u 0 "$container" chmod 700 "$keys_dir" >/dev/null
local config_file="${stage_dir}/ssh_config"
local known_hosts_file="${stage_dir}/ssh_known_hosts"
: > "$config_file"
chmod 600 "$config_file"
: > "$known_hosts_file"
chmod 600 "$known_hosts_file"
local entry name key hostname user port known_host_key key_basename container_key_path
local container_key_paths=()
for entry in "$@"; do
name="$(printf '%s' "$entry" | jq -r '.Host')"
key="$(printf '%s' "$entry" | jq -r '.IdentityFile')"
hostname="$(printf '%s' "$entry" | jq -r '.Hostname')"
user="$(printf '%s' "$entry" | jq -r '.User')"
port="$(printf '%s' "$entry" | jq -r '.Port')"
known_host_key="$(printf '%s' "$entry" | jq -r '.KnownHostKey // empty')"
key="${key/#\~/$HOME}"
key_basename="$(basename "$key")"
container_key_path="${keys_dir}/${key_basename}"
info "copying ssh key for '${name}' -> ${container} (root-only staging)"
docker cp "$key" "${container}:${container_key_path}" >/dev/null
docker exec -u 0 "$container" chown root:root "$container_key_path" >/dev/null
docker exec -u 0 "$container" chmod 600 "$container_key_path" >/dev/null
container_key_paths+=("$container_key_path")
# 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).
#
# 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
# whether the connection uses the alias (`ssh <name>`) or a raw IP/host
# (e.g. git remote URLs that bypass the alias). Skip the duplicate when
# they're already the same string.
if [ "$port" = "22" ]; then
printf '%s %s\n' "$name" "$known_host_key" >> "$known_hosts_file"
[ "$hostname" != "$name" ] && printf '%s %s\n' "$hostname" "$known_host_key" >> "$known_hosts_file"
else
printf '[%s]:%s %s\n' "$name" "$port" "$known_host_key" >> "$known_hosts_file"
[ "$hostname" != "$name" ] && printf '[%s]:%s %s\n' "$hostname" "$port" "$known_host_key" >> "$known_hosts_file"
fi
fi
done
# Boot the agent, load each key, delete the key files, then start the
# root-owned socat forwarder that exposes a node-accessible socket. One
# docker exec so the whole sequence is atomic — if any step fails (e.g.
# passphrase-protected key), set -e dies before we leave behind a
# half-initialized agent.
info "starting in-container ssh-agent at ${agent_socket} (forwarded via ${public_socket})"
local setup_script="set -eu
ssh-agent -a ${agent_socket} >/dev/null
"
local kp
for kp in "${container_key_paths[@]}"; do
setup_script+="SSH_AUTH_SOCK=${agent_socket} ssh-add ${kp}
rm -f ${kp}
"
done
setup_script+="rmdir ${keys_dir} 2>/dev/null || true
# Start the forwarder. Detach from the calling shell so it survives this
# docker exec returning. socat (running as root) connects to the agent on
# node's behalf; the agent's UID-match check sees uid 0 and accepts.
nohup socat UNIX-LISTEN:${public_socket},fork,reuseaddr,mode=666 UNIX-CONNECT:${agent_socket} </dev/null >/dev/null 2>&1 &
# Wait briefly for the forwarder to bind. Without this, an SSH client that
# fires immediately after this script returns can race the listener and hit
# ENOENT/ECONNREFUSED on the public socket.
i=0
while [ \$i -lt 20 ]; do
[ -S ${public_socket} ] && break
i=\$((i + 1))
sleep 0.1
done
[ -S ${public_socket} ] || { echo 'claude-bottle: socat forwarder failed to bind ${public_socket}' >&2; exit 1; }
"
docker exec -u 0 "$container" sh -c "$setup_script"
info "writing ${container_ssh}/config"
docker cp "$config_file" "${container}:${container_ssh}/config" >/dev/null
docker exec -u 0 "$container" chown node:node "${container_ssh}/config" >/dev/null
docker exec -u 0 "$container" chmod 600 "${container_ssh}/config" >/dev/null
if [ -s "$known_hosts_file" ]; then
info "writing ${container_ssh}/known_hosts"
docker cp "$known_hosts_file" "${container}:${container_ssh}/known_hosts" >/dev/null
docker exec -u 0 "$container" chown node:node "${container_ssh}/known_hosts" >/dev/null
docker exec -u 0 "$container" chmod 600 "${container_ssh}/known_hosts" >/dev/null
fi
}
+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