From 9eb5eef676da84e57b66e9cc7e8b9fe782a492f9 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 21:11:14 +0000 Subject: [PATCH 1/6] chore: delete pipelock files and strip from manifest layer - Delete bot_bottle/pipelock.py, backend/docker/pipelock.py, backend/docker/pipelock_apply.py - Delete all pipelock unit/integration/canary tests - Remove PipelockRoutePolicy from manifest_egress.py; drop the Pipelock field from EgressRoute and the 'pipelock' key from EgressRoute.from_dict - Remove PipelockRoutePolicy re-export from manifest.py __all__ --- bot_bottle/backend/docker/pipelock.py | 74 --- bot_bottle/backend/docker/pipelock_apply.py | 200 ------- bot_bottle/manifest.py | 2 - bot_bottle/manifest_egress.py | 77 +-- bot_bottle/pipelock.py | 541 ------------------ tests/canaries/test_pipelock_image.py | 45 -- tests/integration/test_pipelock_allow_node.py | 110 ---- .../test_pipelock_allows_normal_https.py | 83 --- tests/integration/test_pipelock_apply.py | 210 ------- tests/integration/test_pipelock_block_node.py | 114 ---- .../test_pipelock_blocks_secret_https_post.py | 101 ---- .../test_pipelock_blocks_secret_post.py | 132 ----- .../test_pipelock_llm_passthrough.py | 107 ---- tests/unit/test_pipelock_allowlist.py | 169 ------ tests/unit/test_pipelock_apply.py | 115 ---- tests/unit/test_pipelock_yaml.py | 356 ------------ 16 files changed, 3 insertions(+), 2433 deletions(-) delete mode 100644 bot_bottle/backend/docker/pipelock.py delete mode 100644 bot_bottle/backend/docker/pipelock_apply.py delete mode 100644 bot_bottle/pipelock.py delete mode 100644 tests/canaries/test_pipelock_image.py delete mode 100644 tests/integration/test_pipelock_allow_node.py delete mode 100644 tests/integration/test_pipelock_allows_normal_https.py delete mode 100644 tests/integration/test_pipelock_apply.py delete mode 100644 tests/integration/test_pipelock_block_node.py delete mode 100644 tests/integration/test_pipelock_blocks_secret_https_post.py delete mode 100644 tests/integration/test_pipelock_blocks_secret_post.py delete mode 100644 tests/integration/test_pipelock_llm_passthrough.py delete mode 100644 tests/unit/test_pipelock_allowlist.py delete mode 100644 tests/unit/test_pipelock_apply.py delete mode 100644 tests/unit/test_pipelock_yaml.py diff --git a/bot_bottle/backend/docker/pipelock.py b/bot_bottle/backend/docker/pipelock.py deleted file mode 100644 index 53d2c2a..0000000 --- a/bot_bottle/backend/docker/pipelock.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Docker-side pipelock helpers: image pin, container naming, and -the one-shot `pipelock tls init` host-side CA mint. The -prepare-time YAML rendering itself lives on the platform-neutral -`PipelockProxy` ABC — backends instantiate it directly. - -The per-container `.start()` / `.stop()` lifecycle was deleted in -PRD 0024 chunk 3; compose-up owns the container lifecycle (PRD -0018) and the bundle path (PRD 0024) collapses pipelock + egress -+ git-gate + supervise into one container.""" - -from __future__ import annotations - -import os -import subprocess -from pathlib import Path - -from ...log import die - - -# 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( - "BOT_BOTTLE_PIPELOCK_IMAGE", - "ghcr.io/luckypipewrench/pipelock@sha256:" - "3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9", -) - -# Listening port for pipelock's forward proxy. -PIPELOCK_PORT = os.environ.get("BOT_BOTTLE_PIPELOCK_PORT", "8888") - - -# The URL egress dials for its upstream HTTPS_PROXY. egress and pipelock -# share the same container's network namespace inside the sidecar bundle, so -# loopback reaches pipelock directly — no docker DNS aliases involved. -BUNDLE_LOCAL_PIPELOCK_URL = f"http://127.0.0.1:{PIPELOCK_PORT}" - - -def pipelock_tls_init(stage_dir: Path) -> tuple[Path, Path]: - """Generate a fresh per-bottle CA via a one-shot pipelock container. - - Runs `pipelock tls init` against a host-mounted scratch dir, leaving - `ca.pem` (public cert, mode 600) and `ca-key.pem` (private key, mode - 600) under `/pipelock-ca/`. Returns the two host paths. - - The image is pinned (same digest the running sidecar uses) so the - generated CA matches what the sidecar expects. Output is owned by - whatever UID the one-shot ran as; the compose renderer's - bind-mounts pin the files in place at runtime, so ownership - inside the running sidecar (root in pipelock's distroless image) - is independent.""" - work = stage_dir / "pipelock-ca" - work.mkdir(exist_ok=True) - result = subprocess.run( - ["docker", "run", "--rm", - "-v", f"{work}:/h", - "-e", "PIPELOCK_HOME=/h", - PIPELOCK_IMAGE, "tls", "init"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - die(f"pipelock tls init failed: {result.stderr.strip()}") - cert = work / "ca.pem" - key = work / "ca-key.pem" - if not cert.is_file() or not key.is_file(): - die(f"pipelock tls init did not produce ca files in {work}") - # Explicit perms in case a future pipelock release changes - # defaults. Pipelock runs as root in its distroless image and - # bind-mounts work with 0o600 (root reads everything); the key - # has no reason to be readable to anyone else on the host. - key.chmod(0o600) - cert.chmod(0o644) - return (cert, key) diff --git a/bot_bottle/backend/docker/pipelock_apply.py b/bot_bottle/backend/docker/pipelock_apply.py deleted file mode 100644 index e66251d..0000000 --- a/bot_bottle/backend/docker/pipelock_apply.py +++ /dev/null @@ -1,200 +0,0 @@ -"""pipelock_apply — host-side helper to apply an api_allowlist -change to a running pipelock sidecar (PRD 0015). - -Used by the supervise dashboard when the operator approves a -pipelock-block proposal (or runs the operator-initiated `pipelock -edit ` verb). Fetches the current pipelock.yaml via `docker -exec`, parses it, swaps the api_allowlist with the proposed hosts, -re-renders, writes back via the bind-mount path, then signals the -bundle supervisor to restart the pipelock daemon (`docker kill ---signal USR1`) so -pipelock picks up the new config. - -v1 uses restart, not SIGHUP — pipelock has no in-process reload -hook and adding one is the "SIGHUP reload for pipelock" open -question in PRD 0015. Restart drops in-flight outbound calls; the -agent's HTTP client retries pick up against the restarted proxy. -""" - -from __future__ import annotations - -import os -import re -import subprocess -import tempfile -from pathlib import Path - -from ...pipelock import pipelock_render_yaml -from ...yaml_subset import YamlSubsetError, parse_yaml_subset -from .bottle_state import pipelock_state_dir -from .sidecar_bundle import sidecar_bundle_container_name - - -def _pipelock_yaml_host_path(slug: str) -> Path: - """The bind-mount source for the pipelock sidecar's - pipelock.yaml — matches what pipelock.prepare wrote at chunk-2 - paths.""" - return pipelock_state_dir(slug) / "pipelock.yaml" - - -PIPELOCK_YAML_IN_CONTAINER = "/etc/pipelock.yaml" - -# Allowlist proposals are one-hostname-per-line. Blank lines and -# `#`-prefixed comments are ignored. The character set matches the -# supervise sidecar's syntactic check on the agent's pipelock-block -# proposal (alphanumerics + dot/dash/underscore). -_HOST_OK = re.compile(r"^[A-Za-z0-9_.-]+$") - - -class PipelockApplyError(RuntimeError): - """Raised when fetch / parse / apply fails. The dashboard renders - the message and keeps the proposal pending — never crashes.""" - - -def parse_allowlist_content(content: str) -> list[str]: - """One hostname per line. Blanks and `#` comments are ignored. - Raises PipelockApplyError if a line has a disallowed character.""" - hosts: list[str] = [] - for i, raw_line in enumerate(content.splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if not _HOST_OK.match(line): - raise PipelockApplyError( - f"allowlist line {i}: {line!r} has disallowed characters" - ) - hosts.append(line) - return hosts - - -def render_allowlist_content(hosts: list[str]) -> str: - """Hosts → one-per-line string (the operator-facing format).""" - if not hosts: - return "" - return "\n".join(hosts) + "\n" - - -def fetch_current_yaml(slug: str) -> str: - """Read the live /etc/pipelock.yaml from the sidecar bundle. - - Uses `docker cp` because pipelock inside the bundle is the - distroless pipelock binary with no shell, and `docker cp` is a - daemon-API tarball copy that works regardless of what's - available inside the container. - - Raises PipelockApplyError if the read fails.""" - container = sidecar_bundle_container_name(slug) - fd, tmp_path = tempfile.mkstemp(prefix="cb-pipelock-fetch.", suffix=".yaml") - os.close(fd) - try: - r = subprocess.run( - [ - "docker", "cp", - f"{container}:{PIPELOCK_YAML_IN_CONTAINER}", tmp_path, - ], - capture_output=True, text=True, check=False, - ) - if r.returncode != 0: - raise PipelockApplyError( - f"could not fetch pipelock.yaml from {container}: " - f"{(r.stderr or '').strip() or 'container not running?'}" - ) - return Path(tmp_path).read_text(encoding="utf-8") - finally: - try: - Path(tmp_path).unlink() - except OSError: - pass - - -def fetch_current_allowlist(slug: str) -> str: - """Fetch the live yaml, extract api_allowlist, render as one-per- - line — the operator-facing format for the TUI / agent's - current-config mount.""" - yaml = fetch_current_yaml(slug) - try: - cfg = parse_yaml_subset(yaml) - except YamlSubsetError as e: - raise PipelockApplyError(f"running pipelock yaml: {e}") from e - hosts = cfg.get("api_allowlist", []) - if not isinstance(hosts, list): - raise PipelockApplyError( - "running pipelock yaml: api_allowlist is not a list" - ) - return render_allowlist_content([str(h) for h in hosts]) - - -def apply_allowlist_change( - slug: str, new_allowlist_content: str, -) -> tuple[str, str]: - """Apply `new_allowlist_content` to the sidecar bundle: - 1. Parse the proposed hosts (one per line). - 2. Fetch + parse current pipelock.yaml. - 3. Replace api_allowlist with the proposed hosts; re-render. - 4. Write the new yaml to the bind-mount source. - 5. `docker kill --signal USR1 ` so the supervisor - restarts the pipelock daemon in place (leaving egress, - git-gate, and supervise running). Pipelock has no - in-process reload; the supervisor's per-daemon restart - keeps the agent's MCP socket alive — a whole-bundle - `docker restart` would bounce supervise too. - - Returns (before, after) where both are one-per-line allowlist - strings (operator-facing format). Raises PipelockApplyError on - any failure; the sidecar's existing config stays in place until - the host write succeeds, and the SIGUSR1 is what makes it - live.""" - new_hosts = parse_allowlist_content(new_allowlist_content) - container = sidecar_bundle_container_name(slug) - current_yaml = fetch_current_yaml(slug) - try: - cfg = parse_yaml_subset(current_yaml) - except YamlSubsetError as e: - raise PipelockApplyError(f"running pipelock yaml: {e}") from e - current_hosts = cfg.get("api_allowlist", []) - if not isinstance(current_hosts, list): - raise PipelockApplyError( - "running pipelock yaml: api_allowlist is not a list" - ) - - before = render_allowlist_content([str(h) for h in current_hosts]) - after = render_allowlist_content(new_hosts) - - cfg["api_allowlist"] = new_hosts - rendered = pipelock_render_yaml(cfg) - - # pipelock.yaml is bind-mounted into the container as a SINGLE - # FILE — same Docker single-file inode issue as egress_apply: - # write-temp-then-rename swaps the host inode and leaves the - # container's mount pointing at the orphaned old one. Write - # in-place. The SIGUSR1 below makes the new content live - # (pipelock has no in-process reload, so the supervisor - # restarts the pipelock daemon in response). - target = _pipelock_yaml_host_path(slug) - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(rendered) - # pipelock runs as root in its distroless image — any mode is - # fine — but 0o600 matches what prepare wrote. - target.chmod(0o600) - restart = subprocess.run( - ["docker", "kill", "--signal", "USR1", container], - capture_output=True, text=True, check=False, - ) - if restart.returncode != 0: - raise PipelockApplyError( - f"failed to signal {container} for pipelock restart: " - f"{(restart.stderr or '').strip()}" - ) - - return before, after - - -__all__ = [ - "PIPELOCK_YAML_IN_CONTAINER", - "PipelockApplyError", - "apply_allowlist_change", - "fetch_current_allowlist", - "fetch_current_yaml", - "parse_allowlist_content", - "render_allowlist_content", -] diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 8be2e44..63ad90d 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -56,7 +56,6 @@ from .manifest_egress import ( EGRESS_AUTH_SCHEMES, EgressConfig, EgressRoute, - PipelockRoutePolicy, ) from .manifest_git import GitEntry, GitUser, parse_git_gate_config from .manifest_schema import BOTTLE_KEYS @@ -68,7 +67,6 @@ __all__ = [ "GitUser", "AgentProvider", "EGRESS_AUTH_SCHEMES", - "PipelockRoutePolicy", "EgressRoute", "EgressConfig", "Agent", diff --git a/bot_bottle/manifest_egress.py b/bot_bottle/manifest_egress.py index 24a6b67..6f7c1d7 100644 --- a/bot_bottle/manifest_egress.py +++ b/bot_bottle/manifest_egress.py @@ -2,8 +2,7 @@ from __future__ import annotations -import ipaddress -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import cast from .manifest_util import ManifestError, as_json_object @@ -39,68 +38,6 @@ def validate_egress_routes( seen_hosts[key] = None -@dataclass(frozen=True) -class PipelockRoutePolicy: - """Per-route pipelock policy overrides. - - `TlsPassthrough` adds the route host to pipelock's - `tls_interception.passthrough_domains`, so pipelock still enforces - the hostname allowlist but does not MITM/decrypt request bodies or - headers for that host. - - `SsrfIpAllowlist` adds explicit IPs/CIDRs to pipelock's SSRF - allowlist for private/internal destinations behind this route. - """ - - TlsPassthrough: bool = False - SsrfIpAllowlist: tuple[str, ...] = () - - @classmethod - def from_dict( - cls, bottle_name: str, idx: int, raw: object, - ) -> "PipelockRoutePolicy": - label = f"bottle '{bottle_name}' egress.routes[{idx}] pipelock" - d = as_json_object(raw, label) - for k in d: - if k not in ("tls_passthrough", "ssrf_ip_allowlist"): - raise ManifestError( - f"{label} has unknown key {k!r}; " - f"only 'tls_passthrough' and 'ssrf_ip_allowlist' " - f"are accepted" - ) - tls_passthrough_raw = d.get("tls_passthrough", False) - if not isinstance(tls_passthrough_raw, bool): - raise ManifestError( - f"{label}.tls_passthrough must be a boolean " - f"(was {type(tls_passthrough_raw).__name__})" - ) - ssrf_raw = d.get("ssrf_ip_allowlist", []) - if not isinstance(ssrf_raw, list): - raise ManifestError( - f"{label}.ssrf_ip_allowlist must be an array " - f"(was {type(ssrf_raw).__name__})" - ) - ssrf_ip_allowlist: list[str] = [] - for j, item in enumerate(ssrf_raw): - if not isinstance(item, str) or not item: - raise ManifestError( - f"{label}.ssrf_ip_allowlist[{j}] must be a non-empty " - f"string (was {type(item).__name__})" - ) - try: - ipaddress.ip_network(item, strict=False) - except ValueError as e: - raise ManifestError( - f"{label}.ssrf_ip_allowlist[{j}] must be an IP address " - f"or CIDR (was {item!r}): {e}" - ) from e - ssrf_ip_allowlist.append(item) - return cls( - TlsPassthrough=tls_passthrough_raw, - SsrfIpAllowlist=tuple(ssrf_ip_allowlist), - ) - - @dataclass(frozen=True) class EgressRoute: """One route on the per-bottle egress sidecar (PRD 0017). @@ -132,7 +69,6 @@ class EgressRoute: AuthScheme: str = "" TokenRef: str = "" Role: tuple[str, ...] = () - Pipelock: PipelockRoutePolicy = field(default_factory=PipelockRoutePolicy) @classmethod def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute": @@ -229,17 +165,11 @@ class EgressRoute: f"the 'role' field is reserved for future use" ) - pipelock = ( - PipelockRoutePolicy.from_dict(bottle_name, idx, d["pipelock"]) - if "pipelock" in d - else PipelockRoutePolicy() - ) - for k in d: - if k not in ("host", "path_allowlist", "auth", "role", "pipelock"): + if k not in ("host", "path_allowlist", "auth", "role"): raise ManifestError( f"{label} has unknown key {k!r}; accepted keys are " - f"'host', 'path_allowlist', 'auth', 'role', 'pipelock'" + f"'host', 'path_allowlist', 'auth', 'role'" ) return cls( @@ -248,7 +178,6 @@ class EgressRoute: AuthScheme=auth_scheme, TokenRef=token_ref, Role=roles, - Pipelock=pipelock, ) diff --git a/bot_bottle/pipelock.py b/bot_bottle/pipelock.py deleted file mode 100644 index c9ea82d..0000000 --- a/bot_bottle/pipelock.py +++ /dev/null @@ -1,541 +0,0 @@ -"""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. - -Post-PRD-0017 topology: the agent's HTTP_PROXY points at egress -(not pipelock); egress sets `HTTPS_PROXY=pipelock` on its -outbound leg. So pipelock no longer sees the agent's connections -directly — it sees the egress → upstream leg, applies the -hostname allowlist + DLP body scan there, and forwards to the real -upstream. - -Image pin: ghcr.io/luckypipewrench/pipelock@sha256: for tag 2.3.0. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import cast - -from .egress import EgressRoute, egress_routes_for_bottle -from .supervise import SUPERVISE_HOSTNAME -from .manifest import Bottle - -# Hosts pipelock should NOT TLS-MITM, even when tls_interception is -# enabled. This is now route-owned manifest policy via -# `egress.routes[].pipelock.tls_passthrough`; no provider hosts are -# injected implicitly. -DEFAULT_TLS_PASSTHROUGH: tuple[str, ...] = () - - -# In-container paths the rendered pipelock YAML references under -# `tls_interception`. The pipelock binary expects the per-bottle CA -# cert + key at these exact paths inside its container — independent -# of how the daemon is wrapped (own container, sidecar bundle, etc.), -# which is why they live in the platform-neutral module. -PIPELOCK_CA_CERT_IN_CONTAINER = "/etc/pipelock-ca.pem" -PIPELOCK_CA_KEY_IN_CONTAINER = "/etc/pipelock-ca-key.pem" - - -# Short network alias for pipelock inside the sidecar bundle. The -# agent's HTTP_PROXY (when no egress is declared) and any in-bundle -# consumer's URL both reference this name. -PIPELOCK_HOSTNAME = "pipelock" - - -# --- Allowlist resolution -------------------------------------------------- - - -def pipelock_effective_allowlist( - bottle: Bottle, - provider_routes: tuple[EgressRoute, ...] = (), -) -> list[str]: - """Hostnames pipelock allows. Sorted for stability. - - Always mirrors `egress_routes_for_bottle(bottle, provider_routes)` — - egress is the single allowlist surface, and pipelock's allowlist is - the downstream copy for defense-in-depth + DLP body scanning. For - bottles without any `egress.routes[]` declared, this is empty except - for supervise sidecar traffic when `supervise: true`. - - The supervise sidecar's hostname is auto-added when supervise - is enabled (sibling-sidecar traffic that flows through pipelock - would otherwise be 403'd). Git upstreams declared in - `bottle.git` do NOT contribute here — git traffic flows - through git-gate (PRD 0008), not pipelock.""" - seen: dict[str, None] = {} - for r in egress_routes_for_bottle(bottle, provider_routes): - if r.host: - seen.setdefault(r.host, None) - if bottle.supervise: - seen.setdefault(SUPERVISE_HOSTNAME, None) - return sorted(seen.keys()) - - -def pipelock_seed_phrase_detection_enabled(bottle: Bottle) -> bool: - """Whether pipelock's BIP-39 seed-phrase detector stays on. - - LLM conversation bodies legitimately trip the detector — any 12+ - English words that pass the BIP-39 checksum match — so agents can - get blocked on ordinary prompts/responses regardless of provider - (Claude, Codex/OpenAI, or future harnesses). We tried two narrower - knobs first: - - - `suppress: [{rule, path}]` — pipelock accepts the schema - but the entry only silences the alert; the body_dlp block - still fires. - - `rules.disabled: ["dlp:BIP-39 Seed Phrase"]` — same shape, - same outcome: 403 still returned. - - Empirically only `seed_phrase_detection.enabled: false` - actually stops the block (verified by sending a 12-word BIP-39 - body through three pipelock instances). It is a global toggle — - no per-path / per-host knob in pipelock 2.3.0 — so we turn off - only this detector for every bottle. The rest of pipelock's DLP - defaults and request-body/header scanning remain enabled.""" - del bottle # kept for call-site stability and future policy knobs. - return False - - -def pipelock_effective_tls_passthrough( - bottle: Bottle, - provider_routes: tuple[EgressRoute, ...] = (), -) -> list[str]: - """Hostnames pipelock should pass through (no TLS MITM). - - A manifest route opts in with `pipelock.tls_passthrough: true` - (lifted into `EgressRoute.tls_passthrough` in `egress_manifest_routes`). - Provider routes that set `tls_passthrough=True` (e.g. Codex credential - routes where egress injects the host bearer after the agent boundary) - are also included. Both arrive via `egress_routes_for_bottle` — no - provider-specific branching needed here. - """ - seen: dict[str, None] = {host: None for host in DEFAULT_TLS_PASSTHROUGH} - for route in egress_routes_for_bottle(bottle, provider_routes): - if route.tls_passthrough: - seen.setdefault(route.host, None) - return sorted(seen.keys()) - - -def pipelock_effective_ssrf_ip_allowlist( - bottle: Bottle, - extra: tuple[str, ...] = (), -) -> list[str]: - """IP/CIDR entries that bypass pipelock's SSRF destination guard. - - Launch code can pass backend-owned entries through `extra`, while - route-owned entries come from `pipelock.ssrf_ip_allowlist`. - """ - seen: dict[str, None] = {ip: None for ip in extra} - for route in bottle.egress.routes: - for ip in route.Pipelock.SsrfIpAllowlist: - seen.setdefault(ip, None) - return sorted(seen.keys()) - - - - - -# --- Config build + YAML render -------------------------------------------- - - -def pipelock_build_config( - bottle: Bottle, - *, - ca_cert_path: str = "", - ca_key_path: str = "", - ssrf_ip_allowlist: tuple[str, ...] = (), - provider_routes: tuple[EgressRoute, ...] = (), -) -> dict[str, object]: - """Build the structured pipelock config dict the sidecar will load. - - Deliberately carries no env values, no secrets, no per-agent - customization beyond the resolved hostname list. The shape mirrors - the YAML pipelock expects on disk; `pipelock_render_yaml` serializes - it. Tests assert on this dict; production code renders it. - - `ca_cert_path` / `ca_key_path` are the **in-container** paths the - pipelock sidecar will read its CA from at runtime (they're - populated into the container at start time via `docker cp`). - Pass both or neither: both → emit `tls_interception` block with - `enabled: true`; neither → omit the block entirely (pipelock - falls back to its built-in default of `enabled: false`). Used - by PRD 0006 to turn on pipelock's native TLS interception. - - `ssrf_ip_allowlist` is the list of IPs / CIDRs that bypass - pipelock's SSRF guard. Pipelock blocks RFC1918-resolved - destinations by default, which would catch sibling-sidecar - traffic on the bottle's internal Docker network in 172.x space - (e.g. egress → pipelock on the upstream leg). Pass the - bottle's internal network CIDR here so internal-network requests - pass through pipelock while api_allowlist + body-scanning still - apply. Empty by default; omitted from the rendered yaml when - empty so pipelock keeps its built-in SSRF defaults.""" - cfg: dict[str, object] = { - "version": 1, - "mode": "strict", - "enforce": True, - "api_allowlist": pipelock_effective_allowlist(bottle, provider_routes), - "forward_proxy": {"enabled": True}, - } - if not pipelock_seed_phrase_detection_enabled(bottle): - cfg["seed_phrase_detection"] = {"enabled": False} - cfg["dlp"] = {"include_defaults": True, "scan_env": True} - # Body-scan enforcement is a separate pipelock section (each DLP - # "surface" — body, MCP, response — has its own action). Pipelock's - # built-in default for request_body_scanning is "warn" (forward - # with a log line); bot-bottle hard-codes "block" so a hit - # actually stops the request from leaving the egress network. - # - # `scan_headers: true` + `header_mode: all` extends the scan to - # every request header — pipelock's default `header_mode: - # sensitive` only checks Authorization / Cookie / X-Api-Key / - # X-Token / Proxy-Authorization / X-Goog-Api-Key, which an - # agent attempting to exfil could trivially avoid by picking - # a non-sensitive header name. "all" closes the gap; pipelock - # caps it at the same max_body_bytes the body scan uses. - cfg["request_body_scanning"] = { - "action": "block", - "scan_headers": True, - "header_mode": "all", - } - if ca_cert_path or ca_key_path: - if not (ca_cert_path and ca_key_path): - raise ValueError( - "pipelock_build_config: pass both ca_cert_path and ca_key_path " - "to enable tls_interception, or neither to leave it off" - ) - cfg["tls_interception"] = { - "enabled": True, - "ca_cert": ca_cert_path, - "ca_key": ca_key_path, - "passthrough_domains": pipelock_effective_tls_passthrough(bottle, provider_routes), - } - effective_ssrf_ip_allowlist = pipelock_effective_ssrf_ip_allowlist( - bottle, ssrf_ip_allowlist, - ) - if effective_ssrf_ip_allowlist: - cfg["ssrf"] = {"ip_allowlist": effective_ssrf_ip_allowlist} - return cfg - - -_PIPELOCK_TOP_LEVEL_KEYS = { - "version", - "mode", - "enforce", - "api_allowlist", - "seed_phrase_detection", - "forward_proxy", - "dlp", - "request_body_scanning", - "tls_interception", - "ssrf", -} - - -def _pipelock_render_error(section: str, key: str, expected: str) -> ValueError: - return ValueError( - f"pipelock_render_yaml: {section}.{key} must be {expected}" - ) - - -def _reject_unknown_keys( - section: str, - obj: dict[str, object], - allowed: set[str], -) -> None: - for key in sorted(set(obj) - allowed): - raise ValueError(f"pipelock_render_yaml: {section}.{key} is unsupported") - - -def _required_dict( - obj: dict[str, object], - section: str, - key: str, -) -> dict[str, object]: - value = obj.get(key) - if not isinstance(value, dict): - raise _pipelock_render_error(section, key, "a mapping") - return cast(dict[str, object], value) - - -def _required_bool(obj: dict[str, object], section: str, key: str) -> bool: - value = obj.get(key) - if not isinstance(value, bool): - raise _pipelock_render_error(section, key, "a boolean") - return value - - -def _required_int(obj: dict[str, object], section: str, key: str) -> int: - value = obj.get(key) - if isinstance(value, bool) or not isinstance(value, int): - raise _pipelock_render_error(section, key, "an integer") - return value - - -def _required_str(obj: dict[str, object], section: str, key: str) -> str: - value = obj.get(key) - if not isinstance(value, str): - raise _pipelock_render_error(section, key, "a string") - return value - - -def _required_str_list( - obj: dict[str, object], - section: str, - key: str, -) -> list[str]: - value = obj.get(key) - if not isinstance(value, list): - raise _pipelock_render_error(section, key, "a list of strings") - value_list = cast(list[object], value) - if not all(isinstance(v, str) for v in value_list): - raise _pipelock_render_error(section, key, "a list of strings") - return cast(list[str], value) - - -def _optional_str_list( - obj: dict[str, object], - section: str, - key: str, -) -> list[str]: - if key not in obj: - return [] - return _required_str_list(obj, section, key) - - -def _optional_bool( - obj: dict[str, object], - section: str, - key: str, -) -> bool | None: - if key not in obj: - return None - return _required_bool(obj, section, key) - - -def _optional_str( - obj: dict[str, object], - section: str, - key: str, -) -> str | None: - if key not in obj: - return None - return _required_str(obj, section, key) - - -def _validate_pipelock_render_config(cfg: dict[str, object]) -> dict[str, object]: - _reject_unknown_keys("config", cfg, _PIPELOCK_TOP_LEVEL_KEYS) - normalized: dict[str, object] = { - "version": _required_int(cfg, "config", "version"), - "mode": _required_str(cfg, "config", "mode"), - "enforce": _required_bool(cfg, "config", "enforce"), - "api_allowlist": _required_str_list(cfg, "config", "api_allowlist"), - } - - if "seed_phrase_detection" in cfg: - spd = _required_dict(cfg, "config", "seed_phrase_detection") - _reject_unknown_keys("seed_phrase_detection", spd, {"enabled"}) - normalized["seed_phrase_detection"] = { - "enabled": _required_bool(spd, "seed_phrase_detection", "enabled"), - } - - fp = _required_dict(cfg, "config", "forward_proxy") - _reject_unknown_keys("forward_proxy", fp, {"enabled"}) - normalized["forward_proxy"] = { - "enabled": _required_bool(fp, "forward_proxy", "enabled"), - } - - dlp = _required_dict(cfg, "config", "dlp") - _reject_unknown_keys("dlp", dlp, {"include_defaults", "scan_env"}) - normalized["dlp"] = { - "include_defaults": _required_bool(dlp, "dlp", "include_defaults"), - "scan_env": _required_bool(dlp, "dlp", "scan_env"), - } - - rbs = _required_dict(cfg, "config", "request_body_scanning") - _reject_unknown_keys( - "request_body_scanning", - rbs, - {"action", "scan_headers", "header_mode"}, - ) - normalized_rbs: dict[str, object] = { - "action": _required_str(rbs, "request_body_scanning", "action"), - } - scan_headers = _optional_bool(rbs, "request_body_scanning", "scan_headers") - if scan_headers is not None: - normalized_rbs["scan_headers"] = scan_headers - header_mode = _optional_str(rbs, "request_body_scanning", "header_mode") - if header_mode is not None: - normalized_rbs["header_mode"] = header_mode - normalized["request_body_scanning"] = normalized_rbs - - if "tls_interception" in cfg: - tls = _required_dict(cfg, "config", "tls_interception") - _reject_unknown_keys( - "tls_interception", - tls, - {"enabled", "ca_cert", "ca_key", "passthrough_domains"}, - ) - normalized["tls_interception"] = { - "enabled": _required_bool(tls, "tls_interception", "enabled"), - "ca_cert": _required_str(tls, "tls_interception", "ca_cert"), - "ca_key": _required_str(tls, "tls_interception", "ca_key"), - "passthrough_domains": _optional_str_list( - tls, "tls_interception", "passthrough_domains", - ), - } - - if "ssrf" in cfg: - ssrf = _required_dict(cfg, "config", "ssrf") - _reject_unknown_keys("ssrf", ssrf, {"ip_allowlist"}) - normalized["ssrf"] = { - "ip_allowlist": _required_str_list(ssrf, "ssrf", "ip_allowlist"), - } - - return normalized - - -def pipelock_render_yaml(cfg: dict[str, object]) -> str: - """Render a pipelock config dict (as produced by - `pipelock_build_config`) as YAML. Hand-rolled so we don't take a - YAML-parser dependency for a fixed, narrow shape.""" - def _bool(b: object) -> str: - return "true" if b else "false" - - cfg = _validate_pipelock_render_config(cfg) - lines: list[str] = [] - lines.append(f"version: {cfg['version']}") - lines.append(f"mode: {cfg['mode']}") - lines.append(f"enforce: {_bool(cast(bool, cfg['enforce']))}") - lines.append("") - lines.append("api_allowlist:") - api_allowlist = cast(list[str], cfg["api_allowlist"]) - for h in api_allowlist: - lines.append(f' - "{h}"') - lines.append("") - if "seed_phrase_detection" in cfg: - lines.append("seed_phrase_detection:") - spd = cast(dict[str, object], cfg["seed_phrase_detection"]) - lines.append(f" enabled: {_bool(cast(bool, spd['enabled']))}") - lines.append("") - lines.append("forward_proxy:") - fp = cast(dict[str, object], cfg["forward_proxy"]) - lines.append(f" enabled: {_bool(cast(bool, fp['enabled']))}") - lines.append("") - lines.append("dlp:") - dlp = cast(dict[str, object], cfg["dlp"]) - lines.append(f" include_defaults: {_bool(cast(bool, dlp['include_defaults']))}") - lines.append(f" scan_env: {_bool(cast(bool, dlp['scan_env']))}") - lines.append("") - lines.append("request_body_scanning:") - rbs = cast(dict[str, object], cfg["request_body_scanning"]) - lines.append(f' action: "{cast(str, rbs["action"])}"') - if "scan_headers" in rbs: - lines.append(f" scan_headers: {_bool(cast(bool, rbs['scan_headers']))}") - if "header_mode" in rbs: - lines.append(f' header_mode: "{cast(str, rbs["header_mode"])}"') - if "tls_interception" in cfg: - lines.append("") - lines.append("tls_interception:") - tls = cast(dict[str, object], cfg["tls_interception"]) - lines.append(f" enabled: {_bool(cast(bool, tls['enabled']))}") - lines.append(f' ca_cert: "{cast(str, tls["ca_cert"])}"') - lines.append(f' ca_key: "{cast(str, tls["ca_key"])}"') - passthrough = cast(list[str], tls["passthrough_domains"]) - if passthrough: - lines.append(" passthrough_domains:") - for d in passthrough: - lines.append(f' - "{d}"') - if "ssrf" in cfg: - lines.append("") - lines.append("ssrf:") - ssrf = cast(dict[str, object], cfg["ssrf"]) - lines.append(" ip_allowlist:") - ip_allowlist = cast(list[str], ssrf["ip_allowlist"]) - for ip in ip_allowlist: - lines.append(f' - "{ip}"') - return "\n".join(lines) + "\n" - - -# --- Proxy class ----------------------------------------------------------- - - -@dataclass(frozen=True) -class PipelockProxyPlan: - """Output of PipelockProxy.prepare; consumed by .start when the - sidecar needs to be brought up. - - yaml_path + slug are filled in at prepare time (host-side, side- - effect-free; the YAML references the in-container CA paths - already so it doesn't need the host paths to be valid). The - remaining fields are populated by the backend's launch step - via `dataclasses.replace`: internal/egress networks once - those networks exist, the CA host paths once the one-shot - `pipelock tls init` has run, and `internal_network_cidr` once - Docker has assigned a subnet to the internal network. Empty - defaults are sentinels meaning "not yet set"; `.start` validates - that they are populated. - - `internal_network_cidr` ends up on pipelock's `ssrf.ip_allowlist` - so traffic from sibling sidecars (egress → pipelock on the - upstream leg, etc.) bypasses pipelock's RFC1918 SSRF guard while - api_allowlist and body-scanning still apply.""" - - yaml_path: Path - slug: str - internal_network: str = "" - internal_network_cidr: str = "" - egress_network: str = "" - ca_cert_host_path: Path = Path() - ca_key_host_path: Path = Path() - - -class PipelockProxy: - """The pipelock egress proxy. Encapsulates the YAML-config - generation; the container lifecycle is owned by whatever - wraps the daemon (compose-managed pipelock container on docker, - sidecar-bundle PID 1 on smolmachines). - - Backends instantiate the class directly — there are no - platform-specific subclasses; the in-container CA paths are - universal module-level constants - (`PIPELOCK_CA_CERT_IN_CONTAINER` / `PIPELOCK_CA_KEY_IN_CONTAINER`).""" - - def prepare( - self, - bottle: Bottle, - slug: str, - stage_dir: Path, - provider_routes: tuple[EgressRoute, ...] = (), - ) -> PipelockProxyPlan: - """Write the pipelock yaml config (mode 600) under `stage_dir` - and return the plan for launch. Pure host-side, no docker - subprocess. - - `slug` is the agent-derived identifier (lowercased, - hyphen-normalized) used as the suffix in every per-agent - resource name — the agent container, the sidecar bundle - container, the internal/egress networks. It's stored on the - returned plan so the backend's launch step can derive those - names. - - The CA paths the YAML references are the module-level - in-container constants. The host-side counterparts are - generated by the launch step (not here, so prepare stays - side-effect-free on docker) and added to the plan via - `dataclasses.replace` before the daemon starts.""" - yaml_path = stage_dir / "pipelock.yaml" - cfg = pipelock_build_config( - bottle, - ca_cert_path=PIPELOCK_CA_CERT_IN_CONTAINER, - ca_key_path=PIPELOCK_CA_KEY_IN_CONTAINER, - provider_routes=provider_routes, - ) - yaml_path.write_text(pipelock_render_yaml(cfg)) - yaml_path.chmod(0o600) - return PipelockProxyPlan(yaml_path=yaml_path, slug=slug) diff --git a/tests/canaries/test_pipelock_image.py b/tests/canaries/test_pipelock_image.py deleted file mode 100644 index 9bfc5d4..0000000 --- a/tests/canaries/test_pipelock_image.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Canary: the pinned pipelock image's binary actually runs. - -This test exists to catch a broken upstream packaging at the pinned -digest. It is NOT part of the per-push suite — that would couple every -dev push to upstream registry availability. Set -BOT_BOTTLE_RUN_CANARIES=1 to opt in (a scheduled CI workflow does -this; humans can run it ad-hoc the same way). -""" - -import os -import subprocess -import unittest - -from bot_bottle.backend.docker.pipelock import PIPELOCK_IMAGE -from tests._docker import skip_unless_docker - - -@unittest.skipUnless( - os.environ.get("BOT_BOTTLE_RUN_CANARIES") == "1", - "canary suite is opt-in; set BOT_BOTTLE_RUN_CANARIES=1 to run", -) -@skip_unless_docker() -class TestPipelockImage(unittest.TestCase): - @classmethod - def setUpClass(cls): - result = subprocess.run( - ["docker", "pull", PIPELOCK_IMAGE], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if result.returncode != 0: - raise unittest.SkipTest(f"could not pull {PIPELOCK_IMAGE}") - - def test_binary_runs(self): - result = subprocess.run( - ["docker", "run", "--rm", PIPELOCK_IMAGE, "--version"], - capture_output=True, text=True, check=False, - ) - 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/integration/test_pipelock_allow_node.py b/tests/integration/test_pipelock_allow_node.py deleted file mode 100644 index 047df68..0000000 --- a/tests/integration/test_pipelock_allow_node.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Integration: a Node request to a host on pipelock's allowlist is -tunneled through. - -End-to-end mirror of test_pipelock_block_node: drives `BottleBackend. -prepare → launch` so the real image build, network plumbing, and -pipelock sidecar are all in the loop. Inside the bottle, a Node -script issues an HTTPS CONNECT for raw.githubusercontent.com:443 — -a host in the baked-in default allowlist — through `$HTTPS_PROXY`. -Pipelock must answer 200 Connection Established. The 200 vs. 403 -split on CONNECT is decided by pipelock itself (the remote never -sees the CONNECT verb), so it isolates the allowlist decision from -anything the remote might return. -""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from tests._docker import skip_unless_docker -from tests.fixtures import fixture_minimal - - -# Output contract (parsed by the test): -# - "connect=" proxy upgraded to a tunnel (CONNECT success path) -# - "status=" proxy answered without tunneling (block path) -# - "error= " transport-level failure -# - "timeout" request hung -_PROBE_JS = r""" -const http = require('http'); -const proxy = new URL(process.env.HTTPS_PROXY); -const req = http.request({ - host: proxy.hostname, - port: proxy.port, - method: 'CONNECT', - path: 'raw.githubusercontent.com:443', -}); -req.on('connect', (res, socket) => { - console.log('connect=' + res.statusCode); - socket.destroy(); - process.exit(0); -}); -req.on('response', (res) => { - res.resume(); - res.on('end', () => { - console.log('status=' + res.statusCode); - process.exit(0); - }); -}); -req.on('error', (e) => { - console.log('error=' + (e.code || '') + ' ' + e.message); - process.exit(0); -}); -req.setTimeout(5000, () => { - console.log('timeout'); - req.destroy(); -}); -req.end(); -""" - - -@skip_unless_docker() -class TestPipelockAllowsNode(unittest.TestCase): - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_node_request_to_allowed_host_is_tunneled(self): - backend = get_bottle_backend() - stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) - try: - spec = BottleSpec( - manifest=fixture_minimal(), - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - script = ( - "set -e\n" - "cat > /tmp/probe.js <<'PROBE_EOF'\n" - f"{_PROBE_JS}\n" - "PROBE_EOF\n" - "node /tmp/probe.js\n" - ) - result = bottle.exec(script) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual( - 0, result.returncode, - f"exec wrapper failed: stdout={result.stdout!r} stderr={result.stderr!r}", - ) - # raw.githubusercontent.com IS in fixture_minimal's effective - # allowlist (baked-in default). Pipelock must answer the CONNECT - # with 200 Connection Established. - self.assertIn( - "connect=200", result.stdout, - f"pipelock should have tunneled to raw.githubusercontent.com; got: {result.stdout!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_pipelock_allows_normal_https.py b/tests/integration/test_pipelock_allows_normal_https.py deleted file mode 100644 index 8342512..0000000 --- a/tests/integration/test_pipelock_allows_normal_https.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Integration: with pipelock's tls_interception enabled (PRD 0006), -a clean HTTPS GET to an allowlisted host succeeds end-to-end through -the bumped tunnel. - -Complement to test_pipelock_blocks_secret_https_post — together they -pin pipelock's two paths (block on body match, allow on clean -traffic). This test is also the implicit TLS-trust check: if -provision_ca had failed to install pipelock's CA into the agent's -trust store, curl would have rejected the bumped leaf cert and the -fetch would have failed before any HTTP response could come back.""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from tests._docker import skip_unless_docker -from tests.fixtures import fixture_minimal - - -# raw.githubusercontent.com is in the baked-in DEFAULT_ALLOWLIST. -# `git`'s own README on the master branch is a long-lived raw file -# (~3 KB) that any CI runner with internet can fetch. -_TARGET_URL = "https://raw.githubusercontent.com/git/git/master/README.md" - - -@skip_unless_docker() -class TestPipelockAllowsNormalHttps(unittest.TestCase): - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_https_get_to_allowed_host_succeeds(self): - backend = get_bottle_backend() - stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) - try: - spec = BottleSpec( - manifest=fixture_minimal(), - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - script = ( - "set -eu\n" - 'curl --proxy "$HTTPS_PROXY" -s --max-time 10 \\\n' - " -w 'status=%{http_code}\\n' \\\n" - " -o /tmp/probe-body.txt \\\n" - f" {_TARGET_URL}\n" - 'echo "len=$(wc -c < /tmp/probe-body.txt)"\n' - ) - result = bottle.exec(script) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual( - 0, result.returncode, - f"exec wrapper failed: stdout={result.stdout!r} stderr={result.stderr!r}", - ) - # 200 from the upstream (pipelock forwarded after the body - # scan passed). If curl had failed the bumped-cert trust - # check, the exit code or status would be non-200 here. - self.assertIn( - "status=200", result.stdout, - f"expected 200 from raw.githubusercontent.com; got: {result.stdout!r}", - ) - # The git README is ~3 KB. Anything substantially non-zero - # proves the response body actually transferred — i.e. the - # CONNECT tunnel + bumped TLS + body forwarding all worked. - self.assertNotIn( - "len=0\n", result.stdout, - f"response body was empty: {result.stdout!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_pipelock_apply.py b/tests/integration/test_pipelock_apply.py deleted file mode 100644 index e8670ab..0000000 --- a/tests/integration/test_pipelock_apply.py +++ /dev/null @@ -1,210 +0,0 @@ -"""Integration: drive `apply_allowlist_change` against a real -pipelock sidecar (PRD 0015). - -Brings up a real pipelock container via direct `docker run` (the -old `.start()` helper went away in PRD 0024 chunk 3), calls -apply_allowlist_change to swap the api_allowlist, restarts -pipelock, and verifies the running container now serves the new -yaml. - -The hot-reload code path under test (apply_allowlist_change, -fetch_current_yaml, fetch_current_allowlist) is unchanged from -PRD 0015 — only the test's bringup helper moved. - -Setup uses pipelock_tls_init which bind-mounts a host path into a -one-shot pipelock container — that doesn't work in DinD, so the -test skips under GITEA_ACTIONS. -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -import tempfile -import time -import unittest -from pathlib import Path - -from bot_bottle.backend.docker.bottle_state import pipelock_state_dir -from bot_bottle.backend.docker.network import ( - network_create_egress, - network_create_internal, - network_remove, -) -from bot_bottle.pipelock import ( - PIPELOCK_CA_CERT_IN_CONTAINER, - PIPELOCK_CA_KEY_IN_CONTAINER, -) -from bot_bottle.backend.docker.pipelock import pipelock_tls_init -from bot_bottle.pipelock import PipelockProxy -from bot_bottle.backend.docker.pipelock_apply import ( - PipelockApplyError, - apply_allowlist_change, - fetch_current_allowlist, - fetch_current_yaml, -) -from bot_bottle.backend.docker.sidecar_bundle import ( - SIDECAR_BUNDLE_IMAGE, - sidecar_bundle_container_name, -) -from bot_bottle.yaml_subset import parse_yaml_subset -from tests._docker import skip_unless_docker -from tests.fixtures import fixture_minimal - - -@skip_unless_docker() -@unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: pipelock_tls_init uses a host bind mount " - "that doesn't share fs with the runner container", -) -class TestPipelockApply(unittest.TestCase): - def setUp(self): - self.slug = f"cb-test-pla-{os.getpid()}-{int(time.time())}" - self.sidecar_name = "" - self.internal_net = "" - self.egress_net = "" - self.work_dir = Path(tempfile.mkdtemp(prefix="pipelock-apply.")) - - def tearDown(self): - if self.sidecar_name: - subprocess.run( - ["docker", "rm", "-f", self.sidecar_name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - for n in (self.internal_net, self.egress_net): - if n: - network_remove(n) - shutil.rmtree(self.work_dir, ignore_errors=True) - # Clean up the per-slug state dir under ~/.bot-bottle/state/ - # (apply_allowlist_change writes there; _bring_up calls - # proxy.prepare with the same path so the bind-mount and the - # hot-reload write target stay coherent). - shutil.rmtree(pipelock_state_dir(self.slug), ignore_errors=True) - - def _bring_up(self) -> None: - """Brings up the bundle image with only the pipelock daemon - selected. The bundle's Python supervisor is PID 1, which is - what apply_allowlist_change targets via `docker kill - --signal USR1` — pipelock alone as PID 1 wouldn't survive - SIGUSR1 (default disposition = terminate). This shape is - what runs in production minus the other three daemons. - - The yaml stages into the production-real - `pipelock_state_dir(slug)` (not a private temp dir) so the - bind-mount target matches what `apply_allowlist_change` - writes to — otherwise the hot-reload would write to a - nowhere-mounted host path and the container would never see - the updated config.""" - state_dir = pipelock_state_dir(self.slug) - state_dir.mkdir(parents=True, exist_ok=True) - prep = PipelockProxy().prepare( - fixture_minimal().bottles["dev"], self.slug, state_dir, - ) - self.internal_net = network_create_internal(self.slug) - self.egress_net = network_create_egress(self.slug) - ca_cert_host, ca_key_host = pipelock_tls_init(state_dir) - - # Ensure the bundle image is built. compose normally builds - # this lazily; we go through `docker run` here so we have to - # do it ourselves. Idempotent — cached layers make repeats - # fast. - repo_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) - subprocess.run( - ["docker", "build", - "-t", SIDECAR_BUNDLE_IMAGE, - "-f", "Dockerfile.sidecars", "."], - cwd=repo_root, check=True, capture_output=True, - ) - - self.sidecar_name = sidecar_bundle_container_name(self.slug) - subprocess.run( - ["docker", "create", - "--name", self.sidecar_name, - "--network", self.internal_net, - "-e", "BOT_BOTTLE_SIDECAR_DAEMONS=pipelock", - "-v", f"{prep.yaml_path}:/etc/pipelock.yaml:ro", - "-v", f"{ca_cert_host}:{PIPELOCK_CA_CERT_IN_CONTAINER}:ro", - "-v", f"{ca_key_host}:{PIPELOCK_CA_KEY_IN_CONTAINER}:ro", - SIDECAR_BUNDLE_IMAGE], - check=True, capture_output=True, - ) - subprocess.run( - ["docker", "network", "connect", self.egress_net, self.sidecar_name], - check=True, capture_output=True, - ) - subprocess.run( - ["docker", "start", self.sidecar_name], - check=True, capture_output=True, - ) - # Wait until fetch_current_yaml succeeds — it's a docker cp - # which works on a started-but-not-yet-ready pipelock, so - # this is more of a "container exists" probe than a - # readiness one; the hot-reload tests below tolerate - # pipelock briefly being slow to serve. - deadline = time.monotonic() + 15.0 - while time.monotonic() < deadline: - try: - fetch_current_yaml(self.slug) - return - except PipelockApplyError: - pass - time.sleep(0.25) - raise AssertionError("pipelock sidecar never became reachable") - - def _wait_for_yaml(self, contains: str, *, deadline_s: float = 15.0) -> str: - """Poll docker exec until /etc/pipelock.yaml contains `contains`, - returning the yaml. Used to bridge the docker-restart window.""" - deadline = time.monotonic() + deadline_s - while time.monotonic() < deadline: - try: - yaml = fetch_current_yaml(self.slug) - if contains in yaml: - return yaml - except PipelockApplyError: - pass - time.sleep(0.25) - self.fail(f"never saw {contains!r} in /etc/pipelock.yaml") - - def test_apply_swaps_api_allowlist(self): - self._bring_up() - - initial_yaml = fetch_current_yaml(self.slug) - # fixture_minimal yields the baked-in DEFAULT_ALLOWLIST in - # pipelock.py; api.anthropic.com is in there. - self.assertIn("api.anthropic.com", initial_yaml) - - new_content = "api.anthropic.com\nnew-host.example\n" - before, after = apply_allowlist_change(self.slug, new_content) - self.assertIn("api.anthropic.com", before) - self.assertNotIn("new-host.example", before) - self.assertIn("new-host.example", after) - - updated = self._wait_for_yaml("new-host.example") - cfg = parse_yaml_subset(updated) - self.assertIn("new-host.example", cfg["api_allowlist"]) # type: ignore[operator] - self.assertIn("api.anthropic.com", cfg["api_allowlist"]) # type: ignore[operator] - # tls_interception block (set up by the production prepare - # via pipelock_build_config) is preserved across the swap. - self.assertIn("tls_interception", cfg) - - def test_apply_with_invalid_host_raises(self): - self._bring_up() - with self.assertRaises(PipelockApplyError): - apply_allowlist_change(self.slug, "host with space.example\n") - - def test_fetch_current_allowlist_renders_one_per_line(self): - self._bring_up() - listing = fetch_current_allowlist(self.slug) - self.assertTrue(listing.endswith("\n")) - self.assertIn("api.anthropic.com\n", listing) - - def test_apply_against_missing_sidecar_raises(self): - # Don't bring up — the slug points at nothing. - with self.assertRaises(PipelockApplyError): - apply_allowlist_change(self.slug, "x.example\n") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_pipelock_block_node.py b/tests/integration/test_pipelock_block_node.py deleted file mode 100644 index 01671b6..0000000 --- a/tests/integration/test_pipelock_block_node.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Integration: a Node script run inside a launched bottle, hitting -a host outside the pipelock allowlist, is blocked. - -End-to-end: drives `BottleBackend.prepare → launch` so the real -image build, network plumbing, and pipelock sidecar are all in the -loop. Inside the bottle, a Node script forms an HTTP forward-proxy -request (absolute-URI path) to `example.com` via `$HTTPS_PROXY`. The -fixture's effective allowlist contains only the baked-in defaults, -so pipelock must refuse to forward. -""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from tests._docker import skip_unless_docker -from tests.fixtures import fixture_minimal - - -# Node's stdlib http does not respect HTTPS_PROXY on its own; this -# script builds the forward-proxy request shape by hand so the test -# is asserting on pipelock's allowlist decision, not on whatever -# proxy-env auto-detection a Node release happens to ship. -# -# Output contract (parsed by the test): -# - "status=" when the proxy returns an HTTP response -# - "error= " on a transport-level failure -# - "timeout" on a hung request -_PROBE_JS = r""" -const http = require('http'); -const proxy = new URL(process.env.HTTPS_PROXY); -const req = http.request({ - host: proxy.hostname, - port: proxy.port, - method: 'GET', - path: 'http://example.com/', - headers: { Host: 'example.com' }, -}, (res) => { - res.resume(); - res.on('end', () => { - console.log('status=' + res.statusCode); - process.exit(0); - }); -}); -req.on('error', (e) => { - console.log('error=' + (e.code || '') + ' ' + e.message); - process.exit(0); -}); -req.setTimeout(5000, () => { - console.log('timeout'); - req.destroy(); -}); -req.end(); -""" - - -@skip_unless_docker() -class TestPipelockBlocksNode(unittest.TestCase): - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_node_request_to_blocked_host_is_rejected(self): - backend = get_bottle_backend() - stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) - try: - spec = BottleSpec( - manifest=fixture_minimal(), - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - script = ( - "set -e\n" - "cat > /tmp/probe.js <<'PROBE_EOF'\n" - f"{_PROBE_JS}\n" - "PROBE_EOF\n" - "node /tmp/probe.js\n" - ) - result = bottle.exec(script) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual( - 0, result.returncode, - f"exec wrapper failed: stdout={result.stdout!r} stderr={result.stderr!r}", - ) - # The probe always prints exactly one signal line. If it - # doesn't, the script failed in a way the test doesn't - # understand and the surrounding assertions would be - # ambiguous. - self.assertTrue( - "status=" in result.stdout or "error=" in result.stdout or "timeout" in result.stdout, - f"probe produced no recognized output: {result.stdout!r}", - ) - # The core invariant: example.com is NOT in fixture_minimal's - # effective allowlist (only the baked-in defaults), so the - # proxy must not have forwarded a successful response. - self.assertNotIn( - "status=200", result.stdout, - "example.com is outside the allowlist; pipelock should not have forwarded a 200", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_pipelock_blocks_secret_https_post.py b/tests/integration/test_pipelock_blocks_secret_https_post.py deleted file mode 100644 index b1d1320..0000000 --- a/tests/integration/test_pipelock_blocks_secret_https_post.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Integration: with pipelock's tls_interception enabled (PRD 0006), -a credential POST sent over HTTPS is blocked by pipelock's body-scan -layer — closing the gap that motivated this PRD. - -End-to-end: drives `BottleBackend.prepare → launch` so the real -image build, network plumbing, pipelock_tls_init, sidecar bring-up, -and provision_ca (CA install in the agent's trust store) are all in -the loop. The probe is a single `curl --proxy "$HTTPS_PROXY" -X POST -... https://raw.githubusercontent.com/...` — curl natively does -CONNECT through the proxy, the agent's trust store now contains -pipelock's per-bottle CA so curl trusts pipelock's bumped leaf, and -pipelock sees the decrypted body and returns its known -`blocked: request body contains secret: ` 403. - -The host has to be allowlisted (so the CONNECT is accepted) but must -not opt into `pipelock.tls_passthrough` (so the body actually gets -scanned). This probe targets `raw.githubusercontent.com`, which is on -the baked allowlist and intercepted+scanned like any non-passthrough -host.""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.manifest import Manifest -from tests._docker import skip_unless_docker - - -# Synthetic value shaped like a GitHub Personal Access Token; not a -# real credential. Carried into the bottle as an env var so the -# probe shell can read it via $FAKE_TOKEN without ever interpolating -# the value on the bash `bottle.exec` argv. -_FAKE_TOKEN = "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ" - - -@skip_unless_docker() -class TestPipelockBlocksSecretHttpsPost(unittest.TestCase): - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_https_post_with_credential_body_is_blocked(self): - manifest = Manifest.from_json_obj({ - "bottles": { - "dev": {"env": {"FAKE_TOKEN": _FAKE_TOKEN}}, - }, - "agents": { - "demo": {"skills": [], "prompt": "", "bottle": "dev"}, - }, - }) - backend = get_bottle_backend() - stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) - try: - spec = BottleSpec( - manifest=manifest, - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - script = ( - "set -eu\n" - 'curl --proxy "$HTTPS_PROXY" -s --max-time 8 \\\n' - " -w 'status=%{http_code}\\n' \\\n" - " -o /tmp/probe-body.txt \\\n" - ' -X POST -d "token=$FAKE_TOKEN" \\\n' - " https://raw.githubusercontent.com/dlp-probe\n" - 'echo "body=$(head -c 200 /tmp/probe-body.txt)"\n' - ) - result = bottle.exec(script) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual( - 0, result.returncode, - f"exec wrapper failed: stdout={result.stdout!r} stderr={result.stderr!r}", - ) - # Pipelock's body-scan block returns 403 with a plain-text - # body starting `blocked: ` (pinned empirically; see - # tests/unit/test_mitmproxy_verdict.py for the - # corresponding-fingerprint test, retained from PR #8 as - # general pipelock-block-shape coverage). - self.assertIn( - "status=403", result.stdout, - f"expected 403 from pipelock; got: {result.stdout!r}", - ) - self.assertIn( - "body=blocked: ", result.stdout, - f"expected pipelock block body; got: {result.stdout!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_pipelock_blocks_secret_post.py b/tests/integration/test_pipelock_blocks_secret_post.py deleted file mode 100644 index a97ff33..0000000 --- a/tests/integration/test_pipelock_blocks_secret_post.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Integration: pipelock blocks a POST whose body carries a -recognized credential pattern, even when the host is on the -allowlist. - -End-to-end companion to the block / allow node tests. The manifest -carries a literal env var whose value matches pipelock's DLP rules. -A Node script POSTs that value to an allowlisted host via plain -HTTP forward proxy (absolute-URI form) so pipelock can scan the -body — routing the same request over CONNECT would tunnel TLS -opaquely and the DLP layer would have nothing to see. The 403 -return from pipelock isolates the body-scan layer as the active -control, distinct from the host-allowlist decision the other two -tests pin down. -""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.manifest import Manifest -from tests._docker import skip_unless_docker - - -# Synthetic value shaped like a GitHub Personal Access Token -# (`ghp_` + 36 alnum chars). Not a real token; the only relevant -# property is that pipelock's default DLP rules recognize the -# shape. Kept obviously dummy so a stray grep can't mistake it -# for a real credential. -_FAKE_TOKEN = "ghp_aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV3wX4yZ" - - -# Output contract (parsed by the test): -# - "status=" proxy answered with an HTTP response -# - "error= " transport-level failure -# - "timeout" request hung -_PROBE_JS = r""" -const http = require('http'); -const proxy = new URL(process.env.HTTPS_PROXY); -const body = 'token=' + process.env.FAKE_TOKEN; -const req = http.request({ - host: proxy.hostname, - port: proxy.port, - method: 'POST', - // Absolute-URI form: pipelock acts as a plain HTTP forward proxy - // and the body is visible to its DLP scanner. CONNECT would - // tunnel TLS bytes that pipelock can't see into. - path: 'http://api.anthropic.com/dlp-probe', - headers: { - Host: 'api.anthropic.com', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Content-Length': Buffer.byteLength(body), - }, -}, (res) => { - res.resume(); - res.on('end', () => { - console.log('status=' + res.statusCode); - process.exit(0); - }); -}); -req.on('error', (e) => { - console.log('error=' + (e.code || '') + ' ' + e.message); - process.exit(0); -}); -req.setTimeout(5000, () => { - console.log('timeout'); - req.destroy(); -}); -req.write(body); -req.end(); -""" - - -@skip_unless_docker() -class TestPipelockBlocksSecretPost(unittest.TestCase): - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_post_with_credential_body_is_blocked(self): - manifest = Manifest.from_json_obj({ - "bottles": { - "dev": {"env": {"FAKE_TOKEN": _FAKE_TOKEN}}, - }, - "agents": { - "demo": {"skills": [], "prompt": "", "bottle": "dev"}, - }, - }) - backend = get_bottle_backend() - stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) - try: - spec = BottleSpec( - manifest=manifest, - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - script = ( - "set -e\n" - "cat > /tmp/probe.js <<'PROBE_EOF'\n" - f"{_PROBE_JS}\n" - "PROBE_EOF\n" - "node /tmp/probe.js\n" - ) - result = bottle.exec(script) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual( - 0, result.returncode, - f"exec wrapper failed: stdout={result.stdout!r} stderr={result.stderr!r}", - ) - # api.anthropic.com is on the baked-in allowlist, so the - # host-allowlist layer would have let this through. Pipelock's - # DLP body-scan layer must catch the credential pattern and - # answer 403; any other code means the body reached the - # upstream. - self.assertIn( - "status=403", result.stdout, - f"pipelock DLP should have blocked the credential POST; got: {result.stdout!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/integration/test_pipelock_llm_passthrough.py b/tests/integration/test_pipelock_llm_passthrough.py deleted file mode 100644 index f2b008d..0000000 --- a/tests/integration/test_pipelock_llm_passthrough.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Integration: route-owned `pipelock.tls_passthrough` renders into -pipelock's `tls_interception.passthrough_domains`, so request bodies -that would otherwise trip the body-scan layer are not inspected and the -request reaches the provider TLS endpoint. - -Probe: POST the canonical zero-entropy 12-word BIP-39 mnemonic -(`abandon` × 11 + `about`) — checksum-valid by construction — to -`https://api.anthropic.com/v1/messages`. With the route policy, -pipelock relays the CONNECT opaquely and the upstream replies with -whatever it likes (401/4xx from Anthropic for an unauthenticated junk -POST). We assert that the verdict is NOT pipelock's block. -""" - -from __future__ import annotations - -import os -import shutil -import tempfile -import unittest -from pathlib import Path - -from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.manifest import Manifest -from tests._docker import skip_unless_docker - - -# Canonical BIP-39 12-word test mnemonic. Valid SHA-256 checksum — -# pipelock's seed-phrase scanner (default `verify_checksum: true`) -# fires on this exact string if it ever sees the cleartext body. -_BIP39_PHRASE = ( - "abandon abandon abandon abandon abandon abandon " - "abandon abandon abandon abandon abandon about" -) - - -@skip_unless_docker() -class TestPipelockLlmPassthrough(unittest.TestCase): - @unittest.skipIf( - os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: docker socket mount topology breaks " - "in-process visibility of networks created on the host daemon", - ) - def test_bip39_body_to_anthropic_is_not_blocked(self): - manifest = Manifest.from_json_obj({ - "bottles": { - "dev": { - "env": {"SEED": _BIP39_PHRASE}, - "egress": {"routes": [{ - "host": "api.anthropic.com", - "pipelock": {"tls_passthrough": True}, - }]}, - }, - }, - "agents": { - "demo": {"skills": [], "prompt": "", "bottle": "dev"}, - }, - }) - backend = get_bottle_backend() - stage_dir = Path(tempfile.mkdtemp(prefix="cb-test-stage.")) - try: - spec = BottleSpec( - manifest=manifest, - agent_name="demo", - copy_cwd=False, - user_cwd=str(stage_dir), - ) - plan = backend.prepare(spec, stage_dir=stage_dir) - with backend.launch(plan) as bottle: - script = ( - "set -eu\n" - 'curl --proxy "$HTTPS_PROXY" -s --max-time 10 \\\n' - " -w 'status=%{http_code}\\n' \\\n" - " -o /tmp/probe-body.txt \\\n" - ' -X POST -H "content-type: application/json" \\\n' - ' --data "{\\"phrase\\": \\"$SEED\\"}" \\\n' - " https://api.anthropic.com/v1/messages\n" - 'echo "body=$(head -c 200 /tmp/probe-body.txt)"\n' - ) - result = bottle.exec(script) - finally: - shutil.rmtree(stage_dir, ignore_errors=True) - - self.assertEqual( - 0, result.returncode, - f"exec wrapper failed: stdout={result.stdout!r} " - f"stderr={result.stderr!r}", - ) - # The pipelock block verdict starts with `blocked: ` in the - # body. Anything else (auth error, 401, 4xx from Anthropic) is - # an acceptable outcome — it means the body was NOT inspected - # by the proxy and the request was relayed to the upstream - # TLS endpoint. - self.assertNotIn( - "body=blocked: ", result.stdout, - f"unexpected pipelock body-scan block on api.anthropic.com; " - f"expected passthrough to skip MITM. got: {result.stdout!r}", - ) - self.assertNotIn( - "BIP-39", result.stdout, - f"BIP-39 verdict should never appear for api.anthropic.com " - f"requests under tls_interception.passthrough_domains; " - f"got: {result.stdout!r}", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_pipelock_allowlist.py b/tests/unit/test_pipelock_allowlist.py deleted file mode 100644 index 23383e1..0000000 --- a/tests/unit/test_pipelock_allowlist.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Unit: pipelock_effective_allowlist — pipelock's allowlist -mirrors manifest-declared egress routes. Git upstreams declared in -`bottle.git` don't contribute; they flow through the per-agent -git-gate (PRD 0008).""" - -import unittest - -from bot_bottle.agent_provider import CODEX_HOST_CREDENTIAL_HOSTS -from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute -from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import ( - pipelock_effective_allowlist, - pipelock_effective_ssrf_ip_allowlist, - pipelock_effective_tls_passthrough, -) - - -def _bottle(spec): # type: ignore - return Manifest.from_json_obj({ - "bottles": {"dev": spec}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] - - -def _routes(routes): # type: ignore - return {"egress": {"routes": routes}} - - -class TestEffectiveAllowlist(unittest.TestCase): - def test_empty_without_any_manifest_routes(self): - eff = pipelock_effective_allowlist(_bottle({})) - self.assertEqual([], eff) - - def test_sorted_and_deduped(self): - eff = pipelock_effective_allowlist(_bottle(_routes([ - {"host": "api.anthropic.com", - "auth": {"scheme": "Bearer", "token_ref": "T"}}, - ]))) - self.assertEqual(len(eff), len(set(eff))) - self.assertEqual(eff, sorted(eff)) - - -class TestAllowlistWithRoutes(unittest.TestCase): - def test_manifest_route_hosts_present(self): - eff = pipelock_effective_allowlist(_bottle(_routes([ - {"host": "registry.npmjs.org", - "auth": {"scheme": "Bearer", "token_ref": "N"}}, - {"host": "api.github.com", - "auth": {"scheme": "Bearer", "token_ref": "G"}}, - ]))) - self.assertIn("registry.npmjs.org", eff) - self.assertIn("api.github.com", eff) - - def test_no_baked_defaults_alongside_manifest_routes(self): - eff = pipelock_effective_allowlist(_bottle(_routes([ - {"host": "x.example", - "auth": {"scheme": "Bearer", "token_ref": "T"}}, - ]))) - self.assertEqual(["x.example"], eff) - - def test_egress_hostname_NOT_in_pipelock_allowlist(self): - # The agent never dials egress via the proxy mechanism - # — it IS the proxy. Pipelock receives upstream hostnames - # from egress's CONNECT requests, not the - # `egress` hostname itself. - eff = pipelock_effective_allowlist(_bottle(_routes([ - {"host": "x.example", - "auth": {"scheme": "Bearer", "token_ref": "T"}}, - ]))) - self.assertNotIn("egress", eff) - - def test_supervise_hostname_auto_added_when_supervise_enabled(self): - eff = pipelock_effective_allowlist(_bottle({"supervise": True})) - self.assertIn("supervise", eff) - - def test_supervise_hostname_NOT_added_when_disabled(self): - eff = pipelock_effective_allowlist(_bottle({})) - self.assertNotIn("supervise", eff) - eff_explicit = pipelock_effective_allowlist(_bottle({"supervise": False})) - self.assertNotIn("supervise", eff_explicit) - - def test_path_allowlist_does_not_affect_pipelock_allowlist(self): - # path_allowlist is enforced by egress, not pipelock. - # Pipelock only sees the upstream hostname; the path filter - # has already passed (or 403'd) at egress. - eff = pipelock_effective_allowlist(_bottle(_routes([ - {"host": "github.com", "path_allowlist": ["/x/", "/y/"]}, - ]))) - self.assertIn("github.com", eff) - for entry in eff: - self.assertFalse(entry.startswith("/")) - - -class TestTlsPassthrough(unittest.TestCase): - def test_default_empty(self): - passthrough = pipelock_effective_tls_passthrough(_bottle({})) - self.assertEqual([], passthrough) - - def test_route_hosts_not_added_to_passthrough_by_default(self): - passthrough = pipelock_effective_tls_passthrough(_bottle(_routes([ - {"host": "api.github.com", - "auth": {"scheme": "Bearer", "token_ref": "G"}}, - {"host": "registry.npmjs.org", - "auth": {"scheme": "Bearer", "token_ref": "N"}}, - ]))) - self.assertEqual([], passthrough) - - def test_route_policy_adds_tls_passthrough(self): - passthrough = pipelock_effective_tls_passthrough(_bottle(_routes([ - {"host": "api.openai.com", - "auth": {"scheme": "Bearer", "token_ref": "O"}, - "pipelock": {"tls_passthrough": True}}, - {"host": "api.github.com", - "auth": {"scheme": "Bearer", "token_ref": "G"}}, - ]))) - self.assertEqual(["api.openai.com"], passthrough) - - def test_forward_host_credentials_passes_through_codex_hosts(self): - # Egress injects the host bearer on the Codex API hosts; pipelock - # must pass them through or its header DLP blocks the injected JWT - # ("request header contains secret"). Provider routes carry - # tls_passthrough=True; pipelock reads this via egress_routes_for_bottle. - provider_routes = tuple( - EgressRoute( - host=host, - auth_scheme="Bearer", - token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF, - tls_passthrough=True, - ) - for host in CODEX_HOST_CREDENTIAL_HOSTS - ) - passthrough = pipelock_effective_tls_passthrough( - _bottle({}), provider_routes, - ) - self.assertEqual(["api.openai.com", "chatgpt.com"], passthrough) - - def test_no_codex_passthrough_without_provider_routes(self): - passthrough = pipelock_effective_tls_passthrough(_bottle({ - "agent_provider": {"template": "codex"}, - })) - self.assertEqual([], passthrough) - - -class TestSsrfIpAllowlist(unittest.TestCase): - def test_default_empty(self): - allowlist = pipelock_effective_ssrf_ip_allowlist(_bottle({})) - self.assertEqual([], allowlist) - - def test_route_policy_adds_ssrf_ip_allowlist(self): - allowlist = pipelock_effective_ssrf_ip_allowlist(_bottle(_routes([ - {"host": "gitea.dideric.is", - "auth": {"scheme": "token", "token_ref": "G"}, - "pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]}}, - ]))) - self.assertEqual(["100.78.141.42/32"], allowlist) - - def test_route_policy_merges_with_extra(self): - allowlist = pipelock_effective_ssrf_ip_allowlist( - _bottle(_routes([ - {"host": "gitea.dideric.is", - "pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]}}, - ])), - ("172.20.0.0/16",), - ) - self.assertEqual(["100.78.141.42/32", "172.20.0.0/16"], allowlist) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_pipelock_apply.py b/tests/unit/test_pipelock_apply.py deleted file mode 100644 index 8a35729..0000000 --- a/tests/unit/test_pipelock_apply.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Unit: pipelock_apply parsers + helpers (PRD 0015 Phase 1). - -docker exec / cp / restart paths are covered by the integration -test in Phase 4. Here we cover the host-side parsing + yaml roundtrip. -""" - -import unittest - -from bot_bottle.backend.docker.pipelock_apply import ( - PipelockApplyError, - parse_allowlist_content, - render_allowlist_content, -) -from bot_bottle.pipelock import pipelock_render_yaml -from bot_bottle.yaml_subset import parse_yaml_subset - - -class TestParseAllowlistContent(unittest.TestCase): - def test_one_per_line(self): - self.assertEqual( - ["a.example", "b.example"], - parse_allowlist_content("a.example\nb.example\n"), - ) - - def test_blank_lines_ignored(self): - self.assertEqual( - ["a", "b"], - parse_allowlist_content("a\n\n \nb\n"), - ) - - def test_comments_ignored(self): - self.assertEqual( - ["a"], - parse_allowlist_content("# top comment\na\n# trailing\n"), - ) - - def test_invalid_char_raises(self): - with self.assertRaises(PipelockApplyError) as cm: - parse_allowlist_content("host with space\n") - self.assertIn("disallowed characters", str(cm.exception)) - - def test_empty_input_returns_empty_list(self): - self.assertEqual([], parse_allowlist_content("")) - - -class TestRenderAllowlistContent(unittest.TestCase): - def test_one_per_line_with_trailing_newline(self): - self.assertEqual("a\nb\n", render_allowlist_content(["a", "b"])) - - def test_empty_renders_empty(self): - self.assertEqual("", render_allowlist_content([])) - - def test_roundtrip(self): - original = ["api.example.com", "ghcr.io", "example.org"] - self.assertEqual( - original, - parse_allowlist_content(render_allowlist_content(original)), - ) - - -class TestYamlRoundtripPreservesPipelockFields(unittest.TestCase): - """The apply path parses the running pipelock.yaml, swaps - api_allowlist, re-renders. Verify that parse(render(cfg)) == - cfg for the fields pipelock_render_yaml emits — otherwise - the apply would silently drop config.""" - - def test_minimal_config_roundtrips(self): - cfg = { - "version": 1, - "mode": "strict", - "enforce": True, - "api_allowlist": ["a.example", "b.example"], - "forward_proxy": {"enabled": True}, - "dlp": {"include_defaults": True, "scan_env": True}, - "request_body_scanning": {"action": "block"}, - } - rendered = pipelock_render_yaml(cfg) # type: ignore - parsed = parse_yaml_subset(rendered) - self.assertEqual(["a.example", "b.example"], parsed["api_allowlist"]) - self.assertEqual(1, parsed["version"]) - self.assertEqual("strict", parsed["mode"]) - self.assertEqual(True, parsed["enforce"]) - - def test_swap_allowlist_then_render_preserves_other_fields(self): - cfg = { - "version": 1, - "mode": "strict", - "enforce": True, - "api_allowlist": ["old.example"], - "forward_proxy": {"enabled": True}, - "dlp": {"include_defaults": True, "scan_env": True}, - "request_body_scanning": {"action": "block"}, - "tls_interception": { - "enabled": True, - "ca_cert": "/etc/pipelock-ca.pem", - "ca_key": "/etc/pipelock-ca-key.pem", - "passthrough_domains": ["api.anthropic.com"], - }, - } - parsed = parse_yaml_subset(pipelock_render_yaml(cfg)) # type: ignore - parsed["api_allowlist"] = ["new.example"] - rerendered = pipelock_render_yaml(parsed) - roundtripped = parse_yaml_subset(rerendered) - self.assertEqual(["new.example"], roundtripped["api_allowlist"]) - # Non-allowlist fields stay put. - self.assertEqual("strict", roundtripped["mode"]) - tls = roundtripped["tls_interception"] - self.assertIsInstance(tls, dict) - assert isinstance(tls, dict) # type-narrowing - self.assertEqual("/etc/pipelock-ca.pem", tls["ca_cert"]) - self.assertEqual(["api.anthropic.com"], tls["passthrough_domains"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_pipelock_yaml.py b/tests/unit/test_pipelock_yaml.py deleted file mode 100644 index 52e6dd8..0000000 --- a/tests/unit/test_pipelock_yaml.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Unit: pipelock config building and YAML rendering. - -`pipelock_build_config` produces the structured config dict pipelock -will load; tests assert on that dict so they don't break on cosmetic -YAML changes. A small set of tests still hit the rendered output for -properties that only make sense on disk (file mode, no-secret-leakage). -""" - -import os -import tempfile -import unittest -from pathlib import Path -from typing import cast - -from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import ( - DEFAULT_TLS_PASSTHROUGH, - PipelockProxy, - pipelock_build_config, - pipelock_render_yaml, -) -from bot_bottle.yaml_subset import parse_yaml_subset -from tests.fixtures import fixture_minimal - - -class TestBuildConfig(unittest.TestCase): - def test_minimal_shape(self): - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - self.assertEqual("strict", cfg["mode"]) - self.assertEqual(True, cfg["enforce"]) - self.assertEqual({"enabled": True}, cfg["forward_proxy"]) - self.assertEqual( - {"include_defaults": True, "scan_env": True}, cfg["dlp"] - ) - # Body-scan action is hard-coded "block" in pipelock_build_config. - # `scan_headers: True` + `header_mode: "all"` close the - # header-shape exfil gap surfaced by PRD 0022 attack 3. - self.assertEqual( - { - "action": "block", - "scan_headers": True, - "header_mode": "all", - }, - cfg["request_body_scanning"], - ) - # No provider defaults are injected implicitly. - self.assertEqual([], cast(list[str], cfg["api_allowlist"])) - # pipelock has no SSH carve-outs at all — neither - # trusted_domains nor ssrf are emitted from bottle data. - self.assertNotIn("trusted_domains", cfg) - self.assertNotIn("ssrf", cfg) - # Without CA paths, the tls_interception block is omitted — - # pipelock falls back to its built-in default of `enabled: false`. - self.assertNotIn("tls_interception", cfg) - - def test_tls_interception_block_emitted_when_paths_supplied(self): - # PRD 0006: paths flow in via the platform-neutral in-container - # constants; this directly pins the dict shape. - cfg = pipelock_build_config( - fixture_minimal().bottles["dev"], - ca_cert_path="/etc/pipelock-ca.pem", - ca_key_path="/etc/pipelock-ca-key.pem", - ) - self.assertEqual( - { - "enabled": True, - "ca_cert": "/etc/pipelock-ca.pem", - "ca_key": "/etc/pipelock-ca-key.pem", - "passthrough_domains": [], - }, - cfg["tls_interception"], - ) - self.assertEqual((), DEFAULT_TLS_PASSTHROUGH) - - def test_tls_passthrough_route_policy_emits_domain(self): - bottle = Manifest.from_json_obj({ - "bottles": {"dev": {"egress": {"routes": [ - {"host": "api.openai.com", - "auth": {"scheme": "Bearer", "token_ref": "T"}, - "pipelock": {"tls_passthrough": True}}, - ]}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] - cfg = pipelock_build_config( - bottle, - ca_cert_path="/etc/pipelock-ca.pem", - ca_key_path="/etc/pipelock-ca-key.pem", - ) - tls = cast(dict[str, object], cfg["tls_interception"]) - self.assertEqual(["api.openai.com"], tls["passthrough_domains"]) - - def test_tls_interception_requires_both_paths(self): - # Half-set is a programmer error, not a silent omission. - with self.assertRaises(ValueError): - pipelock_build_config( - fixture_minimal().bottles["dev"], - ca_cert_path="/etc/pipelock-ca.pem", - ) - - def test_ssrf_block_omitted_when_no_allowlist(self): - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - self.assertNotIn("ssrf", cfg) - - def test_ssrf_block_emitted_when_allowlist_supplied(self): - # The bottle's internal Docker subnet lands here at launch - # time so sibling-sidecar traffic (172.x.x.x) doesn't trip - # pipelock's RFC1918 SSRF guard. - cfg = pipelock_build_config( - fixture_minimal().bottles["dev"], - ssrf_ip_allowlist=("172.20.0.0/16",), - ) - self.assertIn("ssrf", cfg) - self.assertEqual({"ip_allowlist": ["172.20.0.0/16"]}, cfg["ssrf"]) - - def test_ssrf_block_emitted_from_route_policy(self): - bottle = Manifest.from_json_obj({ - "bottles": {"dev": {"egress": {"routes": [ - {"host": "gitea.dideric.is", - "pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]}}, - ]}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] - cfg = pipelock_build_config(bottle) - self.assertEqual( - {"ip_allowlist": ["100.78.141.42/32"]}, - cfg["ssrf"], - ) - - def test_seed_phrase_detection_disabled_by_default(self): - # Only the broad BIP-39 detector is disabled. The rest of - # DLP remains enabled via the `dlp` and request-body sections. - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - self.assertEqual({"enabled": False}, cfg["seed_phrase_detection"]) - - def test_seed_phrase_detection_disabled_for_openai_route(self): - # OpenAI/Codex chat bodies trip pipelock's BIP-39 detector - # (12+ English words that pass the checksum). pipelock 2.3.0 - # has no per-path knob for this detector, and both `suppress` - # and `rules.disabled` only silence alerts — the block still - # fires. The only knob that actually skips the block is the - # global on/off. - from bot_bottle.manifest import Manifest - bottle = Manifest.from_json_obj({ - "bottles": {"dev": {"egress": {"routes": [ - {"host": "api.openai.com", - "auth": {"scheme": "Bearer", "token_ref": "T"}}, - ]}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] - cfg = pipelock_build_config(bottle) - self.assertEqual({"enabled": False}, cfg["seed_phrase_detection"]) - - -class TestRenderAndWrite(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 assert_render_semantics_match(self, cfg: dict[str, object]) -> None: - parsed = parse_yaml_subset(pipelock_render_yaml(cfg)) - self.assertEqual(cfg["version"], parsed["version"]) - self.assertEqual(cfg["mode"], parsed["mode"]) - self.assertEqual(cfg["enforce"], parsed["enforce"]) - parsed_allowlist = parsed["api_allowlist"] - if cfg["api_allowlist"] == [] and parsed_allowlist is None: - parsed_allowlist = [] - self.assertEqual(cfg["api_allowlist"], parsed_allowlist) - self.assertEqual(cfg["forward_proxy"], parsed["forward_proxy"]) - self.assertEqual(cfg["dlp"], parsed["dlp"]) - self.assertEqual( - cfg["request_body_scanning"], - parsed["request_body_scanning"], - ) - if "seed_phrase_detection" in cfg: - self.assertEqual( - cfg["seed_phrase_detection"], - parsed["seed_phrase_detection"], - ) - else: - self.assertNotIn("seed_phrase_detection", parsed) - - if "tls_interception" in cfg: - expected_tls = cast(dict[str, object], cfg["tls_interception"]) - actual_tls = cast(dict[str, object], parsed["tls_interception"]) - self.assertEqual(expected_tls["enabled"], actual_tls["enabled"]) - self.assertEqual(expected_tls["ca_cert"], actual_tls["ca_cert"]) - self.assertEqual(expected_tls["ca_key"], actual_tls["ca_key"]) - expected_passthrough = expected_tls["passthrough_domains"] - if expected_passthrough: - self.assertEqual( - expected_passthrough, - actual_tls["passthrough_domains"], - ) - else: - self.assertNotIn("passthrough_domains", actual_tls) - else: - self.assertNotIn("tls_interception", parsed) - - if "ssrf" in cfg: - self.assertEqual(cfg["ssrf"], parsed["ssrf"]) - else: - self.assertNotIn("ssrf", parsed) - - def test_render_emits_required_top_level_keys(self): - """One render-level smoke check: the serialized YAML is plausibly - the shape pipelock expects. We don't grep every key here — that's - what TestBuildConfig is for.""" - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - text = pipelock_render_yaml(cfg) - for required in ( - "api_allowlist:", - "forward_proxy:", - "dlp:", - "request_body_scanning:", - ): - self.assertIn(required, text) - # No ssh carve-outs in the rendered yaml. - self.assertNotIn("trusted_domains:", text) - self.assertNotIn("ssrf:", text) - - def test_render_semantics_match_minimal_config(self): - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - self.assert_render_semantics_match(cfg) - - def test_render_semantics_match_tls_with_empty_passthrough(self): - cfg = pipelock_build_config( - fixture_minimal().bottles["dev"], - ca_cert_path="/etc/pipelock-ca.pem", - ca_key_path="/etc/pipelock-ca-key.pem", - ) - self.assert_render_semantics_match(cfg) - - def test_render_semantics_match_all_optional_sections(self): - bottle = Manifest.from_json_obj({ - "bottles": {"dev": {"egress": {"routes": [ - {"host": "api.openai.com", - "pipelock": {"tls_passthrough": True}}, - {"host": "gitea.dideric.is", - "pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]}}, - ]}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] - cfg = pipelock_build_config( - bottle, - ca_cert_path="/etc/pipelock-ca.pem", - ca_key_path="/etc/pipelock-ca-key.pem", - ssrf_ip_allowlist=("172.20.0.0/16",), - ) - self.assert_render_semantics_match(cfg) - - def test_render_rejects_missing_required_key(self): - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - del cfg["mode"] - with self.assertRaisesRegex(ValueError, r"config\.mode"): - pipelock_render_yaml(cfg) - - def test_render_rejects_wrong_section_type(self): - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - cfg["dlp"] = [] - with self.assertRaisesRegex(ValueError, r"config\.dlp.*mapping"): - pipelock_render_yaml(cfg) - - def test_render_rejects_wrong_list_item_type(self): - cfg = pipelock_build_config( - fixture_minimal().bottles["dev"], - ca_cert_path="/etc/pipelock-ca.pem", - ca_key_path="/etc/pipelock-ca-key.pem", - ) - tls = cast(dict[str, object], cfg["tls_interception"]) - tls["passthrough_domains"] = ["api.openai.com", 3] - with self.assertRaisesRegex( - ValueError, r"tls_interception\.passthrough_domains", - ): - pipelock_render_yaml(cfg) - - def test_render_rejects_unsupported_top_level_section(self): - cfg = pipelock_build_config(fixture_minimal().bottles["dev"]) - cfg["trusted_domains"] = [] - with self.assertRaisesRegex(ValueError, r"config\.trusted_domains"): - pipelock_render_yaml(cfg) - - def test_prepare_writes_file_at_mode_600(self): - plan = PipelockProxy().prepare( - fixture_minimal().bottles["dev"], "demo", self.out_dir - ) - self.assertEqual(0o600, os.stat(plan.yaml_path).st_mode & 0o777) - - def test_prepare_does_not_leak_env_names_or_values(self): - manifest = Manifest.from_json_obj({ - "bottles": { - "dev": { - "env": { - "MY_SECRET": "literal-value-should-not-appear", - "ANOTHER": "?prompt-message", - }, - "egress": {"routes": [{"host": "github.com"}]}, - } - }, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) - plan = PipelockProxy().prepare( - manifest.bottles["dev"], "demo", self.out_dir - ) - content = plan.yaml_path.read_text() - self.assertNotIn("literal-value-should-not-appear", content) - self.assertNotIn("MY_SECRET", content) - self.assertNotIn("prompt-message", content) - - def test_render_emits_tls_interception_via_prepare(self): - """`PipelockProxy.prepare` plumbs the module-level in-container - CA constants through to the YAML. The block should land in the - rendered output with `enabled: true`, the configured paths, - and any route-owned passthrough domains. The actual - host-side CA generation happens in launch (not prepare), so - this test exercises only the YAML rendering.""" - bottle = Manifest.from_json_obj({ - "bottles": {"dev": {"egress": {"routes": [ - {"host": "api.openai.com", - "pipelock": {"tls_passthrough": True}}, - ]}}}, - "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }).bottles["dev"] - plan = PipelockProxy().prepare(bottle, "demo", self.out_dir) - content = plan.yaml_path.read_text() - self.assertIn("tls_interception:", content) - self.assertIn("enabled: true", content) - self.assertIn('ca_cert: "/etc/pipelock-ca.pem"', content) - self.assertIn('ca_key: "/etc/pipelock-ca-key.pem"', content) - self.assertIn("passthrough_domains:", content) - self.assertIn('- "api.openai.com"', content) - - def test_render_emits_ssrf_block_when_allowlist_given(self): - cfg = pipelock_build_config( - fixture_minimal().bottles["dev"], - ca_cert_path="/etc/pipelock-ca.pem", - ca_key_path="/etc/pipelock-ca-key.pem", - ssrf_ip_allowlist=("172.20.0.0/16",), - ) - text = pipelock_render_yaml(cfg) - self.assertIn("ssrf:", text) - self.assertIn("ip_allowlist:", text) - self.assertIn('- "172.20.0.0/16"', text) - - def test_render_emits_seed_phrase_off_by_default(self): - text = pipelock_render_yaml( - pipelock_build_config(fixture_minimal().bottles["dev"]) - ) - self.assertIn("seed_phrase_detection:", text) - self.assertIn("enabled: false", text) - - -if __name__ == "__main__": - unittest.main() -- 2.52.0 From ce8cb5f0f1955fadb8742a80472e01b59e539319 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 21:15:36 +0000 Subject: [PATCH 2/6] chore: remove pipelock from supervise plane and egress layer - Remove TOOL_PIPELOCK_BLOCK from supervise.py constants and TOOLS tuple - Remove pipelock-block tool definition from supervise_server.py - Remove _apply_pipelock_url and pipelock imports from cli/supervise.py - Strip pipelock fields (pipelock_ca_host_path, pipelock_proxy_url, tls_passthrough) from egress.py EgressPlan/EgressRoute - Remove pipelock daemon from sidecar_init.py _DAEMONS and SIGUSR1 handler --- bot_bottle/cli/supervise.py | 63 ++------------------ bot_bottle/egress.py | 39 ++++-------- bot_bottle/sidecar_init.py | 34 +---------- bot_bottle/supervise.py | 7 +-- bot_bottle/supervise_server.py | 105 +++++---------------------------- 5 files changed, 35 insertions(+), 213 deletions(-) diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index 86e6215..3bd5975 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -3,9 +3,7 @@ act on them (approve / modify / reject). Curses-based TUI; modify-then-approve shells out to $EDITOR. The approval handlers wire to the per-tool remediation engines: -PRD 0014 (egress, retargeted from cred-proxy in PRD 0017 -chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015 -(pipelock) writes the allowlist + restarts pipelock; PRD 0016 +PRD 0014 (egress) writes routes.yaml + SIGHUPs egress; PRD 0016 (capability) rebuilds the bottle Dockerfile. """ @@ -29,13 +27,6 @@ from ..backend.docker.capability_apply import ( apply_capability_change, ) from ..backend.docker.egress_apply import EgressApplyError, add_route -from ..backend.docker.pipelock_apply import ( - PipelockApplyError, - apply_allowlist_change, - fetch_current_allowlist, - parse_allowlist_content, - render_allowlist_content, -) from ..log import Die, error, info from ..supervise import ( COMPONENT_FOR_TOOL, @@ -47,7 +38,6 @@ from ..supervise import ( STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, archive_proposal, list_pending_proposals, render_diff, @@ -71,7 +61,7 @@ class QueuedProposal: # Errors any remediation engine may raise. Caught by the TUI key # handlers and surfaced in the status line so a failed apply keeps # the proposal pending rather than crashing curses. -ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) +ApplyError = (EgressApplyError, CapabilityApplyError) def discover_pending() -> list[QueuedProposal]: @@ -116,33 +106,12 @@ def _detail_lines( out.extend((" " + line, 0) for line in p.justification.splitlines() or [""]) out.extend([ ("", 0), - (_proposed_payload_label(p.tool) + ":", 0), + ("proposed file:", 0), ]) out.extend((line, 0) for line in p.proposed_file.splitlines() or [""]) - if p.tool == TOOL_PIPELOCK_BLOCK: - host = _failed_url_host(p.proposed_file) - if host: - out.append(("", 0)) - out.append((host, green_attr)) return out -def _failed_url_host(url: str) -> str: - """Best-effort hostname extraction from a pipelock-block proposal.""" - import urllib.parse - - try: - return urllib.parse.urlsplit(url.strip()).hostname or "" - except ValueError: - return "" - - -def _proposed_payload_label(tool: str) -> str: - if tool == TOOL_PIPELOCK_BLOCK: - return "failed URL" - return "proposed file" - - def _suffix_for_tool(tool: str) -> str: if tool == TOOL_CAPABILITY_BLOCK: return ".dockerfile" @@ -167,10 +136,6 @@ def approve( diff_before, diff_after = add_route( qp.proposal.bottle_slug, file_to_apply, ) - elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK: - diff_before, diff_after = _apply_pipelock_url( - qp.proposal.bottle_slug, file_to_apply, - ) elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK: _meta = read_metadata(qp.proposal.bottle_slug) if _meta is not None and not _meta.compose_project: @@ -210,23 +175,6 @@ def reject(qp: QueuedProposal, *, reason: str) -> None: _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") -def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]: - """Merge a pipelock-block failed URL's host into the allowlist.""" - import urllib.parse - - parsed = urllib.parse.urlsplit(failed_url.strip()) - host = parsed.hostname or "" - if not host: - raise PipelockApplyError( - f"proposed failed_url has no extractable host: {failed_url!r}" - ) - current = fetch_current_allowlist(slug) - hosts = parse_allowlist_content(current) - if host not in hosts: - hosts.append(host) - return apply_allowlist_change(slug, render_allowlist_content(hosts)) - - def _write_audit( qp: QueuedProposal, *, @@ -235,7 +183,7 @@ def _write_audit( diff_before: str, diff_after: str, ) -> None: - """Audit log for egress / pipelock tools.""" + """Audit log for egress tool.""" component = COMPONENT_FOR_TOOL.get(qp.proposal.tool) if component is None: return @@ -467,8 +415,7 @@ def _render( cursor = "> " if i == selected else " " line = ( f"{cursor}{ts_short} " - f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]} " - f"{_proposed_payload_label(p.tool)}" + f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]}" ) attr = curses.A_REVERSE if i == selected else curses.A_NORMAL stdscr.addnstr(row, 0, line, w - 1, attr) diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index d5f546e..662342c 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -4,8 +4,7 @@ Replaces the cred-proxy sidecar (PRD 0010) with a mitmproxy-based sidecar that becomes the agent's `HTTP_PROXY` / `HTTPS_PROXY`. It owns three jobs: - 1. MITM the agent's HTTPS with the per-bottle CA (moved from - pipelock). + 1. MITM the agent's HTTPS with the per-bottle CA. 2. Enforce manifest-declared `path_allowlist` per route. 3. Inject `Authorization` headers for routes that declare an `auth` block, the same way cred-proxy does today. @@ -48,9 +47,8 @@ EGRESS_HOSTNAME = "egress" # In-container path the addon reads. Pre-created in # `Dockerfile.sidecars` so the host bind-mount can drop the file -# directly. Content is YAML (hand-rolled by `egress_render_routes` -# in the style of `pipelock_render_yaml`, parsed by `yaml_subset` -# inside the addon). +# directly. Content is YAML (hand-rolled by `egress_render_routes`, +# parsed by `yaml_subset` inside the addon). EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml" @@ -70,15 +68,11 @@ class EgressRoute(Route): `roles` carries the manifest route's role tuple (reserved for future use; always empty today). - `tls_passthrough` signals that pipelock must not TLS-MITM this - host — either because the manifest declared `pipelock.tls_passthrough: - true` (lifted in `egress_manifest_routes`) or because a provider - route set it (e.g. egress injects its own Bearer on that host - after the agent boundary and pipelock's header DLP would block it).""" + `roles` carries the manifest route's role tuple (reserved for + future use; always empty today).""" token_ref: str = "" roles: tuple[str, ...] = () - tls_passthrough: bool = False @dataclass(frozen=True) @@ -87,10 +81,10 @@ class EgressPlan: The slug + routes_path + routes + token_env_map fields are filled at prepare time (host-side, side-effect-free on docker). - The network + CA + pipelock fields are populated by the backend's - launch step via `dataclasses.replace` once those resources - exist. Empty defaults are sentinels meaning "not yet set"; - `.start` validates that they are populated. + The network + CA fields are populated by the backend's launch step + via `dataclasses.replace` once those resources exist. Empty defaults + are sentinels meaning "not yet set"; `.start` validates that they are + populated. `token_env_map` is `{: }`. The backend's start step reads `os.environ[token_ref]` and @@ -108,16 +102,6 @@ class EgressPlan: key) for installing into the agent's trust store via `provision_ca`. Separate file rather than re-parsing the concat so secrets and trust artefacts stay on distinct paths. - - `pipelock_ca_host_path` is the host path of the pipelock CA - (cert only). `.start` docker-cps it into the sidecar so the - proxy's outbound HTTPS client trusts pipelock's MITM on the - egress → upstream leg. - - `pipelock_proxy_url` is the URL egress sets as `HTTPS_PROXY` - in its environ so outbound HTTPS traverses pipelock — keeping - pipelock's hostname allowlist + DLP body scanner on the - egress → upstream leg. """ slug: str @@ -128,8 +112,6 @@ class EgressPlan: egress_network: str = "" mitmproxy_ca_host_path: Path = Path() mitmproxy_ca_cert_only_host_path: Path = Path() - pipelock_ca_host_path: Path = Path() - pipelock_proxy_url: str = "" def egress_manifest_routes( @@ -147,7 +129,6 @@ def egress_manifest_routes( auth_scheme=r.AuthScheme, token_ref=r.TokenRef, roles=r.Role, - tls_passthrough=r.Pipelock.TlsPassthrough, )) return tuple(out) @@ -306,7 +287,7 @@ class Egress(ABC): forward values from the host's environ into the sidecar's environ. Returned plan is incomplete: the launch step must fill - `internal_network` / `egress_network` / `pipelock_proxy_url` + `internal_network` / `egress_network` via `dataclasses.replace` before passing it to `.start`.""" routes = egress_routes_for_bottle(bottle, provider_routes) routes_path = stage_dir / "egress_routes.yaml" diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index 44cb63e..afabd0a 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -1,7 +1,7 @@ """Per-bottle sidecar supervisor (PRD 0024 chunk 1). PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns -the configured daemons (egress, pipelock, git-gate, supervise), +the configured daemons (egress, git-gate, supervise), forwards SIGTERM/SIGINT to each child, and propagates per-daemon stdout+stderr to the container log with a `[name] ` prefix. @@ -19,7 +19,7 @@ PR; the interim policy is "don't take the bundle down for one sick daemon." Daemon subset is env-driven. The compose renderer narrows it via -`BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that +`BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that don't use git-gate or supervise. Default: all daemons. Stdlib-only by design — adding supervisord/s6/runit for four @@ -57,14 +57,7 @@ class _DaemonSpec: # Env-var name prefixes that carry egress-only credentials. # `egress_apply.py` assigns `EGRESS_TOKEN_` slots that egress # reads to inject `Authorization` headers on configured routes; -# every other daemon in the bundle (especially pipelock with -# `scan_env: true`) MUST NOT see these values or it'll match the -# injected token in the request egress just sent and 403-block -# the legitimate traffic (issue #84). The agent itself runs in a -# different machine and never has access to these slots in the -# first place, so stripping them from non-egress daemons loses no -# DLP coverage — pipelock can't catch the exfil of a value the -# agent doesn't have. +# no other daemon in the bundle should see these values. _EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) @@ -81,22 +74,8 @@ def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]: } -# Order matters only for first-launch race-window reasons: egress -# starts first so pipelock's upstream connect succeeds during -# pipelock's own startup. git-gate and supervise are independent. -# Pipelock binds 0.0.0.0:8888 explicitly. Without `--listen` it -# defaults to 127.0.0.1 which would be unreachable from sibling -# services on the docker network. The legacy four-sidecar -# compose renderer passed the same flag; the bundle keeps the -# explicit binding. _DAEMONS: tuple[_DaemonSpec, ...] = ( _DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")), - _DaemonSpec( - "pipelock", - ("/usr/local/bin/pipelock", "run", - "--config", "/etc/pipelock.yaml", - "--listen", "0.0.0.0:8888"), - ), _DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")), _DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")), _DaemonSpec("supervise", ("python3", "/app/supervise_server.py")), @@ -367,13 +346,6 @@ def main(argv: Sequence[str] | None = None) -> int: # delivers SIGHUP to PID 1 (this supervisor); forward it to # mitmdump so it reloads its addon. signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore - # SIGUSR1 pipelock-restart path: pipelock_apply.py runs - # `docker kill --signal USR1 ` after writing - # pipelock.yaml. Pipelock has no in-process reload, so the - # supervisor restarts the pipelock daemon in place (other - # daemons keep running — specifically supervise, whose MCP - # socket would drop on a whole-container `docker restart`). - signal.signal(signal.SIGUSR1, lambda *_: sup.request_restart("pipelock")) # type: ignore while not sup.tick(): time.sleep(_POLL_INTERVAL) diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 10ca381..3e26c46 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -6,8 +6,7 @@ sits on the bottle's internal network and exposes three MCP tools the agent calls when it hits a stuck-recovery category: * egress-block — agent proposes a new routes.yaml - * pipelock-block — agent proposes a new pipelock allowlist - * capability-block — agent proposes a new agent Dockerfile + * capability-block — agent proposes a new agent Dockerfile Each tool call: the agent passes the full proposed file plus a justification text. The sidecar validates the proposal syntactically, @@ -50,12 +49,10 @@ SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 TOOL_EGRESS_BLOCK = "egress-block" -TOOL_PIPELOCK_BLOCK = "pipelock-block" TOOL_CAPABILITY_BLOCK = "capability-block" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOLS: tuple[str, ...] = ( TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK, TOOL_LIST_EGRESS_ROUTES, ) @@ -76,7 +73,6 @@ EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist" # record laid down in PRD 0016. COMPONENT_FOR_TOOL: dict[str, str] = { TOOL_EGRESS_BLOCK: "egress", - TOOL_PIPELOCK_BLOCK: "pipelock", } STATUS_APPROVED = "approved" @@ -562,7 +558,6 @@ __all__ = [ "TOOL_CAPABILITY_BLOCK", "TOOL_EGRESS_BLOCK", "TOOL_LIST_EGRESS_ROUTES", - "TOOL_PIPELOCK_BLOCK", "archive_proposal", "audit_dir", "audit_log_path", diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index 90ad6c6..6413215 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -1,8 +1,8 @@ """Supervise sidecar HTTP server (PRD 0013). -Per-bottle MCP server exposing three tools — `egress-block`, -`pipelock-block`, `capability-block` — that the agent calls to -propose config changes when stuck. Each tool call: +Per-bottle MCP server exposing two tools — `egress-block`, +`capability-block` — that the agent calls to propose config changes +when stuck. Each tool call: 1. Validates the proposed file syntactically. 2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from @@ -18,7 +18,7 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled: * `initialize` — handshake; returns server info + caps. * `notifications/initialized` — ack-only. - * `tools/list` — returns the three tool definitions. + * `tools/list` — returns the tool definitions. * `tools/call` — validates, queues, blocks, returns. Everything else returns JSON-RPC error -32601 (method not found). @@ -151,8 +151,8 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ "or rejects in the supervise TUI. On approval the " "supervisor writes the merged routes.yaml, SIGHUPs " "egress (atomic swap, no dropped connections), and " - "mirrors the host onto pipelock's allowlist for the " - "downstream gate." + "writes the merged routes.yaml and SIGHUPs egress " + "(atomic swap, no dropped connections)." ), "inputSchema": { "type": "object", @@ -203,15 +203,11 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ "name": _sv.TOOL_LIST_EGRESS_ROUTES, "description": ( "List the current egress route table — the bottle's " - "primary egress allowlist. Returns JSON with one entry " - "per allowed host, each carrying its path_allowlist (if " - "any) and whether the proxy injects Authorization for " - "the route. Use this before composing an " - "`egress-block` proposal so the new routes file " - "extends the live one rather than replacing it. " - "Pipelock's allowlist is a mirror of this set — every " - "host listed here is also reachable through pipelock's " - "downstream hostname gate." + "allowlist. Returns JSON with one entry per allowed host, " + "each carrying its path_allowlist (if any) and whether " + "the proxy injects Authorization for the route. Use this " + "before composing an `egress-block` proposal so the new " + "routes file extends the live one rather than replacing it." ), "inputSchema": { "type": "object", @@ -219,48 +215,12 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ "additionalProperties": False, }, }, - { - "name": _sv.TOOL_PIPELOCK_BLOCK, - "description": ( - "Call when pipelock refused your outbound request and " - "the failing host is genuinely missing from the bottle's " - "allowlist (vs. blocked for DLP reasons — those need a " - "different remediation). In practice pipelock's allowlist " - "is now a mirror of the egress routes set by " - "`egress-block`, so prefer that tool when you want " - "to add a host. This tool stays available for the rare " - "case where pipelock and egress have diverged. " - "Pass the full URL you tried to hit (scheme + host + " - "path); the supervisor extracts the hostname and merges " - "it into pipelock's allowlist. On approval the " - "supervisor restarts pipelock." - ), - "inputSchema": { - "type": "object", - "properties": { - "failed_url": { - "type": "string", - "description": ( - "The full URL pipelock blocked, e.g. " - "https://api.github.com/repos/foo/bar. Scheme " - "and hostname are required; path is recorded " - "as operator context." - ), - }, - "justification": { - "type": "string", - "description": "Why the new host should be allowed.", - }, - }, - "required": ["failed_url", "justification"], - }, - }, { "name": _sv.TOOL_CAPABILITY_BLOCK, "description": ( "Call when the bottle is missing a tool, skill, permission, " "or env var you need — something that lives in the agent " - "Dockerfile rather than in routes or the pipelock allowlist. " + "Dockerfile rather than in the egress routes. " "Read the current Dockerfile from " "/etc/bot-bottle/current-config/Dockerfile, compose a " "modified version, and pass the full new file plus a " @@ -286,27 +246,10 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ ] -# Map each tool to the input field that carries the agent's -# tool-specific payload (stored in Proposal.proposed_file as -# free-form text the apply path interprets per tool). -# -# egress-block: JSON object describing a SINGLE route to -# add — `{host, path_allowlist?, auth?}`. The -# supervisor merges this into the live routes -# file at approval time. -# pipelock-block: the full failed URL (scheme + host + path) — -# supervisor extracts the host, merges into the -# bottle's current allowlist; the path is shown -# to the operator for context (pipelock doesn't -# do path-level matching). -# capability-block: full proposed Dockerfile -# -# Egress-proxy-block doesn't use a single "field name" → the JSON -# payload is constructed from multiple structured input fields in -# `handle_egress_block`. The mapping stays one-entry-per-tool -# so the generic dispatch keeps working for the other two. +# Map each non-egress tool to the input field that carries the agent's +# payload (stored in Proposal.proposed_file). egress-block builds its +# payload from structured input fields in `handle_egress_block`. PROPOSED_FILE_FIELD: dict[str, str] = { - _sv.TOOL_PIPELOCK_BLOCK: "failed_url", _sv.TOOL_CAPABILITY_BLOCK: "dockerfile", } @@ -325,23 +268,7 @@ def validate_proposed_file(tool: str, content: str) -> None: enter the queue.""" if not content.strip(): raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty") - if tool == _sv.TOOL_PIPELOCK_BLOCK: - # `content` is the full failed URL. Require scheme + host so - # the supervisor can extract a hostname for the allowlist - # merge; the path is preserved for operator context. - parsed = urllib.parse.urlsplit(content.strip()) - if parsed.scheme not in ("http", "https"): - raise _RpcError( - ERR_INVALID_PARAMS, - f"{tool}: failed_url must start with http:// or https:// " - f"(got {content!r})", - ) - if not parsed.hostname: - raise _RpcError( - ERR_INVALID_PARAMS, - f"{tool}: failed_url is missing a hostname (got {content!r})", - ) - elif tool == _sv.TOOL_CAPABILITY_BLOCK: + if tool == _sv.TOOL_CAPABILITY_BLOCK: # Dockerfiles are too varied to validate syntactically beyond # non-empty. The operator reads the diff in the TUI. pass -- 2.52.0 From bbd6ec85ac1e316666d5057059ad8b0bea25d872 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 21:20:07 +0000 Subject: [PATCH 3/6] chore: strip pipelock from Docker backend - Remove pipelock_state_dir, _PIPELOCK_SUBDIR from bottle_state.py - Remove proxy_plan: PipelockProxyPlan from DockerBottlePlan - Remove EGRESS_PIPELOCK_CA_IN_CONTAINER from docker/egress.py - Remove pipelock TLS init and proxy_plan population from launch.py - Remove PipelockProxy import and pipelock_dir setup from prepare.py - Remove pipelock volumes, daemon entry, and network alias from compose.py - Remove pipelock mirroring entirely from egress_apply.py - Agent HTTP_PROXY now always points at egress (no pipelock fallback) --- bot_bottle/backend/docker/bottle_plan.py | 2 - bot_bottle/backend/docker/bottle_state.py | 8 -- bot_bottle/backend/docker/compose.py | 128 +++++----------------- bot_bottle/backend/docker/egress.py | 20 +--- bot_bottle/backend/docker/egress_apply.py | 106 +----------------- bot_bottle/backend/docker/launch.py | 44 +------- bot_bottle/backend/docker/prepare.py | 10 -- 7 files changed, 36 insertions(+), 282 deletions(-) diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 6fd4a56..29a8100 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -11,7 +11,6 @@ from dataclasses import dataclass, field from pathlib import Path from ...agent_provider import PromptMode -from ...pipelock import PipelockProxyPlan from .. import BottlePlan @@ -40,7 +39,6 @@ class DockerBottlePlan(BottlePlan): # accidental log of the plan dataclass. forwarded_env: dict[str, str] = field(repr=False) prompt_file: Path - proxy_plan: PipelockProxyPlan use_runsc: bool @property diff --git a/bot_bottle/backend/docker/bottle_state.py b/bot_bottle/backend/docker/bottle_state.py index f0e8497..5195264 100644 --- a/bot_bottle/backend/docker/bottle_state.py +++ b/bot_bottle/backend/docker/bottle_state.py @@ -49,7 +49,6 @@ _TRANSCRIPT_SUBDIR = "transcript" # live here so chunk 3's `docker compose up` can find them at stable # paths. Each sidecar's `prepare()` writes config + CAs into its own # subdir; the launch step is unchanged today (still `docker cp`). -_PIPELOCK_SUBDIR = "pipelock" _EGRESS_SUBDIR = "egress" _GIT_GATE_SUBDIR = "git-gate" _SUPERVISE_SUBDIR = "supervise" @@ -234,12 +233,6 @@ def transcript_snapshot_dir(identity: str) -> Path: # nothing requested preservation. -def pipelock_state_dir(identity: str) -> Path: - """State subdir for the pipelock sidecar: pipelock.yaml + the - per-bottle CA cert/key. Bind-mount source from chunk 3 onward.""" - return bottle_state_dir(identity) / _PIPELOCK_SUBDIR - - def egress_state_dir(identity: str) -> Path: """State subdir for the egress sidecar: routes.yaml + the per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward.""" @@ -325,7 +318,6 @@ __all__ = [ "per_bottle_dockerfile", "per_bottle_dockerfile_path", "per_bottle_image_tag", - "pipelock_state_dir", "preserve_marker_path", "read_metadata", "supervise_state_dir", diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 4abcd71..88bd233 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -7,34 +7,14 @@ two networks, no named volumes. Pure function. No I/O, no subprocess. Expects every launch-time field (network names, CA host paths, etc.) on the plan's inner -plans to be populated; chunks 2+3 own that ordering. Chunk 1 just -encodes the translation so it can be unit-tested in isolation. +plans to be populated; chunks 2+3 own that ordering. -Conditional services follow the plan content (matches the -SDK-call branching in `launch.py` today): +Conditional services follow the plan content: - - pipelock + agent: always. - - git-gate: iff plan.git_gate_plan.upstreams. - - egress: iff plan.egress_plan.routes. - - supervise: iff plan.supervise_plan is not None. - -Naming: - - - Compose project: `bot-bottle-`. - - Service names (inside the file): `agent`, `pipelock`, - `egress`, `git-gate`, `supervise`. - - `container_name:` matches today's pattern - (`bot-bottle--`) so dashboard/cleanup discovery - via the prefix scan keeps working through the transition. - - Network aliases preserve the current dial-by-shortname pattern - for `egress` / `supervise`, and add the long container-name as - an internal-network alias for `pipelock` / `git-gate` so any - caller still referencing the long name resolves. - -Sidecars that are built (egress, git-gate, supervise) get a -compose `build:` block pointing at the repo Dockerfile; the -`image:` tag is set explicitly so cached images on the daemon -aren't rebuilt on every up. + - agent + sidecars bundle: always. + - git-gate: iff plan.git_gate_plan.upstreams. + - egress: iff plan.egress_plan.routes. + - supervise: iff plan.supervise_plan is not None. """ from __future__ import annotations @@ -51,7 +31,6 @@ from ...egress import ( ) from ...git_gate import GIT_GATE_HOSTNAME from ...log import die, warn -from ...pipelock import PIPELOCK_HOSTNAME from ...supervise import ( CURRENT_CONFIG_DIR_IN_AGENT, QUEUE_DIR_IN_CONTAINER, @@ -63,7 +42,7 @@ from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH from .bottle_plan import DockerBottlePlan from .egress import ( EGRESS_CA_IN_CONTAINER, - EGRESS_PIPELOCK_CA_IN_CONTAINER, + EGRESS_PORT, ) from .git_gate import ( GIT_GATE_ACCESS_HOOK_IN_CONTAINER, @@ -71,11 +50,6 @@ from .git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) -from ...pipelock import ( - PIPELOCK_CA_CERT_IN_CONTAINER, - PIPELOCK_CA_KEY_IN_CONTAINER, -) -from .pipelock import PIPELOCK_PORT from .sidecar_bundle import ( SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_IMAGE, @@ -91,12 +65,11 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]: """Render a Compose v2 spec dict from a fully-resolved DockerBottlePlan. - The plan must have its inner plans (`proxy_plan`, - `git_gate_plan`, `egress_plan`, `supervise_plan`) populated - with launch-time fields — network names, CA host paths, - pipelock_proxy_url. The renderer doesn't validate; callers - feed it a fully-resolved plan or get an incomplete compose - spec back. + The plan must have its inner plans (`git_gate_plan`, + `egress_plan`, `supervise_plan`) populated with launch-time + fields — network names, CA host paths. The renderer doesn't + validate; callers feed it a fully-resolved plan or get an + incomplete compose spec back. """ project = f"bot-bottle-{plan.slug}" services: dict[str, Any] = { @@ -142,29 +115,12 @@ def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str, def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: """The `sidecars` service: one container per bottle, bundle - image, all four daemons under a Python init supervisor. + image, all daemons under a Python init supervisor. - Mechanics: - - - Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` - env. pipelock is always present; egress / git-gate / - supervise are conditional on the plan. - - Volumes are the union of the four daemons' bind-mounts, - preserving the same in-container paths so each daemon - finds its config / hooks / CA where it expects. - - Environment is the union of *daemon-private* env vars - (EGRESS_UPSTREAM_PROXY, SUPERVISE_BOTTLE_SLUG, etc). - HTTPS_PROXY is NOT propagated here — see the comment in - egress_entrypoint.sh; setting it at the container level - would route git-gate's git fetches through pipelock, - which is wrong. - - Network aliases register every legacy short/long - hostname (pipelock, egress, git-gate, supervise plus - their `bot-bottle--` long forms) so - the agent's HTTPS_PROXY URL and any other inter-service - reference resolves to the bundle. + Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env. + egress is always present; git-gate / supervise are conditional. """ - daemons: list[str] = ["egress", "pipelock"] + daemons: list[str] = ["egress"] if plan.git_gate_plan.upstreams: daemons.append("git-gate") if plan.supervise_plan is not None: @@ -173,31 +129,17 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"] volumes: list[dict[str, Any]] = [] - # --- pipelock ---------------------------------------------------- - pp = plan.proxy_plan - volumes += [ - _bind(pp.yaml_path, "/etc/pipelock.yaml"), - _bind(pp.ca_cert_host_path, PIPELOCK_CA_CERT_IN_CONTAINER), - _bind(pp.ca_key_host_path, PIPELOCK_CA_KEY_IN_CONTAINER), - ] - - # --- egress (always part of the bundle; the EGRESS_UPSTREAM_* - # env vars + ca bind-mounts are needed iff routes exist; when - # the bottle has no routes the egress daemon falls back to its - # `regular@9099` mode and is unused) ----------------------------- + # --- egress ------------------------------------------------------- ep = plan.egress_plan if ep.routes: - env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}") - env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}") volumes += [ _bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER), _bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER), - _bind(ep.pipelock_ca_host_path, EGRESS_PIPELOCK_CA_IN_CONTAINER), ] for token_env in sorted(ep.token_env_map.keys()): env.append(token_env) - # --- git-gate ---------------------------------------------------- + # --- git-gate ----------------------------------------------------- gp = plan.git_gate_plan if gp.upstreams: volumes += [ @@ -217,7 +159,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts", )) - # --- supervise --------------------------------------------------- + # --- supervise ---------------------------------------------------- sp = plan.supervise_plan if sp is not None: env += [ @@ -232,13 +174,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: "read_only": False, }) - # Internal-network aliases: the agent reaches each daemon through - # its short name (pipelock / egress / git-gate / supervise) which - # the bundle answers as if it were the daemon itself. - internal_aliases = [ - PIPELOCK_HOSTNAME, - EGRESS_HOSTNAME, - ] + internal_aliases = [EGRESS_HOSTNAME] if gp.upstreams: internal_aliases.append(GIT_GATE_HOSTNAME) if sp is not None: @@ -263,11 +199,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]: """Agent container. Runs `sleep infinity`; claude is `docker - exec -it`'d into it later. No TTY at the container level — - interactivity is per-exec. HTTP_PROXY/HTTPS_PROXY point at the - egress short-alias when an egress is declared, otherwise - straight at pipelock's container name. CA trust trio matches - the existing launch.py wiring.""" + exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the + egress sidecar.""" proxy_url = _agent_proxy_url(plan) no_proxy = _agent_no_proxy(plan) env: list[str] = [ @@ -319,21 +252,14 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]: def _agent_proxy_url(plan: DockerBottlePlan) -> str: - """Pick the agent's HTTP_PROXY. With egress declared, the agent - goes through egress (which in turn HTTPS_PROXYs to pipelock on - its outbound leg). Without egress, the agent talks straight to - pipelock.""" - if plan.egress_plan.routes: - from .egress import EGRESS_PORT - return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}" - return f"http://{PIPELOCK_HOSTNAME}:{PIPELOCK_PORT}" + """Agent's HTTP_PROXY — always points at egress.""" + return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}" def _agent_no_proxy(plan: DockerBottlePlan) -> str: - """NO_PROXY for the agent. Matches the launch.py rules: - loopback always, supervise hostname when the supervise sidecar - is up (the MCP long-poll pattern needs to bypass pipelock's - idle timeout).""" + """NO_PROXY for the agent: loopback always; supervise hostname + when the supervise sidecar is up (MCP long-poll must bypass + the egress proxy).""" hosts = ["localhost", "127.0.0.1"] if plan.supervise_plan is not None: hosts.append(SUPERVISE_HOSTNAME) diff --git a/bot_bottle/backend/docker/egress.py b/bot_bottle/backend/docker/egress.py index a025c15..080e2c6 100644 --- a/bot_bottle/backend/docker/egress.py +++ b/bot_bottle/backend/docker/egress.py @@ -22,14 +22,8 @@ from ...log import die EGRESS_PORT = int(os.environ.get("BOT_BOTTLE_EGRESS_PORT", "9099")) # In-container path for mitmproxy's CA. The format is a single PEM -# file holding BOTH the cert and the private key, concatenated. The -# upstream-trust CA (pipelock's, so egress trusts the upstream -# leg) is a separate file because pipelock keeps a different CA on -# its end. +# file holding BOTH the cert and the private key, concatenated. EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem" -EGRESS_PIPELOCK_CA_IN_CONTAINER = ( - "/home/mitmproxy/.mitmproxy/pipelock-ca.pem" -) def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: @@ -42,16 +36,8 @@ def egress_tls_init(stage_dir: Path) -> tuple[Path, Path]: trust store by `provision_ca` so the agent trusts the bumped CONNECT cert egress presents. - Why openssl req (not the pipelock binary's `tls init`): - pipelock's CA generator stamps a non-standard `Subject Key - Identifier` on the CA (random rather than SHA-1 of the pubkey). - mitmproxy computes the `Authority Key Identifier` on each leaf - it mints as SHA-1(issuer's pubkey). openssl's chain validator - uses the leaf's AKI to find the issuer cert by SKI; pipelock's - SKI doesn't match → openssl reports "unable to get local issuer - certificate" even though the CA is right there in the trust - store. openssl req's `subjectKeyIdentifier=hash` extension uses - SHA-1(pubkey), matching mitmproxy's computation. + openssl req's `subjectKeyIdentifier=hash` extension uses + SHA-1(pubkey), matching mitmproxy's AKI computation on leaves. Both files live under `/egress-ca/` (mode 644 — `docker cp` preserves the mode into the container, where the diff --git a/bot_bottle/backend/docker/egress_apply.py b/bot_bottle/backend/docker/egress_apply.py index 80eb507..850ca10 100644 --- a/bot_bottle/backend/docker/egress_apply.py +++ b/bot_bottle/backend/docker/egress_apply.py @@ -8,13 +8,6 @@ egress-block proposal (or runs the operator-initiated sidecar via `docker cp`, then `docker kill --signal HUP` to make the addon reload without dropping connections. -Also mirrors the new route hosts into pipelock's hostname allowlist -so the downstream leg lets them through — egress enforces -the path-aware allowlist on the agent leg, pipelock enforces the -hostname allowlist + DLP body scan on the upstream leg, and a -host added to one must be in the other or the request 403s -somewhere along the chain. - Raises EgressApplyError on any failure — the dashboard surfaces the message and keeps the proposal pending so the operator can retry. @@ -23,7 +16,6 @@ operator can retry. from __future__ import annotations import json -import re import subprocess from pathlib import Path from typing import cast @@ -33,13 +25,6 @@ from ...egress_addon_core import load_routes from ...yaml_subset import YamlSubsetError, parse_yaml_subset from .bottle_state import egress_state_dir from .sidecar_bundle import sidecar_bundle_container_name -from .pipelock_apply import ( - PipelockApplyError, - apply_allowlist_change, - fetch_current_allowlist, - parse_allowlist_content, - render_allowlist_content, -) def _render_routes_payload(routes_list: list[dict[str, object]]) -> str: @@ -108,82 +93,12 @@ def validate_routes_content(content: str) -> None: ) from e -def _hosts_in_routes(content: str) -> list[str]: - """Extract the host list from a routes.yaml content string. - Uses the addon's own parser so any host the addon will match on - also lands in pipelock's allowlist. Returns sorted+deduped.""" - try: - routes = load_routes(content) - except ValueError as e: - raise EgressApplyError( - f"proposed routes.yaml is not valid: {e}" - ) from e - return sorted({r.host for r in routes if r.host}) - - -# Pipelock's allowlist parser accepts only literal hostnames: -# `[A-Za-z0-9_.-]+`. Anything else (wildcards, IPv6 literals, -# stray characters) is silently dropped from the mirror so the -# pipelock apply doesn't fail parse before the new yaml is even -# written. The dropped hosts stay on egress's route table — -# but the addon does exact-host match only, so they'll never -# match anything either. (Wildcard host matching was removed — -# see `match_route` in egress_addon_core for the rationale.) -_PIPELOCK_HOST_RE = re.compile(r"^[A-Za-z0-9_.-]+$") - - -def _pipelock_safe_hosts(hosts: list[str]) -> list[str]: - """Drop any host pipelock's allowlist parser would reject. - Order preserved.""" - return [h for h in hosts if _PIPELOCK_HOST_RE.match(h)] - - -def _mirror_hosts_to_pipelock(slug: str, hosts: list[str]) -> None: - """Ensure every pipelock-compatible `hosts` entry is on - pipelock's allowlist. Fetches pipelock's current allowlist, - merges, re-applies. Hosts pipelock can't represent (wildcards, - etc.) are silently skipped — they stay live on egress - but aren't enforced at pipelock. No-op if every host is already - present (apply still restarts pipelock if any host is new). - Raises EgressApplyError on pipelock failures so the - caller's diff/audit reflects the half-state.""" - safe_hosts = _pipelock_safe_hosts(hosts) - try: - current = fetch_current_allowlist(slug) - existing = parse_allowlist_content(current) - merged = sorted(set(existing) | set(safe_hosts)) - if merged == sorted(existing): - return # nothing to add - apply_allowlist_change(slug, render_allowlist_content(merged)) - except PipelockApplyError as e: - # Mirror runs BEFORE the egress write, so egress - # is unchanged on this failure path. Report it as a - # pipelock-side problem so the operator looks in the right - # place; their `pipelock edit` flow can repair manually. - raise EgressApplyError( - f"pipelock allowlist mirror failed (egress NOT " - f"updated): {e}. Fix pipelock's allowlist manually with " - f"`pipelock edit ` then retry the proposal." - ) from e - - def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: """Apply `new_content` to the egress sidecar for `slug`: 1. Fetch current routes.yaml (for the before-diff). 2. Validate the new content via the addon's own parser. - 3. Mirror the route hosts onto pipelock's allowlist (so the - downstream hostname gate lets them through). - 4. Write to a temp file, `docker cp` into the egress - sidecar. - 5. `docker kill --signal HUP` so the addon reloads. - - Order matters: pipelock first, then egress. If the - pipelock step fails, egress hasn't been touched and the - old routes stay live. If the egress step fails after - pipelock succeeded, pipelock has the host in its allowlist but - egress doesn't enforce it yet — harmless extra-permissive - state at pipelock, and a re-approval will land the egress - side. + 3. Write to the bind-mount source path. + 4. `docker kill --signal HUP` so the addon reloads. Returns (before, after) where `after` == `new_content`. Raises EgressApplyError on any step.""" @@ -191,10 +106,6 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: before = fetch_current_routes(slug) validate_routes_content(new_content) - # Pipelock mirror first — if it fails, egress stays intact - # and the operator gets a clear error about the half-state. - _mirror_hosts_to_pipelock(slug, _hosts_in_routes(new_content)) - # routes.yaml is bind-mounted into the egress container as a # SINGLE FILE. Docker single-file bind mounts pin the source # inode at mount time; write-temp-then-rename swaps the inode @@ -209,12 +120,6 @@ def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: target = _egress_routes_host_path(slug) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(new_content) - # mitmproxy in the container reads through the bind mount as - # uid 1000; the host file has to be world-readable for that - # read to succeed (parent dir at 0o700 still restricts who - # can reach the file on the host). Routes content is not - # secret — tokens live in the container's environ — so 0o644 - # is the right trade-off. target.chmod(0o644) sig = subprocess.run( ["docker", "kill", "--signal", "HUP", container], @@ -311,13 +216,6 @@ def _merge_single_route( next_idx = len(existing_slots) entry_typed["auth_scheme"] = str(cast(object, auth_typed.get("scheme"))) entry_typed["token_env"] = f"EGRESS_TOKEN_{next_idx}" - # NOTE: the addon reads token VALUES from its container's - # environ keyed by token_env. A newly-added auth route at - # runtime points at a slot that has no env value → the - # addon will 403 with "token env unset" until the operator - # arranges for the value to land in the container's env. - # Recording this here so the operator-facing diff carries - # the slot name they'll need to provision. routes_typed.append(entry_typed) return _render_routes_payload(cast(list[dict[str, object]], routes_typed)) diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 6420e58..dc09b30 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -6,16 +6,10 @@ The flow is: 1. Build the agent's base + derived image (compose builds the sidecar images via the `build:` directive on first up). - 2. Pre-create the per-bottle networks. We do this outside compose - so we can inspect the assigned internal CIDR and embed it in - pipelock's yaml (compose's `external: true` lets the compose - file reference these pre-existing networks). - 3. Mint the per-bottle CAs (chunk 2 writes them under - state//{pipelock,egress}/). - 4. Re-render pipelock yaml with the now-known internal CIDR so - the SSRF allowlist exempts the bottle's own subnet. - 5. Populate the inner plans with launch-time fields so the - renderer can read network names, CA paths, pipelock URL. + 2. Mint the per-bottle egress CA (chunk 2 writes it under + state//egress/). + 3. Populate the inner plans with launch-time fields so the + renderer can read network names, CA paths. 6. Render the compose spec, write it to state//docker-compose.yml, write metadata.json. 7. `docker compose up -d` (token + OAuth values flow into the @@ -53,7 +47,6 @@ from .bottle_state import ( bottle_state_dir, egress_state_dir, git_gate_state_dir, - pipelock_state_dir, ) from .compose import ( bottle_plan_to_compose, @@ -66,10 +59,6 @@ from .compose import ( write_compose_file, ) from .egress import egress_tls_init -from .pipelock import ( - BUNDLE_LOCAL_PIPELOCK_URL, - pipelock_tls_init, -) # Where the repo root lives, for `docker build` context. Computed once. @@ -113,35 +102,13 @@ def launch( plan.derived_image, plan.image, plan.workspace_plan ) - # Networks: compose-managed. The names are derived - # deterministically from the slug so the renderer can put - # them on the services and `compose up` creates them with - # those names. The empirical spike confirmed pipelock's - # SSRF guard only checks proxied-request destinations, not - # source IPs — so the bottle's own internal CIDR doesn't - # need to be in `ssrf.ip_allowlist`. Pre-create + CIDR - # introspection are gone; compose owns the network - # lifecycle. internal_network = network_mod.network_name_for_slug(plan.slug) egress_network = network_mod.network_egress_name_for_slug(plan.slug) - # Mint per-bottle CAs into state//{pipelock,egress}/. - ca_cert_host, ca_key_host = pipelock_tls_init(pipelock_state_dir(plan.slug)) egress_ca_host, egress_ca_cert_only = egress_tls_init( egress_state_dir(plan.slug), ) - # Populate launch-time fields on every inner plan so the - # renderer reads concrete network names, CA paths, and - # pipelock URL. - proxy_plan = dataclasses.replace( - plan.proxy_plan, - internal_network=internal_network, - internal_network_cidr="", - egress_network=egress_network, - ca_cert_host_path=ca_cert_host, - ca_key_host_path=ca_key_host, - ) git_gate_plan = plan.git_gate_plan if git_gate_plan.upstreams: git_gate_plan = dataclasses.replace( @@ -157,8 +124,6 @@ def launch( egress_network=egress_network, mitmproxy_ca_host_path=egress_ca_host, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, - pipelock_ca_host_path=ca_cert_host, - pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL, ) supervise_plan = plan.supervise_plan if supervise_plan is not None: @@ -168,7 +133,6 @@ def launch( ) plan = dataclasses.replace( plan, - proxy_plan=proxy_plan, git_gate_plan=git_gate_plan, egress_plan=egress_plan, supervise_plan=supervise_plan, diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 2f8eaaf..38e7f67 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -20,7 +20,6 @@ from ...egress import Egress from ...env import ResolvedEnv, resolve_env from ...git_gate import GitGate from ...log import die -from ...pipelock import PipelockProxy from ...supervise import Supervise from ...workspace import workspace_plan as resolve_workspace_plan from .. import BottleSpec @@ -36,7 +35,6 @@ from .bottle_state import ( per_bottle_dockerfile, per_bottle_dockerfile_path, per_bottle_image_tag, - pipelock_state_dir, supervise_state_dir, write_metadata, ) @@ -53,7 +51,6 @@ def resolve_plan( validation already ran in the base class.""" docker_mod.require_docker() - proxy = PipelockProxy() git_gate = GitGate() egress = Egress() supervise = Supervise() @@ -191,12 +188,6 @@ def resolve_plan( guest_env.setdefault(key, val) agent_provision = replace(agent_provision, guest_env=guest_env) - pipelock_dir = pipelock_state_dir(slug) - pipelock_dir.mkdir(parents=True, exist_ok=True) - proxy_plan = proxy.prepare( - bottle, slug, pipelock_dir, agent_provision.egress_routes, - ) - egress_dir = egress_state_dir(slug) egress_dir.mkdir(parents=True, exist_ok=True) egress_plan = egress.prepare( @@ -244,7 +235,6 @@ def resolve_plan( env_file=env_file, forwarded_env=forwarded_env, prompt_file=prompt_file, - proxy_plan=proxy_plan, git_gate_plan=git_gate_plan, egress_plan=egress_plan, supervise_plan=supervise_plan, -- 2.52.0 From a59da9921ef123bdd0b20d0f9a8f6b00c017a94c Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 21:54:06 +0000 Subject: [PATCH 4/6] chore: remove all pipelock references from tests, docs, and non-pipelock source - Strip pipelock from all unit and integration test fixtures: proxy_plan fields removed from DockerBottlePlan/SmolmachinesBottlePlan constructors; pipelock-specific test classes deleted or renamed - Update test_sidecar_init: remove test_pipelock_loses_egress_tokens, rename "pipelock" daemon fixtures to "git-gate" throughout - Remove test_pipelock_binary_present_and_versioned from integration test - Remove test_pipelock_answers_on_bundle_ip from smolmachines launch test - Update _SANDBOX_BLOCK_MARKERS: remove "pipelock" marker (egress blocks) - Dockerfile.sidecars: remove pipelock build stage and COPY; update layout comments and port table - egress_entrypoint.sh: update comments now that egress is sole proxy - Clean up pipelock references in comments/docstrings across backend, network, manifest, supervise, git_gate, yaml_subset, agent_provider, sidecar_bundle, sidecar_init, egress_addon_core modules Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/canaries.yml | 6 +- Dockerfile.claude | 2 +- Dockerfile.sidecars | 37 ++---- bot_bottle/agent_provider.py | 6 +- bot_bottle/backend/__init__.py | 10 +- bot_bottle/backend/docker/__init__.py | 1 - bot_bottle/backend/docker/bottle_state.py | 4 +- bot_bottle/backend/docker/compose.py | 11 +- bot_bottle/backend/docker/launch.py | 16 ++- bot_bottle/backend/docker/network.py | 19 +-- bot_bottle/backend/docker/prepare.py | 7 +- bot_bottle/backend/docker/provision/ca.py | 21 +--- bot_bottle/backend/docker/sidecar_bundle.py | 8 +- .../backend/smolmachines/bottle_plan.py | 2 - bot_bottle/backend/smolmachines/enumerate.py | 4 +- bot_bottle/backend/smolmachines/launch.py | 110 ++++-------------- .../backend/smolmachines/local_registry.py | 2 +- bot_bottle/backend/smolmachines/prepare.py | 22 +--- .../backend/smolmachines/provision/ca.py | 15 +-- .../backend/smolmachines/sidecar_bundle.py | 4 +- bot_bottle/backend/util.py | 38 ++---- bot_bottle/egress_addon_core.py | 9 +- bot_bottle/egress_entrypoint.sh | 29 ++--- bot_bottle/git_gate.py | 4 +- bot_bottle/manifest.py | 14 +-- bot_bottle/sidecar_init.py | 10 +- bot_bottle/supervise.py | 3 +- bot_bottle/yaml_subset.py | 2 +- tests/integration/test_sandbox_escape.py | 44 ++++--- .../test_sidecar_bundle_compose.py | 32 ++--- .../integration/test_sidecar_bundle_image.py | 17 +-- .../test_smolmachines_bundle_bringup.py | 10 +- tests/integration/test_smolmachines_launch.py | 26 ----- tests/unit/test_backend_selection.py | 2 +- tests/unit/test_compose.py | 77 ++++-------- tests/unit/test_contrib_claude_provider.py | 4 - tests/unit/test_contrib_codex_provider.py | 4 - tests/unit/test_docker_enumerate_active.py | 16 +-- tests/unit/test_docker_launch_teardown.py | 9 -- tests/unit/test_docker_provision_git_user.py | 4 - tests/unit/test_egress.py | 13 --- tests/unit/test_egress_addon_core.py | 8 +- tests/unit/test_egress_apply.py | 75 ------------ tests/unit/test_manifest_egress.py | 53 +-------- tests/unit/test_plan_print_parity.py | 10 -- tests/unit/test_sidecar_init.py | 92 +++++++-------- tests/unit/test_smolmachines_prepare.py | 2 - tests/unit/test_smolmachines_provision.py | 46 ++------ .../unit/test_smolmachines_sidecar_bundle.py | 13 ++- tests/unit/test_supervise.py | 8 +- tests/unit/test_supervise_cli.py | 109 ----------------- tests/unit/test_supervise_cli_detail_lines.py | 99 ---------------- tests/unit/test_supervise_server.py | 22 ---- 53 files changed, 266 insertions(+), 945 deletions(-) delete mode 100644 tests/unit/test_supervise_cli_detail_lines.py diff --git a/.gitea/workflows/canaries.yml b/.gitea/workflows/canaries.yml index 0085b76..bf568b5 100644 --- a/.gitea/workflows/canaries.yml +++ b/.gitea/workflows/canaries.yml @@ -1,6 +1,6 @@ -# Weekly canary suite. Catches upstream regressions (broken pipelock -# image packaging at the pinned digest, etc.) without coupling every -# dev push to upstream registry availability. +# Weekly canary suite. Catches upstream regressions (broken pinned +# digest, etc.) without coupling every dev push to upstream registry +# availability. # # Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run # locally with the same gating. diff --git a/Dockerfile.claude b/Dockerfile.claude index d5bec57..c7346d1 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -21,7 +21,7 @@ FROM node:22-slim # runs as root and rejects non-root connections, so socat sits between # node and the agent socket. curl is here so any HTTPS_PROXY-aware # tool (curl itself, plus anything that shells out to it) works -# against pipelock's bumped TLS without the agent needing local DNS. +# against egress's bumped TLS without the agent needing local DNS. RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils python3 python3-pip python3-venv \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.sidecars b/Dockerfile.sidecars index 3483974..c5cbe42 100644 --- a/Dockerfile.sidecars +++ b/Dockerfile.sidecars @@ -1,23 +1,18 @@ # Per-bottle sidecar bundle image (PRD 0024). # -# Collapses the four prior per-sidecar images (pipelock, egress, -# git-gate, supervise) into one. A small stdlib-Python init -# supervisor at /app/sidecar_init.py spawns all four daemons, -# forwards SIGTERM, and propagates per-daemon stdout/stderr to the -# container log with a `[name]` prefix. See PRD 0024 for the -# rationale. +# Collapses the prior per-sidecar images (egress, git-gate, +# supervise) into one. A small stdlib-Python init supervisor at +# /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and +# propagates per-daemon stdout/stderr to the container log with a +# `[name]` prefix. See PRD 0024 for the rationale. # -# Layout (preserved verbatim from the prior four Dockerfiles so the -# compose renderer's bind-mount paths and docker-cp targets keep -# working): +# Layout: # -# /usr/local/bin/pipelock pipelock binary # /usr/bin/gitleaks gitleaks binary # /app/egress_addon.py + siblings mitmproxy addon (egress) # /app/egress-entrypoint.sh mitmdump launcher # /app/supervise_server.py + .py supervise MCP server # /app/sidecar_init.py PID 1 supervisor -# /etc/pipelock.yaml bind-mounted at run time # /etc/egress/routes.yaml bind-mounted at run time # /etc/git-gate/pre-receive docker-cp'd at start time # /git-gate-entrypoint.sh docker-cp'd at start time @@ -27,25 +22,17 @@ # /home/mitmproxy/.mitmproxy/ mitmproxy CA dir # # Exposed ports inside the container: -# 8888 pipelock (HTTPS_PROXY) -# 9099 egress (mitmproxy, pipelock's upstream — not externally -# addressed by the agent) +# 9099 egress (mitmproxy, agent-facing HTTPS proxy) # 9418 git-gate (git-daemon) # 9420 git-gate smart HTTP (smolmachines agent-facing transport) # 9100 supervise (MCP HTTP) -# Stage 1: pipelock binary. The upstream pipelock image is a -# scratch image with the binary at /pipelock (entrypoint). -# Pinned by digest in lockstep with -# bot_bottle/backend/docker/pipelock.py:PIPELOCK_IMAGE. -FROM ghcr.io/luckypipewrench/pipelock@sha256:3b1a39417b98406ddc5dc2d8fcb42865ddc0c68a43d355db55f0f8cb06bc6de9 AS pipelock-src - -# Stage 2: gitleaks binary. The upstream gitleaks image is alpine +# Stage 1: gitleaks binary. The upstream gitleaks image is alpine # with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep # with Dockerfile.git-gate's prior base (now deleted at chunk 3). FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src -# Stage 3: assembly. mitmproxy/mitmproxy is debian-slim-based with +# Stage 2: assembly. mitmproxy/mitmproxy is debian-slim-based with # Python + mitmdump pre-installed — heavier than the others, so # this stage starts there and pulls the standalone binaries in. FROM mitmproxy/mitmproxy:11.1.3 @@ -60,16 +47,14 @@ USER root # plus the core `git` binary the pre-receive hook invokes. # openssh-client supplies the upstream SSH transport the # pre-receive hook uses to forward accepted refs. -# ca-certificates is needed for both pipelock and mitmdump -# upstream TLS (the base image already has it; listed for -# explicitness). +# ca-certificates is needed for mitmdump upstream TLS (the +# base image already has it; listed for explicitness). RUN apt-get update \ && apt-get install -y --no-install-recommends \ git openssh-client ca-certificates \ && rm -rf /var/lib/apt/lists/* # Pull the standalone binaries into the final image. -COPY --from=pipelock-src /pipelock /usr/local/bin/pipelock COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks # Project Python: addon + server modules + the init supervisor. diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 4a5e01a..eeb52c4 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -84,9 +84,9 @@ class AgentProvisionPlan: return the same shape without adding backend-plan fields. `egress_routes` are provider-declared EgressRoutes that backends - pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps - provider logic out of the egress and pipelock modules — they merge - provider routes generically without knowing the provider type. + pass to `Egress.prepare`. This keeps provider logic out of the + egress module — it merges provider routes generically without + knowing the provider type. `hidden_env_names` is the set of env var names the provider injected as non-secret placeholders. `print_util.visible_agent_env_names` uses diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index cc408b7..b761ed5 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -163,8 +163,8 @@ class ActiveAgent: bottle is the container, the agent is what runs in it.) Fields are deliberately backend-neutral. `services` is the set - of sidecar daemons currently up for this bottle (`pipelock`, - `egress`, `git-gate`, `supervise`); the dashboard uses it to + of sidecar daemons currently up for this bottle (`egress`, + `git-gate`, `supervise`); the dashboard uses it to gate edit verbs. `backend_name` is the matching key in `_BACKENDS` (`docker` / `smolmachines`) — used by the active- list rendering to disambiguate and by the dashboard's @@ -213,7 +213,7 @@ class Bottle(ABC): `user` (default `node`, matching the agent image's USER directive) and return the captured stdout/stderr/returncode. The bottle's environment (including HTTPS_PROXY pointing at - the pipelock sidecar) is inherited by the child. Non-zero + the egress sidecar) is inherited by the child. Non-zero exit does not raise — callers inspect `returncode` themselves. @@ -352,8 +352,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None: """Install the per-bottle CA into the agent's trust store so - the agent trusts the bumped CONNECT cert egress (was - pipelock, pre-PRD-0017) presents. Default impl is a no-op so + the agent trusts the bumped CONNECT cert egress presents. + Default impl is a no-op so backends that don't yet support TLS interception (every backend except Docker today) aren't forced to implement it. The Docker backend overrides to docker-cp the cert in and run diff --git a/bot_bottle/backend/docker/__init__.py b/bot_bottle/backend/docker/__init__.py index 9ad729d..dc2340b 100644 --- a/bot_bottle/backend/docker/__init__.py +++ b/bot_bottle/backend/docker/__init__.py @@ -4,7 +4,6 @@ The bulk of the implementation lives in sibling modules: - util: thin Docker subprocess wrappers - network: Docker network plumbing - - pipelock: DockerPipelockProxy lifecycle - bottle_plan: DockerBottlePlan - bottle_cleanup_plan: DockerBottleCleanupPlan - bottle: DockerBottle handle diff --git a/bot_bottle/backend/docker/bottle_state.py b/bot_bottle/backend/docker/bottle_state.py index 5195264..673a278 100644 --- a/bot_bottle/backend/docker/bottle_state.py +++ b/bot_bottle/backend/docker/bottle_state.py @@ -56,8 +56,8 @@ _AGENT_SUBDIR = "agent" _METADATA_NAME = "metadata.json" # Live-config dir bind-mounted into the supervise sidecar (read-only). # Host's apply paths keep these files fresh so supervise's -# `list-pipelock-allowlist` / `list-egress-routes` MCP tools -# return the current state — not a snapshot from launch time. +# `list-egress-routes` MCP tool returns the current state — +# not a snapshot from launch time. _LIVE_CONFIG_SUBDIR = "live-config" LIVE_CONFIG_ROUTES_NAME = "routes.yaml" LIVE_CONFIG_ALLOWLIST_NAME = "allowlist" diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 88bd233..4ba695d 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -50,6 +50,7 @@ from .git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) +from . import network as network_mod from .sidecar_bundle import ( SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_IMAGE, @@ -91,11 +92,11 @@ def _networks(plan: DockerBottlePlan) -> dict[str, Any]: bridge.""" return { "internal": { - "name": plan.proxy_plan.internal_network, + "name": network_mod.network_name_for_slug(plan.slug), "internal": True, }, "egress": { - "name": plan.proxy_plan.egress_network, + "name": network_mod.network_egress_name_for_slug(plan.slug), }, } @@ -131,11 +132,9 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: # --- egress ------------------------------------------------------- ep = plan.egress_plan + volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER)) if ep.routes: - volumes += [ - _bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER), - _bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER), - ] + volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER)) for token_env in sorted(ep.token_env_map.keys()): env.append(token_env) diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index dc09b30..2d1bf05 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -116,15 +116,13 @@ def launch( internal_network=internal_network, egress_network=egress_network, ) - egress_plan = plan.egress_plan - if egress_plan.routes: - egress_plan = dataclasses.replace( - egress_plan, - internal_network=internal_network, - egress_network=egress_network, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, - ) + egress_plan = dataclasses.replace( + plan.egress_plan, + internal_network=internal_network, + egress_network=egress_network, + mitmproxy_ca_host_path=egress_ca_host, + mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + ) supervise_plan = plan.supervise_plan if supervise_plan is not None: supervise_plan = dataclasses.replace( diff --git a/bot_bottle/backend/docker/network.py b/bot_bottle/backend/docker/network.py index 3247636..6ec13f9 100644 --- a/bot_bottle/backend/docker/network.py +++ b/bot_bottle/backend/docker/network.py @@ -1,11 +1,10 @@ """Docker network plumbing for the per-agent egress 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 +gateway). Egress straddles that network and a per-agent user-defined +bridge for upstream traffic. 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. +embedded DNS resolver, which egress needs to resolve upstream hostnames. Naming: bot-bottle-net- (internal), bot-bottle-egress- (egress). Numeric suffix on conflict @@ -77,20 +76,12 @@ def network_create_internal(slug: str) -> str: 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.""" + so the egress sidecar has working DNS for upstream hostnames.""" return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False) def network_inspect_cidr(name: str) -> str: - """Return the IPv4 CIDR Docker assigned to a user-defined network. - - Used by pipelock's SSRF guard exception: the bottle's internal - network sits in RFC1918 space, so pipelock's `internal:` list - would block any agent request whose destination resolves there - — including the cred-proxy sidecar's address. Adding the - network's CIDR to pipelock's `ssrf.ip_allowlist` lets traffic - targeted at the bottle's own sidecars through while pipelock - still body-scans and api_allowlist-gates as usual.""" + """Return the IPv4 CIDR Docker assigned to a user-defined network.""" result = subprocess.run( ["docker", "network", "inspect", "--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name], diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 38e7f67..f86373f 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -200,10 +200,9 @@ def resolve_plan( # root; for `--cwd` derived images the base Dockerfile is what # the agent should propose changes against (the derived layer # is just a workspace copy). - # (routes.yaml + pipelock allowlist used to land here too but - # PRD 0017 chunk 3 moved them behind the - # `list-egress-routes` MCP tool so the agent gets live - # state rather than a launch-time snapshot.) + # (routes.yaml used to land here too but PRD 0017 chunk 3 + # moved it behind the `list-egress-routes` MCP tool so the + # agent gets live state rather than a launch-time snapshot.) supervise_dockerfile_path = ( Path(dockerfile_path) if dockerfile_path diff --git a/bot_bottle/backend/docker/provision/ca.py b/bot_bottle/backend/docker/provision/ca.py index 5b5ef31..40dee9f 100644 --- a/bot_bottle/backend/docker/provision/ca.py +++ b/bot_bottle/backend/docker/provision/ca.py @@ -1,19 +1,8 @@ -"""Install the per-bottle MITM CA into the agent container's trust -store. +"""Install the per-bottle egress MITM CA into the agent container's +trust store. -Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target: - - - Bottle declares `egress.routes[]` → agent's HTTP_PROXY - points at egress; the cert the agent must trust is the - one egress mints leaf certs with (the egress CA). - - No egress routes → agent's HTTP_PROXY points straight at - pipelock; the cert the agent must trust is pipelock's CA (the - pre-cutover behavior). - -By the time this provisioner runs, the corresponding `tls_init` -helper has generated the chosen CA under `plan.stage_dir`, and the -sidecar (pipelock or egress) is up referencing the -in-container CA paths. +By the time this provisioner runs, `egress_tls_init` has generated +the egress CA and the path is re-bound into `plan.egress_plan`. Cert lands on Debian's standard source path (`/usr/local/share/ca-certificates/`); `update-ca-certificates` @@ -40,7 +29,7 @@ def provision_ca(plan: DockerBottlePlan, bottle: Bottle) -> None: """Copy the agent-facing CA cert into the agent, rebuild the trust bundle, emit a one-line fingerprint log. Called from `BottleBackend.provision` after the agent container is up.""" - cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) + cert_host_path, label = select_ca_cert(plan.egress_plan) bottle.cp_in(str(cert_host_path), AGENT_CA_PATH) bottle.exec( diff --git a/bot_bottle/backend/docker/sidecar_bundle.py b/bot_bottle/backend/docker/sidecar_bundle.py index 85a2402..fca0dfe 100644 --- a/bot_bottle/backend/docker/sidecar_bundle.py +++ b/bot_bottle/backend/docker/sidecar_bundle.py @@ -2,10 +2,10 @@ (PRD 0024). The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1) -runs pipelock + egress + git-gate + supervise as one container -per bottle under a small Python init supervisor. As of chunk 5 -the bundle is the only shape — the legacy four-sidecar topology -and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" +runs egress + git-gate + supervise as one container per bottle +under a small Python init supervisor. As of chunk 5 the bundle +is the only shape — the legacy four-sidecar topology and its +`BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" from __future__ import annotations diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index e3d3cb2..2e8b583 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -12,7 +12,6 @@ from dataclasses import dataclass from pathlib import Path from ...agent_provider import PromptMode -from ...pipelock import PipelockProxyPlan from .. import BottlePlan @@ -71,7 +70,6 @@ class SmolmachinesBottlePlan(BottlePlan): # docker's `--internal` + egress bridge topology; it's on a # per-bottle bridge with a pinned IP. The unused fields stay # at their dataclass defaults. - proxy_plan: PipelockProxyPlan # Agent-side endpoints. On Docker Desktop the docker bridge # IPs aren't reachable from the smolvm guest (TSI uses macOS # networking; docker container IPs live in the daemon's VM), diff --git a/bot_bottle/backend/smolmachines/enumerate.py b/bot_bottle/backend/smolmachines/enumerate.py index 668c6e9..f1a81ff 100644 --- a/bot_bottle/backend/smolmachines/enumerate.py +++ b/bot_bottle/backend/smolmachines/enumerate.py @@ -69,8 +69,8 @@ def enumerate_active() -> list[ActiveAgent]: def _query_bundle_services() -> dict[str, tuple[str, ...]]: - """`{slug: ('egress', 'pipelock', ...)}` from each running - bundle container's `BOT_BOTTLE_SIDECAR_DAEMONS` env var. + """`{slug: ('egress', ...)}` from each running bundle container's + `BOT_BOTTLE_SIDECAR_DAEMONS` env var. Smolmachines bundles all run the PRD-0024 image with the same daemon set declared via env, so one inspect per bundle gets us the picture without exec'ing into the container. diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index bf0fbd4..0c5a6b5 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -9,13 +9,9 @@ guest pointed at the bundle's pinned IP via TSI's exit. The bundle's daemons consume the inner Plans the docker backend -already produces: pipelock reads its yaml + CA from the -PipelockProxyPlan; egress reads routes + CAs from the EgressPlan -+ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle -local), since the agent dials pipelock first (not egress) on the -smolmachines path. Git-gate + supervise plumb through the same -plans the docker backend uses, minus the docker-network fields -that don't apply here.""" +already produces: egress reads routes + CAs from the EgressPlan. +Git-gate + supervise plumb through the same plans the docker +backend uses, minus the docker-network fields that don't apply here.""" from __future__ import annotations @@ -29,16 +25,11 @@ from ...egress import ( EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values, ) -from ...pipelock import ( - PIPELOCK_CA_CERT_IN_CONTAINER, - PIPELOCK_CA_KEY_IN_CONTAINER, -) from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...util import expand_tilde from ..docker import util as docker_mod from ..docker.egress import ( EGRESS_CA_IN_CONTAINER, - EGRESS_PIPELOCK_CA_IN_CONTAINER, EGRESS_PORT as _EGRESS_PORT, egress_tls_init, ) @@ -48,14 +39,9 @@ from ..docker.git_gate import ( GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER, ) -from ..docker.pipelock import ( - BUNDLE_LOCAL_PIPELOCK_URL, - PIPELOCK_PORT as _PIPELOCK_PORT_STR, - pipelock_tls_init, -) from ...git_gate import revoke_git_gate_provisioned_keys from ...log import warn -from ..docker.bottle_state import git_gate_state_dir +from ..docker.bottle_state import egress_state_dir, git_gate_state_dir from . import loopback_alias as _loopback from . import sidecar_bundle as _bundle from . import smolvm as _smolvm @@ -78,9 +64,7 @@ _SMOLMACHINE_CACHE_DIR = Path.home() / ".cache" / "bot-bottle" / "smolmachines" # Container-internal listening ports for each bundle daemon. The # bundle publishes each one on a random host loopback port (see # `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks -# them up post-start. Pipelock's port is an env-overridable string -# in docker.pipelock; coerce to int here. -_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR) +# them up post-start. _GIT_HTTP_PORT = 9420 _SUPERVISE_PORT = SUPERVISE_PORT @@ -167,33 +151,16 @@ def _allocate_resources( def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan: - """Mint per-bottle CAs and return the plan with CA paths filled. - - Pipelock always runs in the bundle. Egress's CA is only minted - when the bottle declares routes — otherwise egress runs idle - without MITM and the CA files would be unused.""" - ca_cert_host, ca_key_host = pipelock_tls_init(plan.proxy_plan.yaml_path.parent) - proxy_plan = dataclasses.replace( - plan.proxy_plan, - ca_cert_host_path=ca_cert_host, - ca_key_host_path=ca_key_host, + """Mint the egress MITM CA and return the plan with CA paths filled.""" + egress_ca_host, egress_ca_cert_only = egress_tls_init( + egress_state_dir(plan.slug), ) - egress_plan = plan.egress_plan - if egress_plan.routes: - egress_ca_host, egress_ca_cert_only = egress_tls_init( - plan.egress_plan.routes_path.parent, - ) - egress_plan = dataclasses.replace( - egress_plan, - mitmproxy_ca_host_path=egress_ca_host, - mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, - pipelock_ca_host_path=ca_cert_host, - # On smolmachines, egress's upstream is pipelock on the - # bundle's localhost — they're in the same container's - # network namespace. - pipelock_proxy_url=BUNDLE_LOCAL_PIPELOCK_URL, - ) - return dataclasses.replace(plan, proxy_plan=proxy_plan, egress_plan=egress_plan) + egress_plan = dataclasses.replace( + plan.egress_plan, + mitmproxy_ca_host_path=egress_ca_host, + mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + ) + return dataclasses.replace(plan, egress_plan=egress_plan) def _start_bundle( @@ -224,17 +191,10 @@ def _discover_urls( macOS networking, and macOS sees the daemon's bridge via the published-port loopback forward only. - Proxy hop order: when the bottle declares egress routes, the - agent's first hop is egress (for token injection), then - pipelock. Without routes, the agent dials pipelock directly. NO_PROXY includes the per-bottle loopback alias so the supervise + git-gate URLs bypass HTTPS_PROXY.""" - if plan.egress_plan.routes: - agent_facing_port = _EGRESS_PORT - else: - agent_facing_port = _PIPELOCK_PORT agent_facing_host_port = _bundle.bundle_host_port( - plan.slug, agent_facing_port, host_ip=loopback_ip, + plan.slug, _EGRESS_PORT, host_ip=loopback_ip, ) agent_proxy_url = f"http://{loopback_ip}:{agent_facing_host_port}" @@ -328,8 +288,7 @@ def _bundle_launch_spec( """Build a BundleLaunchSpec from the resolved inner Plans. Daemons in the CSV: - - egress + pipelock are always present (pipelock is the - agent's first hop; egress is its upstream). + - egress is always present. - git-gate + git-http are conditional on plan.git_gate_plan.upstreams. - supervise is conditional on plan.supervise_plan. @@ -337,36 +296,15 @@ def _bundle_launch_spec( daemon-private values only (HTTPS_PROXY is scoped to the egress process by egress_entrypoint.sh — see PRD 0024's bundle bind-address PR).""" - daemons: list[str] = ["egress", "pipelock"] + daemons: list[str] = ["egress"] env: list[str] = [] volumes: list[tuple[str, str, bool]] = [] - # In this Docker-Desktop-compatible topology, whichever daemon - # is "agent-facing" gets its port published on the host - # loopback (see `_ensure_smolmachine`'s discovery loop) and the - # other stays bundle-internal. The bundle is NOT reachable by - # bridge IP from the smolvm guest on macOS — TSI uses macOS - # networking, and macOS sees the daemon's bridge via the - # published-port loopback forward only. - - # --- pipelock --------------------------------------------- - pp = plan.proxy_plan - volumes += [ - (str(pp.yaml_path), "/etc/pipelock.yaml", True), - (str(pp.ca_cert_host_path), PIPELOCK_CA_CERT_IN_CONTAINER, True), - (str(pp.ca_key_host_path), PIPELOCK_CA_KEY_IN_CONTAINER, True), - ] - # --- egress ----------------------------------------------- ep = plan.egress_plan + volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True)) if ep.routes: - env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}") - env.append(f"EGRESS_UPSTREAM_CA={EGRESS_PIPELOCK_CA_IN_CONTAINER}") - volumes += [ - (str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True), - (str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True), - (str(ep.pipelock_ca_host_path), EGRESS_PIPELOCK_CA_IN_CONTAINER, True), - ] + volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True)) # Bare-name entries for upstream-token slots. Their values # come from the docker-run subprocess env (inherited from # the operator's shell), never landing on argv. @@ -409,14 +347,8 @@ def _bundle_launch_spec( # Container ports the agent reaches from the smolvm guest — # published on host loopback so the guest can dial via TSI + - # macOS networking. The HTTP/HTTPS chokepoint is whichever - # daemon's port we publish: egress when routes are declared - # (token injection first, then forwards to bundle-internal - # pipelock), pipelock otherwise. - if ep.routes: - ports_to_publish: list[int] = [_EGRESS_PORT] - else: - ports_to_publish = [_PIPELOCK_PORT] + # macOS networking. Egress is always the agent's HTTP/HTTPS proxy. + ports_to_publish: list[int] = [_EGRESS_PORT] if gp.upstreams: ports_to_publish.append(_GIT_HTTP_PORT) if sp is not None: diff --git a/bot_bottle/backend/smolmachines/local_registry.py b/bot_bottle/backend/smolmachines/local_registry.py index ba60076..ed864c0 100644 --- a/bot_bottle/backend/smolmachines/local_registry.py +++ b/bot_bottle/backend/smolmachines/local_registry.py @@ -48,7 +48,7 @@ from ...log import die # registry:2.8.3, pinned by digest. Same env-override pattern as the -# pipelock image pin in bot_bottle/backend/docker/pipelock.py. +# sidecar image pin in bot_bottle/backend/docker/sidecar_bundle.py. REGISTRY_IMAGE = os.environ.get( "BOT_BOTTLE_REGISTRY_IMAGE", "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index b4c7690..87c13f6 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -23,24 +23,21 @@ from ...backend.docker.bottle_state import ( bottle_identity, egress_state_dir, git_gate_state_dir, - pipelock_state_dir, supervise_state_dir, write_metadata, ) from ...egress import Egress from ...env import resolve_env from ...git_gate import GitGate -from ...pipelock import PipelockProxy from ...supervise import Supervise from ...workspace import workspace_plan as resolve_workspace_plan from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight -# Gateway ports the bundle exposes inside its container — pipelock -# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent -# inside the smolvm guest dials these on the bundle's pinned IP. -_BUNDLE_PIPELOCK_PORT = 8888 +# Gateway ports the bundle exposes inside its container — git-gate's +# git-daemon, supervise's MCP. The agent inside the smolvm guest +# dials these on the bundle's pinned IP. _BUNDLE_GIT_GATE_PORT = 9418 _BUNDLE_SUPERVISE_PORT = 9100 @@ -145,18 +142,6 @@ def resolve_plan( merged_guest_env.setdefault(key, val) agent_provision = replace(agent_provision, guest_env=merged_guest_env) - # Inner Plans for the four bundle daemons. The ABCs are - # platform-neutral — `.prepare()` writes config files + returns - # a Plan dataclass with no backend-specific assumptions. State - # dirs are still keyed by slug under the docker backend's - # bottle_state layout (shared on-host convention; not a docker - # dependency). - pipelock_dir = pipelock_state_dir(slug) - pipelock_dir.mkdir(parents=True, exist_ok=True) - proxy_plan = PipelockProxy().prepare( - bottle, slug, pipelock_dir, agent_provision.egress_routes, - ) - egress_dir = egress_state_dir(slug) egress_dir.mkdir(parents=True, exist_ok=True) egress_plan = Egress().prepare( @@ -181,7 +166,6 @@ def resolve_plan( agent_image_ref=agent_image_ref, guest_env=agent_provision.guest_env, prompt_file=prompt_file, - proxy_plan=proxy_plan, git_gate_plan=git_gate_plan, egress_plan=egress_plan, supervise_plan=supervise_plan, diff --git a/bot_bottle/backend/smolmachines/provision/ca.py b/bot_bottle/backend/smolmachines/provision/ca.py index a745f1f..dbce04a 100644 --- a/bot_bottle/backend/smolmachines/provision/ca.py +++ b/bot_bottle/backend/smolmachines/provision/ca.py @@ -1,13 +1,10 @@ -"""Install the per-bottle MITM CA into the smolmachines guest's -trust store (PRD 0023 chunk 4d). +"""Install the per-bottle egress MITM CA into the smolmachines +guest's trust store (PRD 0023 chunk 4d). -Mirrors `backend.docker.provision.ca`: select the right CA (egress -when the bottle has routes, else pipelock), copy it to Debian's -`/usr/local/share/ca-certificates/` path, +Mirrors `backend.docker.provision.ca`: copy the egress CA to +Debian's `/usr/local/share/ca-certificates/` path, `update-ca-certificates` to rebuild the trust bundle, and log the -fingerprint once. The selected cert depends on the agent's -HTTP_PROXY target — same logic as the docker backend, since the -agent dials the same daemons through the same bundle. +fingerprint once. `smolvm machine exec` runs commands as root in the VM (no `-u` flag exists; the VM init is root), so we don't need the explicit @@ -35,7 +32,7 @@ def provision_ca(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None: """Copy the agent-facing CA cert into the guest, rebuild the trust bundle, emit a one-line fingerprint log. Called from `BottleBackend.provision` after the smolvm guest is up.""" - cert_host_path, label = select_ca_cert(plan.egress_plan, plan.proxy_plan) + cert_host_path, label = select_ca_cert(plan.egress_plan) bottle.cp_in(str(cert_host_path), AGENT_CA_PATH) # Mode 0644 — readable to non-root tools in the guest. diff --git a/bot_bottle/backend/smolmachines/sidecar_bundle.py b/bot_bottle/backend/smolmachines/sidecar_bundle.py index 4fe7085..60b3413 100644 --- a/bot_bottle/backend/smolmachines/sidecar_bundle.py +++ b/bot_bottle/backend/smolmachines/sidecar_bundle.py @@ -19,7 +19,7 @@ This module ships the lifecycle primitives only — create network, start bundle, stop bundle, remove network — wrapped around `subprocess.run(["docker", ...])`. Wiring them into the launch flow + populating the `BundleLaunchSpec` from the inner -Plans (PipelockProxyPlan, EgressPlan, …) lands in chunk 2d.""" +Plans (EgressPlan, …) lands in chunk 2d.""" from __future__ import annotations @@ -69,7 +69,7 @@ class BundleLaunchSpec: # Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The # supervisor inside the bundle reads it to skip # bottle-irrelevant daemons (e.g. supervise=False bottles). - daemons_csv: str = "egress,pipelock" + daemons_csv: str = "egress" # Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name # form inherits the value from the docker-run subprocess env, # matching the docker backend's compose-up secret-forwarding diff --git a/bot_bottle/backend/util.py b/bot_bottle/backend/util.py index 8e64b1d..f5ea929 100644 --- a/bot_bottle/backend/util.py +++ b/bot_bottle/backend/util.py @@ -14,7 +14,6 @@ from ..log import die, info if TYPE_CHECKING: from ..egress import EgressPlan - from ..pipelock import PipelockProxyPlan # Debian-family CA layout, shared by every backend (all guest images @@ -35,35 +34,20 @@ def host_skill_dir(name: str) -> str: return f"{home}/.claude/skills/{name}" -def select_ca_cert( - egress_plan: EgressPlan, proxy_plan: PipelockProxyPlan -) -> tuple[Path, str]: - """Pick the agent-facing CA cert (and a short label for the log - line) that matches the proxy the agent's HTTP_PROXY points at. - Egress wins when the bottle declares any routes (it sits in front - of pipelock); else pipelock. +def select_ca_cert(egress_plan: EgressPlan) -> tuple[Path, str]: + """Return the egress MITM CA cert path and label for provision_ca. - Shared by every backend's `provision_ca`: launch mints the chosen - CA(s) and re-binds their host paths into these inner plans before - provision runs, so an empty/missing path here means launch's - bringup is broken — fatal.""" - if egress_plan.routes: - cert = egress_plan.mitmproxy_ca_cert_only_host_path - if cert == Path() or not cert.is_file(): - die( - f"egress CA cert missing at {cert or '(empty)'}; " - f"launch must have called egress_tls_init and " - f"re-bound the plan before provision" - ) - return cert, "egress" - cert = proxy_plan.ca_cert_host_path - if not cert or not cert.is_file(): + Launch always mints the CA and re-binds the host path into the + egress_plan before provision runs, so an empty/missing path here + means launch's bringup is broken — fatal.""" + cert = egress_plan.mitmproxy_ca_cert_only_host_path + if cert == Path() or not cert.is_file(): die( - f"pipelock CA cert missing at {cert or '(empty)'}; " - f"launch must have called pipelock_tls_init and re-bound " - f"the plan before provision" + f"egress CA cert missing at {cert or '(empty)'}; " + f"launch must have called egress_tls_init and " + f"re-bound the plan before provision" ) - return cert, "pipelock" + return cert, "egress" def log_ca_fingerprint(cert_host_path: Path, label: str) -> None: diff --git a/bot_bottle/egress_addon_core.py b/bot_bottle/egress_addon_core.py index b59a4dd..af9e674 100644 --- a/bot_bottle/egress_addon_core.py +++ b/bot_bottle/egress_addon_core.py @@ -167,7 +167,7 @@ def is_git_push_request(path: str, query: str) -> bool: Universal across routes — the block fires even when no egress route matches the host. A bare-pass route (host with no auth, no path_allowlist) would otherwise let push through to - pipelock + upstream untouched. + the upstream untouched. """ if path.endswith("/git-receive-pack"): return True @@ -189,8 +189,8 @@ def match_route( exactly (case-insensitive). DNS names are case-insensitive. Wildcard hosts (`*.foo.com`) are NOT supported — they caused - too many edge cases (apex match? cert validation? pipelock - mirror mismatch?) for too little payoff. Operators that need + too many edge cases (apex match? cert validation?) for too + little payoff. Operators that need multiple subdomains declare them individually (or one common parent host as a bare-pass route).""" target = request_host.lower() @@ -210,8 +210,7 @@ def decide( return what the addon should do with the request. - No matching route → BLOCK. The route table is the bottle's - egress allowlist; defense-in-depth complements pipelock's - hostname gate on the downstream leg. A bottle that wants a + egress allowlist. A bottle that wants a host reachable from the agent must declare a route for it (bare-pass route — no `auth`, no `path_allowlist` — is fine for hosts that just need passthrough). diff --git a/bot_bottle/egress_entrypoint.sh b/bot_bottle/egress_entrypoint.sh index c56de23..fb64ff4 100644 --- a/bot_bottle/egress_entrypoint.sh +++ b/bot_bottle/egress_entrypoint.sh @@ -6,15 +6,15 @@ # call it as a normal child. Behavior is unchanged: # # * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch -# to `--mode upstream:URL` to forward all post-MITM traffic -# through pipelock. mitmproxy does NOT honor HTTPS_PROXY on -# its outbound side, so the upstream wiring has to be the -# mitmproxy mode flag, not env. +# to `--mode upstream:URL` to chain through an upstream proxy. +# mitmproxy does NOT honor HTTPS_PROXY on its outbound side, +# so the upstream wiring has to be the mitmproxy mode flag, +# not env. # * Upstream trust: when EGRESS_UPSTREAM_CA is set, build a -# combined trust bundle (system roots + pipelock CA) and point +# combined trust bundle (system roots + upstream CA) and point # mitmproxy at it. The option REPLACES mitmproxy's default -# trust store, so passing pipelock's CA alone would break -# route-configured pipelock passthrough hosts. +# trust store, so passing the upstream CA alone would break +# non-chained hosts. # * `-s /app/egress_addon.py` loads the addon that reads # /etc/egress/routes.yaml. @@ -38,11 +38,7 @@ fi # Bind address. Docker backend wants `0.0.0.0` (agent dials egress # directly via the docker network alias). Smolmachines backend -# wants `127.0.0.1` because the agent dials pipelock — not egress -# — and egress is pipelock's localhost-only upstream inside the -# bundle. TSI's IP-only allowlist would otherwise let the agent -# reach `:9099` and bypass pipelock's DLP; binding -# 127.0.0.1 inside the bundle closes that gap (PRD 0023 chunk 3). +# uses EGRESS_LISTEN_HOST when a non-default binding is needed. LISTEN_HOST_FLAG="" if [ -n "$EGRESS_LISTEN_HOST" ]; then LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST" @@ -56,13 +52,10 @@ if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then fi # Scope the proxy env to this process tree only. In the bundle -# image (PRD 0024) the four daemons share one container — setting +# image (PRD 0024) multiple daemons share one container — setting # HTTPS_PROXY at the container level would route git-gate's git -# pushes through pipelock, which is wrong (pipelock doesn't proxy -# SSH and would block public git repos). Setting them here means -# only mitmdump's subprocess inherits them. In the legacy -# four-sidecar setup these env vars are also set in compose; here -# they're additionally defensive. +# pushes through an upstream proxy unintentionally. Setting them +# here means only mitmdump's subprocess inherits them. if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY" export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY" diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 6a5c0ac..58427a2 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -15,9 +15,9 @@ a bare repo on the gate; `git daemon` serves the bare repos over The agent never sees the upstream credential under either path. -Why a third sidecar (not folded into pipelock or ssh-gate): the +Why a separate sidecar (not folded into egress or ssh-gate): the gate is the only one of the three that holds upstream push -credentials. Mixing it with pipelock would put push creds in the +credentials. Mixing it with egress would put push creds in the same blast radius as internet-facing TLS interception; mixing it with ssh-gate would force ssh-gate above L4 and into git-protocol land. See `docs/prds/0008-git-gate.md`. diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 63ad90d..2ab2c0c 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -18,8 +18,7 @@ Bottle schema (frontmatter): user: { name: , email: } # optional repos: { : , ... } # optional egress: { routes: [ , ... ] } - # route keys: host, path_allowlist, auth, role, pipelock - # pipelock: { tls_passthrough: , ssrf_ip_allowlist: [, ...] } + # route keys: host, path_allowlist, auth, role supervise: # optional Agent schema (frontmatter): @@ -98,12 +97,11 @@ class Bottle: git_user: GitUser = field(default_factory=GitUser) egress: EgressConfig = field(default_factory=EgressConfig) # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, - # the launch step brings up a supervise sidecar that exposes three - # MCP tools to the agent (cred-proxy-block, pipelock-block, - # capability-block; the cred-proxy-block tool is renamed and - # retargeted at egress in PRD 0017 chunk 3) plus mounts the - # current-config dir read-only into the agent at /etc/bot-bottle/ - # current-config. False (the default) skips the sidecar and mount. + # the launch step brings up a supervise sidecar that exposes MCP + # tools to the agent (egress-block, capability-block) plus mounts + # the current-config dir read-only into the agent at + # /etc/bot-bottle/current-config. False (the default) skips the + # sidecar and mount. supervise: bool = False @classmethod diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index afabd0a..9ea62b8 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -282,10 +282,8 @@ class _Supervisor: def restart_daemon(self, daemon_name: str, *, grace: float = 5.0) -> bool: """Terminate one named child and spawn a fresh one, leaving - the other daemons running. Used by the pipelock-apply path: - pipelock has no in-process reload, so apply_allowlist_change - runs `docker kill --signal USR1 ` after writing the - new yaml; the supervisor catches SIGUSR1 and calls this. + the other daemons running. A daemon that has no in-process + reload can be restarted this way after its config file changes. Behavior: SIGTERM → wait up to `grace` seconds → SIGKILL if still alive → spawn a replacement under the same DaemonSpec. @@ -293,8 +291,8 @@ class _Supervisor: forward_signal / shutdown calls reach the new pid. Returns True iff a daemon by that name was running and a - replacement spawned; False if no such daemon (the - compose-renderer subset said this bottle doesn't run it).""" + replacement spawned; False if no such daemon (not wired + for this bottle).""" if self.shutdown_at is not None: _log(f"restart {daemon_name} skipped; supervisor is shutting down") return False diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 3e26c46..b6a2806 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -81,8 +81,7 @@ STATUS_REJECTED = "rejected" STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) # Operator-initiated audit entries (no tool call). PRD 0014's -# `routes edit ` and PRD 0015's `pipelock edit ` -# verbs write entries with this action. +# `routes edit ` verb writes entries with this action. ACTION_OPERATOR_EDIT = "operator-edit" QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue" diff --git a/bot_bottle/yaml_subset.py b/bot_bottle/yaml_subset.py index e110d95..159189f 100644 --- a/bot_bottle/yaml_subset.py +++ b/bot_bottle/yaml_subset.py @@ -63,7 +63,7 @@ from typing import cast class YamlSubsetError(ValueError): """Raised when input violates the YAML subset's rules. Callers - that want fatal-exit semantics (manifest loader, pipelock-apply, + that want fatal-exit semantics (manifest loader, egress-apply, etc.) catch this at their own boundary and forward to `die`; callers running outside the bot-bottle CLI process (the egress sidecar's addon) handle it as a normal exception.""" diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index 63e9fb6..14a2e44 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -53,7 +53,7 @@ _FAKE_SECRETS = { @skip_unless_docker() @unittest.skipIf( os.environ.get("GITEA_ACTIONS") == "true", - "skipped under act_runner: pipelock_tls_init uses a host bind mount " + "skipped under act_runner: egress_tls_init uses a host bind mount " "the runner container can't see, and the network topology hides " "sibling-sidecar visibility — same constraint as the other " "bottle-bringup integration tests", @@ -256,14 +256,11 @@ class TestSandboxEscape(unittest.TestCase): # ---- attack 3: HTTP exfil shapes --------------------------------- - # Sandbox-block signature: pipelock / egress return HTTP 403 on - # policy reject; the response body carries a recognizable - # marker. Egress's reject message starts `"egress: host '...' - # is not in the bottle's egress.routes allowlist"`; pipelock's - # DLP rejects start `"blocked: "` (e.g. - # `"blocked: DLP match: Anthropic API Key (critical)"`, - # `"blocked: request body contains secret"`). - _SANDBOX_BLOCK_MARKERS = ("egress:", "pipelock", "blocked:") + # Sandbox-block signature: egress returns HTTP 403 on policy + # reject; the response body carries a recognizable marker. + # Egress's reject message starts with `"egress: host '...' + # is not in the bottle's egress.routes allowlist"`. + _SANDBOX_BLOCK_MARKERS = ("egress:", "blocked:") def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore """A real sandbox block produces an HTTP 403 with a @@ -286,7 +283,7 @@ class TestSandboxEscape(unittest.TestCase): self.assertTrue( has_marker and http_code == "403", f"{label}: expected a sandbox-level block (HTTP 403 with " - f"egress/pipelock marker), got HTTP {http_code!r}. " + f"egress marker), got HTTP {http_code!r}. " f"If the response came from the actual upstream, the " f"secret REACHED the network — that's the leak this " f"test exists to catch. body={body!r} " @@ -297,9 +294,8 @@ class TestSandboxEscape(unittest.TestCase): """The agent tries to leak `$TEST_SECRET_ANTHROPIC` to an ALLOWLISTED destination (api.anthropic.com) via four different request shapes. Each must be blocked by a - sandbox layer (pipelock DLP / egress filter / etc.) — - producing an HTTP 403 with an `egress:` or `pipelock` - marker in the response body. + sandbox layer (egress allowlist filter) — producing an + HTTP 403 with an `egress:` marker in the response body. Anything else means the request reached upstream and the secret leaked, even if the upstream responded with its @@ -310,10 +306,10 @@ class TestSandboxEscape(unittest.TestCase): remediation lands as its own PRD before this test merges. DON'T mark expectedFailure to silence it. - Destination note: we use `raw.githubusercontent.com`, one - of the DEFAULT_ALLOWLIST hosts. It is not route-configured - for pipelock TLS passthrough, so pipelock MITMs it and the - DLP scan applies, which is what this attack exercises.""" + Destination note: we use `raw.githubusercontent.com`, which + is not in the bottle's egress.routes allowlist, so egress + blocks every request to it regardless of the secret's + location in the request.""" # Capture HTTP code via curl's -w; don't use --fail so # we get the response body even on 4xx. url_base = "https://raw.githubusercontent.com" @@ -352,13 +348,13 @@ class TestSandboxEscape(unittest.TestCase): def test_4_dns_exfil_blocked(self) -> None: """Two sub-attacks against DNS: - 4a — crafted subdomain that pipelock would resolve. The - hostname `.api.anthropic.com` looks "under" - the allowlisted apex but pipelock's allowlist is - exact-match — it should reject the host BEFORE - issuing the DNS query, so the secret never reaches - an external resolver. - 4b — direct DNS query bypassing pipelock entirely. The + 4a — crafted subdomain attack. The hostname + `.api.anthropic.com` looks "under" the + allowlisted apex but egress's allowlist is + exact-match — it rejects the host before issuing + a DNS query, so the secret never reaches an + external resolver. + 4b — direct DNS query bypassing egress entirely. The agent's internal network has no default gateway; even an explicit resolver like 8.8.8.8 should be unreachable. Confirms the network isolation is diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py index 5829588..2051ea3 100644 --- a/tests/integration/test_sidecar_bundle_compose.py +++ b/tests/integration/test_sidecar_bundle_compose.py @@ -2,8 +2,8 @@ Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a working bottle: `docker compose up` brings the agent + bundle pair -online, the four daemons inside the bundle bind their ports, and -the agent can reach pipelock + supervise via the bundle's network +online, the daemons inside the bundle bind their ports, and the +agent can reach egress + supervise via the bundle's network aliases (no agent-side config changes between flag positions). Skipped under GITEA_ACTIONS — the bundle image is a multi-stage @@ -27,11 +27,9 @@ from tests._docker import skip_unless_docker def _manifest() -> Manifest: - """Bottle with supervise on so the bundle exercises three of - the four daemons (pipelock, egress, supervise). Git is off - because a meaningful git-gate test needs a real upstream and - SSH keys — out of scope for a bundle smoke. Egress is - implicitly on as pipelock's upstream regardless of routes.""" + """Bottle with supervise on so the bundle exercises egress + + supervise. Git is off because a meaningful git-gate test needs + a real upstream and SSH keys — out of scope for a bundle smoke.""" return Manifest.from_json_obj({ "bottles": { "dev": { @@ -68,21 +66,16 @@ class TestSidecarBundleCompose(unittest.TestCase): plan = backend.prepare(spec, stage_dir=stage_dir) with backend.launch(plan) as bottle: # The agent's HTTPS_PROXY URL (resolved at - # renderer-time, unchanged from the legacy - # shape) should reach pipelock inside the - # bundle. We probe by asking for the proxy's - # listening port from inside the agent. + # renderer-time) should reach egress inside + # the bundle. A bare CONNECT with no upstream + # URL gets rejected with 400 or 405 but proves + # the listener is alive at the alias. probe = bottle.exec( "set -eu\n" "echo HTTPS_PROXY=$HTTPS_PROXY\n" "PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n" "HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n" "echo HOST=$HOST PORT=$PORT\n" - # nc is not in the agent image but curl is — - # a CONNECT with no upstream URL will get - # rejected by pipelock with 400 or 405 but - # confirms the listener is alive at the - # alias. "curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' " " \"http://$HOST:$PORT/\" || true\n" ) @@ -98,11 +91,10 @@ class TestSidecarBundleCompose(unittest.TestCase): shutil.rmtree(stage_dir, ignore_errors=True) self.assertEqual(0, probe.returncode, msg=probe.stderr) - # pipelock answered SOMETHING — any 4xx is fine, just proves - # the bundle's pipelock daemon is listening at the - # `pipelock` alias on port 8888 (or whatever the env says). + # egress answered SOMETHING — any 4xx is fine, just proves + # the egress daemon is listening at the proxy address. self.assertIn("http=", probe.stdout, - f"no HTTP response from pipelock: {probe.stdout!r}") + f"no HTTP response from egress: {probe.stdout!r}") # supervise's /health endpoint exists (PRD 0013); it should # answer 200 or similar — anything non-empty proves the # third daemon's alias resolves to the same bundle. diff --git a/tests/integration/test_sidecar_bundle_image.py b/tests/integration/test_sidecar_bundle_image.py index 08c2644..771f438 100644 --- a/tests/integration/test_sidecar_bundle_image.py +++ b/tests/integration/test_sidecar_bundle_image.py @@ -1,15 +1,15 @@ """Integration: PRD 0024 chunk 1 — the sidecar bundle image builds -and the four daemon binaries are present + executable inside it. +and the daemon binaries are present + executable inside it. This test does NOT exercise the daemons running against real -config (pipelock.yaml, routes.yaml, etc) — that lands in chunk 2 -when the renderer wires the bundle into compose. What we verify -here is the chunk-1 contract: +config (routes.yaml, etc) — that lands in chunk 2 when the +renderer wires the bundle into compose. What we verify here is +the chunk-1 contract: - Dockerfile.sidecars builds (multi-stage works, base layers pull, COPYs resolve). - - pipelock, gitleaks, mitmdump are at the documented paths and - answer `--version`. + - gitleaks, mitmdump are at the documented paths and answer + `--version`. - The Python init at /app/sidecar_init.py runs and prints the expected "no daemons selected" line when the supervisor is pointed at an empty daemon set. @@ -74,11 +74,6 @@ class TestSidecarBundleImage(unittest.TestCase): ) return proc.returncode, proc.stdout.decode("utf-8", errors="replace") - def test_pipelock_binary_present_and_versioned(self): - rc, out = self._run_in_image("/usr/local/bin/pipelock", "version") - self.assertEqual(0, rc, msg=out) - self.assertIn("pipelock version", out) - def test_gitleaks_binary_present_and_versioned(self): rc, out = self._run_in_image("/usr/bin/gitleaks", "version") self.assertEqual(0, rc, msg=out) diff --git a/tests/integration/test_smolmachines_bundle_bringup.py b/tests/integration/test_smolmachines_bundle_bringup.py index 350abfc..043a4a9 100644 --- a/tests/integration/test_smolmachines_bundle_bringup.py +++ b/tests/integration/test_smolmachines_bundle_bringup.py @@ -81,13 +81,9 @@ class TestBundleBringup(unittest.TestCase): subnet=subnet, gateway=gateway, bundle_ip=bundle_ip, - # Only run the pipelock daemon for this smoke — it's - # the lightest of the four and doesn't need bind - # mounts beyond what we'd skip without - # BOT_BOTTLE_SIDECAR_DAEMONS. (The init - # supervisor will exit if pipelock fails to find its - # yaml — that's expected here; we just need the - # container to land on the network at the right IP.) + # Empty daemons_csv → init exits "no daemons selected" + # immediately. We just need the container to land on + # the network at the right IP before it exits. daemons_csv="", # empty → init exits "no daemons selected" ) start_bundle(spec) diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 75c2b41..b4ea4bd 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -124,32 +124,6 @@ class TestSmolmachinesLaunch(unittest.TestCase): f"expected a connect-refusal message; got: {r.stdout!r}", ) - def test_pipelock_answers_on_bundle_ip(self): - # Chunk 4b: the bundle's pipelock daemon is now actually - # running (was daemons_csv="" in chunks 2d/3). From inside - # the guest, a TCP connect to :8888 must succeed - # — distinct from the egress-port-bypass probe below where - # the connect must FAIL. - # - # We don't try to speak proxy protocol here — pipelock will - # 4xx a bare GET — we just verify the socket answers. - r = self.bottle.exec( - f"wget -T 5 -t 1 -O - http://{self.plan.bundle_ip}:8888/ " - "2>&1 || true" - ) - # Any HTTP response (even a 4xx) proves pipelock is up. - # "connection refused" / "unable to connect" / "timed out" - # would mean it isn't. - msg = r.stdout.lower() - self.assertNotIn( - "connection refused", msg, - f"pipelock connect refused — daemon not listening? {r.stdout!r}", - ) - self.assertNotIn( - "timed out", msg, - f"pipelock connect timed out: {r.stdout!r}", - ) - def test_prompt_file_lands_in_guest(self): # provision_prompt copies the host-side prompt.txt into the # guest at /root/.bot-bottle-prompt.txt. The content diff --git a/tests/unit/test_backend_selection.py b/tests/unit/test_backend_selection.py index a4ce017..3df9003 100644 --- a/tests/unit/test_backend_selection.py +++ b/tests/unit/test_backend_selection.py @@ -57,7 +57,7 @@ class TestEnumerateActiveAgents(unittest.TestCase): def test_concatenates_per_backend(self): a = ActiveAgent( backend_name="docker", slug="a-1", agent_name="impl", - started_at="", services=("pipelock",), + started_at="", services=("egress",), ) b = ActiveAgent( backend_name="smolmachines", slug="b-2", agent_name="research", diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 3633f39..426ee8f 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -32,7 +32,6 @@ from bot_bottle.egress import ( ) from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -80,18 +79,6 @@ def _spec(*, supervise: bool, with_git: bool, with_egress: bool) -> BottleSpec: ) -def _proxy_plan() -> PipelockProxyPlan: - return PipelockProxyPlan( - yaml_path=STATE / "pipelock.yaml", - slug=SLUG, - internal_network=f"bot-bottle-net-{SLUG}", - internal_network_cidr="10.1.2.0/24", - egress_network=f"bot-bottle-egress-{SLUG}", - ca_cert_host_path=STATE / "pipelock-ca" / "ca.pem", - ca_key_host_path=STATE / "pipelock-ca" / "ca-key.pem", - ) - - def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan: return GitGatePlan( slug=SLUG, @@ -119,8 +106,6 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan: egress_network=f"bot-bottle-egress-{SLUG}", mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem", - pipelock_ca_host_path=STATE / "pipelock-ca" / "ca.pem", - pipelock_proxy_url="http://127.0.0.1:8888", ) @@ -178,7 +163,6 @@ def _plan( env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, prompt_file=STAGE / "prompt", - proxy_plan=_proxy_plan(), git_gate_plan=_git_gate_plan(upstreams), egress_plan=_egress_plan(routes), supervise_plan=_supervise_plan() if supervise else None, @@ -233,16 +217,15 @@ class TestAgentAlwaysPresent(unittest.TestCase): s = bottle_plan_to_compose(_plan())["services"]["agent"] self.assertEqual({"internal"}, set(s["networks"].keys())) - def test_agent_proxy_via_pipelock_when_no_egress(self): - s = bottle_plan_to_compose(_plan(with_egress=False))["services"]["agent"] - env = s["environment"] - # Looking for HTTPS_PROXY pointing at pipelock's container name. - proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")] - self.assertEqual(1, len(proxy_lines)) - self.assertEqual( - "HTTPS_PROXY=http://pipelock:8888", - proxy_lines[0], - ) + def test_agent_proxy_always_via_egress(self): + for with_egress in (False, True): + with self.subTest(with_egress=with_egress): + s = bottle_plan_to_compose( + _plan(with_egress=with_egress) + )["services"]["agent"] + proxy_lines = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")] + self.assertEqual(1, len(proxy_lines)) + self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy_lines[0]) def test_agent_proxy_via_egress_when_egress_present(self): s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"] @@ -306,9 +289,9 @@ class TestAgentAlwaysPresent(unittest.TestCase): class TestSidecarBundleShape(unittest.TestCase): """The compose renderer emits exactly one `sidecars` service in - place of the four daemons it owns (pipelock + egress + git-gate - + supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar - shape entirely, so the bundle is the only thing exercised here.""" + place of the daemons it owns (egress + git-gate + supervise). + PRD 0024 chunk 5 dropped the legacy four-sidecar shape entirely, + so the bundle is the only thing exercised here.""" def _render(self, **plan_kwargs: object) -> Any: # type: ignore return bottle_plan_to_compose(_plan(**plan_kwargs)) # type: ignore @@ -335,13 +318,10 @@ class TestSidecarBundleShape(unittest.TestCase): sc = self._render()["services"]["sidecars"] self.assertEqual({"internal", "egress"}, set(sc["networks"].keys())) - def test_internal_aliases_cover_pipelock_and_egress_shortnames(self): - # The agent's HTTPS_PROXY url references either `egress` or - # `pipelock`. Both must resolve to the bundle. + def test_internal_aliases_include_egress_shortname(self): sc = self._render()["services"]["sidecars"] aliases = set(sc["networks"]["internal"]["aliases"]) self.assertIn("egress", aliases) - self.assertIn("pipelock", aliases) def test_internal_aliases_omit_inactive_sidecars(self): # With no git-gate / supervise, those names are NOT aliased @@ -359,16 +339,13 @@ class TestSidecarBundleShape(unittest.TestCase): self.assertIn("supervise", aliases) def test_daemons_csv_lists_only_active(self): - # Egress + pipelock are always in the daemon set even when - # the bottle has no routes (egress falls back to regular@9099 - # and is just unused; cheaper than special-casing). sc = self._render()["services"]["sidecars"] daemons = { line.split("=", 1)[1] for line in sc["environment"] if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=") } - self.assertEqual({"egress,pipelock"}, daemons) + self.assertEqual({"egress"}, daemons) def test_daemons_csv_expands_with_optional_sidecars(self): sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] @@ -379,13 +356,13 @@ class TestSidecarBundleShape(unittest.TestCase): else: self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env") self.assertEqual( - ["egress", "pipelock", "git-gate", "supervise"], + ["egress", "git-gate", "supervise"], csv.split(","), ) def test_bundle_env_does_not_set_https_proxy(self): # HTTPS_PROXY at the container level would route git-gate's - # git fetches through pipelock. Scoping it to mitmdump is + # git fetches through the proxy. Scoping it to mitmdump is # the job of egress_entrypoint.sh; the bundle env must not # leak it. sc = self._render(with_egress=True)["services"]["sidecars"] @@ -397,22 +374,15 @@ class TestSidecarBundleShape(unittest.TestCase): f"bundle env must not set {line!r}", ) - def test_egress_env_present_when_routes_declared(self): + def test_egress_token_env_present_when_routes_declared(self): sc = self._render(with_egress=True)["services"]["sidecars"] env_strings = sc["environment"] - self.assertTrue(any( - e.startswith("EGRESS_UPSTREAM_PROXY=") for e in env_strings)) - self.assertTrue(any( - e.startswith("EGRESS_UPSTREAM_CA=") for e in env_strings)) - # Token env name is forwarded as a bare entry. self.assertIn("EGRESS_TOKEN_0", env_strings) - def test_egress_env_omitted_when_no_routes(self): + def test_egress_token_env_omitted_when_no_routes(self): sc = self._render()["services"]["sidecars"] env_strings = sc["environment"] - for e in env_strings: - self.assertFalse(e.startswith("EGRESS_UPSTREAM_PROXY=")) - self.assertFalse(e.startswith("EGRESS_UPSTREAM_CA=")) + self.assertNotIn("EGRESS_TOKEN_0", env_strings) def test_supervise_env_present_when_active(self): sc = self._render(supervise=True)["services"]["sidecars"] @@ -421,22 +391,19 @@ class TestSidecarBundleShape(unittest.TestCase): self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings)) self.assertTrue(any(e.startswith("SUPERVISE_PORT=") for e in env_strings)) - def test_volumes_union_minimal_includes_pipelock(self): + def test_volumes_always_includes_egress_ca(self): sc = self._render()["services"]["sidecars"] targets = {v["target"] for v in sc["volumes"]} - self.assertIn("/etc/pipelock.yaml", targets) + self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) def test_volumes_union_full_matrix(self): sc = self._render(with_git=True, with_egress=True, supervise=True)[ "services"]["sidecars"] targets = {v["target"] for v in sc["volumes"]} - # Pipelock + egress + git-gate + supervise paths all - # present. - self.assertIn("/etc/pipelock.yaml", targets) + self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) self.assertIn("/etc/egress/routes.yaml", targets) self.assertIn("/git-gate-entrypoint.sh", targets) self.assertIn("/git-gate/creds/upstream-known_hosts", targets) - # supervise queue dir target = QUEUE_DIR_IN_CONTAINER self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise") for t in targets)) diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 9225d90..a53a51b 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -23,7 +23,6 @@ from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -90,9 +89,6 @@ def _plan( env_file=Path("/tmp/agent.env"), forwarded_env={}, prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12", - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 7de7b0e..db9f4a2 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -24,7 +24,6 @@ from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -91,9 +90,6 @@ def _plan( env_file=Path("/tmp/agent.env"), forwarded_env={}, prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12", - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index 0ba71c2..f0eb26a 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -40,22 +40,22 @@ class TestParseServicesByProject(unittest.TestCase): def test_multiple_services_per_project(self): out = _enumerate._parse_services_by_project( "bot-bottle-dev-abc\tegress\n" - "bot-bottle-dev-abc\tpipelock\n" + "bot-bottle-dev-abc\tgit-gate\n" "bot-bottle-dev-abc\tsupervise\n" ) self.assertEqual( - {"bot-bottle-dev-abc": {"egress", "pipelock", "supervise"}}, + {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}}, out, ) def test_multiple_projects(self): out = _enumerate._parse_services_by_project( "proj-a\tegress\n" - "proj-b\tpipelock\n" + "proj-b\tgit-gate\n" "proj-a\tsupervise\n" ) self.assertEqual( - {"proj-a": {"egress", "supervise"}, "proj-b": {"pipelock"}}, + {"proj-a": {"egress", "supervise"}, "proj-b": {"git-gate"}}, out, ) @@ -117,7 +117,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): )) self._stub( ["dev-abc"], - {"bot-bottle-dev-abc": {"pipelock", "egress", "supervise"}}, + {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}}, ) active = _enumerate.enumerate_active() self.assertEqual(1, len(active)) @@ -126,17 +126,17 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase): self.assertEqual("dev-abc", a.slug) self.assertEqual("implementer", a.agent_name) self.assertEqual("2026-05-26T03:00:00+00:00", a.started_at) - self.assertEqual(("egress", "pipelock", "supervise"), a.services) + self.assertEqual(("egress", "git-gate", "supervise"), a.services) def test_missing_metadata_renders_question_mark(self): # State dir doesn't exist for this slug — agent_name falls # back to "?" rather than dropping the row. - self._stub(["mystery-zzz"], {"bot-bottle-mystery-zzz": {"pipelock"}}) + self._stub(["mystery-zzz"], {"bot-bottle-mystery-zzz": {"egress"}}) active = _enumerate.enumerate_active() self.assertEqual(1, len(active)) self.assertEqual("?", active[0].agent_name) self.assertEqual("", active[0].started_at) - self.assertEqual(("pipelock",), active[0].services) + self.assertEqual(("egress",), active[0].services) def test_no_services_for_project_yields_empty_tuple(self): # Race window between `compose up` returning and the actual diff --git a/tests/unit/test_docker_launch_teardown.py b/tests/unit/test_docker_launch_teardown.py index 73b3b1e..8761237 100644 --- a/tests/unit/test_docker_launch_teardown.py +++ b/tests/unit/test_docker_launch_teardown.py @@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.workspace import workspace_plan @@ -80,10 +79,6 @@ def _plan(tmp: str) -> DockerBottlePlan: env_file=stage / "env", forwarded_env={}, prompt_file=stage / "prompt.txt", - proxy_plan=PipelockProxyPlan( - yaml_path=stage / "pipelock.yaml", - slug="test-teardown-00001", - ), use_runsc=False, ) @@ -101,10 +96,6 @@ class TestTeardownWarning(unittest.TestCase): buf = io.StringIO() with mock.patch.object(launch_mod.docker_mod, "build_image"), \ - mock.patch.object( - launch_mod, "pipelock_tls_init", - return_value=(Path("/ca.crt"), Path("/ca.key")), - ), \ mock.patch.object( launch_mod, "egress_tls_init", return_value=(Path("/egress_ca"), Path("/egress_cert")), diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 9e6fc39..1fb447c 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -20,7 +20,6 @@ from bot_bottle.backend.docker.provision import git as _git from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.workspace import workspace_plan @@ -53,9 +52,6 @@ def _plan(*, git_user: dict | None = None, # type: ignore env_file=Path("/tmp/agent.env"), forwarded_env={}, prompt_file=Path("/tmp/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12", - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index bb8de25..9eb715a 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -133,19 +133,6 @@ class TestRoutesForBottleManifestOnly(unittest.TestCase): effective = [r.host for r in egress_routes_for_bottle(b)] self.assertEqual(["x.example"], effective) - def test_tls_passthrough_lifted_from_manifest(self): - b = _bottle([{ - "host": "api.openai.com", - "auth": {"scheme": "Bearer", "token_ref": "T"}, - "pipelock": {"tls_passthrough": True}, - }]) - routes = egress_routes_for_bottle(b) - self.assertTrue(routes[0].tls_passthrough) - - def test_tls_passthrough_false_by_default(self): - b = _bottle([{"host": "api.github.com"}]) - routes = egress_routes_for_bottle(b) - self.assertFalse(routes[0].tls_passthrough) class TestProviderRouteMerge(unittest.TestCase): diff --git a/tests/unit/test_egress_addon_core.py b/tests/unit/test_egress_addon_core.py index 0b1fa90..904b870 100644 --- a/tests/unit/test_egress_addon_core.py +++ b/tests/unit/test_egress_addon_core.py @@ -180,7 +180,7 @@ class TestMatchRoute(unittest.TestCase): def test_wildcard_hosts_not_supported(self): # `*.example.com` is treated as a literal host string by # the exact-only matcher. Removed from the design after - # the apex/RFC-6125/pipelock-mirror edge cases stacked up. + # the apex/RFC-6125 edge cases stacked up. routes = (Route(host="*.example.com"),) self.assertIsNone(match_route(routes, "foo.example.com")) self.assertIsNone(match_route(routes, "example.com")) @@ -191,10 +191,8 @@ class TestMatchRoute(unittest.TestCase): class TestDecide(unittest.TestCase): def test_no_matching_route_blocks(self): - # Defense-in-depth: egress gates the bottle's allowlist - # too, not just pipelock. Any host the operator didn't declare - # in egress.routes is 403'd at egress before it - # ever reaches pipelock. + # Egress gates the bottle's allowlist. Any host the operator + # didn't declare in egress.routes is 403'd at egress. d = decide((), "elsewhere.example", "/anything", {}) self.assertEqual("block", d.action) self.assertIn("allowlist", d.reason) diff --git a/tests/unit/test_egress_apply.py b/tests/unit/test_egress_apply.py index ea54f20..4a78c98 100644 --- a/tests/unit/test_egress_apply.py +++ b/tests/unit/test_egress_apply.py @@ -6,9 +6,7 @@ import unittest from bot_bottle.backend.docker.egress_apply import ( EgressApplyError, - _hosts_in_routes, _merge_single_route, - _pipelock_safe_hosts, validate_routes_content, ) from bot_bottle.yaml_subset import parse_yaml_subset @@ -66,44 +64,6 @@ class TestValidateRoutesContent(unittest.TestCase): ) -class TestHostsInRoutes(unittest.TestCase): - def test_extracts_each_unique_host(self): - hosts = _hosts_in_routes( - 'routes:\n' - ' - host: "api.github.com"\n' - ' - host: "github.com"\n' - ' - host: "api.anthropic.com"\n' - ) - # Sorted+deduped. - self.assertEqual( - ["api.anthropic.com", "api.github.com", "github.com"], - hosts, - ) - - def test_dedupes_same_host(self): - hosts = _hosts_in_routes( - 'routes:\n' - ' - host: "x.example"\n' - ' path_allowlist:\n' - ' - "/a/"\n' - ' - host: "x.example"\n' - ' path_allowlist:\n' - ' - "/b/"\n' - ) - self.assertEqual(["x.example"], hosts) - - def test_empty_routes_returns_empty(self): - self.assertEqual([], _hosts_in_routes(_ROUTES_EMPTY)) - - def test_invalid_routes_raises(self): - # The mirror helper relies on parsing succeeding; bad input - # should error before pipelock is touched. - with self.assertRaises(EgressApplyError): - _hosts_in_routes( - 'routes:\n - path_allowlist:\n - "/no-host/"\n' - ) - - class TestMergeSingleRoute(unittest.TestCase): BASE = _ROUTES_ONE @@ -214,40 +174,5 @@ class TestMergeSingleRoute(unittest.TestCase): _merge_single_route("routes:\n\tbad", {"host": "x.example"}) -class TestPipelockSafeHosts(unittest.TestCase): - def test_passes_normal_hostnames_through(self): - self.assertEqual( - ["api.github.com", "registry.npmjs.org"], - _pipelock_safe_hosts(["api.github.com", "registry.npmjs.org"]), - ) - - def test_drops_wildcards(self): - # Wildcard host matching was removed from egress too, - # so a `*.foo.com` route is dead weight anyway; we drop it - # entirely from the pipelock mirror so the apply doesn't - # fail parse. - self.assertEqual( - ["api.github.com"], - _pipelock_safe_hosts(["*.example.com", "api.github.com"]), - ) - - def test_drops_bare_wildcard(self): - self.assertEqual([], _pipelock_safe_hosts(["*"])) - - def test_drops_ipv6_literals(self): - self.assertEqual( - ["api.example.com"], - _pipelock_safe_hosts(["[::1]", "api.example.com"]), - ) - - def test_preserves_order(self): - self.assertEqual( - ["a.example", "b.example", "c.example"], - _pipelock_safe_hosts([ - "a.example", "*.junk", "b.example", "weird host", "c.example", - ]), - ) - - if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index caf6cc4..4fa023e 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -219,57 +219,10 @@ class TestRole(unittest.TestCase): _bottle([{"host": "x.example", "role": ["x", 42]}]) -class TestPipelockPolicy(unittest.TestCase): - def test_tls_passthrough_route_policy(self): - b = _bottle([{ - "host": "api.openai.com", - "pipelock": {"tls_passthrough": True}, - }]) - self.assertTrue(b.egress.routes[0].Pipelock.TlsPassthrough) - - def test_ssrf_ip_allowlist_route_policy(self): - b = _bottle([{ - "host": "gitea.dideric.is", - "pipelock": {"ssrf_ip_allowlist": ["100.78.141.42/32"]}, - }]) - self.assertEqual( - ("100.78.141.42/32",), - b.egress.routes[0].Pipelock.SsrfIpAllowlist, - ) - - def test_tls_passthrough_defaults_false(self): - b = _bottle([{"host": "api.openai.com"}]) - self.assertFalse(b.egress.routes[0].Pipelock.TlsPassthrough) - self.assertEqual((), b.egress.routes[0].Pipelock.SsrfIpAllowlist) - - def test_pipelock_policy_must_be_object(self): +class TestPipelockKeyRejected(unittest.TestCase): + def test_pipelock_key_rejected_as_unknown(self): with self.assertRaises(ManifestError): - _bottle([{"host": "x.example", "pipelock": True}]) - - def test_tls_passthrough_must_be_bool(self): - with self.assertRaises(ManifestError): - _bottle([{ - "host": "x.example", - "pipelock": {"tls_passthrough": "yes"}, - }]) - - def test_ssrf_ip_allowlist_must_be_array(self): - with self.assertRaises(ManifestError): - _bottle([{ - "host": "x.example", - "pipelock": {"ssrf_ip_allowlist": "100.78.141.42/32"}, - }]) - - def test_ssrf_ip_allowlist_items_must_be_cidr_or_ip(self): - with self.assertRaises(ManifestError): - _bottle([{ - "host": "x.example", - "pipelock": {"ssrf_ip_allowlist": ["not-an-ip"]}, - }]) - - def test_unknown_pipelock_key_rejected(self): - with self.assertRaises(ManifestError): - _bottle([{"host": "x.example", "pipelock": {"wat": True}}]) + _bottle([{"host": "x.example", "pipelock": {"tls_passthrough": True}}]) class TestRouteValidation(unittest.TestCase): diff --git a/tests/unit/test_plan_print_parity.py b/tests/unit/test_plan_print_parity.py index e0fdb29..66458eb 100644 --- a/tests/unit/test_plan_print_parity.py +++ b/tests/unit/test_plan_print_parity.py @@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.workspace import workspace_plan @@ -93,13 +92,6 @@ def _agent_provision() -> AgentProvisionPlan: ) -def _proxy_plan(tmp: str) -> PipelockProxyPlan: - return PipelockProxyPlan( - yaml_path=Path(tmp) / "pipelock.yaml", - slug="test-00001", - ) - - def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: stage = Path(tmp) return DockerBottlePlan( @@ -121,7 +113,6 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: env_file=stage / "env", forwarded_env={}, prompt_file=stage / "prompt.txt", - proxy_plan=_proxy_plan(tmp), use_runsc=False, ) @@ -145,7 +136,6 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan: agent_image_ref="bot-bottle-claude:latest", guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, prompt_file=stage / "prompt.txt", - proxy_plan=_proxy_plan(tmp), ) diff --git a/tests/unit/test_sidecar_init.py b/tests/unit/test_sidecar_init.py index 1256af0..cb77a35 100644 --- a/tests/unit/test_sidecar_init.py +++ b/tests/unit/test_sidecar_init.py @@ -29,11 +29,8 @@ from bot_bottle.sidecar_init import ( class TestEnvForDaemon(unittest.TestCase): """Scope egress-only credential env vars to the egress daemon. - Regression for issue #84: pipelock's `scan_env: true` matched - `EGRESS_TOKEN_*` against egress's just-injected Authorization - header and 403-blocked the legitimate request. The agent - never has access to these slots, so stripping them from - non-egress daemons loses no DLP coverage.""" + The agent never has access to EGRESS_TOKEN_* slots, so stripping + them from non-egress daemons loses no DLP coverage.""" _BASE = { "PATH": "/usr/bin", @@ -47,26 +44,20 @@ class TestEnvForDaemon(unittest.TestCase): env = _env_for_daemon("egress", self._BASE) self.assertEqual(self._BASE, env) - def test_pipelock_loses_egress_tokens(self): - env = _env_for_daemon("pipelock", self._BASE) - self.assertNotIn("EGRESS_TOKEN_0", env) - self.assertNotIn("EGRESS_TOKEN_1", env) - # Non-token bundle env stays — supervise / git-gate / git-http / the - # upstream proxy URL are all load-bearing for other - # daemons. - self.assertEqual("/usr/bin", env["PATH"]) - self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"]) - self.assertEqual("9100", env["SUPERVISE_PORT"]) - - def test_git_daemons_and_supervise_also_lose_egress_tokens(self): + def test_git_daemons_and_supervise_lose_egress_tokens(self): for name in ("git-gate", "git-http", "supervise"): env = _env_for_daemon(name, self._BASE) self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_1", env) + # Non-token bundle env stays — supervise / git-gate / git-http are + # all load-bearing for other daemons. + self.assertEqual("/usr/bin", env["PATH"]) + self.assertEqual("http://127.0.0.1:8888", env["EGRESS_UPSTREAM_PROXY"]) + self.assertEqual("9100", env["SUPERVISE_PORT"]) def test_returns_independent_dict(self): # Caller mutation mustn't affect the original. - env = _env_for_daemon("pipelock", self._BASE) + env = _env_for_daemon("git-gate", self._BASE) env["X"] = "y" self.assertNotIn("X", self._BASE) @@ -78,7 +69,6 @@ class TestSelectedDaemons(unittest.TestCase): _DAEMONS = ( _DaemonSpec("egress", ("/bin/sh", "-c", ":")), - _DaemonSpec("pipelock", ("/bin/sh", "-c", ":")), _DaemonSpec("git-gate", ("/bin/sh", "-c", ":")), _DaemonSpec("supervise", ("/bin/sh", "-c", ":")), ) @@ -86,35 +76,34 @@ class TestSelectedDaemons(unittest.TestCase): def test_unset_returns_all(self): got = _selected_daemons({}, all_daemons=self._DAEMONS) self.assertEqual([d.name for d in got], - ["egress", "pipelock", "git-gate", "supervise"]) + ["egress", "git-gate", "supervise"]) def test_empty_returns_all(self): got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""}, all_daemons=self._DAEMONS) - self.assertEqual(4, len(got)) + self.assertEqual(3, len(got)) def test_whitespace_only_returns_all(self): got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "}, all_daemons=self._DAEMONS) - self.assertEqual(4, len(got)) + self.assertEqual(3, len(got)) def test_explicit_subset(self): got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,pipelock"}, + {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"}, all_daemons=self._DAEMONS, ) - self.assertEqual([d.name for d in got], ["egress", "pipelock"]) + self.assertEqual([d.name for d in got], ["egress", "git-gate"]) def test_preserves_canonical_order(self): # Order in the env var doesn't matter; the result follows - # the canonical _DAEMONS order so egress starts before - # pipelock (race-window reason). + # the canonical _DAEMONS order so egress starts first. got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,pipelock,egress"}, + {"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"}, all_daemons=self._DAEMONS, ) self.assertEqual([d.name for d in got], - ["egress", "pipelock", "supervise"]) + ["egress", "git-gate", "supervise"]) def test_unknown_names_ignored(self): got = _selected_daemons( @@ -125,10 +114,10 @@ class TestSelectedDaemons(unittest.TestCase): def test_whitespace_in_names_stripped(self): got = _selected_daemons( - {"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , pipelock "}, + {"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "}, all_daemons=self._DAEMONS, ) - self.assertEqual([d.name for d in got], ["egress", "pipelock"]) + self.assertEqual([d.name for d in got], ["egress", "git-gate"]) class TestSupervisor(unittest.TestCase): @@ -279,25 +268,24 @@ class TestSupervisor(unittest.TestCase): self._drive(sup) def test_restart_daemon_replaces_in_place(self): - # pipelock_apply.py sends SIGUSR1 to the bundle, supervisor - # restarts the pipelock daemon, supervise (the other - # daemon's MCP server in production) stays up. + # Restart one daemon; the other (supervise, the MCP server + # in production) must remain untouched. specs = [ - _DaemonSpec("pipelock", ("/bin/sleep", "30")), + _DaemonSpec("git-gate", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")), ] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) - old_pipelock_pid = sup.procs[0][1].pid + old_git_gate_pid = sup.procs[0][1].pid supervise_pid = sup.procs[1][1].pid - ok = sup.restart_daemon("pipelock", grace=2.0) + ok = sup.restart_daemon("git-gate", grace=2.0) self.assertTrue(ok) - # Pipelock got a fresh PID — different process. - new_pipelock_pid = sup.procs[0][1].pid - self.assertNotEqual(old_pipelock_pid, new_pipelock_pid) + # git-gate got a fresh PID — different process. + new_git_gate_pid = sup.procs[0][1].pid + self.assertNotEqual(old_git_gate_pid, new_git_gate_pid) # Supervise's PID is unchanged — it was NOT restarted. self.assertEqual(supervise_pid, sup.procs[1][1].pid) self.assertIsNone(sup.procs[1][1].poll(), @@ -308,38 +296,38 @@ class TestSupervisor(unittest.TestCase): def test_request_restart_is_drained_by_tick(self): specs = [ - _DaemonSpec("pipelock", ("/bin/sleep", "30")), + _DaemonSpec("git-gate", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")), ] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) - old_pipelock_pid = sup.procs[0][1].pid + old_git_gate_pid = sup.procs[0][1].pid supervise_pid = sup.procs[1][1].pid - ok = sup.request_restart("pipelock") + ok = sup.request_restart("git-gate") self.assertTrue(ok) # The non-blocking request path only records intent. - self.assertEqual(old_pipelock_pid, sup.procs[0][1].pid) + self.assertEqual(old_git_gate_pid, sup.procs[0][1].pid) done = sup.tick() self.assertFalse(done) - self.assertNotEqual(old_pipelock_pid, sup.procs[0][1].pid) + self.assertNotEqual(old_git_gate_pid, sup.procs[0][1].pid) self.assertEqual(supervise_pid, sup.procs[1][1].pid) sup.request_shutdown(reason="cleanup") self._drive(sup) def test_repeated_restart_requests_coalesce(self): - specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] + specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) - self.assertTrue(sup.request_restart("pipelock")) - self.assertTrue(sup.request_restart("pipelock")) - self.assertEqual({"pipelock"}, sup._restart_requested) + self.assertTrue(sup.request_restart("git-gate")) + self.assertTrue(sup.request_restart("git-gate")) + self.assertEqual({"git-gate"}, sup._restart_requested) old_pid = sup.procs[0][1].pid sup.tick() @@ -374,23 +362,23 @@ class TestSupervisor(unittest.TestCase): self._drive(sup) def test_restart_during_shutdown_is_no_op(self): - specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] + specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] sup = _Supervisor(specs) sup.start_all() sup.request_shutdown(reason="test") - ok = sup.restart_daemon("pipelock") + ok = sup.restart_daemon("git-gate") self.assertFalse(ok, "must not respawn a daemon during teardown") self._drive(sup) def test_pending_restart_dropped_during_shutdown(self): - specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] + specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))] sup = _Supervisor(specs) sup.start_all() time.sleep(0.1) old_pid = sup.procs[0][1].pid - self.assertTrue(sup.request_restart("pipelock")) + self.assertTrue(sup.request_restart("git-gate")) sup.request_shutdown(reason="test") self.assertEqual(set(), sup._restart_requested) self._drive(sup) diff --git a/tests/unit/test_smolmachines_prepare.py b/tests/unit/test_smolmachines_prepare.py index c851f76..a1237b3 100644 --- a/tests/unit/test_smolmachines_prepare.py +++ b/tests/unit/test_smolmachines_prepare.py @@ -56,7 +56,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase): patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet", return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")), patch("bot_bottle.backend.smolmachines.prepare.GitGate") as mock_gg, - patch("bot_bottle.backend.smolmachines.prepare.PipelockProxy") as mock_pl, patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg, patch("bot_bottle.backend.smolmachines.prepare.Supervise"), patch( @@ -65,7 +64,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase): patch("bot_bottle.backend.smolmachines.prepare.runtime_for"), ): mock_gg.return_value.prepare.return_value = MagicMock() - mock_pl.return_value.prepare.return_value = MagicMock() mock_eg.return_value.prepare.return_value = MagicMock() def _make_provision(**kwargs): # type: ignore return AgentProvisionPlan( diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 48d8d60..586f9bb 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -32,7 +32,6 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import GitEntry, Manifest -from bot_bottle.pipelock import PipelockProxyPlan from bot_bottle.supervise import SupervisePlan from bot_bottle.workspace import workspace_plan @@ -71,7 +70,6 @@ def _plan( stage_dir: Path | None = None, egress_routes: tuple[EgressRoute, ...] = (), egress_ca_path: Path = Path(), - pipelock_ca_path: Path = Path(), supervise: bool = False, bundle_ip: str = "192.168.50.2", agent_git_gate_host: str = "127.0.0.1:55555", @@ -131,11 +129,6 @@ def _plan( agent_image_ref="bot-bottle-claude:latest", guest_env=dict(guest_env or {}), prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), - proxy_plan=PipelockProxyPlan( - yaml_path=Path("/tmp/pipelock.yaml"), - slug="demo-abc12", - ca_cert_host_path=pipelock_ca_path, - ), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), @@ -235,16 +228,13 @@ def _write_self_signed_cert(path: Path) -> None: class TestProvisionCA(unittest.TestCase): - """provision_ca selects the right CA cert (egress when the - bottle has routes, else pipelock) and dispatches + """provision_ca always uses the egress MITM CA and dispatches cp_in + exec in the right order.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") self.tmp = Path(self._tmp.name) - self.pipelock_ca = self.tmp / "pipelock-ca.pem" self.egress_ca = self.tmp / "egress-ca.pem" - _write_self_signed_cert(self.pipelock_ca) _write_self_signed_cert(self.egress_ca) def tearDown(self): @@ -259,40 +249,22 @@ class TestProvisionCA(unittest.TestCase): stderr="", ) - def test_pipelock_path_when_no_routes(self): - plan = _plan(pipelock_ca_path=self.pipelock_ca) + def test_egress_ca_always_installed(self): + plan = _plan(egress_ca_path=self.egress_ca) bottle = _make_bottle(exec_result=self._UPDATE_OK) _ca.provision_ca(plan, bottle) bottle.cp_in.assert_called_once_with( - str(self.pipelock_ca), + str(self.egress_ca), _ca.AGENT_CA_PATH, ) - # chmod + chown + update-ca-certificates are folded into - # one exec invocation; look at the single exec's script - # rather than expecting separate calls. bottle.exec.assert_called_once() script = bottle.exec.call_args.args[0] self.assertIn("chmod 644", script) self.assertIn("update-ca-certificates", script) self.assertEqual("root", bottle.exec.call_args.kwargs.get("user")) - def test_egress_path_when_routes_declared(self): - plan = _plan( - egress_routes=(EgressRoute(host="api.anthropic.com"),), - egress_ca_path=self.egress_ca, - pipelock_ca_path=self.pipelock_ca, - ) - bottle = _make_bottle(exec_result=self._UPDATE_OK) - _ca.provision_ca(plan, bottle) - # When routes are declared, egress is the agent's first hop, - # so egress's CA is the one that gets installed. - bottle.cp_in.assert_called_once_with( - str(self.egress_ca), - _ca.AGENT_CA_PATH, - ) - def test_retries_smolvm_sigkill_during_update_ca(self): - plan = _plan(pipelock_ca_path=self.pipelock_ca) + plan = _plan(egress_ca_path=self.egress_ca) killed = ExecResult( returncode=137, stdout="Updating certificates in /etc/ssl/certs...\n", @@ -308,10 +280,8 @@ class TestProvisionCA(unittest.TestCase): self.assertEqual(2, bottle.exec.call_count) sleep.assert_called_once_with(1.0) - def test_dies_when_selected_cert_missing(self): - # Plan claims a pipelock cert at a path that doesn't exist — - # something went wrong in launch's pipelock_tls_init. - plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem") + def test_dies_when_egress_cert_missing(self): + plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem") bottle = _make_bottle() with self.assertRaises(SystemExit): _ca.provision_ca(plan, bottle) @@ -414,7 +384,7 @@ class TestBundleLaunchSpec(unittest.TestCase): spec = _bundle_launch_spec(plan, "net", "127.0.0.16") self.assertEqual( - "egress,pipelock,git-gate,git-http", + "egress,git-gate,git-http", spec.daemons_csv, ) self.assertIn(9420, spec.ports_to_publish) diff --git a/tests/unit/test_smolmachines_sidecar_bundle.py b/tests/unit/test_smolmachines_sidecar_bundle.py index 5426770..a207582 100644 --- a/tests/unit/test_smolmachines_sidecar_bundle.py +++ b/tests/unit/test_smolmachines_sidecar_bundle.py @@ -134,34 +134,35 @@ class TestStartBundle(unittest.TestCase): def test_daemons_env_passed_in(self): with self._patch_run() as m: - start_bundle(_spec(daemons_csv="egress,pipelock,supervise")) + start_bundle(_spec(daemons_csv="egress,supervise")) argv = m.call_args.args[0] self.assertIn("-e", argv) self.assertIn( - "BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock,supervise", + "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise", argv, ) def test_environment_entries_pass_through(self): with self._patch_run() as m: start_bundle(_spec(environment=( - "EGRESS_UPSTREAM_PROXY=http://...", "SUPERVISE_BOTTLE_SLUG=demo-abc12", "EGRESS_TOKEN_0", # bare-name → host env inherit ))) argv = m.call_args.args[0] - self.assertIn("EGRESS_UPSTREAM_PROXY=http://...", argv) self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv) self.assertIn("EGRESS_TOKEN_0", argv) def test_volumes_render_with_ro_flag(self): with self._patch_run() as m: start_bundle(_spec(volumes=( - ("/host/pipelock.yaml", "/etc/pipelock.yaml", True), + ("/host/egress-ca.pem", "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", True), ("/host/queue", "/run/supervise/queue", False), ))) argv = m.call_args.args[0] - self.assertIn("/host/pipelock.yaml:/etc/pipelock.yaml:ro", argv) + self.assertIn( + "/host/egress-ca.pem:/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem:ro", + argv, + ) self.assertIn("/host/queue:/run/supervise/queue", argv) def test_failure_dies(self): diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 1f8671d..0191e36 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -247,13 +247,13 @@ class TestAuditLog(unittest.TestCase): write_audit_entry(AuditEntry( timestamp=f"2026-05-25T12:00:0{i}+00:00", bottle_slug="dev", - component="pipelock", + component="egress", operator_action=STATUS_APPROVED, operator_notes=f"n{i}", justification="", diff="", )) - path = audit_log_path("pipelock", "dev") + path = audit_log_path("egress", "dev") with path.open() as f: lines = [line for line in f if line.strip()] self.assertEqual(3, len(lines)) @@ -273,7 +273,7 @@ class TestAuditLog(unittest.TestCase): write_audit_entry(AuditEntry( timestamp="t", bottle_slug="dev", - component="pipelock", + component="egress", operator_action=STATUS_APPROVED, operator_notes="", justification="", @@ -289,7 +289,7 @@ class TestAuditLog(unittest.TestCase): diff="", )) self.assertEqual(1, len(read_audit_entries("cred-proxy", "dev"))) - self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) + self.assertEqual(1, len(read_audit_entries("egress", "dev"))) self.assertEqual(1, len(read_audit_entries("cred-proxy", "other"))) def test_read_audit_entries_missing_log_returns_empty(self): diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index b02bf3c..3fd8a09 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -18,7 +18,6 @@ from pathlib import Path from bot_bottle import supervise from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.backend.docker.egress_apply import EgressApplyError -from bot_bottle.backend.docker.pipelock_apply import PipelockApplyError from bot_bottle.cli import supervise as supervise_cli from bot_bottle.supervise import ( Proposal, @@ -27,7 +26,6 @@ from bot_bottle.supervise import ( STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, read_audit_entries, read_response, sha256_hex, @@ -38,13 +36,8 @@ FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc) def _proposal(slug: str = "dev", tool: str = TOOL_EGRESS_BLOCK) -> Proposal: - # Per-tool payload shape: cred-proxy gets routes.yaml, pipelock - # gets a failed URL (PR #25 follow-up), capability gets a - # Dockerfile-ish blob. Match the production dispatch in - # PROPOSED_FILE_FIELD. payloads = { TOOL_EGRESS_BLOCK: '{"routes": []}\n', - TOOL_PIPELOCK_BLOCK: "https://example.com/path", TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", } payload = payloads.get(tool, "") @@ -128,26 +121,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() self._original_add_route = supervise_cli.add_route - self._original_apply_allowlist = supervise_cli.apply_allowlist_change - self._original_fetch_allowlist = supervise_cli.fetch_current_allowlist self._original_apply_capability = supervise_cli.apply_capability_change # Default stubs: succeed with deterministic before/after so the # audit log shows a non-empty diff. supervise_cli.add_route = lambda slug, content: ( # type: ignore '{"routes": []}\n', '{"routes": [{"host": "x"}]}\n', ) - supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore - "old.example\n", content, - ) - supervise_cli.fetch_current_allowlist = lambda slug: "old.example\n" # type: ignore supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore "FROM old\n", content, ) def tearDown(self): supervise_cli.add_route = self._original_add_route - supervise_cli.apply_allowlist_change = self._original_apply_allowlist - supervise_cli.fetch_current_allowlist = self._original_fetch_allowlist supervise_cli.apply_capability_change = self._original_apply_capability self._teardown_fake_home() @@ -192,15 +177,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK) supervise_cli.approve(qp) # No audit log for capability-block (per PRD 0013 / 0016). - # cred-proxy and pipelock logs both empty. self.assertEqual([], read_audit_entries("egress", "dev")) - self.assertEqual([], read_audit_entries("pipelock", "dev")) - - def test_pipelock_audit_distinct_from_egress(self): - qp = self._enqueue(tool=TOOL_PIPELOCK_BLOCK) - supervise_cli.approve(qp) - self.assertEqual(1, len(read_audit_entries("pipelock", "dev"))) - self.assertEqual(0, len(read_audit_entries("egress", "dev"))) class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): @@ -299,91 +276,6 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase): self.assertEqual("", entries[0].diff) -class TestPipelockApplyWiring(_FakeHomeMixin, unittest.TestCase): - """PRD 0015 Phase 2 + PR #25 follow-up: approve() on a - pipelock-block proposal carries the failed URL; the supervise TUI - extracts the host, merges it into the running allowlist, and - calls apply_allowlist_change with the merged content.""" - - def setUp(self): - self._setup_fake_home() - self._original_apply = supervise_cli.apply_allowlist_change - self._original_fetch = supervise_cli.fetch_current_allowlist - - def tearDown(self): - supervise_cli.apply_allowlist_change = self._original_apply - supervise_cli.fetch_current_allowlist = self._original_fetch - self._teardown_fake_home() - - def _enqueue_pipelock(self, failed_url: str = "https://api.github.com/repos/foo/bar"): - p = Proposal.new( - bottle_slug="dev", tool=TOOL_PIPELOCK_BLOCK, - proposed_file=failed_url, - justification="need to read PR metadata", - current_file_hash=sha256_hex(failed_url), - now=FIXED, - ) - qdir = supervise.queue_dir_for_slug("dev") - qdir.mkdir(parents=True, exist_ok=True) - supervise.write_proposal(qdir, p) - return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) - - def test_url_host_merged_into_current_allowlist(self): - supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" # type: ignore - applied = [] - supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore - applied.append((slug, content)) - or ("existing.example\n", content) - ) - qp = self._enqueue_pipelock("https://api.github.com/repos/foo/bar") - supervise_cli.approve(qp) - # apply_allowlist_change was called with the merged content: - # existing host + the URL's host (no path, since pipelock is - # hostname-only). - self.assertEqual(1, len(applied)) - slug, content = applied[0] - self.assertEqual("dev", slug) - self.assertIn("existing.example", content) - self.assertIn("api.github.com", content) - self.assertNotIn("/repos/foo/bar", content) # path stripped - - def test_host_already_in_allowlist_is_idempotent(self): - supervise_cli.fetch_current_allowlist = lambda slug: "api.github.com\n" # type: ignore - applied = [] - supervise_cli.apply_allowlist_change = lambda slug, content: ( # type: ignore - applied.append(content) - or ("api.github.com\n", content) - ) - qp = self._enqueue_pipelock("https://api.github.com/some/path") - supervise_cli.approve(qp) - # Still applied, but the content is unchanged from current — - # before/after diff is empty. - self.assertEqual(1, len(applied)) - self.assertEqual("api.github.com\n", applied[0]) - - def test_apply_failure_blocks_response_and_audit(self): - supervise_cli.fetch_current_allowlist = lambda slug: "existing.example\n" # type: ignore - supervise_cli.apply_allowlist_change = lambda slug, content: (_ for _ in ()).throw( # type: ignore - PipelockApplyError("docker exec failed") - ) - qp = self._enqueue_pipelock() - with self.assertRaises(PipelockApplyError): - supervise_cli.approve(qp) - self.assertEqual( - [qp.proposal.id], - [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], - ) - self.assertEqual([], read_audit_entries("pipelock", "dev")) - - def test_url_without_host_raises(self): - supervise_cli.fetch_current_allowlist = lambda slug: "" # type: ignore - # supervise_server's validator would catch this; if a broken - # URL ever makes it through, the supervise TUI surfaces it too. - qp = self._enqueue_pipelock("https:///nohost") - with self.assertRaises(PipelockApplyError): - supervise_cli.approve(qp) - - class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): """PRD 0016 Phase 3: approve() on a capability-block proposal calls apply_capability_change, archives the proposal afterward @@ -439,7 +331,6 @@ class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): # capability-block has no audit log per PRD 0013 — its record # lives in the per-bottle Dockerfile + transcript state. self.assertEqual([], read_audit_entries("egress", "dev")) - self.assertEqual([], read_audit_entries("pipelock", "dev")) def test_proposal_archived_after_apply(self): supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore diff --git a/tests/unit/test_supervise_cli_detail_lines.py b/tests/unit/test_supervise_cli_detail_lines.py deleted file mode 100644 index da7d358..0000000 --- a/tests/unit/test_supervise_cli_detail_lines.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Unit: supervise's detail-view line builder. - -_detail_lines returns (text, attr) tuples. Most are plain; for -pipelock-block proposals it appends a "→ would allow host: " -line tagged with the green attr so the operator sees at a glance -which hostname will land in pipelock's allowlist on approval.""" - -import unittest - -from bot_bottle.cli import supervise as supervise_cli -from bot_bottle.supervise import ( - Proposal, - TOOL_CAPABILITY_BLOCK, - TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, - sha256_hex, -) - - -def _qp(tool: str, payload: str) -> supervise_cli.QueuedProposal: - from datetime import datetime, timezone - from pathlib import Path - p = Proposal.new( - bottle_slug="dev", - tool=tool, - proposed_file=payload, - justification="needs", - current_file_hash=sha256_hex(payload), - now=datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc), - ) - return supervise_cli.QueuedProposal(proposal=p, queue_dir=Path("/tmp/q")) - - -class TestPipelockHostHighlight(unittest.TestCase): - GREEN = 0xDEADBEEF # arbitrary sentinel; _detail_lines passes through - - def test_appends_green_host_line_for_pipelock_block(self): - lines = supervise_cli._detail_lines( - _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/repos/foo/bar"), - green_attr=self.GREEN, - ) - # The host appears as its own green-tagged line — literal - # text of what gets appended to pipelock's allowlist on - # approve. - green_lines = [text for text, attr in lines if attr == self.GREEN] - self.assertEqual(["api.github.com"], green_lines) - - def test_no_green_lines_for_egress_block(self): - lines = supervise_cli._detail_lines( - _qp(TOOL_EGRESS_BLOCK, '{"routes": []}'), - green_attr=self.GREEN, - ) - self.assertEqual([], [t for t, a in lines if a == self.GREEN]) - - def test_no_green_lines_for_capability_block(self): - lines = supervise_cli._detail_lines( - _qp(TOOL_CAPABILITY_BLOCK, "FROM python:3.13\n"), - green_attr=self.GREEN, - ) - self.assertEqual([], [t for t, a in lines if a == self.GREEN]) - - def test_skips_host_line_when_url_unparseable(self): - # Shouldn't happen in production — supervise_server validates - # the URL before queuing — but if a malformed payload ever - # reaches the supervise TUI, don't render a misleading host line. - lines = supervise_cli._detail_lines( - _qp(TOOL_PIPELOCK_BLOCK, "garbage-not-a-url"), - green_attr=self.GREEN, - ) - self.assertEqual([], [t for t, a in lines if a == self.GREEN]) - - def test_no_green_attr_passed_still_renders_host(self): - # Even without color support (green_attr=0), the host line - # is still present — it just won't be coloured. - lines = supervise_cli._detail_lines( - _qp(TOOL_PIPELOCK_BLOCK, "https://api.github.com/x"), - green_attr=0, - ) - # Last non-empty line should be the host. - non_empty = [t for t, _ in lines if t] - self.assertEqual("api.github.com", non_empty[-1]) - - -class TestFailedUrlHost(unittest.TestCase): - def test_extracts_hostname(self): - self.assertEqual( - "api.github.com", - supervise_cli._failed_url_host("https://api.github.com/repos/foo"), - ) - - def test_returns_empty_for_unparseable(self): - self.assertEqual("", supervise_cli._failed_url_host("not a url")) - - def test_returns_empty_for_url_without_host(self): - self.assertEqual("", supervise_cli._failed_url_host("https:///nohost")) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index a9a0985..c53411c 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -47,28 +47,6 @@ from bot_bottle.supervise_server import ( class TestValidation(unittest.TestCase): - def test_pipelock_block_accepts_https_url(self): - validate_proposed_file( - _sv.TOOL_PIPELOCK_BLOCK, - "https://api.github.com/repos/foo/bar", - ) - - def test_pipelock_block_accepts_http_url(self): - validate_proposed_file( - _sv.TOOL_PIPELOCK_BLOCK, - "http://internal.example/path/to/thing", - ) - - def test_pipelock_block_rejects_missing_scheme(self): - with self.assertRaises(_RpcError) as cm: - validate_proposed_file(_sv.TOOL_PIPELOCK_BLOCK, "api.github.com/foo") - self.assertIn("http://", str(cm.exception.message)) - - def test_pipelock_block_rejects_missing_host(self): - with self.assertRaises(_RpcError) as cm: - validate_proposed_file(_sv.TOOL_PIPELOCK_BLOCK, "https:///just-a-path") - self.assertIn("hostname", str(cm.exception.message)) - def test_capability_block_accepts_anything_nonempty(self): validate_proposed_file( _sv.TOOL_CAPABILITY_BLOCK, -- 2.52.0 From 05b12b41b6623de59ed305c8d7d3d0231cf291ec Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 21:58:36 +0000 Subject: [PATCH 5/6] fix: remove remaining pipelock references missed in prior pass - test_supervise.py: drop TOOL_PIPELOCK_BLOCK import; update TOOLS assertion to match the 3-item tuple (egress, capability, list-egress) - test_supervise_server.py: remove pipelock from tools-list assertion, fix test_rejected_response_sets_isError to use capability-block - contrib/claude and contrib/codex: remove tls_passthrough=True from EgressRoute constructors (field removed with pipelock) - test_egress.py: drop tls_passthrough parameter from _provider_route, remove tls_passthrough-only tests, fix EgressRoute constructions - test_agent_provider.py: drop route.tls_passthrough assertions Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/contrib/claude/agent_provider.py | 1 - bot_bottle/contrib/codex/agent_provider.py | 1 - tests/unit/test_agent_provider.py | 4 ---- tests/unit/test_egress.py | 21 +++------------------ tests/unit/test_supervise.py | 5 +---- tests/unit/test_supervise_server.py | 15 ++++++--------- 6 files changed, 10 insertions(+), 37 deletions(-) diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 81f5b4a..17f2de7 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -94,7 +94,6 @@ class ClaudeAgentProvider(AgentProvider): host="api.anthropic.com", auth_scheme="Bearer" if auth_token else "", token_ref=auth_token, - tls_passthrough=True, ),) hidden_env_names: frozenset[str] = frozenset() if auth_token: diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 472999c..e781938 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -110,7 +110,6 @@ class CodexAgentProvider(AgentProvider): host=host, auth_scheme="Bearer" if forward_host_credentials else "", token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "", - tls_passthrough=True, )) if forward_host_credentials: diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index ec9157d..7f33a0b 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -101,7 +101,6 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual("api.anthropic.com", route.host) self.assertEqual("Bearer", route.auth_scheme) self.assertEqual("BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", route.token_ref) - self.assertTrue(route.tls_passthrough) self.assertEqual("egress-placeholder", plan.env_vars["CLAUDE_CODE_OAUTH_TOKEN"]) self.assertEqual("1", plan.env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"]) self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) @@ -143,7 +142,6 @@ class TestAgentProviderRuntime(unittest.TestCase): for r in plan.egress_routes: self.assertEqual("Bearer", r.auth_scheme) self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, r.token_ref) - self.assertTrue(r.tls_passthrough) def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: @@ -161,7 +159,6 @@ class TestAgentProviderRuntime(unittest.TestCase): for r in plan.egress_routes: self.assertEqual("", r.auth_scheme) self.assertEqual("", r.token_ref) - self.assertTrue(r.tls_passthrough) def test_claude_without_auth_token_has_passthrough_egress_route(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: @@ -176,7 +173,6 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertEqual("api.anthropic.com", route.host) self.assertEqual("", route.auth_scheme) self.assertEqual("", route.token_ref) - self.assertTrue(route.tls_passthrough) self.assertNotIn("CLAUDE_CODE_OAUTH_TOKEN", plan.env_vars) self.assertEqual(frozenset(), plan.hidden_env_names) diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index 9eb715a..b0f4531 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -24,12 +24,11 @@ def _bottle(routes): # type: ignore }).bottles["dev"] -def _provider_route(host: str, token_ref: str, *, tls_passthrough: bool = False) -> EgressRoute: +def _provider_route(host: str, token_ref: str) -> EgressRoute: return EgressRoute( host=host, auth_scheme="Bearer", token_ref=token_ref, - tls_passthrough=tls_passthrough, ) @@ -150,7 +149,7 @@ class TestProviderRouteMerge(unittest.TestCase): def test_unauthenticated_provider_route_appends_without_token_slot(self): b = _bottle([]) - pr = EgressRoute(host="api.openai.com", tls_passthrough=True) + pr = EgressRoute(host="api.openai.com") routes = egress_routes_for_bottle(b, (pr,)) self.assertEqual(1, len(routes)) self.assertEqual("api.openai.com", routes[0].host) @@ -162,13 +161,12 @@ class TestProviderRouteMerge(unittest.TestCase): def test_provider_route_wins_over_bare_manifest_route(self): # Provisioned host wins outright; manifest path_allowlist is dropped. b = _bottle([{"host": "api.openai.com", "path_allowlist": ["/v1/"]}]) - pr = EgressRoute(host="api.openai.com", tls_passthrough=True) + pr = EgressRoute(host="api.openai.com") routes = egress_routes_for_bottle(b, (pr,)) self.assertEqual(1, len(routes)) self.assertEqual("", routes[0].auth_scheme) self.assertEqual("", routes[0].token_env) self.assertEqual("", routes[0].token_ref) - self.assertTrue(routes[0].tls_passthrough) self.assertEqual((), routes[0].path_allowlist) self.assertEqual({}, egress_token_env_map(routes)) @@ -209,19 +207,6 @@ class TestProviderRouteMerge(unittest.TestCase): self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref) self.assertEqual("GH_PAT", routes[1].token_ref) - def test_provider_route_tls_passthrough_set_on_appended_route(self): - b = _bottle([]) - pr = _provider_route("api.openai.com", "TOK", tls_passthrough=True) - routes = egress_routes_for_bottle(b, (pr,)) - self.assertTrue(routes[0].tls_passthrough) - - def test_provider_route_tls_passthrough_wins_over_bare_manifest_route(self): - b = _bottle([{"host": "api.openai.com"}]) - pr = _provider_route("api.openai.com", "TOK", tls_passthrough=True) - routes = egress_routes_for_bottle(b, (pr,)) - self.assertTrue(routes[0].tls_passthrough) - - class TestTokenEnvMap(unittest.TestCase): def test_only_authenticated_routes_contribute(self): b = _bottle([ diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index 0191e36..de92d73 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -18,7 +18,6 @@ from bot_bottle.supervise import ( STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, archive_proposal, audit_log_path, list_pending_proposals, @@ -320,16 +319,14 @@ class TestToolConstants(unittest.TestCase): self.assertEqual( ( TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK, supervise.TOOL_LIST_EGRESS_ROUTES, ), supervise.TOOLS, ) - def test_component_map_covers_two_remediation_tools_only(self): + def test_component_map_covers_egress_remediation_only(self): self.assertIn(TOOL_EGRESS_BLOCK, supervise.COMPONENT_FOR_TOOL) - self.assertIn(TOOL_PIPELOCK_BLOCK, supervise.COMPONENT_FOR_TOOL) self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL) diff --git a/tests/unit/test_supervise_server.py b/tests/unit/test_supervise_server.py index c53411c..8f63eb6 100644 --- a/tests/unit/test_supervise_server.py +++ b/tests/unit/test_supervise_server.py @@ -56,12 +56,10 @@ class TestValidation(unittest.TestCase): def test_empty_proposed_file_rejected_for_tools_with_file_field(self): # egress-block has structured input (validated in # _validate_and_bundle_egress_route, not here) and - # list-egress-routes takes no input. Only the other - # two go through `validate_proposed_file`. - for tool in (_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK): - with self.subTest(tool=tool): - with self.assertRaises(_RpcError): - validate_proposed_file(tool, " \n\t") + # list-egress-routes takes no input. Only capability-block + # goes through `validate_proposed_file`. + with self.assertRaises(_RpcError): + validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t") # --- JSON-RPC parsing ------------------------------------------------------ @@ -144,7 +142,6 @@ class TestHandleToolsList(unittest.TestCase): self.assertEqual( sorted([ _sv.TOOL_EGRESS_BLOCK, - _sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_LIST_EGRESS_ROUTES, ]), @@ -229,9 +226,9 @@ class TestHandleToolsCall(unittest.TestCase): try: result = handle_tools_call( { - "name": _sv.TOOL_PIPELOCK_BLOCK, + "name": _sv.TOOL_CAPABILITY_BLOCK, "arguments": { - "failed_url": "https://example.com/path", + "dockerfile": "FROM python:3.13\n", "justification": "needed for tests", }, }, -- 2.52.0 From e6ad7ae10eb25fad248c10822df6802324ddc87d Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 23:38:11 +0000 Subject: [PATCH 6/6] fix(supervise_server): remove unused urllib.parse import Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/supervise_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index 6413215..a6390b4 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -38,7 +38,6 @@ import sys import time import typing import urllib.error -import urllib.parse import urllib.request from dataclasses import dataclass from pathlib import Path -- 2.52.0