diff --git a/.githooks/commit-msg b/.githooks/commit-msg index d3205e1..4a7ab5d 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -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: [()][!]: \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: [()][!]: \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:])) diff --git a/claude_bottle/__init__.py b/claude_bottle/__init__.py new file mode 100644 index 0000000..e909a27 --- /dev/null +++ b/claude_bottle/__init__.py @@ -0,0 +1 @@ +"""claude-bottle: Python implementation of the agent container launcher.""" diff --git a/claude_bottle/cli.py b/claude_bottle/cli.py new file mode 100644 index 0000000..1ed338b --- /dev/null +++ b/claude_bottle/cli.py @@ -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 '" + ) + + # --- 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 None: + sys.stderr.write(f"usage: {PROG} [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} --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()) diff --git a/claude_bottle/docker.py b/claude_bottle/docker.py new file mode 100644 index 0000000..80304ec --- /dev/null +++ b/claude_bottle/docker.py @@ -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=^$` 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 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 diff --git a/claude_bottle/env_resolve.py b/claude_bottle/env_resolve.py new file mode 100644 index 0000000..82f2980 --- /dev/null +++ b/claude_bottle/env_resolve.py @@ -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; + "?" uses 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") diff --git a/claude_bottle/log.py b/claude_bottle/log.py new file mode 100644 index 0000000..b666e1e --- /dev/null +++ b/claude_bottle/log.py @@ -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) diff --git a/claude_bottle/manifest.py b/claude_bottle/manifest.py new file mode 100644 index 0000000..8e1ff65 --- /dev/null +++ b/claude_bottle/manifest.py @@ -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": { + "": { + "env": { "": , ... }, + "ssh": [ , ... ], + "egress": { "allowlist": [ "", ... ] } + } + }, + "agents": { + "": { + "skills": [ "", ... ], + "prompt": "", + "bottle": "" + } + } + } + +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 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 \"?\" 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__ diff --git a/claude_bottle/network.py b/claude_bottle/network.py new file mode 100644 index 0000000..2a60eb7 --- /dev/null +++ b/claude_bottle/network.py @@ -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- (internal), +claude-bottle-egress- (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 (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 '" + ) + + 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 diff --git a/claude_bottle/pipelock.py b/claude_bottle/pipelock.py new file mode 100644 index 0000000..b4b2256 --- /dev/null +++ b/claude_bottle/pipelock.py @@ -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: 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[].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: + " 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:`. + 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__ diff --git a/claude_bottle/skills.py b/claude_bottle/skills.py new file mode 100644 index 0000000..4efee86 --- /dev/null +++ b/claude_bottle/skills.py @@ -0,0 +1,76 @@ +"""Skill copier: host's ~/.claude/skills// -> container's +~/.claude/skills//, 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 /. :/` 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, + ) diff --git a/claude_bottle/ssh.py b/claude_bottle/ssh.py new file mode 100644 index 0000000..3c37e16 --- /dev/null +++ b/claude_bottle/ssh.py @@ -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//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 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 diff --git a/cli.py b/cli.py new file mode 100755 index 0000000..a7c21c6 --- /dev/null +++ b/cli.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""cli.py — manage claude-bottle containers. + +usage: cli.py [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()) diff --git a/cli.sh b/cli.sh deleted file mode 100755 index d727cb6..0000000 --- a/cli.sh +++ /dev/null @@ -1,1055 +0,0 @@ -#!/usr/bin/env bash -# cli.sh — manage claude-bottle containers. -# -# usage: cli.sh [args...] -# -# Commands: -# build build (or rebuild) the claude-bottle Docker image. -# cleanup stop and remove all active claude-bottle containers. -# info print env, skills, and prompt details for a named agent. -# 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. - -set -euo pipefail - -# Capture the user's cwd before anything else touches it. -USER_CWD="${PWD}" - -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -REPO_DIR="$SCRIPT_DIR" - -# shellcheck source=lib/log.sh -. "${SCRIPT_DIR}/lib/log.sh" -# shellcheck source=lib/docker.sh -. "${SCRIPT_DIR}/lib/docker.sh" -# shellcheck source=lib/env.sh -. "${SCRIPT_DIR}/lib/env.sh" -# shellcheck source=lib/manifest.sh -. "${SCRIPT_DIR}/lib/manifest.sh" -# shellcheck source=lib/env_resolve.sh -. "${SCRIPT_DIR}/lib/env_resolve.sh" -# shellcheck source=lib/skills.sh -. "${SCRIPT_DIR}/lib/skills.sh" -# shellcheck source=lib/ssh.sh -. "${SCRIPT_DIR}/lib/ssh.sh" -# shellcheck source=lib/network.sh -. "${SCRIPT_DIR}/lib/network.sh" -# shellcheck source=lib/pipelock.sh -. "${SCRIPT_DIR}/lib/pipelock.sh" - -usage() { - printf 'usage: %s [args...]\n' "$(basename "$0")" >&2 - printf '\n' >&2 - printf 'Commands:\n' >&2 - printf ' build build (or rebuild) the claude-bottle Docker image\n' >&2 - printf ' cleanup stop and remove all active claude-bottle containers\n' >&2 - printf ' edit open an agent in vim for editing\n' >&2 - printf ' info print env, skills, and prompt details for a named agent\n' >&2 - printf ' init interactively create a new agent and add it to claude-bottle.json\n' >&2 - printf ' list list available agents or active containers\n' >&2 - printf ' start boot a container for a named agent and attach an interactive session\n' >&2 - printf '\n' >&2 - printf "Run '%s --help' for command-specific usage.\n" "$(basename "$0")" >&2 -} - -cmd_build() { - require_docker - build_image "${CLAUDE_BOTTLE_IMAGE:-claude-bottle:latest}" "$REPO_DIR" -} - -cmd_info() { - usage_info() { - printf 'usage: %s info \n' "$(basename "$0")" >&2 - printf ' must be defined in claude-bottle.json at the repo root.\n' >&2 - } - - if [ "$#" -lt 1 ]; then - usage_info - exit 2 - fi - - case "$1" in - -h|--help) usage_info; exit 0 ;; - -*) usage_info; die "unknown flag: $1" ;; - esac - - local NAME="$1" - require_jq - local MANIFEST_FILE - MANIFEST_FILE="$(mktemp -t claude-bottle-manifest.XXXXXX.json)" - trap 'rm -f "${MANIFEST_FILE:-}"' EXIT - manifest_resolve "$USER_CWD" > "$MANIFEST_FILE" - manifest_require_agent "$MANIFEST_FILE" "$NAME" - - local env_names="" _en - while IFS= read -r _en; do - [ -z "$_en" ] && continue - env_names="${env_names:+${env_names}, }${_en}" - done < <(manifest_env_names "$MANIFEST_FILE" "$NAME") - - local skill_names=() _sk - while IFS= read -r _sk; do - [ -z "$_sk" ] && continue - skill_names+=("$_sk") - done < <(manifest_skills "$MANIFEST_FILE" "$NAME") - - local prompt_content prompt_len prompt_first_line - prompt_content="$(manifest_prompt "$MANIFEST_FILE" "$NAME")" - prompt_len="${#prompt_content}" - prompt_first_line="$(printf '%s' "$prompt_content" | awk 'NR==1{print; exit}')" - - local bottle_name - bottle_name="$(manifest_agent_bottle "$MANIFEST_FILE" "$NAME")" - - local ssh_entries=() _se - while IFS= read -r _se; do - [ -z "$_se" ] && continue - ssh_entries+=("$_se") - done < <(manifest_ssh "$MANIFEST_FILE" "$NAME") - - printf '\n' - info "agent : ${NAME}" - info "env (names only): ${env_names:-(none)}" - info "skills : ${skill_names[*]:-(none)}" - info "prompt : ${prompt_len} chars; first line: ${prompt_first_line:-(empty)}" - if [ -n "$bottle_name" ]; then - info "bottle : ${bottle_name}" - if [ "${#ssh_entries[@]}" -gt 0 ]; then - local _n _h _u _p _k _khk - for _se in "${ssh_entries[@]}"; do - _n="$(printf '%s' "$_se" | jq -r '.Host')" - _h="$(printf '%s' "$_se" | jq -r '.Hostname')" - _u="$(printf '%s' "$_se" | jq -r '.User')" - _p="$(printf '%s' "$_se" | jq -r '.Port')" - _k="$(printf '%s' "$_se" | jq -r '.IdentityFile')" - _khk="$(printf '%s' "$_se" | jq -r '.KnownHostKey // empty')" - info " ssh host : ${_n} (Hostname=${_h}, User=${_u}, Port=${_p}, IdentityFile=${_k})" - [ -n "$_khk" ] && info " KnownHostKey: ${_khk}" - done - else - info " ssh hosts : (none)" - fi - else - info "bottle : (none)" - fi - printf '\n' -} - -cmd_list() { - usage_list() { - printf 'usage: %s list \n' "$(basename "$0")" >&2 - printf ' available list agent names defined in claude-bottle.json\n' >&2 - printf ' active list running claude-bottle containers\n' >&2 - } - - if [ "$#" -lt 1 ]; then - usage_list - exit 2 - fi - - case "$1" in - available) - require_jq - local MANIFEST_FILE - MANIFEST_FILE="$(mktemp -t claude-bottle-manifest.XXXXXX.json)" - trap 'rm -f "${MANIFEST_FILE:-}"' EXIT - manifest_resolve "$USER_CWD" > "$MANIFEST_FILE" - jq -r '.agents | keys_unsorted[]' "$MANIFEST_FILE" - ;; - active) - require_docker - local containers - containers="$(docker ps --filter 'name=^claude-bottle-' --format '{{.Names}}{{"\t"}}{{.Status}}' 2>/dev/null || true)" - if [ -z "$containers" ]; then - info "no active claude-bottle containers" - return 0 - fi - printf '\n' - local name status - while IFS=$'\t' read -r name status; do - info "container: ${name} status: ${status}" - done <<< "$containers" - printf '\n' - ;; - -h|--help) usage_list; exit 0 ;; - *) usage_list; die "unknown argument: $1" ;; - esac -} - -cmd_cleanup() { - require_docker - local containers - containers="$(docker ps --filter 'name=^claude-bottle-' --format '{{.Names}}' 2>/dev/null || true)" - if [ -z "$containers" ]; then - info "no active claude-bottle containers" - return 0 - fi - printf '\n' >&2 - local name - while IFS= read -r name; do - info "found: ${name}" - done <<< "$containers" - printf '\n' >&2 - printf 'claude-bottle: remove all of the above? [y/N] ' >&2 - local REPLY - IFS= read -r REPLY /dev/null - done <<< "$containers" - info "done" -} - -# --------------------------------------------------------------------------- -# cmd_start — bring up an ephemeral claude-bottle container configured for a -# named agent from the repo-root claude-bottle.json manifest, and drop the -# user into an interactive claude-code session inside it. -# -# Lifecycle (per PRD 0001 "ephemeral" requirement): the container is -# removed automatically when the interactive session ends. We use -# `docker run --rm -d` plus a trap that forces removal on exit, so -# signals like Ctrl-C also clean up. -# -# ASSUMPTION: the container is started detached (`-d`) running `sleep -# infinity` so that skills and config can be copied in via `docker cp` -# before `docker exec` attaches the claude session. The container therefore -# stays alive in the background between launch and attach — the EXIT/INT/TERM -# trap is what guarantees teardown on normal exit. SIGKILL bypasses the -# trap; if this process is killed that way the container will be left -# running and must be removed manually with `docker rm -f `. -# -# Per-agent configuration (PRD 0002): -# - env vars in three modes (secret-prompted, literal, interpolated -# from the host process env). Resolved by lib/env_resolve.sh. -# * secret → prompted from /dev/tty, exported, forwarded via -# `docker run -e NAME` (no `=value`). -# * interpolated→ copied from a host var into this process under -# the target name, forwarded the same way as a -# secret (off argv, off disk). -# * literal → written to a mode-600 env-file under mktemp -d -# and forwarded with `--env-file `. -# - skills: host directories under ~/.claude/skills// are -# `docker cp`'d into the running container's -# ~/.claude/skills// by lib/skills.sh. -# - prompt: written to a host-side mode-600 file, then `docker cp`'d -# into the container (so the prompt content never lands on -# `docker exec` argv) and passed to -# `claude --append-system-prompt-file `. -# -# Confirmation: the resolved plan (skill names, env var names — never -# values, prompt length and first line) is shown before launch and -# gated on a single y/N. -# -# Dry-run: pass --dry-run (or set CLAUDE_BOTTLE_DRY_RUN=1) to print the -# resolved plan and exit BEFORE docker run / cp / exec. Used for -# verifying the manifest wiring without booting Claude. -# --------------------------------------------------------------------------- -cmd_start() { - usage_start() { - printf 'usage: %s start [--dry-run] [--cwd] [--remote-control] \n' "$(basename "$0")" >&2 - printf ' must be defined in claude-bottle.json at the repo root.\n' >&2 - printf ' --cwd copy the current working directory into a derived image at\n' >&2 - printf ' /home/node/workspace and start claude there.\n' >&2 - printf ' --remote-control start claude with --remote-control enabled.\n' >&2 - } - - local DRY_RUN="${CLAUDE_BOTTLE_DRY_RUN:-0}" - local COPY_CWD=0 - local REMOTE_CONTROL=0 - local NAME="" - - while [ "$#" -gt 0 ]; do - case "$1" in - --dry-run) DRY_RUN=1; shift ;; - --cwd) COPY_CWD=1; shift ;; - --remote-control) REMOTE_CONTROL=1; shift ;; - -h|--help) usage_start; exit 0 ;; - --) shift; break ;; - -*) usage_start; die "unknown flag: $1" ;; - *) - if [ -z "$NAME" ]; then - NAME="$1" - else - usage_start; die "unexpected extra argument: $1" - fi - shift - ;; - esac - done - - if [ -z "${NAME:-}" ]; then - usage_start - exit 2 - fi - - # Not declared local: needed by cleanup_all after cmd_start returns (see MANIFEST_FILE note below). - SLUG="$(slugify "$NAME")" - - local IMAGE="${CLAUDE_BOTTLE_IMAGE:-claude-bottle:latest}" - # Default container name is claude-bottle-. If the user pinned a - # specific name via CLAUDE_BOTTLE_CONTAINER we honor it as-is below. - # Otherwise we auto-suffix on conflict so concurrent starts of the - # same agent get distinct containers (claude-bottle-journal, - # claude-bottle-journal-2, ...). Final resolution happens just below, - # after require_docker, since container_exists needs docker reachable. - local DEFAULT_CONTAINER="claude-bottle-${SLUG}" - local PINNED_CONTAINER="${CLAUDE_BOTTLE_CONTAINER:-}" - - # When --cwd is on, runtime image is a thin derived image FROM $IMAGE - # with the user's cwd COPY'd in. Tag it per-agent so the layer cache - # stays effective across repeated launches of the same agent. - local RUNTIME_IMAGE="$IMAGE" - local DERIVED_IMAGE="" - if [ "$COPY_CWD" = "1" ]; then - DERIVED_IMAGE="${CLAUDE_BOTTLE_DERIVED_IMAGE:-claude-bottle:cwd-${SLUG}}" - RUNTIME_IMAGE="$DERIVED_IMAGE" - fi - - require_docker - require_jq - - # Resolve the manifest (merge USER_CWD and HOME configs) into a temp file - # early so it is available for all subsequent manifest calls. - # Not declared local: the EXIT trap fires after cmd_start returns, so local - # variables would already be out of scope when cleanup_all runs. - MANIFEST_FILE="$(mktemp -t claude-bottle-manifest.XXXXXX.json)" - trap 'rm -f "${MANIFEST_FILE:-}"' EXIT - manifest_resolve "$USER_CWD" > "$MANIFEST_FILE" - - manifest_require_agent "$MANIFEST_FILE" "$NAME" - - # Not declared local: needed by cleanup_all after cmd_start returns (see MANIFEST_FILE note above). - CONTAINER="" - local _suffix=2 - if [ -n "$PINNED_CONTAINER" ]; then - CONTAINER="$PINNED_CONTAINER" - if container_exists "$CONTAINER"; then - die "container '${CONTAINER}' already exists (pinned via CLAUDE_BOTTLE_CONTAINER). Remove it with 'docker rm -f ${CONTAINER}' or unset the override." - fi - else - CONTAINER="$DEFAULT_CONTAINER" - while container_exists "$CONTAINER"; do - CONTAINER="${DEFAULT_CONTAINER}-${_suffix}" - _suffix=$((_suffix + 1)) - if [ "$_suffix" -gt 100 ]; then - die "could not find a free container name after ${DEFAULT_CONTAINER}-99; clean up old containers with 'docker rm -f '" - fi - done - fi - - # --- Plan resolution (host-only, no container yet) --- - - # Collect the env names (for display) and the skill names (for both - # display and validation). - local ENV_NAMES_LIST="" - local _en - while IFS= read -r _en; do - [ -z "$_en" ] && continue - if [ -z "$ENV_NAMES_LIST" ]; then - ENV_NAMES_LIST="$_en" - else - ENV_NAMES_LIST="${ENV_NAMES_LIST}, ${_en}" - fi - done < <(manifest_env_names "$MANIFEST_FILE" "$NAME") - - # CLAUDE_BOTTLE_OAUTH_TOKEN → CLAUDE_CODE_OAUTH_TOKEN forwarding. - # When the host has the token set, it is always forwarded regardless of the - # manifest so that every container can authenticate without wiring the token - # into each agent definition. - local FORWARD_OAUTH_TOKEN=0 - if [ -n "${CLAUDE_BOTTLE_OAUTH_TOKEN:-}" ]; then - FORWARD_OAUTH_TOKEN=1 - if [ -z "$ENV_NAMES_LIST" ]; then - ENV_NAMES_LIST="CLAUDE_CODE_OAUTH_TOKEN" - else - ENV_NAMES_LIST="${ENV_NAMES_LIST}, CLAUDE_CODE_OAUTH_TOKEN" - fi - fi - - # Skills as an array. - local SKILL_NAMES=() - local _sk - while IFS= read -r _sk; do - [ -z "$_sk" ] && continue - SKILL_NAMES+=("$_sk") - done < <(manifest_skills "$MANIFEST_FILE" "$NAME") - - # Validate every requested skill exists on the host BEFORE the y/N. - if [ "${#SKILL_NAMES[@]}" -gt 0 ]; then - skills_validate_all "${SKILL_NAMES[@]}" - fi - - # Resolve the bottle referenced by this agent and validate it exists. - # A bottle is required — agents without one are rejected before launch. - local BOTTLE_NAME - BOTTLE_NAME="$(manifest_agent_bottle "$MANIFEST_FILE" "$NAME")" - if [ -z "$BOTTLE_NAME" ]; then - die "agent '${NAME}' has no 'bottle' field. Add a bottle association to this agent in claude-bottle.json." - fi - manifest_require_bottle "$MANIFEST_FILE" "$BOTTLE_NAME" - - # SSH entries come from the agent's bottle (empty if no bottle set). - local SSH_ENTRIES=() - local _se - while IFS= read -r _se; do - [ -z "$_se" ] && continue - SSH_ENTRIES+=("$_se") - done < <(manifest_ssh "$MANIFEST_FILE" "$NAME") - - # Validate key files exist on the host BEFORE the y/N. - if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then - ssh_validate_entries "${SSH_ENTRIES[@]}" - fi - - # Stage env-file + args-file + pipelock yaml under a mktemp dir; - # clean up on exit. - # Not declared local: needed by cleanup_stage after cmd_start returns (see MANIFEST_FILE note above). - STAGE_DIR="$(mktemp -d -t claude-bottle-stage.XXXXXX)" - local ENV_FILE="${STAGE_DIR}/agent.env" - local ARGS_FILE="${STAGE_DIR}/docker-args" - local PROMPT_FILE="${STAGE_DIR}/prompt.txt" - local PIPELOCK_YAML_FILENAME="pipelock.yaml" - local PIPELOCK_YAML="${STAGE_DIR}/${PIPELOCK_YAML_FILENAME}" - : > "$ENV_FILE" - chmod 600 "$ENV_FILE" - : > "$ARGS_FILE" - : > "$PROMPT_FILE" - chmod 600 "$PROMPT_FILE" - - cleanup_stage() { - if [ -n "${STAGE_DIR:-}" ] && [ -d "$STAGE_DIR" ]; then - rm -rf "$STAGE_DIR" - fi - rm -f "${MANIFEST_FILE:-}" - } - trap cleanup_stage EXIT - - # Generate the pipelock YAML config from the bottle's egress.allowlist - # union'd with the baked-in defaults. The file is mode 600 inside the - # mktemp dir; cleanup_stage removes the whole dir on exit. - pipelock_write_yaml "$MANIFEST_FILE" "$BOTTLE_NAME" "$PIPELOCK_YAML" - - # Resolved one-line summary for the preflight display. - local PIPELOCK_ALLOWLIST_SUMMARY - PIPELOCK_ALLOWLIST_SUMMARY="$(pipelock_allowlist_summary "$MANIFEST_FILE" "$BOTTLE_NAME")" - - # Resolve env entries: prompts secrets (silent /dev/tty), copies - # interpolated host vars into this process, writes literal pairs to - # ENV_FILE. - env_resolve "$MANIFEST_FILE" "$NAME" "$ENV_FILE" "$ARGS_FILE" - - # Read the prompt and write it to PROMPT_FILE. Inside the container the - # prompt will be passed via `--append-system-prompt-file `, so - # the content does NOT land on `docker exec` argv even if it grows - # arbitrarily large. - local PROMPT_CONTENT - PROMPT_CONTENT="$(manifest_prompt "$MANIFEST_FILE" "$NAME")" - printf '%s' "$PROMPT_CONTENT" > "$PROMPT_FILE" - - local PROMPT_LEN="${#PROMPT_CONTENT}" - local PROMPT_FIRST_LINE - PROMPT_FIRST_LINE="$(printf '%s' "$PROMPT_CONTENT" | awk 'NR==1{print; exit}')" - - # --- Show plan + confirm --- - - printf '\n' >&2 - info "agent : ${NAME}" - info "image : ${IMAGE}" - if [ -n "$DERIVED_IMAGE" ]; then - info "cwd : ${USER_CWD} -> /home/node/workspace (derived: ${DERIVED_IMAGE})" - fi - info "container : ${CONTAINER}" - info "stage dir : ${STAGE_DIR}" - if [ -n "$ENV_NAMES_LIST" ]; then - info "env (names only): ${ENV_NAMES_LIST}" - else - info "env (names only): (none)" - fi - if [ "${#SKILL_NAMES[@]}" -gt 0 ]; then - info "skills : ${SKILL_NAMES[*]}" - else - info "skills : (none)" - fi - if [ -n "$BOTTLE_NAME" ]; then - info "bottle : ${BOTTLE_NAME}" - if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then - local _ssh_names="" _se - for _se in "${SSH_ENTRIES[@]}"; do - local _n - _n="$(printf '%s' "$_se" | jq -r '.Host')" - _ssh_names="${_ssh_names:+${_ssh_names}, }${_n}" - done - info " ssh hosts : ${_ssh_names}" - else - info " ssh hosts : (none)" - fi - info " egress : ${PIPELOCK_ALLOWLIST_SUMMARY}" - else - info "bottle : (none)" - fi - info "prompt : ${PROMPT_LEN} chars; first line: ${PROMPT_FIRST_LINE:-(empty)}" - if [ "$REMOTE_CONTROL" = "1" ]; then - info "remote-control : enabled" - else - info "remote-control : disabled" - fi - printf '\n' >&2 - - if [ "$DRY_RUN" = "1" ]; then - info "dry-run requested; not starting container." - exit 0 - fi - - printf 'claude-bottle: launch this agent? [y/N] ' >&2 - local REPLY - IFS= read -r REPLY /dev/null 2>&1 || true - fi - if [ -n "${PIPELOCK_CONTAINER:-}" ]; then - pipelock_stop "$SLUG" - fi - if [ -n "${INTERNAL_NETWORK:-}" ]; then - network_remove "$INTERNAL_NETWORK" - fi - if [ -n "${EGRESS_NETWORK:-}" ]; then - network_remove "$EGRESS_NETWORK" - fi - cleanup_stage - } - # Replaces the cleanup_stage EXIT trap above; cleanup_all calls cleanup_stage internally. - trap cleanup_all EXIT INT TERM - - INTERNAL_NETWORK="$(network_create_internal "$SLUG")" - EGRESS_NETWORK="$(network_create_egress "$SLUG")" - PIPELOCK_CONTAINER="$(pipelock_start "$SLUG" "$INTERNAL_NETWORK" "$EGRESS_NETWORK" "$STAGE_DIR" "$PIPELOCK_YAML_FILENAME")" - - # Assemble docker run argv: - # - --rm -d --name CONTAINER - # - --network INTERNAL_NETWORK so the agent's only egress route is - # the pipelock sidecar (the network is created with --internal, - # so there's no default gateway). - # - --env-file ENV_FILE (only if it has any entries) - # - one `-e NAME` pair per line in ARGS_FILE (secret + interpolated) - # - HTTPS_PROXY / HTTP_PROXY pointing at the sidecar by service - # name on the internal network. Belt-and-suspenders alongside - # --internal: any code path that ignores the proxy env will hit - # the no-route-to-host wall instead of leaking; any code path - # that honors it goes through pipelock. - # - IMAGE - # - sleep infinity (so we can `docker exec` an interactive session) - local PIPELOCK_PROXY_URL - PIPELOCK_PROXY_URL="$(pipelock_proxy_url "$SLUG")" - local DOCKER_ARGS=(--rm -d --name "$CONTAINER" --network "$INTERNAL_NETWORK") - DOCKER_ARGS+=(-e "HTTPS_PROXY=${PIPELOCK_PROXY_URL}") - DOCKER_ARGS+=(-e "HTTP_PROXY=${PIPELOCK_PROXY_URL}") - # NO_PROXY: leave loopback off so the agent does not bypass pipelock - # for unexpected localhost services. The deployment-recipes guide - # warns specifically against widening NO_PROXY for sidecar-on-loopback, - # but our sidecar is on a separate network, so the safe minimum here - # is just localhost / 127.0.0.1, which is what most clients honor. - DOCKER_ARGS+=(-e "NO_PROXY=localhost,127.0.0.1") - if [ -s "$ENV_FILE" ]; then - DOCKER_ARGS+=(--env-file "$ENV_FILE") - fi - # Read pairs of (-e, NAME) lines from ARGS_FILE. - local flag vname - while IFS= read -r flag; do - [ -z "$flag" ] && continue - IFS= read -r vname || break - DOCKER_ARGS+=("$flag" "$vname") - done <"$ARGS_FILE" - if [ "$FORWARD_OAUTH_TOKEN" = "1" ]; then - export CLAUDE_CODE_OAUTH_TOKEN="$CLAUDE_BOTTLE_OAUTH_TOKEN" - DOCKER_ARGS+=(-e CLAUDE_CODE_OAUTH_TOKEN) - fi - DOCKER_ARGS+=("$RUNTIME_IMAGE" sleep infinity) - - info "starting container ${CONTAINER} from ${RUNTIME_IMAGE}" - # The pre-check loop above is best-effort: two parallel starts can both - # observe the same bare name as free, so we also retry here when docker - # rejects the run with a name conflict. Pinned names skip the retry — - # user-chosen, user-owned. - local RUN_ERR_FILE="${STAGE_DIR}/docker-run.err" - local RUN_ERR_TEXT - while :; do - : > "$RUN_ERR_FILE" - if docker run "${DOCKER_ARGS[@]}" >/dev/null 2>"$RUN_ERR_FILE"; then - break - fi - RUN_ERR_TEXT="$(cat "$RUN_ERR_FILE")" - if [ -n "$PINNED_CONTAINER" ] || ! printf '%s' "$RUN_ERR_TEXT" | grep -q "is already in use"; then - printf '%s\n' "$RUN_ERR_TEXT" >&2 - die "docker run failed for container '${CONTAINER}'" - fi - if [ "$_suffix" -gt 100 ]; then - die "could not find a free container name after ${DEFAULT_CONTAINER}-99 retries; clean up old containers with 'docker rm -f '" - fi - CONTAINER="${DEFAULT_CONTAINER}-${_suffix}" - _suffix=$((_suffix + 1)) - DOCKER_ARGS[3]="$CONTAINER" - info "name conflict; retrying as ${CONTAINER}" - done - - # Copy prompt file into the container WITHOUT putting its contents on - # argv. `docker cp` reads the file from disk and streams it in. - local CONTAINER_PROMPT_PATH="${CLAUDE_BOTTLE_CONTAINER_HOME:-/home/node}/.claude-bottle-prompt.txt" - docker cp "$PROMPT_FILE" "${CONTAINER}:${CONTAINER_PROMPT_PATH}" >/dev/null - # `docker cp` preserves the host file's numeric UID, which on hosts where - # the user is not uid 1000 (e.g. macOS uid 501) leaves the in-container - # file unreadable by the `node` user. Re-own and re-mode as root inside - # the container so `node` can read its own mode-600 prompt regardless of - # host UID. - docker exec -u 0 "$CONTAINER" chown node:node "$CONTAINER_PROMPT_PATH" >/dev/null - docker exec -u 0 "$CONTAINER" chmod 600 "$CONTAINER_PROMPT_PATH" >/dev/null - - # Copy each requested skill. - if [ "${#SKILL_NAMES[@]}" -gt 0 ]; then - skills_copy_into "$CONTAINER" "${SKILL_NAMES[@]}" - fi - - # Set up SSH keys and config. - if [ "${#SSH_ENTRIES[@]}" -gt 0 ]; then - local PIPELOCK_PROXY_HOST_PORT - PIPELOCK_PROXY_HOST_PORT="$(pipelock_proxy_host_port "$SLUG")" - ssh_setup "$CONTAINER" "$STAGE_DIR" "$PIPELOCK_PROXY_HOST_PORT" "${SSH_ENTRIES[@]}" - fi - - # When --cwd is on, ship the host repo's .git directory in via `docker cp` - # rather than the build-time COPY. Two reasons: (1) build-time COPY honors - # the host project's .dockerignore, which often excludes .git/ (e.g. the - # openemr fork) — without .git the agent inside has no branch, no remotes, - # and can't commit; (2) keeping .git out of the cached image layer avoids - # bloating the layer (a real repo's .git can be several GB) and avoids - # baking a stale snapshot of refs/index into the image. The cp at run - # time means the agent always sees the host's current refs. - if [ "$COPY_CWD" = "1" ] && [ -d "$USER_CWD/.git" ]; then - info "copying ${USER_CWD}/.git -> ${CONTAINER}:/home/node/workspace/.git" - docker cp "$USER_CWD/.git" "${CONTAINER}:/home/node/workspace/.git" >/dev/null - docker exec -u 0 "$CONTAINER" chown -R node:node /home/node/workspace/.git >/dev/null - fi - - info "attaching interactive claude session (Ctrl-D or 'exit' to leave; container will be removed)" - # --dangerously-skip-permissions: bypass permission prompts. Safe here because the whole point of - # claude-bottle is sandboxing claude inside a container (see CLAUDE.md "What this is"). - # --remote-control (opt-in via `start --remote-control`): enable Remote Control (hidden flag; see - # --remote-control-session-name-prefix in `claude --help` — the prefix flag is the only surfaced - # piece, the toggle itself is hidden, same pattern as --append-system-prompt-file). - local CLAUDE_ARGS=(--dangerously-skip-permissions) - if [ "$REMOTE_CONTROL" = "1" ]; then - CLAUDE_ARGS+=(--remote-control) - fi - # `|| true` so a non-zero exit from the REPL doesn't skip the trap output. - if [ -n "$PROMPT_CONTENT" ]; then - docker exec -it "$CONTAINER" claude "${CLAUDE_ARGS[@]}" --append-system-prompt-file "$CONTAINER_PROMPT_PATH" || true - else - docker exec -it "$CONTAINER" claude "${CLAUDE_ARGS[@]}" || true - fi - - info "session ended; container ${CONTAINER} will be removed" -} - -# --------------------------------------------------------------------------- -# cmd_init — interactively populate a new agent and write it to either -# ~/claude-bottle.json (user) or ./claude-bottle.json (project). -# -# Prompts for: -# - agent name (required) -# - env vars: name + mode (secret / interpolated / literal) -# - skills (space-separated) -# - system prompt (multi-line, terminated by a lone ".") -# - SSH host entries (optional) -# -# Merges the new agent into the target file if it already exists -# (existing agents are preserved; name conflicts prompt for confirmation). -# --------------------------------------------------------------------------- -cmd_init() { - usage_init() { - printf 'usage: %s init \n' "$(basename "$0")" >&2 - printf ' user add the agent to ~/claude-bottle.json\n' >&2 - printf ' project add the agent to ./claude-bottle.json in the current directory\n' >&2 - } - - if [ "$#" -lt 1 ]; then - usage_init - exit 2 - fi - - local SCOPE TARGET_FILE - case "$1" in - -h|--help) usage_init; exit 0 ;; - user) SCOPE="user"; TARGET_FILE="${HOME}/claude-bottle.json" ;; - project) SCOPE="project"; TARGET_FILE="${USER_CWD}/claude-bottle.json" ;; - *) usage_init; die "expected 'user' or 'project', got: $1" ;; - esac - - require_jq - - printf '\n' >&2 - info "claude-bottle init — adding a new agent to ${TARGET_FILE}" - printf '\n' >&2 - - # --- Agent name --- - local AGENT_NAME="" - while [ -z "$AGENT_NAME" ]; do - printf 'Agent name: ' >&2 - IFS= read -r AGENT_NAME /dev/null 2>&1; then - printf 'claude-bottle: agent "%s" already exists in %s. Overwrite? [y/N] ' "$AGENT_NAME" "$TARGET_FILE" >&2 - local _ow - IFS= read -r _ow &2 - printf 'Skills (space or comma separated, or Enter for none): ' >&2 - local _skills_input="" - IFS= read -r _skills_input &2 - info "System prompt — enter text, then a lone '.' on its own line to finish (just '.' to leave empty):" - local PROMPT_CONTENT="" _pline _pfirst=1 - while :; do - IFS= read -r _pline &2 - printf 'Associate this agent with a bottle? [y/N] ' >&2 - local _bottle_yn="" - IFS= read -r _bottle_yn &2 - IFS= read -r BOTTLE_NAME /dev/null 2>&1; then - _bottle_exists=1 - info "Bottle '${BOTTLE_NAME}' already exists in ${TARGET_FILE}; agent will reference it." - else - info "Creating new bottle '${BOTTLE_NAME}'." - - # --- Env vars (stored on the bottle) --- - printf '\n' >&2 - 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)" - while :; do - printf '\n Var name (or Enter to finish): ' >&2 - local _vname="" - IFS= read -r _vname &2 - local _vmode="" - IFS= read -r _vmode &2 - local _smsg="" - IFS= read -r _smsg &2 - local _hvar="" - IFS= read -r _hvar &2 - IFS= read -r _vval &2 - local _ssh_yn="" - IFS= read -r _ssh_yn &2 - local _shost="" - IFS= read -r _shost &2 - local _shostname="" - IFS= read -r _shostname &2 - local _suser="" - IFS= read -r _suser &2 - local _sport="" - IFS= read -r _sport &2 - local _sidentity="" - IFS= read -r _sidentity &2 - local _skhk="" - IFS= read -r _skhk &2 - - local TMP_FILE - TMP_FILE="$(mktemp -t claude-bottle-init.XXXXXX.json)" - - if [ -f "$TARGET_FILE" ]; then - if ! jq -e . "$TARGET_FILE" >/dev/null 2>&1; then - rm -f "$TMP_FILE" - die "${TARGET_FILE} exists but is not valid JSON; fix or remove it first" - fi - if ! printf '%s' "$NEW_ENTRY" | jq -s '{ - "bottles": ((.[0].bottles // {}) * (.[1].bottles // {})), - "agents": ((.[0].agents // {}) * (.[1].agents // {})) - }' "$TARGET_FILE" - > "$TMP_FILE"; then - rm -f "$TMP_FILE" - die "failed to merge agent into ${TARGET_FILE}" - fi - else - if ! printf '%s\n' "$NEW_ENTRY" > "$TMP_FILE"; then - rm -f "$TMP_FILE" - die "failed to write ${TARGET_FILE}" - fi - fi - - mv "$TMP_FILE" "$TARGET_FILE" - - info "Agent '${AGENT_NAME}' written to ${TARGET_FILE}." - info "Run '$(basename "$0") info ${AGENT_NAME}' to verify." - printf '\n' >&2 -} - -# --------------------------------------------------------------------------- -# cmd_edit — open an agent entry in vim at the line where its key appears. -# --------------------------------------------------------------------------- -cmd_edit() { - usage_edit() { - printf 'usage: %s edit \n' "$(basename "$0")" >&2 - printf ' user edit an agent in ~/claude-bottle.json\n' >&2 - printf ' project edit an agent in ./claude-bottle.json in the current directory\n' >&2 - printf ' name of the agent to jump to\n' >&2 - } - - if [ "$#" -lt 2 ]; then - usage_edit - exit 2 - fi - - local TARGET_FILE - case "$1" in - -h|--help) usage_edit; exit 0 ;; - user) TARGET_FILE="${HOME}/claude-bottle.json" ;; - project) TARGET_FILE="${USER_CWD}/claude-bottle.json" ;; - *) usage_edit; die "expected 'user' or 'project', got: $1" ;; - esac - - local NAME="$2" - - require_jq - - if [ ! -f "$TARGET_FILE" ]; then - die "${TARGET_FILE} does not exist" - fi - - if ! jq -e --arg n "$NAME" '.agents | has($n)' "$TARGET_FILE" >/dev/null 2>&1; then - die "agent '${NAME}' not found in ${TARGET_FILE}" - fi - - local LINE - LINE="$(grep -Fn "\"${NAME}\"" "$TARGET_FILE" | head -1 | cut -d: -f1)" - LINE="${LINE:-1}" - - exec vim +"${LINE}" "$TARGET_FILE" -} - -# --------------------------------------------------------------------------- -# Dispatch -# --------------------------------------------------------------------------- - -if [ "$#" -lt 1 ]; then - usage - exit 2 -fi - -COMMAND="$1" -shift - -case "$COMMAND" in - build) cmd_build ;; - cleanup) cmd_cleanup ;; - edit) cmd_edit "$@" ;; - info) cmd_info "$@" ;; - init) cmd_init "$@" ;; - list) cmd_list "$@" ;; - start) cmd_start "$@" ;; - -h|--help) usage; exit 0 ;; - *) usage; die "unknown command: ${COMMAND}" ;; -esac diff --git a/lib/docker.sh b/lib/docker.sh deleted file mode 100644 index 8873404..0000000 --- a/lib/docker.sh +++ /dev/null @@ -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 — 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 — 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=^$` prints the container id if it exists. - local id - id="$(docker ps -a -q -f "name=^${name}$" 2>/dev/null || true)" - [ -n "$id" ] -} - -# slugify — 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 — 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 -# -# Builds a thin derived image that copies the contents of 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 -# — only the build context is read from there. Any .dockerignore -# already in 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" < — 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 -} diff --git a/lib/env_resolve.sh b/lib/env_resolve.sh deleted file mode 100644 index 872a99b..0000000 --- a/lib/env_resolve.sh +++ /dev/null @@ -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 `. -# 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; "?" uses 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 — 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 — 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 — 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 [] — 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 is provided and non-empty, the prompt rendered to -# the tty is " (input hidden): "; otherwise it falls back -# to "claude-bottle: secret value for (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 - 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 -# -# 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 (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 -# . Never written to disk. -# - literal → append `NAME=VALUE` to ; the resolver -# does NOT add anything to for this entry -# (the caller adds a single `--env-file ` -# if the file is non-empty). -# -# The caller is responsible for: -# - creating as an empty file with mode 600 under a -# mktemp dir, -# - creating as an empty file, -# - cleaning both up on exit (trap), -# - reading 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") -} diff --git a/lib/log.sh b/lib/log.sh deleted file mode 100644 index d379093..0000000 --- a/lib/log.sh +++ /dev/null @@ -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 — informational message to stderr. -info() { - printf 'claude-bottle: %s\n' "$*" >&2 -} - -# warn — warning to stderr. -warn() { - printf 'claude-bottle: warning: %s\n' "$*" >&2 -} - -# die — error to stderr, exit 1. -die() { - printf 'claude-bottle: error: %s\n' "$*" >&2 - exit 1 -} diff --git a/lib/manifest.sh b/lib/manifest.sh deleted file mode 100644 index d232ad4..0000000 --- a/lib/manifest.sh +++ /dev/null @@ -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": { -# "": { -# "env": { "": , ... }, -# "ssh": [ , ... ], -# "egress": { "allowlist": [ "", ... ] } -# }, -# ... -# }, -# "agents": { -# "": { -# "skills": [ "", ... ], -# "prompt": "", -# "bottle": "" -# }, -# ... -# } -# } -# -# A bottle groups shared infrastructure (SSH keys, known hosts, egress -# allowlist) that multiple agents can reference by name. The "bottle" field -# is required on every agent; cli.sh start rejects agents that omit it. -# -# The "egress" object is added in PRD 0001. Today it carries one key: -# - allowlist: array of hostnames the agent is allowed to reach. The -# effective allowlist at launch is this list UNIONED with the -# baked-in defaults for Claude Code's required hosts (see -# lib/pipelock.sh). Bottles with no "egress" block use defaults -# only. Future keys (mode, dlp, data_budget, ...) are reserved -# under the same object; v1 ignores anything we don't recognize. -# -# An is a JSON string. Mode is selected by sentinel prefix: -# "?" → prompt for the value at runtime, displaying -# (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 — looks for claude-bottle.json in 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 — 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 — 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 — 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 — 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 "?", 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 \"?\" for prompt-at-runtime." - fi - jq -r --arg b "$bottle" --arg v "$var" '.bottles[$b].env[$v]' "$manifest_file" -} - -# manifest_skills — 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 — 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 — 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 — 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 — 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 — 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 — prints one -# hostname per line on stdout for the entries in -# bottles[bottle_name].egress.allowlist. Prints nothing if the field is missing -# or the array is empty. Validates only that the field, when present, is an -# array; per-element string typing is checked at use-time in lib/pipelock.sh -# so the validation lives next to the YAML generator that consumes it. -manifest_bottle_egress_allowlist() { - local manifest_file="${1:?manifest_bottle_egress_allowlist: missing manifest file}" - local bottle_name="${2:?manifest_bottle_egress_allowlist: missing bottle name}" - local field_type - field_type="$(jq -r --arg b "$bottle_name" '.bottles[$b].egress.allowlist | type' "$manifest_file" 2>/dev/null || echo "null")" - case "$field_type" in - array|null) : ;; - *) die "bottle '${bottle_name}' egress.allowlist must be an array (was ${field_type})." ;; - esac - jq -r --arg b "$bottle_name" '.bottles[$b].egress.allowlist // [] | .[]' "$manifest_file" -} - -# manifest_ssh — prints one compact JSON object per line -# for each ssh entry associated with the agent. SSH entries are resolved via -# the agent's "bottle" field: if set, entries come from bottles[bottle].ssh; if the -# 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" -} diff --git a/lib/network.sh b/lib/network.sh deleted file mode 100644 index ad0a819..0000000 --- a/lib/network.sh +++ /dev/null @@ -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- (internal), -# claude-bottle-egress- (egress). On conflict we append a -# numeric suffix (-2, -3, ...) to mirror the container-naming scheme -# in cli.sh, so two parallel starts of the same agent get distinct -# networks. -# -# Idempotent: safe to source multiple times. - -if [ -n "${CLAUDE_BOTTLE_LIB_NETWORK_SOURCED:-}" ]; then - return 0 -fi -CLAUDE_BOTTLE_LIB_NETWORK_SOURCED=1 - -_iso_lib_network_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=./log.sh -. "${_iso_lib_network_dir}/log.sh" - -# network_name_for_slug — prints the canonical internal-network -# name for a given agent slug. No conflict resolution; that lives in -# network_create_internal. -network_name_for_slug() { - local slug="${1:?network_name_for_slug: missing slug}" - printf 'claude-bottle-net-%s' "$slug" -} - -# network_egress_name_for_slug — prints the canonical egress-network -# name for a given agent slug. No conflict resolution; that lives in -# network_create_egress. -network_egress_name_for_slug() { - local slug="${1:?network_egress_name_for_slug: missing slug}" - printf 'claude-bottle-egress-%s' "$slug" -} - -# network_exists — returns 0 if the named docker network exists, -# else 1. Uses `docker network inspect` (not `docker network ls -f name=...`) -# because the latter does substring matching, which would falsely report -# claude-bottle-net-foo as existing when only claude-bottle-net-foo-2 was -# present. -network_exists() { - local name="${1:?network_exists: missing network name}" - docker network inspect "$name" >/dev/null 2>&1 -} - -# _network_create_with_prefix -# -# Internal helper. Creates a per-agent Docker network whose name is -# (with -2, -3, ... appended on conflict, capped at 100). -# When is 1, the network is created with `--internal` (no -# default gateway). When 0, it's a plain user-defined bridge with -# upstream connectivity. Echoes the resolved name on stdout. -_network_create_with_prefix() { - local base="${1:?_network_create_with_prefix: missing prefix}" - local internal_flag="${2:?_network_create_with_prefix: missing internal flag}" - - local name="$base" - local _suffix=2 - while network_exists "$name"; do - name="${base}-${_suffix}" - _suffix=$((_suffix + 1)) - if [ "$_suffix" -gt 100 ]; then - die "could not find a free network name after ${base}-99; clean up old networks with 'docker network rm '" - fi - done - - local kind="bridge (egress)" - local args=() - if [ "$internal_flag" = "1" ]; then - kind="internal" - args+=(--internal) - fi - info "creating ${kind} network ${name}" - # Defaults give us a bridge driver with Docker-managed addressing, - # which is what we want for both internal and egress networks. - if ! docker network create "${args[@]}" "$name" >/dev/null; then - die "docker network create ${args[*]} ${name} failed" - fi - printf '%s' "$name" -} - -# network_create_internal -# -# Creates a Docker `--internal` network for the agent and prints the -# resolved network name on stdout. If the canonical name is already -# taken, appends -2, -3, ... (capped at 100, matching the -# container-name retry loop in cli.sh) until a free name is found. -# -# `--internal` is the load-bearing flag: Docker creates the bridge -# without a default route, so the agent container attached here cannot -# reach the public internet directly. The pipelock sidecar (attached -# to both this network and a per-agent egress network) is the only -# egress route. -# -# Side effect: emits one info line naming the network actually created. -network_create_internal() { - local slug="${1:?network_create_internal: missing slug}" - local base - base="$(network_name_for_slug "$slug")" - _network_create_with_prefix "$base" 1 -} - -# network_create_egress -# -# Creates a per-agent user-defined bridge network used by the pipelock -# sidecar for upstream egress, and prints the resolved network name on -# stdout. Conflict resolution mirrors network_create_internal. -# -# We use a user-defined bridge (NOT the legacy `bridge` network) -# because only user-defined bridges run Docker's embedded DNS resolver -# — pipelock needs DNS to resolve `api.anthropic.com` and similar -# upstream hostnames. The legacy `bridge` network would force pipelock -# onto the host's resolv.conf and fail in environments where Docker -# Desktop's NAT path is the only working DNS route. -# -# Side effect: emits one info line naming the network actually created. -network_create_egress() { - local slug="${1:?network_create_egress: missing slug}" - local base - base="$(network_egress_name_for_slug "$slug")" - _network_create_with_prefix "$base" 0 -} - -# network_attach -# -# Attaches an already-running container to the named network. Used to -# add the pipelock sidecar to a second (default-bridge) network so it -# has upstream egress, while staying reachable from the agent on the -# internal network. -# -# Note: for the agent container itself we pass `--network ` to -# `docker run` directly in cli.sh rather than using this function. The -# agent never touches anything except the internal network. -network_attach() { - local network="${1:?network_attach: missing network name}" - local container="${2:?network_attach: missing container name}" - if ! docker network connect "$network" "$container" >/dev/null 2>&1; then - die "docker network connect ${network} ${container} failed" - fi -} - -# network_remove -# -# Removes the named network. Idempotent: a missing network is treated -# as success so this can be called unconditionally from a teardown -# trap. A network that still has containers attached will fail to -# remove; the caller is expected to tear those containers down first. -network_remove() { - local name="${1:?network_remove: missing network name}" - if ! network_exists "$name"; then - return 0 - fi - if ! docker network rm "$name" >/dev/null 2>&1; then - # Don't `die` here: this runs in cleanup paths where we'd rather - # warn and continue than abort and leave more orphans behind. - warn "failed to remove network ${name}; clean up with 'docker network rm ${name}'" - return 1 - fi -} diff --git a/lib/pipelock.sh b/lib/pipelock.sh deleted file mode 100644 index 187175f..0000000 --- a/lib/pipelock.sh +++ /dev/null @@ -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:. The -# digest is resolved by hand against ghcr.io for tag 2.3.0 (the -# `v2.3.0` GitHub release maps to the unprefixed `2.3.0` Docker tag — -# see pipelock-assessment.md and the resolution log in PRD 0001's -# implementation thread). Bump deliberately when upgrading. -# -# YAML config we generate: minimum-viable settings to satisfy the PRD's -# observable success criteria. -# - mode: strict — only api_allowlist domains are reachable -# (per docs/configuration.md §Modes) -# - enforce: true — blocks rather than warn-only -# - api_allowlist: [...] — defaults ∪ bottle.egress.allowlist -# - forward_proxy.enabled: true — turns on the CONNECT-tunnel proxy -# the agent's HTTPS_PROXY actually uses -# (docs §Forward Proxy: this is off by -# default, restart-required to flip) -# - dlp.include_defaults: true — load all 48 built-in patterns -# (docs §DLP §Pattern Merging) -# - dlp.scan_env: true — flags URLs containing high-entropy env -# values (≥16 chars, Shannon entropy >3.0, -# checked in raw/base64/hex/base32). This -# is the documented home for pipelock's -# "subdomain entropy detection" surface -# (docs §Environment Variable Leak -# Detection); the URL-path-entropy knob -# under fetch_proxy.monitoring is for the -# /fetch?url=... helper, not the forward -# proxy we use. -# We deliberately do NOT set tls_interception (out of PRD scope), and -# do NOT carry any env-var values into the YAML — only hostnames. -# -# Idempotent: safe to source multiple times. - -if [ -n "${CLAUDE_BOTTLE_LIB_PIPELOCK_SOURCED:-}" ]; then - return 0 -fi -CLAUDE_BOTTLE_LIB_PIPELOCK_SOURCED=1 - -_iso_lib_pipelock_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=./log.sh -. "${_iso_lib_pipelock_dir}/log.sh" -# shellcheck source=./manifest.sh -. "${_iso_lib_pipelock_dir}/manifest.sh" -# shellcheck source=./network.sh -. "${_iso_lib_pipelock_dir}/network.sh" - -# --- Constants ------------------------------------------------------------- - -# Pipelock image, pinned by digest. The digest is the multi-arch image -# index for ghcr.io/luckypipewrench/pipelock:2.3.0 (resolved 2026-05-08 -# from the ghcr.io v2 manifests endpoint). Ties match the v2.3.0 GitHub -# release; the registry uses unprefixed tags so v2.3.0→2.3.0. -CLAUDE_BOTTLE_PIPELOCK_IMAGE="${CLAUDE_BOTTLE_PIPELOCK_IMAGE:-ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9}" - -# Listening port for pipelock's forward proxy. Default per -# docs/configuration.md §Forward Proxy / §Fetch Proxy and the -# deployment-recipes generator. Override via env if a future image -# changes it. -CLAUDE_BOTTLE_PIPELOCK_PORT="${CLAUDE_BOTTLE_PIPELOCK_PORT:-8888}" - -# Baked-in default allowlist for hosts Claude Code itself needs. -# Source: pipelock-assessment.md and the Claude Code network-config -# docs (https://code.claude.com/docs/en/network-config). The effective -# allowlist used at launch is this set unioned with whatever the -# bottle's egress.allowlist names. Kept as a newline-separated string -# because bash arrays don't survive sourcing into a function-only -# context cleanly; callers split on newlines. -CLAUDE_BOTTLE_PIPELOCK_DEFAULT_ALLOWLIST="api.anthropic.com -statsig.anthropic.com -sentry.io -claude.ai -platform.claude.com -downloads.claude.ai -raw.githubusercontent.com" - -# --- Naming ---------------------------------------------------------------- - -# pipelock_container_name — prints the canonical sidecar -# container name for a given agent slug. The agent reaches the sidecar -# at this name as a hostname on the internal network. -pipelock_container_name() { - local slug="${1:?pipelock_container_name: missing slug}" - printf 'claude-bottle-pipelock-%s' "$slug" -} - -# pipelock_proxy_url — prints http://:, suitable -# for HTTPS_PROXY / HTTP_PROXY in the agent container. -pipelock_proxy_url() { - local slug="${1:?pipelock_proxy_url: missing slug}" - local name - name="$(pipelock_container_name "$slug")" - printf 'http://%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT" -} - -# pipelock_proxy_host_port — prints : (no scheme), -# suitable for socat's PROXY: directive in an SSH ProxyCommand. The -# agent's --internal network has no default route, so SSH (and any other -# raw TCP) must tunnel via pipelock's HTTP CONNECT. -pipelock_proxy_host_port() { - local slug="${1:?pipelock_proxy_host_port: missing slug}" - local name - name="$(pipelock_container_name "$slug")" - printf '%s:%s' "$name" "$CLAUDE_BOTTLE_PIPELOCK_PORT" -} - -# --- Allowlist resolution -------------------------------------------------- - -# pipelock_bottle_allowlist -# -# Prints one hostname per line on stdout for the allowlist declared at -# bottles[].egress.allowlist. Empty (no output) if the -# field is missing or the array is empty. Validates that each entry is -# a JSON string; dies with a clear message if any element is not. -pipelock_bottle_allowlist() { - local manifest_file="${1:?pipelock_bottle_allowlist: missing manifest file}" - local bottle_name="${2:?pipelock_bottle_allowlist: missing bottle name}" - - # Validate shape first: if egress.allowlist exists, every element - # must be a string. We do this in one jq pass. - local types - types="$(jq -r --arg b "$bottle_name" ' - .bottles[$b].egress.allowlist // [] | map(type) | unique[] - ' "$manifest_file")" - local t - while IFS= read -r t; do - [ -z "$t" ] && continue - if [ "$t" != "string" ]; then - die "bottle '${bottle_name}' egress.allowlist must contain only strings; found a '${t}' entry." - fi - done <<< "$types" - - jq -r --arg b "$bottle_name" ' - .bottles[$b].egress.allowlist // [] | .[] - ' "$manifest_file" -} - -# pipelock_bottle_ssh_hostnames -# -# Prints one hostname per line for each entry in bottles[].ssh[].Hostname. -# These need to reach pipelock's allowlist so the agent can tunnel SSH -# through pipelock via HTTP CONNECT (see ssh_setup's ProxyCommand -# wiring). Empty output if the bottle has no ssh entries. -pipelock_bottle_ssh_hostnames() { - local manifest_file="${1:?pipelock_bottle_ssh_hostnames: missing manifest file}" - local bottle_name="${2:?pipelock_bottle_ssh_hostnames: missing bottle name}" - - jq -r --arg b "$bottle_name" ' - .bottles[$b].ssh // [] | .[] | .Hostname // empty - ' "$manifest_file" -} - -# _pipelock_is_ipv4_literal — exit 0 if looks like an IPv4 -# literal (four dot-separated octets). Pipelock's SSRF check fires on -# the resolved IP, so a Hostname that's already an IP literal needs -# `ssrf.ip_allowlist`, while a hostname needs `trusted_domains`. -_pipelock_is_ipv4_literal() { - local s="${1:?}" - [[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] -} - -# pipelock_bottle_ssh_trusted_domains -# -# Hostname-shaped ssh[].Hostname entries that should bypass pipelock's -# SSRF check (so a name resolving to a private IP — e.g. internal API -# behind a VPN — is reachable). IP-literal entries are excluded; -# trusted_domains is hostname-based per pipelock's docs. -pipelock_bottle_ssh_trusted_domains() { - local manifest_file="${1:?}" - local bottle_name="${2:?}" - local h - while IFS= read -r h; do - [ -z "$h" ] && continue - _pipelock_is_ipv4_literal "$h" && continue - printf '%s\n' "$h" - done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name") -} - -# pipelock_bottle_ssh_ip_cidrs -# -# Emits one canonical /32 CIDR per IPv4-literal ssh[].Hostname so they -# pass pipelock's SSRF IP-range check (which blocks RFC 1918, RFC 6598 -# CGNAT, link-local, loopback, etc. by default). Hostnames are skipped -# — they go through trusted_domains instead. -pipelock_bottle_ssh_ip_cidrs() { - local manifest_file="${1:?}" - local bottle_name="${2:?}" - local h - while IFS= read -r h; do - [ -z "$h" ] && continue - if _pipelock_is_ipv4_literal "$h"; then - printf '%s/32\n' "$h" - fi - done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name") -} - -# pipelock_effective_allowlist -# -# Prints the deduplicated union of: the baked-in default allowlist, the -# bottle's declared egress.allowlist, and any bottle.ssh[].Hostname -# entries (so SSH tunneling through pipelock is permitted by the same -# allowlist check that gates HTTP CONNECT). One hostname per line, -# sorted for stability. This is the single source of truth callers -# should use for both YAML generation and the preflight summary. -pipelock_effective_allowlist() { - local manifest_file="${1:?pipelock_effective_allowlist: missing manifest file}" - local bottle_name="${2:?pipelock_effective_allowlist: missing bottle name}" - - { - printf '%s\n' "$CLAUDE_BOTTLE_PIPELOCK_DEFAULT_ALLOWLIST" - pipelock_bottle_allowlist "$manifest_file" "$bottle_name" - pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name" - } | awk 'NF && !seen[$0]++' | LC_ALL=C sort -} - -# pipelock_allowlist_summary -# -# One-line summary of the effective allowlist for the y/N preflight -# display. Format: -# " hosts allowed (host1, host2, host3 +M more)" -# When the allowlist has 5 or fewer entries, all are listed and the -# "+M more" suffix is omitted. -pipelock_allowlist_summary() { - local manifest_file="${1:?pipelock_allowlist_summary: missing manifest file}" - local bottle_name="${2:?pipelock_allowlist_summary: missing bottle name}" - - local hosts=() - local h - while IFS= read -r h; do - [ -z "$h" ] && continue - hosts+=("$h") - done < <(pipelock_effective_allowlist "$manifest_file" "$bottle_name") - - local count="${#hosts[@]}" - if [ "$count" -eq 0 ]; then - printf '0 hosts allowed (none)' - return 0 - fi - - local show=$count - local more=0 - if [ "$count" -gt 5 ]; then - show=3 - more=$((count - show)) - fi - - local first_n=() - local i=0 - while [ "$i" -lt "$show" ]; do - first_n+=("${hosts[$i]}") - i=$((i + 1)) - done - - local joined="" - local h2 - for h2 in "${first_n[@]}"; do - if [ -z "$joined" ]; then - joined="$h2" - else - joined="${joined}, ${h2}" - fi - done - - if [ "$more" -gt 0 ]; then - printf '%s hosts allowed (%s, +%s more)' "$count" "$joined" "$more" - else - printf '%s hosts allowed (%s)' "$count" "$joined" - fi -} - -# --- YAML generation ------------------------------------------------------- - -# pipelock_write_yaml -# -# Writes a pipelock YAML config file to (mode 600). The -# config carries only: -# - the effective allowlist (hostnames), -# - a fixed listen port (CLAUDE_BOTTLE_PIPELOCK_PORT), -# - the minimum knobs needed to satisfy PRD 0001 success criteria -# (strict mode, forward_proxy on, DLP defaults + env scanning). -# -# It deliberately contains no env values, no secrets, and no per-agent -# customization beyond the hostname list. -# -# YAML keys + defaults sourced from -# https://github.com/luckyPipewrench/pipelock/blob/main/docs/configuration.md -# (top-level fields, api_allowlist, forward_proxy, dlp). -pipelock_write_yaml() { - local manifest_file="${1:?pipelock_write_yaml: missing manifest file}" - local bottle_name="${2:?pipelock_write_yaml: missing bottle name}" - local out_path="${3:?pipelock_write_yaml: missing out_path}" - - : > "$out_path" - chmod 600 "$out_path" - - { - printf 'version: 1\n' - printf 'mode: strict\n' - printf 'enforce: true\n' - printf '\n' - printf '# Hostnames the agent is allowed to reach. Effective list is\n' - printf '# claude-bottle defaults UNION bottle.egress.allowlist (sorted, deduped).\n' - printf 'api_allowlist:\n' - local h - while IFS= read -r h; do - [ -z "$h" ] && continue - # Validate: pipelock allows hostnames + wildcards. We accept - # anything that does not contain whitespace or the YAML special - # chars that would break unquoted strings; quote on output to be - # safe. - printf ' - "%s"\n' "$h" - done < <(pipelock_effective_allowlist "$manifest_file" "$bottle_name") - printf '\n' - printf 'forward_proxy:\n' - printf ' enabled: true\n' - printf '\n' - # SSRF exemptions for declared SSH hosts. Pipelock blocks the CGNAT - # range (100.64.0.0/10, where Tailscale IPs live) and the rest of - # RFC 1918 / link-local by default. Hostname entries go to - # trusted_domains; IP-literal entries to ssrf.ip_allowlist as /32. - local trusted_count=0 ssrf_count=0 - local td - while IFS= read -r td; do - [ -z "$td" ] && continue - if [ "$trusted_count" -eq 0 ]; then - printf 'trusted_domains:\n' - fi - printf ' - "%s"\n' "$td" - trusted_count=$((trusted_count + 1)) - done < <(pipelock_bottle_ssh_trusted_domains "$manifest_file" "$bottle_name") - [ "$trusted_count" -gt 0 ] && printf '\n' - local cidr - while IFS= read -r cidr; do - [ -z "$cidr" ] && continue - if [ "$ssrf_count" -eq 0 ]; then - printf 'ssrf:\n' - printf ' ip_allowlist:\n' - fi - printf ' - "%s"\n' "$cidr" - ssrf_count=$((ssrf_count + 1)) - done < <(pipelock_bottle_ssh_ip_cidrs "$manifest_file" "$bottle_name") - [ "$ssrf_count" -gt 0 ] && printf '\n' - printf 'dlp:\n' - printf ' include_defaults: true\n' - printf ' scan_env: true\n' - } > "$out_path" -} - -# --- Sidecar lifecycle ----------------------------------------------------- - -# pipelock_start -# -# Boots the pipelock sidecar: -# 1. `docker run -d` on the internal network with the canonical -# service name. The image runs `pipelock` as its CMD; we override -# with `run --config ` and the listen address. -# 2. `docker cp` the YAML config from the host mktemp dir into the -# container at /etc/pipelock.yaml. -# -# We use docker cp rather than `-v :` because Docker -# Desktop bind mounts have ownership / case-sensitivity quirks on -# macOS; copying the file in sidesteps both. The host-side mktemp dir -# is the caller's responsibility to clean up. -# -# After the cp the container is restarted so pipelock picks up the -# config it boots from. Pipelock's hot-reload feature would let us -# avoid the restart, but `forward_proxy.enabled` is one of the few -# restart-required keys (per docs/configuration.md), so a restart is -# the simplest correct path on first boot. -# -# Args: -# — agent slug; sidecar name will be claude-bottle-pipelock- -# — name of the agent's internal docker network -# — name of the agent's user-defined egress -# network; the sidecar joins this so it can -# reach upstream hostnames with working DNS -# — host directory containing the YAML -# — filename within yaml_dir -# -# Echoes the container name on stdout on success. -pipelock_start() { - local slug="${1:?pipelock_start: missing slug}" - local internal_network="${2:?pipelock_start: missing internal network}" - local egress_network="${3:?pipelock_start: missing egress network}" - local yaml_dir="${4:?pipelock_start: missing yaml dir}" - local yaml_filename="${5:?pipelock_start: missing yaml filename}" - - local name - name="$(pipelock_container_name "$slug")" - local host_yaml="${yaml_dir}/${yaml_filename}" - if [ ! -f "$host_yaml" ]; then - die "pipelock yaml not found at ${host_yaml}; pipelock_write_yaml must run first" - fi - - # Container layout: pipelock reads its config from /etc/pipelock.yaml. - # We `docker create` the sidecar, `docker cp` the YAML into the - # writable layer, then `docker start` it — no bind mount, no shell - # shim. The image is distroless (no `sh`), and `docker cp` to a - # stopped container does NOT create intermediate parent directories, - # so the YAML lives directly under /etc rather than in a /etc/pipelock - # subdirectory. - info "starting pipelock sidecar ${name} on network ${internal_network}" - - # Sidecar argv verification (PR #1 review). The pinned digest - # (CLAUDE_BOTTLE_PIPELOCK_IMAGE above) has: - # ENTRYPOINT ["/pipelock"] - # CMD ["run", "--listen", "0.0.0.0:8888"] - # `pipelock run --help` documents `-l, --listen` (default - # 127.0.0.1:8888) as the forward-proxy listen address — the - # `--mcp-listen` flag is for the separate MCP HTTP listener and is - # not what we want here. `--config` reads the YAML and hot-reloads - # on file change; values in YAML can also drive the listen address - # via `fetch_proxy.listen`, but the CLI flag takes precedence and - # is the simpler contract for our launcher. Smoke-tested 2026-05-08 - # by running this exact argv against the digest and confirming the - # /health endpoint responded on :8888. - if ! docker create \ - --name "$name" \ - --network "$internal_network" \ - "$CLAUDE_BOTTLE_PIPELOCK_IMAGE" \ - run --config /etc/pipelock.yaml --listen "0.0.0.0:${CLAUDE_BOTTLE_PIPELOCK_PORT}" \ - >/dev/null 2>&1; then - die "failed to create pipelock sidecar ${name}" - fi - - # `docker cp` to a created-but-not-started container writes into the - # writable layer directly. The parent directory must already exist in - # the image — docker cp does NOT create missing intermediate dirs to - # a stopped container, contrary to a common assumption. The pipelock - # image is distroless (no `sh`), so we cannot prepopulate dirs with a - # shell shim either. We therefore put the config in /etc/pipelock.yaml - # (file directly under /etc) rather than /etc/pipelock/pipelock.yaml. - local cp_err - cp_err="$(docker cp "$host_yaml" "${name}:/etc/pipelock.yaml" 2>&1)" || { - docker rm -f "$name" >/dev/null 2>&1 || true - die "failed to copy pipelock yaml into ${name}: ${cp_err}" - } - - # Attach to a per-agent user-defined bridge network for upstream - # egress. The internal network has no gateway by definition, so - # without a second network the sidecar can't reach the public - # internet at all. We deliberately do NOT use Docker's legacy - # `bridge` network: only user-defined bridges run Docker's embedded - # DNS resolver, which pipelock needs to resolve `api.anthropic.com` - # and similar upstream hostnames. The egress network is created by - # network_create_egress in lib/network.sh. - if ! docker network connect "$egress_network" "$name" >/dev/null 2>&1; then - docker rm -f "$name" >/dev/null 2>&1 || true - die "failed to attach pipelock sidecar ${name} to egress network ${egress_network}" - fi - - if ! docker start "$name" >/dev/null 2>&1; then - docker rm -f "$name" >/dev/null 2>&1 || true - die "failed to start pipelock sidecar ${name}" - fi - - printf '%s' "$name" -} - -# pipelock_stop -# -# Stops and removes the sidecar by canonical name. Idempotent: a -# missing container is treated as success so this can be wired into -# cli.sh's exit trap unconditionally. Used as the first step of -# teardown — must run BEFORE the network is torn down, because docker -# refuses to remove a network that still has containers attached. -pipelock_stop() { - local slug="${1:?pipelock_stop: missing slug}" - local name - name="$(pipelock_container_name "$slug")" - if docker inspect "$name" >/dev/null 2>&1; then - docker rm -f "$name" >/dev/null 2>&1 || warn "failed to remove pipelock sidecar ${name}; clean up with 'docker rm -f ${name}'" - fi -} diff --git a/lib/skills.sh b/lib/skills.sh deleted file mode 100644 index 011fb61..0000000 --- a/lib/skills.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# Skill copier. Copies named skills from the host's ~/.claude/skills// -# into the running container's ~/.claude/skills//, 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//` 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 — 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 — returns 0 if the host has a skill directory -# at ~/.claude/skills//, else 1. -host_skill_exists() { - local name="${1:?host_skill_exists: missing skill name}" - [ -d "$(host_skill_dir "$name")" ] -} - -# require_host_skill — 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 [ ...] — 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 [ ...] -# -# For each named skill: -# 1. ensure ~/.claude/skills/ exists in the container (mkdir -p) -# 2. `docker cp /. ://` -# — 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 -} diff --git a/lib/ssh.sh b/lib/ssh.sh deleted file mode 100644 index 81bd9be..0000000 --- a/lib/ssh.sh +++ /dev/null @@ -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//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 ... — 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 ... — 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 : (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 `) 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 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 -} diff --git a/tests/README.md b/tests/README.md index 2ea901f..150e669 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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_.sh`. Make it executable: `chmod +x`. -3. Start with the boilerplate the existing files use: - ```bash - #!/usr/bin/env bash - TEST_NAME="" - . "$(dirname "$0")/../lib/common.sh" - . "${REPO_ROOT}/lib/log.sh" - . "${REPO_ROOT}/lib/.sh" - # ...assert_eq / assert_contains / ... - test_summary +1. Pick a filename: `test_.py`. Add it to `INTEGRATION_NAMES` + in `run_tests.py` if it needs Docker. +2. Boilerplate: + ```python + import unittest + + from claude_bottle. import + + 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`. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/_docker.py b/tests/_docker.py new file mode 100644 index 0000000..deb0d39 --- /dev/null +++ b/tests/_docker.py @@ -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) diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..d4c6432 --- /dev/null +++ b/tests/fixtures.py @@ -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) diff --git a/tests/integration/test_dry_run_plan.sh b/tests/integration/test_dry_run_plan.sh deleted file mode 100755 index c1ba8df..0000000 --- a/tests/integration/test_dry_run_plan.sh +++ /dev/null @@ -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 -# ", , , +N more" prefix, so this assertion is robust against -# alphabetical sort order changes. -assert_match "$out" "8 hosts allowed" "preflight: bottle entry counted in effective allowlist" -assert_contains "$out" "api.anthropic.com" "preflight: baked default shown" -assert_contains "$out" "dry-run requested" "dry-run banner present" -assert_not_contains "$out" "/dev/tty" "no /dev/tty prompt reached (dry-run exited first)" - -# No docker side effects. -nets_after="$(docker network ls --format '{{.Name}}' | grep -c '^claude-bottle' || true)" -ctrs_after="$(docker ps -a --format '{{.Names}}' | grep -c '^claude-bottle' || true)" -assert_eq "$nets_before" "$nets_after" "dry-run: no claude-bottle networks created" -assert_eq "$ctrs_before" "$ctrs_after" "dry-run: no claude-bottle containers created" - -test_summary diff --git a/tests/integration/test_orphan_cleanup.sh b/tests/integration/test_orphan_cleanup.sh deleted file mode 100755 index 41b814e..0000000 --- a/tests/integration/test_orphan_cleanup.sh +++ /dev/null @@ -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 diff --git a/tests/integration/test_pipelock_image.sh b/tests/integration/test_pipelock_image.sh deleted file mode 100755 index afff10e..0000000 --- a/tests/integration/test_pipelock_image.sh +++ /dev/null @@ -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 diff --git a/tests/integration/test_pipelock_sidecar_smoke.sh b/tests/integration/test_pipelock_sidecar_smoke.sh deleted file mode 100755 index 5340441..0000000 --- a/tests/integration/test_pipelock_sidecar_smoke.sh +++ /dev/null @@ -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 diff --git a/tests/lib/assert.sh b/tests/lib/assert.sh deleted file mode 100644 index 9d92ab2..0000000 --- a/tests/lib/assert.sh +++ /dev/null @@ -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 — runs the command, fails the assertion -# if it exits non-zero. Captures stdout+stderr for the failure message. -assert_exit_zero() { - local label="$1"; shift - local out - if out="$("$@" 2>&1)"; then - _pass "$label" - else - _fail "$label" "exit non-zero" "output: ${out}" - fi -} - -assert_exit_nonzero() { - local label="$1"; shift - local out - if out="$("$@" 2>&1)"; then - _fail "$label" "exit was 0; expected non-zero" "output: ${out}" - else - _pass "$label" - fi -} - -skip() { - printf ' %sSKIP%s %s\n' "$_C_SKIP" "$_C_RESET" "$1" -} - -skip_test_if_no_docker() { - if ! command -v docker >/dev/null 2>&1; then - printf '%sSKIP%s %s — docker not on PATH\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME" - exit 0 - fi - if ! docker info >/dev/null 2>&1; then - printf '%sSKIP%s %s — docker daemon unreachable\n' "$_C_SKIP" "$_C_RESET" "$TEST_NAME" - exit 0 - fi -} - -test_summary() { - printf '\n%s: %d passed, %d failed\n' "$TEST_NAME" "$TEST_PASS" "$TEST_FAIL" - if [ "$TEST_FAIL" -gt 0 ]; then - exit 1 - fi - exit 0 -} diff --git a/tests/lib/common.sh b/tests/lib/common.sh deleted file mode 100644 index 152107f..0000000 --- a/tests/lib/common.sh +++ /dev/null @@ -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/.sh"` to load the code -# they're exercising. - -if [ -n "${CLAUDE_BOTTLE_TESTS_COMMON_SOURCED:-}" ]; then - return 0 -fi -CLAUDE_BOTTLE_TESTS_COMMON_SOURCED=1 - -set -euo pipefail - -_tests_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -TESTS_ROOT="$_tests_dir" -REPO_ROOT="$(CDPATH= cd -- "${TESTS_ROOT}/.." && pwd)" - -# shellcheck source=./assert.sh -. "${TESTS_ROOT}/lib/assert.sh" -# shellcheck source=./fixtures.sh -. "${TESTS_ROOT}/lib/fixtures.sh" diff --git a/tests/lib/fixtures.sh b/tests/lib/fixtures.sh deleted file mode 100644 index e001c39..0000000 --- a/tests/lib/fixtures.sh +++ /dev/null @@ -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 — write fixture to a temp file, print -# the path. Caller must rm. -write_fixture() { - local fn="${1:?write_fixture: missing fixture function}" - local f - f="$(mktemp)" - "$fn" > "$f" - printf '%s' "$f" -} diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100755 index 0000000..f8edfab --- /dev/null +++ b/tests/run_tests.py @@ -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:])) diff --git a/tests/run_tests.sh b/tests/run_tests.sh deleted file mode 100755 index d3825c0..0000000 --- a/tests/run_tests.sh +++ /dev/null @@ -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 < run a single test file -EOF -} - -# Collect test files. -declare -a FILES=() -case "${1:-}" in - -h|--help) usage; exit 0 ;; - unit) FILES=("${_dir}"/unit/test_*.sh) ;; - integration) FILES=("${_dir}"/integration/test_*.sh) ;; - "") FILES=("${_dir}"/unit/test_*.sh "${_dir}"/integration/test_*.sh) ;; - *) - if [ -f "$1" ]; then - FILES=("$1") - else - printf 'no such file: %s\n' "$1" >&2 - usage - exit 2 - fi - ;; -esac - -# Filter out non-existent globs (no matching files). -declare -a EXISTING=() -for f in "${FILES[@]}"; do - [ -f "$f" ] && EXISTING+=("$f") -done - -if [ "${#EXISTING[@]}" -eq 0 ]; then - printf 'no test files found\n' >&2 - exit 2 -fi - -PASS_COUNT=0 -FAIL_COUNT=0 -declare -a FAIL_FILES=() - -for f in "${EXISTING[@]}"; do - rel="${f#${_dir}/}" - printf '%s== %s ==%s\n' "$C_HEAD" "$rel" "$C_RESET" - if bash "$f"; then - PASS_COUNT=$((PASS_COUNT + 1)) - else - FAIL_COUNT=$((FAIL_COUNT + 1)) - FAIL_FILES+=("$rel") - fi - printf '\n' -done - -# Summary. -TOTAL=$((PASS_COUNT + FAIL_COUNT)) -printf '%ssummary%s: %d/%d test files passed\n' "$C_HEAD" "$C_RESET" "$PASS_COUNT" "$TOTAL" -if [ "$FAIL_COUNT" -gt 0 ]; then - printf '%sfailed%s:\n' "$C_FAIL" "$C_RESET" - for f in "${FAIL_FILES[@]}"; do - printf ' - %s\n' "$f" - done - exit 1 -fi -printf '%sall tests passed%s\n' "$C_PASS" "$C_RESET" diff --git a/tests/test_dry_run_plan.py b/tests/test_dry_run_plan.py new file mode 100644 index 0000000..b279aa7 --- /dev/null +++ b/tests/test_dry_run_plan.py @@ -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() diff --git a/tests/test_orphan_cleanup.py b/tests/test_orphan_cleanup.py new file mode 100644 index 0000000..bb2382e --- /dev/null +++ b/tests/test_orphan_cleanup.py @@ -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() diff --git a/tests/test_pipelock_allowlist.py b/tests/test_pipelock_allowlist.py new file mode 100644 index 0000000..08911fd --- /dev/null +++ b/tests/test_pipelock_allowlist.py @@ -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() diff --git a/tests/test_pipelock_classify.py b/tests/test_pipelock_classify.py new file mode 100644 index 0000000..749a732 --- /dev/null +++ b/tests/test_pipelock_classify.py @@ -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() diff --git a/tests/test_pipelock_image.py b/tests/test_pipelock_image.py new file mode 100644 index 0000000..68f32a9 --- /dev/null +++ b/tests/test_pipelock_image.py @@ -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() diff --git a/tests/test_pipelock_naming.py b/tests/test_pipelock_naming.py new file mode 100644 index 0000000..a547a32 --- /dev/null +++ b/tests/test_pipelock_naming.py @@ -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() diff --git a/tests/test_pipelock_sidecar_smoke.py b/tests/test_pipelock_sidecar_smoke.py new file mode 100644 index 0000000..8cffd76 --- /dev/null +++ b/tests/test_pipelock_sidecar_smoke.py @@ -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() diff --git a/tests/test_pipelock_yaml.py b/tests/test_pipelock_yaml.py new file mode 100644 index 0000000..184bf83 --- /dev/null +++ b/tests/test_pipelock_yaml.py @@ -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() diff --git a/tests/unit/test_pipelock_allowlist.sh b/tests/unit/test_pipelock_allowlist.sh deleted file mode 100755 index 6c2e059..0000000 --- a/tests/unit/test_pipelock_allowlist.sh +++ /dev/null @@ -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 diff --git a/tests/unit/test_pipelock_classify.sh b/tests/unit/test_pipelock_classify.sh deleted file mode 100755 index 513bfc8..0000000 --- a/tests/unit/test_pipelock_classify.sh +++ /dev/null @@ -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 diff --git a/tests/unit/test_pipelock_naming.sh b/tests/unit/test_pipelock_naming.sh deleted file mode 100755 index 4a39055..0000000 --- a/tests/unit/test_pipelock_naming.sh +++ /dev/null @@ -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 diff --git a/tests/unit/test_pipelock_yaml.sh b/tests/unit/test_pipelock_yaml.sh deleted file mode 100755 index e1c2e5e..0000000 --- a/tests/unit/test_pipelock_yaml.sh +++ /dev/null @@ -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