Remove pipelock #193

Merged
didericis merged 6 commits from remove-pipelock into main 2026-06-04 20:22:06 -04:00
77 changed files with 350 additions and 3911 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Weekly canary suite. Catches upstream regressions (broken pipelock # Weekly canary suite. Catches upstream regressions (broken pinned
# image packaging at the pinned digest, etc.) without coupling every # digest, etc.) without coupling every dev push to upstream registry
# dev push to upstream registry availability. # availability.
# #
# Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run # Opt-in via CLAUDE_BOTTLE_RUN_CANARIES=1 so the same files can be run
# locally with the same gating. # locally with the same gating.
+1 -1
View File
@@ -21,7 +21,7 @@ FROM node:22-slim
# runs as root and rejects non-root connections, so socat sits between # 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 # node and the agent socket. curl is here so any HTTPS_PROXY-aware
# tool (curl itself, plus anything that shells out to it) works # 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 \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client socat curl dnsutils python3 python3-pip python3-venv \ && 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/* && rm -rf /var/lib/apt/lists/*
+11 -26
View File
@@ -1,23 +1,18 @@
# Per-bottle sidecar bundle image (PRD 0024). # Per-bottle sidecar bundle image (PRD 0024).
# #
# Collapses the four prior per-sidecar images (pipelock, egress, # Collapses the prior per-sidecar images (egress, git-gate,
# git-gate, supervise) into one. A small stdlib-Python init # supervise) into one. A small stdlib-Python init supervisor at
# supervisor at /app/sidecar_init.py spawns all four daemons, # /app/sidecar_init.py spawns all daemons, forwards SIGTERM, and
# forwards SIGTERM, and propagates per-daemon stdout/stderr to the # propagates per-daemon stdout/stderr to the container log with a
# container log with a `[name]` prefix. See PRD 0024 for the # `[name]` prefix. See PRD 0024 for the rationale.
# rationale.
# #
# Layout (preserved verbatim from the prior four Dockerfiles so the # Layout:
# compose renderer's bind-mount paths and docker-cp targets keep
# working):
# #
# /usr/local/bin/pipelock pipelock binary
# /usr/bin/gitleaks gitleaks binary # /usr/bin/gitleaks gitleaks binary
# /app/egress_addon.py + siblings mitmproxy addon (egress) # /app/egress_addon.py + siblings mitmproxy addon (egress)
# /app/egress-entrypoint.sh mitmdump launcher # /app/egress-entrypoint.sh mitmdump launcher
# /app/supervise_server.py + .py supervise MCP server # /app/supervise_server.py + .py supervise MCP server
# /app/sidecar_init.py PID 1 supervisor # /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/egress/routes.yaml bind-mounted at run time
# /etc/git-gate/pre-receive docker-cp'd at start time # /etc/git-gate/pre-receive docker-cp'd at start time
# /git-gate-entrypoint.sh 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 # /home/mitmproxy/.mitmproxy/ mitmproxy CA dir
# #
# Exposed ports inside the container: # Exposed ports inside the container:
# 8888 pipelock (HTTPS_PROXY) # 9099 egress (mitmproxy, agent-facing HTTPS proxy)
# 9099 egress (mitmproxy, pipelock's upstream — not externally
# addressed by the agent)
# 9418 git-gate (git-daemon) # 9418 git-gate (git-daemon)
# 9420 git-gate smart HTTP (smolmachines agent-facing transport) # 9420 git-gate smart HTTP (smolmachines agent-facing transport)
# 9100 supervise (MCP HTTP) # 9100 supervise (MCP HTTP)
# Stage 1: pipelock binary. The upstream pipelock image is a # Stage 1: gitleaks binary. The upstream gitleaks image is alpine
# 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
# with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep # with the binary at /usr/bin/gitleaks. Pinned by digest in lockstep
# with Dockerfile.git-gate's prior base (now deleted at chunk 3). # with Dockerfile.git-gate's prior base (now deleted at chunk 3).
FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f AS gitleaks-src 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 # Python + mitmdump pre-installed — heavier than the others, so
# this stage starts there and pulls the standalone binaries in. # this stage starts there and pulls the standalone binaries in.
FROM mitmproxy/mitmproxy:11.1.3 FROM mitmproxy/mitmproxy:11.1.3
@@ -60,16 +47,14 @@ USER root
# plus the core `git` binary the pre-receive hook invokes. # plus the core `git` binary the pre-receive hook invokes.
# openssh-client supplies the upstream SSH transport the # openssh-client supplies the upstream SSH transport the
# pre-receive hook uses to forward accepted refs. # pre-receive hook uses to forward accepted refs.
# ca-certificates is needed for both pipelock and mitmdump # ca-certificates is needed for mitmdump upstream TLS (the
# upstream TLS (the base image already has it; listed for # base image already has it; listed for explicitness).
# explicitness).
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
git openssh-client ca-certificates \ git openssh-client ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Pull the standalone binaries into the final image. # 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 COPY --from=gitleaks-src /usr/bin/gitleaks /usr/bin/gitleaks
# Project Python: addon + server modules + the init supervisor. # Project Python: addon + server modules + the init supervisor.
+3 -3
View File
@@ -84,9 +84,9 @@ class AgentProvisionPlan:
return the same shape without adding backend-plan fields. return the same shape without adding backend-plan fields.
`egress_routes` are provider-declared EgressRoutes that backends `egress_routes` are provider-declared EgressRoutes that backends
pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps pass to `Egress.prepare`. This keeps provider logic out of the
provider logic out of the egress and pipelock modules — they merge egress module — it merges provider routes generically without
provider routes generically without knowing the provider type. knowing the provider type.
`hidden_env_names` is the set of env var names the provider injected `hidden_env_names` is the set of env var names the provider injected
as non-secret placeholders. `print_util.visible_agent_env_names` uses as non-secret placeholders. `print_util.visible_agent_env_names` uses
+5 -5
View File
@@ -163,8 +163,8 @@ class ActiveAgent:
bottle is the container, the agent is what runs in it.) bottle is the container, the agent is what runs in it.)
Fields are deliberately backend-neutral. `services` is the set Fields are deliberately backend-neutral. `services` is the set
of sidecar daemons currently up for this bottle (`pipelock`, of sidecar daemons currently up for this bottle (`egress`,
`egress`, `git-gate`, `supervise`); the dashboard uses it to `git-gate`, `supervise`); the dashboard uses it to
gate edit verbs. `backend_name` is the matching key in gate edit verbs. `backend_name` is the matching key in
`_BACKENDS` (`docker` / `smolmachines`) — used by the active- `_BACKENDS` (`docker` / `smolmachines`) — used by the active-
list rendering to disambiguate and by the dashboard's 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 `user` (default `node`, matching the agent image's USER
directive) and return the captured stdout/stderr/returncode. directive) and return the captured stdout/stderr/returncode.
The bottle's environment (including HTTPS_PROXY pointing at 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` exit does not raise — callers inspect `returncode`
themselves. themselves.
@@ -352,8 +352,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None: def provision_ca(self, plan: PlanT, bottle: "Bottle") -> None:
"""Install the per-bottle CA into the agent's trust store so """Install the per-bottle CA into the agent's trust store so
the agent trusts the bumped CONNECT cert egress (was the agent trusts the bumped CONNECT cert egress presents.
pipelock, pre-PRD-0017) presents. Default impl is a no-op so Default impl is a no-op so
backends that don't yet support TLS interception (every backend backends that don't yet support TLS interception (every backend
except Docker today) aren't forced to implement it. The Docker except Docker today) aren't forced to implement it. The Docker
backend overrides to docker-cp the cert in and run backend overrides to docker-cp the cert in and run
-1
View File
@@ -4,7 +4,6 @@ The bulk of the implementation lives in sibling modules:
- util: thin Docker subprocess wrappers - util: thin Docker subprocess wrappers
- network: Docker network plumbing - network: Docker network plumbing
- pipelock: DockerPipelockProxy lifecycle
- bottle_plan: DockerBottlePlan - bottle_plan: DockerBottlePlan
- bottle_cleanup_plan: DockerBottleCleanupPlan - bottle_cleanup_plan: DockerBottleCleanupPlan
- bottle: DockerBottle handle - bottle: DockerBottle handle
-2
View File
@@ -11,7 +11,6 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from ...agent_provider import PromptMode from ...agent_provider import PromptMode
from ...pipelock import PipelockProxyPlan
from .. import BottlePlan from .. import BottlePlan
@@ -40,7 +39,6 @@ class DockerBottlePlan(BottlePlan):
# accidental log of the plan dataclass. # accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False) forwarded_env: dict[str, str] = field(repr=False)
prompt_file: Path prompt_file: Path
proxy_plan: PipelockProxyPlan
use_runsc: bool use_runsc: bool
@property @property
+2 -10
View File
@@ -49,7 +49,6 @@ _TRANSCRIPT_SUBDIR = "transcript"
# live here so chunk 3's `docker compose up` can find them at stable # 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 # paths. Each sidecar's `prepare()` writes config + CAs into its own
# subdir; the launch step is unchanged today (still `docker cp`). # subdir; the launch step is unchanged today (still `docker cp`).
_PIPELOCK_SUBDIR = "pipelock"
_EGRESS_SUBDIR = "egress" _EGRESS_SUBDIR = "egress"
_GIT_GATE_SUBDIR = "git-gate" _GIT_GATE_SUBDIR = "git-gate"
_SUPERVISE_SUBDIR = "supervise" _SUPERVISE_SUBDIR = "supervise"
@@ -57,8 +56,8 @@ _AGENT_SUBDIR = "agent"
_METADATA_NAME = "metadata.json" _METADATA_NAME = "metadata.json"
# Live-config dir bind-mounted into the supervise sidecar (read-only). # Live-config dir bind-mounted into the supervise sidecar (read-only).
# Host's apply paths keep these files fresh so supervise's # Host's apply paths keep these files fresh so supervise's
# `list-pipelock-allowlist` / `list-egress-routes` MCP tools # `list-egress-routes` MCP tool returns the current state —
# return the current state — not a snapshot from launch time. # not a snapshot from launch time.
_LIVE_CONFIG_SUBDIR = "live-config" _LIVE_CONFIG_SUBDIR = "live-config"
LIVE_CONFIG_ROUTES_NAME = "routes.yaml" LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist" LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
@@ -234,12 +233,6 @@ def transcript_snapshot_dir(identity: str) -> Path:
# nothing requested preservation. # 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: def egress_state_dir(identity: str) -> Path:
"""State subdir for the egress sidecar: routes.yaml + the """State subdir for the egress sidecar: routes.yaml + the
per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward.""" per-bottle mitmproxy CA. Bind-mount source from chunk 3 onward."""
@@ -325,7 +318,6 @@ __all__ = [
"per_bottle_dockerfile", "per_bottle_dockerfile",
"per_bottle_dockerfile_path", "per_bottle_dockerfile_path",
"per_bottle_image_tag", "per_bottle_image_tag",
"pipelock_state_dir",
"preserve_marker_path", "preserve_marker_path",
"read_metadata", "read_metadata",
"supervise_state_dir", "supervise_state_dir",
+32 -107
View File
@@ -7,34 +7,14 @@ two networks, no named volumes.
Pure function. No I/O, no subprocess. Expects every launch-time Pure function. No I/O, no subprocess. Expects every launch-time
field (network names, CA host paths, etc.) on the plan's inner 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 plans to be populated; chunks 2+3 own that ordering.
encodes the translation so it can be unit-tested in isolation.
Conditional services follow the plan content (matches the Conditional services follow the plan content:
SDK-call branching in `launch.py` today):
- pipelock + agent: always. - agent + sidecars bundle: always.
- git-gate: iff plan.git_gate_plan.upstreams. - git-gate: iff plan.git_gate_plan.upstreams.
- egress: iff plan.egress_plan.routes. - egress: iff plan.egress_plan.routes.
- supervise: iff plan.supervise_plan is not None. - supervise: iff plan.supervise_plan is not None.
Naming:
- Compose project: `bot-bottle-<slug>`.
- Service names (inside the file): `agent`, `pipelock`,
`egress`, `git-gate`, `supervise`.
- `container_name:` matches today's pattern
(`bot-bottle-<service>-<slug>`) 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.
""" """
from __future__ import annotations from __future__ import annotations
@@ -51,7 +31,6 @@ from ...egress import (
) )
from ...git_gate import GIT_GATE_HOSTNAME from ...git_gate import GIT_GATE_HOSTNAME
from ...log import die, warn from ...log import die, warn
from ...pipelock import PIPELOCK_HOSTNAME
from ...supervise import ( from ...supervise import (
CURRENT_CONFIG_DIR_IN_AGENT, CURRENT_CONFIG_DIR_IN_AGENT,
QUEUE_DIR_IN_CONTAINER, QUEUE_DIR_IN_CONTAINER,
@@ -63,7 +42,7 @@ from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .egress import ( from .egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER, EGRESS_PORT,
) )
from .git_gate import ( from .git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER, GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
@@ -71,11 +50,7 @@ from .git_gate import (
GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER,
) )
from ...pipelock import ( from . import network as network_mod
PIPELOCK_CA_CERT_IN_CONTAINER,
PIPELOCK_CA_KEY_IN_CONTAINER,
)
from .pipelock import PIPELOCK_PORT
from .sidecar_bundle import ( from .sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
@@ -91,12 +66,11 @@ def bottle_plan_to_compose(plan: DockerBottlePlan) -> dict[str, Any]:
"""Render a Compose v2 spec dict from a fully-resolved """Render a Compose v2 spec dict from a fully-resolved
DockerBottlePlan. DockerBottlePlan.
The plan must have its inner plans (`proxy_plan`, The plan must have its inner plans (`git_gate_plan`,
`git_gate_plan`, `egress_plan`, `supervise_plan`) populated `egress_plan`, `supervise_plan`) populated with launch-time
with launch-time fields — network names, CA host paths, fields — network names, CA host paths. The renderer doesn't
pipelock_proxy_url. The renderer doesn't validate; callers validate; callers feed it a fully-resolved plan or get an
feed it a fully-resolved plan or get an incomplete compose incomplete compose spec back.
spec back.
""" """
project = f"bot-bottle-{plan.slug}" project = f"bot-bottle-{plan.slug}"
services: dict[str, Any] = { services: dict[str, Any] = {
@@ -118,11 +92,11 @@ def _networks(plan: DockerBottlePlan) -> dict[str, Any]:
bridge.""" bridge."""
return { return {
"internal": { "internal": {
"name": plan.proxy_plan.internal_network, "name": network_mod.network_name_for_slug(plan.slug),
"internal": True, "internal": True,
}, },
"egress": { "egress": {
"name": plan.proxy_plan.egress_network, "name": network_mod.network_egress_name_for_slug(plan.slug),
}, },
} }
@@ -142,29 +116,12 @@ def _bind(host: str | Path, target: str, *, read_only: bool = True) -> dict[str,
def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]: def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""The `sidecars` service: one container per bottle, bundle """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.
egress is always present; git-gate / supervise are conditional.
- 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-<service>-<slug>` long forms) so
the agent's HTTPS_PROXY URL and any other inter-service
reference resolves to the bundle.
""" """
daemons: list[str] = ["egress", "pipelock"] daemons: list[str] = ["egress"]
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
daemons.append("git-gate") daemons.append("git-gate")
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
@@ -173,31 +130,15 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"] env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
volumes: list[dict[str, Any]] = [] volumes: list[dict[str, Any]] = []
# --- pipelock ---------------------------------------------------- # --- egress -------------------------------------------------------
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) -----------------------------
ep = plan.egress_plan ep = plan.egress_plan
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
if ep.routes: if ep.routes:
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}") volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
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()): for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env) env.append(token_env)
# --- git-gate ---------------------------------------------------- # --- git-gate -----------------------------------------------------
gp = plan.git_gate_plan gp = plan.git_gate_plan
if gp.upstreams: if gp.upstreams:
volumes += [ volumes += [
@@ -217,7 +158,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts", f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{u.name}-known_hosts",
)) ))
# --- supervise --------------------------------------------------- # --- supervise ----------------------------------------------------
sp = plan.supervise_plan sp = plan.supervise_plan
if sp is not None: if sp is not None:
env += [ env += [
@@ -232,13 +173,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
"read_only": False, "read_only": False,
}) })
# Internal-network aliases: the agent reaches each daemon through internal_aliases = [EGRESS_HOSTNAME]
# 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,
]
if gp.upstreams: if gp.upstreams:
internal_aliases.append(GIT_GATE_HOSTNAME) internal_aliases.append(GIT_GATE_HOSTNAME)
if sp is not None: if sp is not None:
@@ -263,11 +198,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]: def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
"""Agent container. Runs `sleep infinity`; claude is `docker """Agent container. Runs `sleep infinity`; claude is `docker
exec -it`'d into it later. No TTY at the container level — exec -it`'d into it later. HTTP_PROXY/HTTPS_PROXY point at the
interactivity is per-exec. HTTP_PROXY/HTTPS_PROXY point at the egress sidecar."""
egress short-alias when an egress is declared, otherwise
straight at pipelock's container name. CA trust trio matches
the existing launch.py wiring."""
proxy_url = _agent_proxy_url(plan) proxy_url = _agent_proxy_url(plan)
no_proxy = _agent_no_proxy(plan) no_proxy = _agent_no_proxy(plan)
env: list[str] = [ env: list[str] = [
@@ -319,21 +251,14 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
def _agent_proxy_url(plan: DockerBottlePlan) -> str: def _agent_proxy_url(plan: DockerBottlePlan) -> str:
"""Pick the agent's HTTP_PROXY. With egress declared, the agent """Agent's HTTP_PROXY — always points at egress."""
goes through egress (which in turn HTTPS_PROXYs to pipelock on return f"http://{EGRESS_HOSTNAME}:{EGRESS_PORT}"
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}"
def _agent_no_proxy(plan: DockerBottlePlan) -> str: def _agent_no_proxy(plan: DockerBottlePlan) -> str:
"""NO_PROXY for the agent. Matches the launch.py rules: """NO_PROXY for the agent: loopback always; supervise hostname
loopback always, supervise hostname when the supervise sidecar when the supervise sidecar is up (MCP long-poll must bypass
is up (the MCP long-poll pattern needs to bypass pipelock's the egress proxy)."""
idle timeout)."""
hosts = ["localhost", "127.0.0.1"] hosts = ["localhost", "127.0.0.1"]
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
hosts.append(SUPERVISE_HOSTNAME) hosts.append(SUPERVISE_HOSTNAME)
+3 -17
View File
@@ -22,14 +22,8 @@ from ...log import die
EGRESS_PORT = int(os.environ.get("BOT_BOTTLE_EGRESS_PORT", "9099")) EGRESS_PORT = int(os.environ.get("BOT_BOTTLE_EGRESS_PORT", "9099"))
# In-container path for mitmproxy's CA. The format is a single PEM # In-container path for mitmproxy's CA. The format is a single PEM
# file holding BOTH the cert and the private key, concatenated. The # file holding BOTH the cert and the private key, concatenated.
# upstream-trust CA (pipelock's, so egress trusts the upstream
# leg) is a separate file because pipelock keeps a different CA on
# its end.
EGRESS_CA_IN_CONTAINER = "/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem" 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]: 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 trust store by `provision_ca` so the agent trusts the bumped
CONNECT cert egress presents. CONNECT cert egress presents.
Why openssl req (not the pipelock binary's `tls init`): openssl req's `subjectKeyIdentifier=hash` extension uses
pipelock's CA generator stamps a non-standard `Subject Key SHA-1(pubkey), matching mitmproxy's AKI computation on leaves.
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.
Both files live under `<stage_dir>/egress-ca/` (mode 644 — Both files live under `<stage_dir>/egress-ca/` (mode 644 —
`docker cp` preserves the mode into the container, where the `docker cp` preserves the mode into the container, where the
+2 -104
View File
@@ -8,13 +8,6 @@ egress-block proposal (or runs the operator-initiated
sidecar via `docker cp`, then `docker kill --signal HUP` to make sidecar via `docker cp`, then `docker kill --signal HUP` to make
the addon reload without dropping connections. 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 Raises EgressApplyError on any failure — the dashboard
surfaces the message and keeps the proposal pending so the surfaces the message and keeps the proposal pending so the
operator can retry. operator can retry.
@@ -23,7 +16,6 @@ operator can retry.
from __future__ import annotations from __future__ import annotations
import json import json
import re
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import cast from typing import cast
@@ -33,13 +25,6 @@ from ...egress_addon_core import load_routes
from ...yaml_subset import YamlSubsetError, parse_yaml_subset from ...yaml_subset import YamlSubsetError, parse_yaml_subset
from .bottle_state import egress_state_dir from .bottle_state import egress_state_dir
from .sidecar_bundle import sidecar_bundle_container_name 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: def _render_routes_payload(routes_list: list[dict[str, object]]) -> str:
@@ -108,82 +93,12 @@ def validate_routes_content(content: str) -> None:
) from e ) 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 <bottle>` then retry the proposal."
) from e
def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]: def apply_routes_change(slug: str, new_content: str) -> tuple[str, str]:
"""Apply `new_content` to the egress sidecar for `slug`: """Apply `new_content` to the egress sidecar for `slug`:
1. Fetch current routes.yaml (for the before-diff). 1. Fetch current routes.yaml (for the before-diff).
2. Validate the new content via the addon's own parser. 2. Validate the new content via the addon's own parser.
3. Mirror the route hosts onto pipelock's allowlist (so the 3. Write to the bind-mount source path.
downstream hostname gate lets them through). 4. `docker kill --signal HUP` so the addon reloads.
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.
Returns (before, after) where `after` == `new_content`. Raises Returns (before, after) where `after` == `new_content`. Raises
EgressApplyError on any step.""" 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) before = fetch_current_routes(slug)
validate_routes_content(new_content) 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 # routes.yaml is bind-mounted into the egress container as a
# SINGLE FILE. Docker single-file bind mounts pin the source # SINGLE FILE. Docker single-file bind mounts pin the source
# inode at mount time; write-temp-then-rename swaps the inode # 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 = _egress_routes_host_path(slug)
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(new_content) 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) target.chmod(0o644)
sig = subprocess.run( sig = subprocess.run(
["docker", "kill", "--signal", "HUP", container], ["docker", "kill", "--signal", "HUP", container],
@@ -311,13 +216,6 @@ def _merge_single_route(
next_idx = len(existing_slots) next_idx = len(existing_slots)
entry_typed["auth_scheme"] = str(cast(object, auth_typed.get("scheme"))) entry_typed["auth_scheme"] = str(cast(object, auth_typed.get("scheme")))
entry_typed["token_env"] = f"EGRESS_TOKEN_{next_idx}" 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) routes_typed.append(entry_typed)
return _render_routes_payload(cast(list[dict[str, object]], routes_typed)) return _render_routes_payload(cast(list[dict[str, object]], routes_typed))
+11 -49
View File
@@ -6,16 +6,10 @@ The flow is:
1. Build the agent's base + derived image (compose builds the 1. Build the agent's base + derived image (compose builds the
sidecar images via the `build:` directive on first up). sidecar images via the `build:` directive on first up).
2. Pre-create the per-bottle networks. We do this outside compose 2. Mint the per-bottle egress CA (chunk 2 writes it under
so we can inspect the assigned internal CIDR and embed it in state/<slug>/egress/).
pipelock's yaml (compose's `external: true` lets the compose 3. Populate the inner plans with launch-time fields so the
file reference these pre-existing networks). renderer can read network names, CA paths.
3. Mint the per-bottle CAs (chunk 2 writes them under
state/<slug>/{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.
6. Render the compose spec, write it to 6. Render the compose spec, write it to
state/<slug>/docker-compose.yml, write metadata.json. state/<slug>/docker-compose.yml, write metadata.json.
7. `docker compose up -d` (token + OAuth values flow into the 7. `docker compose up -d` (token + OAuth values flow into the
@@ -53,7 +47,6 @@ from .bottle_state import (
bottle_state_dir, bottle_state_dir,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
pipelock_state_dir,
) )
from .compose import ( from .compose import (
bottle_plan_to_compose, bottle_plan_to_compose,
@@ -66,10 +59,6 @@ from .compose import (
write_compose_file, write_compose_file,
) )
from .egress import egress_tls_init 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. # 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 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) internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_name_for_slug(plan.slug) egress_network = network_mod.network_egress_name_for_slug(plan.slug)
# Mint per-bottle CAs into state/<slug>/{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_ca_host, egress_ca_cert_only = egress_tls_init(
egress_state_dir(plan.slug), 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 git_gate_plan = plan.git_gate_plan
if git_gate_plan.upstreams: if git_gate_plan.upstreams:
git_gate_plan = dataclasses.replace( git_gate_plan = dataclasses.replace(
@@ -149,17 +116,13 @@ def launch(
internal_network=internal_network, internal_network=internal_network,
egress_network=egress_network, egress_network=egress_network,
) )
egress_plan = plan.egress_plan egress_plan = dataclasses.replace(
if egress_plan.routes: plan.egress_plan,
egress_plan = dataclasses.replace( internal_network=internal_network,
egress_plan, egress_network=egress_network,
internal_network=internal_network, mitmproxy_ca_host_path=egress_ca_host,
egress_network=egress_network, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
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 supervise_plan = plan.supervise_plan
if supervise_plan is not None: if supervise_plan is not None:
supervise_plan = dataclasses.replace( supervise_plan = dataclasses.replace(
@@ -168,7 +131,6 @@ def launch(
) )
plan = dataclasses.replace( plan = dataclasses.replace(
plan, plan,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
+5 -14
View File
@@ -1,11 +1,10 @@
"""Docker network plumbing for the per-agent egress topology. """Docker network plumbing for the per-agent egress topology.
The agent container sits on a Docker `--internal` network (no default The agent container sits on a Docker `--internal` network (no default
gateway). Pipelock straddles that network and a per-agent user-defined gateway). Egress straddles that network and a per-agent user-defined
bridge for upstream egress. We deliberately do NOT use Docker's legacy bridge for upstream traffic. We deliberately do NOT use Docker's legacy
`bridge` network because only user-defined bridges run Docker's `bridge` network because only user-defined bridges run Docker's
embedded DNS resolver, which pipelock needs to resolve api.anthropic.com embedded DNS resolver, which egress needs to resolve upstream hostnames.
and similar upstream hostnames.
Naming: bot-bottle-net-<slug> (internal), Naming: bot-bottle-net-<slug> (internal),
bot-bottle-egress-<slug> (egress). Numeric suffix on conflict bot-bottle-egress-<slug> (egress). Numeric suffix on conflict
@@ -77,20 +76,12 @@ def network_create_internal(slug: str) -> str:
def network_create_egress(slug: str) -> str: def network_create_egress(slug: str) -> str:
"""Create a per-agent user-defined bridge (NOT the legacy `bridge`) """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) return _network_create_with_prefix(network_egress_name_for_slug(slug), internal=False)
def network_inspect_cidr(name: str) -> str: def network_inspect_cidr(name: str) -> str:
"""Return the IPv4 CIDR Docker assigned to a user-defined network. """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."""
result = subprocess.run( result = subprocess.run(
["docker", "network", "inspect", ["docker", "network", "inspect",
"--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name], "--format", "{{range .IPAM.Config}}{{.Subnet}}{{end}}", name],
-74
View File
@@ -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 `<stage_dir>/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)
-200
View File
@@ -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 <bottle>` 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 <bundle>` 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",
]
+3 -14
View File
@@ -20,7 +20,6 @@ from ...egress import Egress
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
from ...log import die from ...log import die
from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec from .. import BottleSpec
@@ -36,7 +35,6 @@ from .bottle_state import (
per_bottle_dockerfile, per_bottle_dockerfile,
per_bottle_dockerfile_path, per_bottle_dockerfile_path,
per_bottle_image_tag, per_bottle_image_tag,
pipelock_state_dir,
supervise_state_dir, supervise_state_dir,
write_metadata, write_metadata,
) )
@@ -53,7 +51,6 @@ def resolve_plan(
validation already ran in the base class.""" validation already ran in the base class."""
docker_mod.require_docker() docker_mod.require_docker()
proxy = PipelockProxy()
git_gate = GitGate() git_gate = GitGate()
egress = Egress() egress = Egress()
supervise = Supervise() supervise = Supervise()
@@ -191,12 +188,6 @@ def resolve_plan(
guest_env.setdefault(key, val) guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=guest_env) 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 = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True) egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = egress.prepare( egress_plan = egress.prepare(
@@ -209,10 +200,9 @@ def resolve_plan(
# root; for `--cwd` derived images the base Dockerfile is what # root; for `--cwd` derived images the base Dockerfile is what
# the agent should propose changes against (the derived layer # the agent should propose changes against (the derived layer
# is just a workspace copy). # is just a workspace copy).
# (routes.yaml + pipelock allowlist used to land here too but # (routes.yaml used to land here too but PRD 0017 chunk 3
# PRD 0017 chunk 3 moved them behind the # moved it behind the `list-egress-routes` MCP tool so the
# `list-egress-routes` MCP tool so the agent gets live # agent gets live state rather than a launch-time snapshot.)
# state rather than a launch-time snapshot.)
supervise_dockerfile_path = ( supervise_dockerfile_path = (
Path(dockerfile_path) Path(dockerfile_path)
if dockerfile_path if dockerfile_path
@@ -244,7 +234,6 @@ def resolve_plan(
env_file=env_file, env_file=env_file,
forwarded_env=forwarded_env, forwarded_env=forwarded_env,
prompt_file=prompt_file, prompt_file=prompt_file,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
+5 -16
View File
@@ -1,19 +1,8 @@
"""Install the per-bottle MITM CA into the agent container's trust """Install the per-bottle egress MITM CA into the agent container's
store. trust store.
Post-PRD-0017 the CA depends on the agent's HTTP_PROXY target: By the time this provisioner runs, `egress_tls_init` has generated
the egress CA and the path is re-bound into `plan.egress_plan`.
- 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.
Cert lands on Debian's standard source path Cert lands on Debian's standard source path
(`/usr/local/share/ca-certificates/`); `update-ca-certificates` (`/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 """Copy the agent-facing CA cert into the agent, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the agent container is up.""" `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.cp_in(str(cert_host_path), AGENT_CA_PATH)
bottle.exec( bottle.exec(
+4 -4
View File
@@ -2,10 +2,10 @@
(PRD 0024). (PRD 0024).
The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1) The bundle image (built by Dockerfile.sidecars, PRD 0024 chunk 1)
runs pipelock + egress + git-gate + supervise as one container runs egress + git-gate + supervise as one container per bottle
per bottle under a small Python init supervisor. As of chunk 5 under a small Python init supervisor. As of chunk 5 the bundle
the bundle is the only shape — the legacy four-sidecar topology is the only shape — the legacy four-sidecar topology and its
and its `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone.""" `BOT_BOTTLE_SIDECAR_BUNDLE` feature flag are gone."""
from __future__ import annotations from __future__ import annotations
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ...agent_provider import PromptMode from ...agent_provider import PromptMode
from ...pipelock import PipelockProxyPlan
from .. import BottlePlan from .. import BottlePlan
@@ -71,7 +70,6 @@ class SmolmachinesBottlePlan(BottlePlan):
# docker's `--internal` + egress bridge topology; it's on a # docker's `--internal` + egress bridge topology; it's on a
# per-bottle bridge with a pinned IP. The unused fields stay # per-bottle bridge with a pinned IP. The unused fields stay
# at their dataclass defaults. # at their dataclass defaults.
proxy_plan: PipelockProxyPlan
# Agent-side endpoints. On Docker Desktop the docker bridge # Agent-side endpoints. On Docker Desktop the docker bridge
# IPs aren't reachable from the smolvm guest (TSI uses macOS # IPs aren't reachable from the smolvm guest (TSI uses macOS
# networking; docker container IPs live in the daemon's VM), # networking; docker container IPs live in the daemon's VM),
+2 -2
View File
@@ -69,8 +69,8 @@ def enumerate_active() -> list[ActiveAgent]:
def _query_bundle_services() -> dict[str, tuple[str, ...]]: def _query_bundle_services() -> dict[str, tuple[str, ...]]:
"""`{slug: ('egress', 'pipelock', ...)}` from each running """`{slug: ('egress', ...)}` from each running bundle container's
bundle container's `BOT_BOTTLE_SIDECAR_DAEMONS` env var. `BOT_BOTTLE_SIDECAR_DAEMONS` env var.
Smolmachines bundles all run the PRD-0024 image with the Smolmachines bundles all run the PRD-0024 image with the
same daemon set declared via env, so one inspect per bundle same daemon set declared via env, so one inspect per bundle
gets us the picture without exec'ing into the container. gets us the picture without exec'ing into the container.
+21 -89
View File
@@ -9,13 +9,9 @@ guest pointed at the bundle's pinned IP via TSI's
exit. exit.
The bundle's daemons consume the inner Plans the docker backend The bundle's daemons consume the inner Plans the docker backend
already produces: pipelock reads its yaml + CA from the already produces: egress reads routes + CAs from the EgressPlan.
PipelockProxyPlan; egress reads routes + CAs from the EgressPlan Git-gate + supervise plumb through the same plans the docker
+ EGRESS_UPSTREAM_PROXY pointing at `127.0.0.1:8888` (bundle backend uses, minus the docker-network fields that don't apply here."""
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."""
from __future__ import annotations from __future__ import annotations
@@ -29,16 +25,11 @@ from ...egress import (
EGRESS_ROUTES_IN_CONTAINER, EGRESS_ROUTES_IN_CONTAINER,
egress_resolve_token_values, 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 ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde from ...util import expand_tilde
from ..docker import util as docker_mod from ..docker import util as docker_mod
from ..docker.egress import ( from ..docker.egress import (
EGRESS_CA_IN_CONTAINER, EGRESS_CA_IN_CONTAINER,
EGRESS_PIPELOCK_CA_IN_CONTAINER,
EGRESS_PORT as _EGRESS_PORT, EGRESS_PORT as _EGRESS_PORT,
egress_tls_init, egress_tls_init,
) )
@@ -48,14 +39,9 @@ from ..docker.git_gate import (
GIT_GATE_ENTRYPOINT_IN_CONTAINER, GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_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 ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn 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 loopback_alias as _loopback
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm 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 # Container-internal listening ports for each bundle daemon. The
# bundle publishes each one on a random host loopback port (see # bundle publishes each one on a random host loopback port (see
# `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks # `_bundle.start_bundle`), and `_bundle.bundle_host_port` looks
# them up post-start. Pipelock's port is an env-overridable string # them up post-start.
# in docker.pipelock; coerce to int here.
_PIPELOCK_PORT = int(_PIPELOCK_PORT_STR)
_GIT_HTTP_PORT = 9420 _GIT_HTTP_PORT = 9420
_SUPERVISE_PORT = SUPERVISE_PORT _SUPERVISE_PORT = SUPERVISE_PORT
@@ -167,33 +151,16 @@ def _allocate_resources(
def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan: def _mint_certs(plan: SmolmachinesBottlePlan) -> SmolmachinesBottlePlan:
"""Mint per-bottle CAs and return the plan with CA paths filled. """Mint the egress MITM CA and return the plan with CA paths filled."""
egress_ca_host, egress_ca_cert_only = egress_tls_init(
Pipelock always runs in the bundle. Egress's CA is only minted egress_state_dir(plan.slug),
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,
) )
egress_plan = plan.egress_plan egress_plan = dataclasses.replace(
if egress_plan.routes: plan.egress_plan,
egress_ca_host, egress_ca_cert_only = egress_tls_init( mitmproxy_ca_host_path=egress_ca_host,
plan.egress_plan.routes_path.parent, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
) )
egress_plan = dataclasses.replace( return dataclasses.replace(plan, egress_plan=egress_plan)
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)
def _start_bundle( def _start_bundle(
@@ -224,17 +191,10 @@ def _discover_urls(
macOS networking, and macOS sees the daemon's bridge via the macOS networking, and macOS sees the daemon's bridge via the
published-port loopback forward only. 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 NO_PROXY includes the per-bottle loopback alias so the
supervise + git-gate URLs bypass HTTPS_PROXY.""" 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( 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}" 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. """Build a BundleLaunchSpec from the resolved inner Plans.
Daemons in the CSV: Daemons in the CSV:
- egress + pipelock are always present (pipelock is the - egress is always present.
agent's first hop; egress is its upstream).
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams. - git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
- supervise is conditional on plan.supervise_plan. - 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 daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh — see PRD 0024's bundle egress process by egress_entrypoint.sh — see PRD 0024's bundle
bind-address PR).""" bind-address PR)."""
daemons: list[str] = ["egress", "pipelock"] daemons: list[str] = ["egress"]
env: list[str] = [] env: list[str] = []
volumes: list[tuple[str, str, bool]] = [] 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 ----------------------------------------------- # --- egress -----------------------------------------------
ep = plan.egress_plan ep = plan.egress_plan
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
if ep.routes: if ep.routes:
env.append(f"EGRESS_UPSTREAM_PROXY={ep.pipelock_proxy_url}") volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True))
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),
]
# Bare-name entries for upstream-token slots. Their values # Bare-name entries for upstream-token slots. Their values
# come from the docker-run subprocess env (inherited from # come from the docker-run subprocess env (inherited from
# the operator's shell), never landing on argv. # 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 — # Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI + # published on host loopback so the guest can dial via TSI +
# macOS networking. The HTTP/HTTPS chokepoint is whichever # macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
# daemon's port we publish: egress when routes are declared ports_to_publish: list[int] = [_EGRESS_PORT]
# (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]
if gp.upstreams: if gp.upstreams:
ports_to_publish.append(_GIT_HTTP_PORT) ports_to_publish.append(_GIT_HTTP_PORT)
if sp is not None: if sp is not None:
@@ -48,7 +48,7 @@ from ...log import die
# registry:2.8.3, pinned by digest. Same env-override pattern as the # 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( REGISTRY_IMAGE = os.environ.get(
"BOT_BOTTLE_REGISTRY_IMAGE", "BOT_BOTTLE_REGISTRY_IMAGE",
"registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373", "registry@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
+3 -19
View File
@@ -23,24 +23,21 @@ from ...backend.docker.bottle_state import (
bottle_identity, bottle_identity,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
pipelock_state_dir,
supervise_state_dir, supervise_state_dir,
write_metadata, write_metadata,
) )
from ...egress import Egress from ...egress import Egress
from ...env import resolve_env from ...env import resolve_env
from ...git_gate import GitGate from ...git_gate import GitGate
from ...pipelock import PipelockProxy
from ...supervise import Supervise from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
# Gateway ports the bundle exposes inside its container — pipelock # Gateway ports the bundle exposes inside its container — git-gate's
# HTTPS proxy, git-gate's git-daemon, supervise's MCP. The agent # git-daemon, supervise's MCP. The agent inside the smolvm guest
# inside the smolvm guest dials these on the bundle's pinned IP. # dials these on the bundle's pinned IP.
_BUNDLE_PIPELOCK_PORT = 8888
_BUNDLE_GIT_GATE_PORT = 9418 _BUNDLE_GIT_GATE_PORT = 9418
_BUNDLE_SUPERVISE_PORT = 9100 _BUNDLE_SUPERVISE_PORT = 9100
@@ -145,18 +142,6 @@ def resolve_plan(
merged_guest_env.setdefault(key, val) merged_guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=merged_guest_env) 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 = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True) egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = Egress().prepare( egress_plan = Egress().prepare(
@@ -181,7 +166,6 @@ def resolve_plan(
agent_image_ref=agent_image_ref, agent_image_ref=agent_image_ref,
guest_env=agent_provision.guest_env, guest_env=agent_provision.guest_env,
prompt_file=prompt_file, prompt_file=prompt_file,
proxy_plan=proxy_plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
@@ -1,13 +1,10 @@
"""Install the per-bottle MITM CA into the smolmachines guest's """Install the per-bottle egress MITM CA into the smolmachines
trust store (PRD 0023 chunk 4d). guest's trust store (PRD 0023 chunk 4d).
Mirrors `backend.docker.provision.ca`: select the right CA (egress Mirrors `backend.docker.provision.ca`: copy the egress CA to
when the bottle has routes, else pipelock), copy it to Debian's Debian's `/usr/local/share/ca-certificates/` path,
`/usr/local/share/ca-certificates/` path,
`update-ca-certificates` to rebuild the trust bundle, and log the `update-ca-certificates` to rebuild the trust bundle, and log the
fingerprint once. The selected cert depends on the agent's fingerprint once.
HTTP_PROXY target — same logic as the docker backend, since the
agent dials the same daemons through the same bundle.
`smolvm machine exec` runs commands as root in the VM (no `-u` `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 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 """Copy the agent-facing CA cert into the guest, rebuild the
trust bundle, emit a one-line fingerprint log. Called from trust bundle, emit a one-line fingerprint log. Called from
`BottleBackend.provision` after the smolvm guest is up.""" `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) bottle.cp_in(str(cert_host_path), AGENT_CA_PATH)
# Mode 0644 — readable to non-root tools in the guest. # Mode 0644 — readable to non-root tools in the guest.
@@ -19,7 +19,7 @@ This module ships the lifecycle primitives only — create
network, start bundle, stop bundle, remove network — wrapped network, start bundle, stop bundle, remove network — wrapped
around `subprocess.run(["docker", ...])`. Wiring them into the around `subprocess.run(["docker", ...])`. Wiring them into the
launch flow + populating the `BundleLaunchSpec` from the inner 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 from __future__ import annotations
@@ -69,7 +69,7 @@ class BundleLaunchSpec:
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The # Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
# supervisor inside the bundle reads it to skip # supervisor inside the bundle reads it to skip
# bottle-irrelevant daemons (e.g. supervise=False bottles). # 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 # Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
# form inherits the value from the docker-run subprocess env, # form inherits the value from the docker-run subprocess env,
# matching the docker backend's compose-up secret-forwarding # matching the docker backend's compose-up secret-forwarding
+11 -27
View File
@@ -14,7 +14,6 @@ from ..log import die, info
if TYPE_CHECKING: if TYPE_CHECKING:
from ..egress import EgressPlan from ..egress import EgressPlan
from ..pipelock import PipelockProxyPlan
# Debian-family CA layout, shared by every backend (all guest images # 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}" return f"{home}/.claude/skills/{name}"
def select_ca_cert( def select_ca_cert(egress_plan: EgressPlan) -> tuple[Path, str]:
egress_plan: EgressPlan, proxy_plan: PipelockProxyPlan """Return the egress MITM CA cert path and label for provision_ca.
) -> 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.
Shared by every backend's `provision_ca`: launch mints the chosen Launch always mints the CA and re-binds the host path into the
CA(s) and re-binds their host paths into these inner plans before egress_plan before provision runs, so an empty/missing path here
provision runs, so an empty/missing path here means launch's means launch's bringup is broken — fatal."""
bringup is broken — fatal.""" cert = egress_plan.mitmproxy_ca_cert_only_host_path
if egress_plan.routes: if cert == Path() or not cert.is_file():
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():
die( die(
f"pipelock CA cert missing at {cert or '(empty)'}; " f"egress CA cert missing at {cert or '(empty)'}; "
f"launch must have called pipelock_tls_init and re-bound " f"launch must have called egress_tls_init and "
f"the plan before provision" f"re-bound the plan before provision"
) )
return cert, "pipelock" return cert, "egress"
def log_ca_fingerprint(cert_host_path: Path, label: str) -> None: def log_ca_fingerprint(cert_host_path: Path, label: str) -> None:
+5 -58
View File
@@ -3,9 +3,7 @@ act on them (approve / modify / reject).
Curses-based TUI; modify-then-approve shells out to $EDITOR. The Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handlers wire to the per-tool remediation engines: approval handlers wire to the per-tool remediation engines:
PRD 0014 (egress, retargeted from cred-proxy in PRD 0017 PRD 0014 (egress) writes routes.yaml + SIGHUPs egress; PRD 0016
chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015
(pipelock) writes the allowlist + restarts pipelock; PRD 0016
(capability) rebuilds the bottle Dockerfile. (capability) rebuilds the bottle Dockerfile.
""" """
@@ -29,13 +27,6 @@ from ..backend.docker.capability_apply import (
apply_capability_change, apply_capability_change,
) )
from ..backend.docker.egress_apply import EgressApplyError, add_route 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 ..log import Die, error, info
from ..supervise import ( from ..supervise import (
COMPONENT_FOR_TOOL, COMPONENT_FOR_TOOL,
@@ -47,7 +38,6 @@ from ..supervise import (
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
archive_proposal, archive_proposal,
list_pending_proposals, list_pending_proposals,
render_diff, render_diff,
@@ -71,7 +61,7 @@ class QueuedProposal:
# Errors any remediation engine may raise. Caught by the TUI key # Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps # handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses. # the proposal pending rather than crashing curses.
ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) ApplyError = (EgressApplyError, CapabilityApplyError)
def discover_pending() -> list[QueuedProposal]: 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((" " + line, 0) for line in p.justification.splitlines() or [""])
out.extend([ out.extend([
("", 0), ("", 0),
(_proposed_payload_label(p.tool) + ":", 0), ("proposed file:", 0),
]) ])
out.extend((line, 0) for line in p.proposed_file.splitlines() or [""]) 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 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: def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK: if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile" return ".dockerfile"
@@ -167,10 +136,6 @@ def approve(
diff_before, diff_after = add_route( diff_before, diff_after = add_route(
qp.proposal.bottle_slug, file_to_apply, 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: elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
_meta = read_metadata(qp.proposal.bottle_slug) _meta = read_metadata(qp.proposal.bottle_slug)
if _meta is not None and not _meta.compose_project: 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="") _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( def _write_audit(
qp: QueuedProposal, qp: QueuedProposal,
*, *,
@@ -235,7 +183,7 @@ def _write_audit(
diff_before: str, diff_before: str,
diff_after: str, diff_after: str,
) -> None: ) -> None:
"""Audit log for egress / pipelock tools.""" """Audit log for egress tool."""
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool) component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
if component is None: if component is None:
return return
@@ -467,8 +415,7 @@ def _render(
cursor = "> " if i == selected else " " cursor = "> " if i == selected else " "
line = ( line = (
f"{cursor}{ts_short} " f"{cursor}{ts_short} "
f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]} " f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]}"
f"{_proposed_payload_label(p.tool)}"
) )
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line, w - 1, attr) stdscr.addnstr(row, 0, line, w - 1, attr)
@@ -94,7 +94,6 @@ class ClaudeAgentProvider(AgentProvider):
host="api.anthropic.com", host="api.anthropic.com",
auth_scheme="Bearer" if auth_token else "", auth_scheme="Bearer" if auth_token else "",
token_ref=auth_token, token_ref=auth_token,
tls_passthrough=True,
),) ),)
hidden_env_names: frozenset[str] = frozenset() hidden_env_names: frozenset[str] = frozenset()
if auth_token: if auth_token:
@@ -110,7 +110,6 @@ class CodexAgentProvider(AgentProvider):
host=host, host=host,
auth_scheme="Bearer" if forward_host_credentials else "", auth_scheme="Bearer" if forward_host_credentials else "",
token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "", token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "",
tls_passthrough=True,
)) ))
if forward_host_credentials: if forward_host_credentials:
+10 -29
View File
@@ -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 sidecar that becomes the agent's `HTTP_PROXY` / `HTTPS_PROXY`. It
owns three jobs: owns three jobs:
1. MITM the agent's HTTPS with the per-bottle CA (moved from 1. MITM the agent's HTTPS with the per-bottle CA.
pipelock).
2. Enforce manifest-declared `path_allowlist` per route. 2. Enforce manifest-declared `path_allowlist` per route.
3. Inject `Authorization` headers for routes that declare an 3. Inject `Authorization` headers for routes that declare an
`auth` block, the same way cred-proxy does today. `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 # In-container path the addon reads. Pre-created in
# `Dockerfile.sidecars` so the host bind-mount can drop the file # `Dockerfile.sidecars` so the host bind-mount can drop the file
# directly. Content is YAML (hand-rolled by `egress_render_routes` # directly. Content is YAML (hand-rolled by `egress_render_routes`,
# in the style of `pipelock_render_yaml`, parsed by `yaml_subset` # parsed by `yaml_subset` inside the addon).
# inside the addon).
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml" 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 `roles` carries the manifest route's role tuple (reserved for
future use; always empty today). future use; always empty today).
`tls_passthrough` signals that pipelock must not TLS-MITM this `roles` carries the manifest route's role tuple (reserved for
host — either because the manifest declared `pipelock.tls_passthrough: future use; always empty today)."""
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)."""
token_ref: str = "" token_ref: str = ""
roles: tuple[str, ...] = () roles: tuple[str, ...] = ()
tls_passthrough: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -87,10 +81,10 @@ class EgressPlan:
The slug + routes_path + routes + token_env_map fields are The slug + routes_path + routes + token_env_map fields are
filled at prepare time (host-side, side-effect-free on docker). filled at prepare time (host-side, side-effect-free on docker).
The network + CA + pipelock fields are populated by the backend's The network + CA fields are populated by the backend's launch step
launch step via `dataclasses.replace` once those resources via `dataclasses.replace` once those resources exist. Empty defaults
exist. Empty defaults are sentinels meaning "not yet set"; are sentinels meaning "not yet set"; `.start` validates that they are
`.start` validates that they are populated. populated.
`token_env_map` is `{<token_env in container>: <token_ref on host>}`. `token_env_map` is `{<token_env in container>: <token_ref on host>}`.
The backend's start step reads `os.environ[token_ref]` and 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 key) for installing into the agent's trust store via
`provision_ca`. Separate file rather than re-parsing the `provision_ca`. Separate file rather than re-parsing the
concat so secrets and trust artefacts stay on distinct paths. 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 slug: str
@@ -128,8 +112,6 @@ class EgressPlan:
egress_network: str = "" egress_network: str = ""
mitmproxy_ca_host_path: Path = Path() mitmproxy_ca_host_path: Path = Path()
mitmproxy_ca_cert_only_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( def egress_manifest_routes(
@@ -147,7 +129,6 @@ def egress_manifest_routes(
auth_scheme=r.AuthScheme, auth_scheme=r.AuthScheme,
token_ref=r.TokenRef, token_ref=r.TokenRef,
roles=r.Role, roles=r.Role,
tls_passthrough=r.Pipelock.TlsPassthrough,
)) ))
return tuple(out) return tuple(out)
@@ -306,7 +287,7 @@ class Egress(ABC):
forward values from the host's environ into the sidecar's environ. forward values from the host's environ into the sidecar's environ.
Returned plan is incomplete: the launch step must fill 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`.""" via `dataclasses.replace` before passing it to `.start`."""
routes = egress_routes_for_bottle(bottle, provider_routes) routes = egress_routes_for_bottle(bottle, provider_routes)
routes_path = stage_dir / "egress_routes.yaml" routes_path = stage_dir / "egress_routes.yaml"
+4 -5
View File
@@ -167,7 +167,7 @@ def is_git_push_request(path: str, query: str) -> bool:
Universal across routes — the block fires even when no Universal across routes — the block fires even when no
egress route matches the host. A bare-pass route (host with egress route matches the host. A bare-pass route (host with
no auth, no path_allowlist) would otherwise let push through to no auth, no path_allowlist) would otherwise let push through to
pipelock + upstream untouched. the upstream untouched.
""" """
if path.endswith("/git-receive-pack"): if path.endswith("/git-receive-pack"):
return True return True
@@ -189,8 +189,8 @@ def match_route(
exactly (case-insensitive). DNS names are case-insensitive. exactly (case-insensitive). DNS names are case-insensitive.
Wildcard hosts (`*.foo.com`) are NOT supported — they caused Wildcard hosts (`*.foo.com`) are NOT supported — they caused
too many edge cases (apex match? cert validation? pipelock too many edge cases (apex match? cert validation?) for too
mirror mismatch?) for too little payoff. Operators that need little payoff. Operators that need
multiple subdomains declare them individually (or one common multiple subdomains declare them individually (or one common
parent host as a bare-pass route).""" parent host as a bare-pass route)."""
target = request_host.lower() target = request_host.lower()
@@ -210,8 +210,7 @@ def decide(
return what the addon should do with the request. return what the addon should do with the request.
- No matching route → BLOCK. The route table is the bottle's - No matching route → BLOCK. The route table is the bottle's
egress allowlist; defense-in-depth complements pipelock's egress allowlist. A bottle that wants a
hostname gate on the downstream leg. A bottle that wants a
host reachable from the agent must declare a route for it host reachable from the agent must declare a route for it
(bare-pass route — no `auth`, no `path_allowlist` — is fine (bare-pass route — no `auth`, no `path_allowlist` — is fine
for hosts that just need passthrough). for hosts that just need passthrough).
+11 -18
View File
@@ -6,15 +6,15 @@
# call it as a normal child. Behavior is unchanged: # call it as a normal child. Behavior is unchanged:
# #
# * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch # * Upstream proxy: when EGRESS_UPSTREAM_PROXY is set, switch
# to `--mode upstream:URL` to forward all post-MITM traffic # to `--mode upstream:URL` to chain through an upstream proxy.
# through pipelock. mitmproxy does NOT honor HTTPS_PROXY on # mitmproxy does NOT honor HTTPS_PROXY on its outbound side,
# its outbound side, so the upstream wiring has to be the # so the upstream wiring has to be the mitmproxy mode flag,
# mitmproxy mode flag, not env. # not env.
# * Upstream trust: when EGRESS_UPSTREAM_CA is set, build a # * 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 # mitmproxy at it. The option REPLACES mitmproxy's default
# trust store, so passing pipelock's CA alone would break # trust store, so passing the upstream CA alone would break
# route-configured pipelock passthrough hosts. # non-chained hosts.
# * `-s /app/egress_addon.py` loads the addon that reads # * `-s /app/egress_addon.py` loads the addon that reads
# /etc/egress/routes.yaml. # /etc/egress/routes.yaml.
@@ -38,11 +38,7 @@ fi
# Bind address. Docker backend wants `0.0.0.0` (agent dials egress # Bind address. Docker backend wants `0.0.0.0` (agent dials egress
# directly via the docker network alias). Smolmachines backend # directly via the docker network alias). Smolmachines backend
# wants `127.0.0.1` because the agent dials pipelock — not egress # uses EGRESS_LISTEN_HOST when a non-default binding is needed.
# — and egress is pipelock's localhost-only upstream inside the
# bundle. TSI's IP-only allowlist would otherwise let the agent
# reach `<bundle-ip>:9099` and bypass pipelock's DLP; binding
# 127.0.0.1 inside the bundle closes that gap (PRD 0023 chunk 3).
LISTEN_HOST_FLAG="" LISTEN_HOST_FLAG=""
if [ -n "$EGRESS_LISTEN_HOST" ]; then if [ -n "$EGRESS_LISTEN_HOST" ]; then
LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST" LISTEN_HOST_FLAG="--listen-host $EGRESS_LISTEN_HOST"
@@ -56,13 +52,10 @@ if [ -n "$EGRESS_UPSTREAM_CA" ] && [ -f "$EGRESS_UPSTREAM_CA" ]; then
fi fi
# Scope the proxy env to this process tree only. In the bundle # 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 # HTTPS_PROXY at the container level would route git-gate's git
# pushes through pipelock, which is wrong (pipelock doesn't proxy # pushes through an upstream proxy unintentionally. Setting them
# SSH and would block public git repos). Setting them here means # here means only mitmdump's subprocess inherits them.
# 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.
if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then if [ -n "$EGRESS_UPSTREAM_PROXY" ]; then
export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY" export HTTPS_PROXY="$EGRESS_UPSTREAM_PROXY"
export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY" export HTTP_PROXY="$EGRESS_UPSTREAM_PROXY"
+2 -2
View File
@@ -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. 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 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 same blast radius as internet-facing TLS interception; mixing it
with ssh-gate would force ssh-gate above L4 and into git-protocol with ssh-gate would force ssh-gate above L4 and into git-protocol
land. See `docs/prds/0008-git-gate.md`. land. See `docs/prds/0008-git-gate.md`.
+6 -10
View File
@@ -18,8 +18,7 @@ Bottle schema (frontmatter):
user: { name: <str>, email: <str> } # optional user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # optional repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] } egress: { routes: [ <egress-route>, ... ] }
# route keys: host, path_allowlist, auth, role, pipelock # route keys: host, path_allowlist, auth, role
# pipelock: { tls_passthrough: <bool>, ssrf_ip_allowlist: [<cidr>, ...] }
supervise: <bool> # optional supervise: <bool> # optional
Agent schema (frontmatter): Agent schema (frontmatter):
@@ -56,7 +55,6 @@ from .manifest_egress import (
EGRESS_AUTH_SCHEMES, EGRESS_AUTH_SCHEMES,
EgressConfig, EgressConfig,
EgressRoute, EgressRoute,
PipelockRoutePolicy,
) )
from .manifest_git import GitEntry, GitUser, parse_git_gate_config from .manifest_git import GitEntry, GitUser, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS from .manifest_schema import BOTTLE_KEYS
@@ -68,7 +66,6 @@ __all__ = [
"GitUser", "GitUser",
"AgentProvider", "AgentProvider",
"EGRESS_AUTH_SCHEMES", "EGRESS_AUTH_SCHEMES",
"PipelockRoutePolicy",
"EgressRoute", "EgressRoute",
"EgressConfig", "EgressConfig",
"Agent", "Agent",
@@ -100,12 +97,11 @@ class Bottle:
git_user: GitUser = field(default_factory=GitUser) git_user: GitUser = field(default_factory=GitUser)
egress: EgressConfig = field(default_factory=EgressConfig) egress: EgressConfig = field(default_factory=EgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes three # the launch step brings up a supervise sidecar that exposes MCP
# MCP tools to the agent (cred-proxy-block, pipelock-block, # tools to the agent (egress-block, capability-block) plus mounts
# capability-block; the cred-proxy-block tool is renamed and # the current-config dir read-only into the agent at
# retargeted at egress in PRD 0017 chunk 3) plus mounts the # /etc/bot-bottle/current-config. False (the default) skips the
# current-config dir read-only into the agent at /etc/bot-bottle/ # sidecar and mount.
# current-config. False (the default) skips the sidecar and mount.
supervise: bool = False supervise: bool = False
@classmethod @classmethod
+3 -74
View File
@@ -2,8 +2,7 @@
from __future__ import annotations from __future__ import annotations
import ipaddress from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import cast from typing import cast
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
@@ -39,68 +38,6 @@ def validate_egress_routes(
seen_hosts[key] = None 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) @dataclass(frozen=True)
class EgressRoute: class EgressRoute:
"""One route on the per-bottle egress sidecar (PRD 0017). """One route on the per-bottle egress sidecar (PRD 0017).
@@ -132,7 +69,6 @@ class EgressRoute:
AuthScheme: str = "" AuthScheme: str = ""
TokenRef: str = "" TokenRef: str = ""
Role: tuple[str, ...] = () Role: tuple[str, ...] = ()
Pipelock: PipelockRoutePolicy = field(default_factory=PipelockRoutePolicy)
@classmethod @classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute": 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" 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: 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( raise ManifestError(
f"{label} has unknown key {k!r}; accepted keys are " 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( return cls(
@@ -248,7 +178,6 @@ class EgressRoute:
AuthScheme=auth_scheme, AuthScheme=auth_scheme,
TokenRef=token_ref, TokenRef=token_ref,
Role=roles, Role=roles,
Pipelock=pipelock,
) )
-541
View File
@@ -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:<digest> 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)
+7 -37
View File
@@ -1,7 +1,7 @@
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1). """Per-bottle sidecar supervisor (PRD 0024 chunk 1).
PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns 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 forwards SIGTERM/SIGINT to each child, and propagates per-daemon
stdout+stderr to the container log with a `[name] ` prefix. 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." sick daemon."
Daemon subset is env-driven. The compose renderer narrows it via 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. don't use git-gate or supervise. Default: all daemons.
Stdlib-only by design adding supervisord/s6/runit for four 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. # Env-var name prefixes that carry egress-only credentials.
# `egress_apply.py` assigns `EGRESS_TOKEN_<n>` slots that egress # `egress_apply.py` assigns `EGRESS_TOKEN_<n>` slots that egress
# reads to inject `Authorization` headers on configured routes; # reads to inject `Authorization` headers on configured routes;
# every other daemon in the bundle (especially pipelock with # no other daemon in the bundle should see these values.
# `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.
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) _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, ...] = ( _DAEMONS: tuple[_DaemonSpec, ...] = (
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")), _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-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
_DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")), _DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")),
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")), _DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
@@ -303,10 +282,8 @@ class _Supervisor:
def restart_daemon(self, daemon_name: str, *, grace: float = 5.0) -> bool: def restart_daemon(self, daemon_name: str, *, grace: float = 5.0) -> bool:
"""Terminate one named child and spawn a fresh one, leaving """Terminate one named child and spawn a fresh one, leaving
the other daemons running. Used by the pipelock-apply path: the other daemons running. A daemon that has no in-process
pipelock has no in-process reload, so apply_allowlist_change reload can be restarted this way after its config file changes.
runs `docker kill --signal USR1 <bundle>` after writing the
new yaml; the supervisor catches SIGUSR1 and calls this.
Behavior: SIGTERM wait up to `grace` seconds SIGKILL if Behavior: SIGTERM wait up to `grace` seconds SIGKILL if
still alive spawn a replacement under the same DaemonSpec. still alive spawn a replacement under the same DaemonSpec.
@@ -314,8 +291,8 @@ class _Supervisor:
forward_signal / shutdown calls reach the new pid. forward_signal / shutdown calls reach the new pid.
Returns True iff a daemon by that name was running and a Returns True iff a daemon by that name was running and a
replacement spawned; False if no such daemon (the replacement spawned; False if no such daemon (not wired
compose-renderer subset said this bottle doesn't run it).""" for this bottle)."""
if self.shutdown_at is not None: if self.shutdown_at is not None:
_log(f"restart {daemon_name} skipped; supervisor is shutting down") _log(f"restart {daemon_name} skipped; supervisor is shutting down")
return False return False
@@ -367,13 +344,6 @@ def main(argv: Sequence[str] | None = None) -> int:
# delivers SIGHUP to PID 1 (this supervisor); forward it to # delivers SIGHUP to PID 1 (this supervisor); forward it to
# mitmdump so it reloads its addon. # mitmdump so it reloads its addon.
signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore 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 <bundle>` 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(): while not sup.tick():
time.sleep(_POLL_INTERVAL) time.sleep(_POLL_INTERVAL)
+2 -8
View File
@@ -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: agent calls when it hits a stuck-recovery category:
* egress-block agent proposes a new routes.yaml * 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 Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically, justification text. The sidecar validates the proposal syntactically,
@@ -50,12 +49,10 @@ SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 SUPERVISE_PORT = 9100
TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_BLOCK = "egress-block"
TOOL_PIPELOCK_BLOCK = "pipelock-block"
TOOL_CAPABILITY_BLOCK = "capability-block" TOOL_CAPABILITY_BLOCK = "capability-block"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = ( TOOLS: tuple[str, ...] = (
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_LIST_EGRESS_ROUTES, TOOL_LIST_EGRESS_ROUTES,
) )
@@ -76,7 +73,6 @@ EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
# record laid down in PRD 0016. # record laid down in PRD 0016.
COMPONENT_FOR_TOOL: dict[str, str] = { COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_EGRESS_BLOCK: "egress", TOOL_EGRESS_BLOCK: "egress",
TOOL_PIPELOCK_BLOCK: "pipelock",
} }
STATUS_APPROVED = "approved" STATUS_APPROVED = "approved"
@@ -85,8 +81,7 @@ STATUS_REJECTED = "rejected"
STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED) STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
# Operator-initiated audit entries (no tool call). PRD 0014's # Operator-initiated audit entries (no tool call). PRD 0014's
# `routes edit <bottle>` and PRD 0015's `pipelock edit <bottle>` # `routes edit <bottle>` verb writes entries with this action.
# verbs write entries with this action.
ACTION_OPERATOR_EDIT = "operator-edit" ACTION_OPERATOR_EDIT = "operator-edit"
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue" QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
@@ -562,7 +557,6 @@ __all__ = [
"TOOL_CAPABILITY_BLOCK", "TOOL_CAPABILITY_BLOCK",
"TOOL_EGRESS_BLOCK", "TOOL_EGRESS_BLOCK",
"TOOL_LIST_EGRESS_ROUTES", "TOOL_LIST_EGRESS_ROUTES",
"TOOL_PIPELOCK_BLOCK",
"archive_proposal", "archive_proposal",
"audit_dir", "audit_dir",
"audit_log_path", "audit_log_path",
+16 -90
View File
@@ -1,8 +1,8 @@
"""Supervise sidecar HTTP server (PRD 0013). """Supervise sidecar HTTP server (PRD 0013).
Per-bottle MCP server exposing three tools `egress-block`, Per-bottle MCP server exposing two tools `egress-block`,
`pipelock-block`, `capability-block` that the agent calls to `capability-block` that the agent calls to propose config changes
propose config changes when stuck. Each tool call: when stuck. Each tool call:
1. Validates the proposed file syntactically. 1. Validates the proposed file syntactically.
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from 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. * `initialize` handshake; returns server info + caps.
* `notifications/initialized` ack-only. * `notifications/initialized` ack-only.
* `tools/list` returns the three tool definitions. * `tools/list` returns the tool definitions.
* `tools/call` validates, queues, blocks, returns. * `tools/call` validates, queues, blocks, returns.
Everything else returns JSON-RPC error -32601 (method not found). Everything else returns JSON-RPC error -32601 (method not found).
@@ -38,7 +38,6 @@ import sys
import time import time
import typing import typing
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -151,8 +150,8 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"or rejects in the supervise TUI. On approval the " "or rejects in the supervise TUI. On approval the "
"supervisor writes the merged routes.yaml, SIGHUPs " "supervisor writes the merged routes.yaml, SIGHUPs "
"egress (atomic swap, no dropped connections), and " "egress (atomic swap, no dropped connections), and "
"mirrors the host onto pipelock's allowlist for the " "writes the merged routes.yaml and SIGHUPs egress "
"downstream gate." "(atomic swap, no dropped connections)."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -203,15 +202,11 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"name": _sv.TOOL_LIST_EGRESS_ROUTES, "name": _sv.TOOL_LIST_EGRESS_ROUTES,
"description": ( "description": (
"List the current egress route table — the bottle's " "List the current egress route table — the bottle's "
"primary egress allowlist. Returns JSON with one entry " "allowlist. Returns JSON with one entry per allowed host, "
"per allowed host, each carrying its path_allowlist (if " "each carrying its path_allowlist (if any) and whether "
"any) and whether the proxy injects Authorization for " "the proxy injects Authorization for the route. Use this "
"the route. Use this before composing an " "before composing an `egress-block` proposal so the new "
"`egress-block` proposal so the new routes file " "routes file extends the live one rather than replacing it."
"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."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -219,48 +214,12 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"additionalProperties": False, "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, "name": _sv.TOOL_CAPABILITY_BLOCK,
"description": ( "description": (
"Call when the bottle is missing a tool, skill, permission, " "Call when the bottle is missing a tool, skill, permission, "
"or env var you need — something that lives in the agent " "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 " "Read the current Dockerfile from "
"/etc/bot-bottle/current-config/Dockerfile, compose a " "/etc/bot-bottle/current-config/Dockerfile, compose a "
"modified version, and pass the full new file plus a " "modified version, and pass the full new file plus a "
@@ -286,27 +245,10 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
] ]
# Map each tool to the input field that carries the agent's # Map each non-egress tool to the input field that carries the agent's
# tool-specific payload (stored in Proposal.proposed_file as # payload (stored in Proposal.proposed_file). egress-block builds its
# free-form text the apply path interprets per tool). # payload from structured input fields in `handle_egress_block`.
#
# 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.
PROPOSED_FILE_FIELD: dict[str, str] = { PROPOSED_FILE_FIELD: dict[str, str] = {
_sv.TOOL_PIPELOCK_BLOCK: "failed_url",
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile", _sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
} }
@@ -325,23 +267,7 @@ def validate_proposed_file(tool: str, content: str) -> None:
enter the queue.""" enter the queue."""
if not content.strip(): if not content.strip():
raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty") raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
if tool == _sv.TOOL_PIPELOCK_BLOCK: if tool == _sv.TOOL_CAPABILITY_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:
# Dockerfiles are too varied to validate syntactically beyond # Dockerfiles are too varied to validate syntactically beyond
# non-empty. The operator reads the diff in the TUI. # non-empty. The operator reads the diff in the TUI.
pass pass
+1 -1
View File
@@ -63,7 +63,7 @@ from typing import cast
class YamlSubsetError(ValueError): class YamlSubsetError(ValueError):
"""Raised when input violates the YAML subset's rules. Callers """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`; etc.) catch this at their own boundary and forward to `die`;
callers running outside the bot-bottle CLI process (the callers running outside the bot-bottle CLI process (the
egress sidecar's addon) handle it as a normal exception.""" egress sidecar's addon) handle it as a normal exception."""
-45
View File
@@ -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()
@@ -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=<code>" proxy upgraded to a tunnel (CONNECT success path)
# - "status=<code>" proxy answered without tunneling (block path)
# - "error=<code> <message>" 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()
@@ -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()
-210
View File
@@ -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()
@@ -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=<code>" when the proxy returns an HTTP response
# - "error=<code> <message>" 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()
@@ -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: <pattern>` 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()
@@ -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=<code>" proxy answered with an HTTP response
# - "error=<code> <message>" 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()
@@ -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()
+20 -24
View File
@@ -53,7 +53,7 @@ _FAKE_SECRETS = {
@skip_unless_docker() @skip_unless_docker()
@unittest.skipIf( @unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true", 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 " "the runner container can't see, and the network topology hides "
"sibling-sidecar visibility — same constraint as the other " "sibling-sidecar visibility — same constraint as the other "
"bottle-bringup integration tests", "bottle-bringup integration tests",
@@ -256,14 +256,11 @@ class TestSandboxEscape(unittest.TestCase):
# ---- attack 3: HTTP exfil shapes --------------------------------- # ---- attack 3: HTTP exfil shapes ---------------------------------
# Sandbox-block signature: pipelock / egress return HTTP 403 on # Sandbox-block signature: egress returns HTTP 403 on policy
# policy reject; the response body carries a recognizable # reject; the response body carries a recognizable marker.
# marker. Egress's reject message starts `"egress: host '...' # Egress's reject message starts with `"egress: host '...'
# is not in the bottle's egress.routes allowlist"`; pipelock's # is not in the bottle's egress.routes allowlist"`.
# DLP rejects start `"blocked: "` (e.g. _SANDBOX_BLOCK_MARKERS = ("egress:", "blocked:")
# `"blocked: DLP match: Anthropic API Key (critical)"`,
# `"blocked: request body contains secret"`).
_SANDBOX_BLOCK_MARKERS = ("egress:", "pipelock", "blocked:")
def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore def _assert_sandbox_block(self, label: str, r: object) -> None: # type: ignore
"""A real sandbox block produces an HTTP 403 with a """A real sandbox block produces an HTTP 403 with a
@@ -286,7 +283,7 @@ class TestSandboxEscape(unittest.TestCase):
self.assertTrue( self.assertTrue(
has_marker and http_code == "403", has_marker and http_code == "403",
f"{label}: expected a sandbox-level block (HTTP 403 with " 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"If the response came from the actual upstream, the "
f"secret REACHED the network — that's the leak this " f"secret REACHED the network — that's the leak this "
f"test exists to catch. body={body!r} " 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 """The agent tries to leak `$TEST_SECRET_ANTHROPIC` to an
ALLOWLISTED destination (api.anthropic.com) via four ALLOWLISTED destination (api.anthropic.com) via four
different request shapes. Each must be blocked by a different request shapes. Each must be blocked by a
sandbox layer (pipelock DLP / egress filter / etc.) sandbox layer (egress allowlist filter) producing an
producing an HTTP 403 with an `egress:` or `pipelock` HTTP 403 with an `egress:` marker in the response body.
marker in the response body.
Anything else means the request reached upstream and the Anything else means the request reached upstream and the
secret leaked, even if the upstream responded with its 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. remediation lands as its own PRD before this test merges.
DON'T mark expectedFailure to silence it. DON'T mark expectedFailure to silence it.
Destination note: we use `raw.githubusercontent.com`, one Destination note: we use `raw.githubusercontent.com`, which
of the DEFAULT_ALLOWLIST hosts. It is not route-configured is not in the bottle's egress.routes allowlist, so egress
for pipelock TLS passthrough, so pipelock MITMs it and the blocks every request to it regardless of the secret's
DLP scan applies, which is what this attack exercises.""" location in the request."""
# Capture HTTP code via curl's -w; don't use --fail so # Capture HTTP code via curl's -w; don't use --fail so
# we get the response body even on 4xx. # we get the response body even on 4xx.
url_base = "https://raw.githubusercontent.com" url_base = "https://raw.githubusercontent.com"
@@ -352,13 +348,13 @@ class TestSandboxEscape(unittest.TestCase):
def test_4_dns_exfil_blocked(self) -> None: def test_4_dns_exfil_blocked(self) -> None:
"""Two sub-attacks against DNS: """Two sub-attacks against DNS:
4a crafted subdomain that pipelock would resolve. The 4a crafted subdomain attack. The hostname
hostname `<SECRET>.api.anthropic.com` looks "under" `<SECRET>.api.anthropic.com` looks "under" the
the allowlisted apex but pipelock's allowlist is allowlisted apex but egress's allowlist is
exact-match it should reject the host BEFORE exact-match it rejects the host before issuing
issuing the DNS query, so the secret never reaches a DNS query, so the secret never reaches an
an external resolver. external resolver.
4b direct DNS query bypassing pipelock entirely. The 4b direct DNS query bypassing egress entirely. The
agent's internal network has no default gateway; agent's internal network has no default gateway;
even an explicit resolver like 8.8.8.8 should be even an explicit resolver like 8.8.8.8 should be
unreachable. Confirms the network isolation is unreachable. Confirms the network isolation is
@@ -2,8 +2,8 @@
Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a Verifies that flipping `BOT_BOTTLE_SIDECAR_BUNDLE=1` produces a
working bottle: `docker compose up` brings the agent + bundle pair working bottle: `docker compose up` brings the agent + bundle pair
online, the four daemons inside the bundle bind their ports, and online, the daemons inside the bundle bind their ports, and the
the agent can reach pipelock + supervise via the bundle's network agent can reach egress + supervise via the bundle's network
aliases (no agent-side config changes between flag positions). aliases (no agent-side config changes between flag positions).
Skipped under GITEA_ACTIONS the bundle image is a multi-stage 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: def _manifest() -> Manifest:
"""Bottle with supervise on so the bundle exercises three of """Bottle with supervise on so the bundle exercises egress +
the four daemons (pipelock, egress, supervise). Git is off supervise. Git is off because a meaningful git-gate test needs
because a meaningful git-gate test needs a real upstream and a real upstream and SSH keys out of scope for a bundle smoke."""
SSH keys out of scope for a bundle smoke. Egress is
implicitly on as pipelock's upstream regardless of routes."""
return Manifest.from_json_obj({ return Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
@@ -68,21 +66,16 @@ class TestSidecarBundleCompose(unittest.TestCase):
plan = backend.prepare(spec, stage_dir=stage_dir) plan = backend.prepare(spec, stage_dir=stage_dir)
with backend.launch(plan) as bottle: with backend.launch(plan) as bottle:
# The agent's HTTPS_PROXY URL (resolved at # The agent's HTTPS_PROXY URL (resolved at
# renderer-time, unchanged from the legacy # renderer-time) should reach egress inside
# shape) should reach pipelock inside the # the bundle. A bare CONNECT with no upstream
# bundle. We probe by asking for the proxy's # URL gets rejected with 400 or 405 but proves
# listening port from inside the agent. # the listener is alive at the alias.
probe = bottle.exec( probe = bottle.exec(
"set -eu\n" "set -eu\n"
"echo HTTPS_PROXY=$HTTPS_PROXY\n" "echo HTTPS_PROXY=$HTTPS_PROXY\n"
"PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n" "PORT=$(echo \"$HTTPS_PROXY\" | sed -E 's|.*:([0-9]+).*|\\1|')\n"
"HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n" "HOST=$(echo \"$HTTPS_PROXY\" | sed -E 's|http://([^:]+):.*|\\1|')\n"
"echo HOST=$HOST PORT=$PORT\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' " "curl -sS --max-time 5 -o /dev/null -w 'http=%{http_code}\\n' "
" \"http://$HOST:$PORT/\" || true\n" " \"http://$HOST:$PORT/\" || true\n"
) )
@@ -98,11 +91,10 @@ class TestSidecarBundleCompose(unittest.TestCase):
shutil.rmtree(stage_dir, ignore_errors=True) shutil.rmtree(stage_dir, ignore_errors=True)
self.assertEqual(0, probe.returncode, msg=probe.stderr) self.assertEqual(0, probe.returncode, msg=probe.stderr)
# pipelock answered SOMETHING — any 4xx is fine, just proves # egress answered SOMETHING — any 4xx is fine, just proves
# the bundle's pipelock daemon is listening at the # the egress daemon is listening at the proxy address.
# `pipelock` alias on port 8888 (or whatever the env says).
self.assertIn("http=", probe.stdout, 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 # supervise's /health endpoint exists (PRD 0013); it should
# answer 200 or similar — anything non-empty proves the # answer 200 or similar — anything non-empty proves the
# third daemon's alias resolves to the same bundle. # third daemon's alias resolves to the same bundle.
+6 -11
View File
@@ -1,15 +1,15 @@
"""Integration: PRD 0024 chunk 1 — the sidecar bundle image builds """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 This test does NOT exercise the daemons running against real
config (pipelock.yaml, routes.yaml, etc) that lands in chunk 2 config (routes.yaml, etc) that lands in chunk 2 when the
when the renderer wires the bundle into compose. What we verify renderer wires the bundle into compose. What we verify here is
here is the chunk-1 contract: the chunk-1 contract:
- Dockerfile.sidecars builds (multi-stage works, base layers - Dockerfile.sidecars builds (multi-stage works, base layers
pull, COPYs resolve). pull, COPYs resolve).
- pipelock, gitleaks, mitmdump are at the documented paths and - gitleaks, mitmdump are at the documented paths and answer
answer `--version`. `--version`.
- The Python init at /app/sidecar_init.py runs and prints the - The Python init at /app/sidecar_init.py runs and prints the
expected "no daemons selected" line when the supervisor is expected "no daemons selected" line when the supervisor is
pointed at an empty daemon set. pointed at an empty daemon set.
@@ -74,11 +74,6 @@ class TestSidecarBundleImage(unittest.TestCase):
) )
return proc.returncode, proc.stdout.decode("utf-8", errors="replace") 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): def test_gitleaks_binary_present_and_versioned(self):
rc, out = self._run_in_image("/usr/bin/gitleaks", "version") rc, out = self._run_in_image("/usr/bin/gitleaks", "version")
self.assertEqual(0, rc, msg=out) self.assertEqual(0, rc, msg=out)
@@ -81,13 +81,9 @@ class TestBundleBringup(unittest.TestCase):
subnet=subnet, subnet=subnet,
gateway=gateway, gateway=gateway,
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
# Only run the pipelock daemon for this smoke — it's # Empty daemons_csv → init exits "no daemons selected"
# the lightest of the four and doesn't need bind # immediately. We just need the container to land on
# mounts beyond what we'd skip without # the network at the right IP before it exits.
# 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.)
daemons_csv="", # empty → init exits "no daemons selected" daemons_csv="", # empty → init exits "no daemons selected"
) )
start_bundle(spec) start_bundle(spec)
@@ -124,32 +124,6 @@ class TestSmolmachinesLaunch(unittest.TestCase):
f"expected a connect-refusal message; got: {r.stdout!r}", 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 <bundle-ip>: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): def test_prompt_file_lands_in_guest(self):
# provision_prompt copies the host-side prompt.txt into the # provision_prompt copies the host-side prompt.txt into the
# guest at /root/.bot-bottle-prompt.txt. The content # guest at /root/.bot-bottle-prompt.txt. The content
-4
View File
@@ -101,7 +101,6 @@ class TestAgentProviderRuntime(unittest.TestCase):
self.assertEqual("api.anthropic.com", route.host) self.assertEqual("api.anthropic.com", route.host)
self.assertEqual("Bearer", route.auth_scheme) self.assertEqual("Bearer", route.auth_scheme)
self.assertEqual("BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", route.token_ref) 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("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["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"])
self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"]) self.assertEqual("1", plan.env_vars["DISABLE_ERROR_REPORTING"])
@@ -143,7 +142,6 @@ class TestAgentProviderRuntime(unittest.TestCase):
for r in plan.egress_routes: for r in plan.egress_routes:
self.assertEqual("Bearer", r.auth_scheme) self.assertEqual("Bearer", r.auth_scheme)
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, r.token_ref) 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): def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
@@ -161,7 +159,6 @@ class TestAgentProviderRuntime(unittest.TestCase):
for r in plan.egress_routes: for r in plan.egress_routes:
self.assertEqual("", r.auth_scheme) self.assertEqual("", r.auth_scheme)
self.assertEqual("", r.token_ref) self.assertEqual("", r.token_ref)
self.assertTrue(r.tls_passthrough)
def test_claude_without_auth_token_has_passthrough_egress_route(self): def test_claude_without_auth_token_has_passthrough_egress_route(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: 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("api.anthropic.com", route.host)
self.assertEqual("", route.auth_scheme) self.assertEqual("", route.auth_scheme)
self.assertEqual("", route.token_ref) self.assertEqual("", route.token_ref)
self.assertTrue(route.tls_passthrough)
self.assertNotIn("CLAUDE_CODE_OAUTH_TOKEN", plan.env_vars) self.assertNotIn("CLAUDE_CODE_OAUTH_TOKEN", plan.env_vars)
self.assertEqual(frozenset(), plan.hidden_env_names) self.assertEqual(frozenset(), plan.hidden_env_names)
+1 -1
View File
@@ -57,7 +57,7 @@ class TestEnumerateActiveAgents(unittest.TestCase):
def test_concatenates_per_backend(self): def test_concatenates_per_backend(self):
a = ActiveAgent( a = ActiveAgent(
backend_name="docker", slug="a-1", agent_name="impl", backend_name="docker", slug="a-1", agent_name="impl",
started_at="", services=("pipelock",), started_at="", services=("egress",),
) )
b = ActiveAgent( b = ActiveAgent(
backend_name="smolmachines", slug="b-2", agent_name="research", backend_name="smolmachines", slug="b-2", agent_name="research",
+22 -55
View File
@@ -32,7 +32,6 @@ from bot_bottle.egress import (
) )
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan 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: def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
return GitGatePlan( return GitGatePlan(
slug=SLUG, slug=SLUG,
@@ -119,8 +106,6 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan:
egress_network=f"bot-bottle-egress-{SLUG}", egress_network=f"bot-bottle-egress-{SLUG}",
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem", mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "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 env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
prompt_file=STAGE / "prompt", prompt_file=STAGE / "prompt",
proxy_plan=_proxy_plan(),
git_gate_plan=_git_gate_plan(upstreams), git_gate_plan=_git_gate_plan(upstreams),
egress_plan=_egress_plan(routes), egress_plan=_egress_plan(routes),
supervise_plan=_supervise_plan() if supervise else None, supervise_plan=_supervise_plan() if supervise else None,
@@ -233,16 +217,15 @@ class TestAgentAlwaysPresent(unittest.TestCase):
s = bottle_plan_to_compose(_plan())["services"]["agent"] s = bottle_plan_to_compose(_plan())["services"]["agent"]
self.assertEqual({"internal"}, set(s["networks"].keys())) self.assertEqual({"internal"}, set(s["networks"].keys()))
def test_agent_proxy_via_pipelock_when_no_egress(self): def test_agent_proxy_always_via_egress(self):
s = bottle_plan_to_compose(_plan(with_egress=False))["services"]["agent"] for with_egress in (False, True):
env = s["environment"] with self.subTest(with_egress=with_egress):
# Looking for HTTPS_PROXY pointing at pipelock's container name. s = bottle_plan_to_compose(
proxy_lines = [e for e in env if e.startswith("HTTPS_PROXY=")] _plan(with_egress=with_egress)
self.assertEqual(1, len(proxy_lines)) )["services"]["agent"]
self.assertEqual( proxy_lines = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")]
"HTTPS_PROXY=http://pipelock:8888", self.assertEqual(1, len(proxy_lines))
proxy_lines[0], self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy_lines[0])
)
def test_agent_proxy_via_egress_when_egress_present(self): def test_agent_proxy_via_egress_when_egress_present(self):
s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"] s = bottle_plan_to_compose(_plan(with_egress=True))["services"]["agent"]
@@ -306,9 +289,9 @@ class TestAgentAlwaysPresent(unittest.TestCase):
class TestSidecarBundleShape(unittest.TestCase): class TestSidecarBundleShape(unittest.TestCase):
"""The compose renderer emits exactly one `sidecars` service in """The compose renderer emits exactly one `sidecars` service in
place of the four daemons it owns (pipelock + egress + git-gate place of the daemons it owns (egress + git-gate + supervise).
+ supervise). PRD 0024 chunk 5 dropped the legacy four-sidecar PRD 0024 chunk 5 dropped the legacy four-sidecar shape entirely,
shape entirely, so the bundle is the only thing exercised here.""" so the bundle is the only thing exercised here."""
def _render(self, **plan_kwargs: object) -> Any: # type: ignore def _render(self, **plan_kwargs: object) -> Any: # type: ignore
return bottle_plan_to_compose(_plan(**plan_kwargs)) # 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"] sc = self._render()["services"]["sidecars"]
self.assertEqual({"internal", "egress"}, set(sc["networks"].keys())) self.assertEqual({"internal", "egress"}, set(sc["networks"].keys()))
def test_internal_aliases_cover_pipelock_and_egress_shortnames(self): def test_internal_aliases_include_egress_shortname(self):
# The agent's HTTPS_PROXY url references either `egress` or
# `pipelock`. Both must resolve to the bundle.
sc = self._render()["services"]["sidecars"] sc = self._render()["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"]) aliases = set(sc["networks"]["internal"]["aliases"])
self.assertIn("egress", aliases) self.assertIn("egress", aliases)
self.assertIn("pipelock", aliases)
def test_internal_aliases_omit_inactive_sidecars(self): def test_internal_aliases_omit_inactive_sidecars(self):
# With no git-gate / supervise, those names are NOT aliased # With no git-gate / supervise, those names are NOT aliased
@@ -359,16 +339,13 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("supervise", aliases) self.assertIn("supervise", aliases)
def test_daemons_csv_lists_only_active(self): 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"] sc = self._render()["services"]["sidecars"]
daemons = { daemons = {
line.split("=", 1)[1] line.split("=", 1)[1]
for line in sc["environment"] for line in sc["environment"]
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=") 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): def test_daemons_csv_expands_with_optional_sidecars(self):
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
@@ -379,13 +356,13 @@ class TestSidecarBundleShape(unittest.TestCase):
else: else:
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env") self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
self.assertEqual( self.assertEqual(
["egress", "pipelock", "git-gate", "supervise"], ["egress", "git-gate", "supervise"],
csv.split(","), csv.split(","),
) )
def test_bundle_env_does_not_set_https_proxy(self): def test_bundle_env_does_not_set_https_proxy(self):
# HTTPS_PROXY at the container level would route git-gate's # 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 # the job of egress_entrypoint.sh; the bundle env must not
# leak it. # leak it.
sc = self._render(with_egress=True)["services"]["sidecars"] sc = self._render(with_egress=True)["services"]["sidecars"]
@@ -397,22 +374,15 @@ class TestSidecarBundleShape(unittest.TestCase):
f"bundle env must not set {line!r}", 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"] sc = self._render(with_egress=True)["services"]["sidecars"]
env_strings = sc["environment"] 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) 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"] sc = self._render()["services"]["sidecars"]
env_strings = sc["environment"] env_strings = sc["environment"]
for e in env_strings: self.assertNotIn("EGRESS_TOKEN_0", env_strings)
self.assertFalse(e.startswith("EGRESS_UPSTREAM_PROXY="))
self.assertFalse(e.startswith("EGRESS_UPSTREAM_CA="))
def test_supervise_env_present_when_active(self): def test_supervise_env_present_when_active(self):
sc = self._render(supervise=True)["services"]["sidecars"] 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_QUEUE_DIR=") for e in env_strings))
self.assertTrue(any(e.startswith("SUPERVISE_PORT=") 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"] sc = self._render()["services"]["sidecars"]
targets = {v["target"] for v in sc["volumes"]} 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): def test_volumes_union_full_matrix(self):
sc = self._render(with_git=True, with_egress=True, supervise=True)[ sc = self._render(with_git=True, with_egress=True, supervise=True)[
"services"]["sidecars"] "services"]["sidecars"]
targets = {v["target"] for v in sc["volumes"]} targets = {v["target"] for v in sc["volumes"]}
# Pipelock + egress + git-gate + supervise paths all self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
# present.
self.assertIn("/etc/pipelock.yaml", targets)
self.assertIn("/etc/egress/routes.yaml", targets) self.assertIn("/etc/egress/routes.yaml", targets)
self.assertIn("/git-gate-entrypoint.sh", targets) self.assertIn("/git-gate-entrypoint.sh", targets)
self.assertIn("/git-gate/creds/upstream-known_hosts", 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") self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
for t in targets)) for t in targets))
@@ -23,7 +23,6 @@ from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -90,9 +89,6 @@ def _plan(
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), 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( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -24,7 +24,6 @@ from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -91,9 +90,6 @@ def _plan(
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), 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( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
+8 -8
View File
@@ -40,22 +40,22 @@ class TestParseServicesByProject(unittest.TestCase):
def test_multiple_services_per_project(self): def test_multiple_services_per_project(self):
out = _enumerate._parse_services_by_project( out = _enumerate._parse_services_by_project(
"bot-bottle-dev-abc\tegress\n" "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" "bot-bottle-dev-abc\tsupervise\n"
) )
self.assertEqual( self.assertEqual(
{"bot-bottle-dev-abc": {"egress", "pipelock", "supervise"}}, {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}},
out, out,
) )
def test_multiple_projects(self): def test_multiple_projects(self):
out = _enumerate._parse_services_by_project( out = _enumerate._parse_services_by_project(
"proj-a\tegress\n" "proj-a\tegress\n"
"proj-b\tpipelock\n" "proj-b\tgit-gate\n"
"proj-a\tsupervise\n" "proj-a\tsupervise\n"
) )
self.assertEqual( self.assertEqual(
{"proj-a": {"egress", "supervise"}, "proj-b": {"pipelock"}}, {"proj-a": {"egress", "supervise"}, "proj-b": {"git-gate"}},
out, out,
) )
@@ -117,7 +117,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
)) ))
self._stub( self._stub(
["dev-abc"], ["dev-abc"],
{"bot-bottle-dev-abc": {"pipelock", "egress", "supervise"}}, {"bot-bottle-dev-abc": {"egress", "git-gate", "supervise"}},
) )
active = _enumerate.enumerate_active() active = _enumerate.enumerate_active()
self.assertEqual(1, len(active)) self.assertEqual(1, len(active))
@@ -126,17 +126,17 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("dev-abc", a.slug) self.assertEqual("dev-abc", a.slug)
self.assertEqual("implementer", a.agent_name) self.assertEqual("implementer", a.agent_name)
self.assertEqual("2026-05-26T03:00:00+00:00", a.started_at) 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): def test_missing_metadata_renders_question_mark(self):
# State dir doesn't exist for this slug — agent_name falls # State dir doesn't exist for this slug — agent_name falls
# back to "?" rather than dropping the row. # 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() active = _enumerate.enumerate_active()
self.assertEqual(1, len(active)) self.assertEqual(1, len(active))
self.assertEqual("?", active[0].agent_name) self.assertEqual("?", active[0].agent_name)
self.assertEqual("", active[0].started_at) 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): def test_no_services_for_project_yields_empty_tuple(self):
# Race window between `compose up` returning and the actual # Race window between `compose up` returning and the actual
@@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -80,10 +79,6 @@ def _plan(tmp: str) -> DockerBottlePlan:
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
proxy_plan=PipelockProxyPlan(
yaml_path=stage / "pipelock.yaml",
slug="test-teardown-00001",
),
use_runsc=False, use_runsc=False,
) )
@@ -101,10 +96,6 @@ class TestTeardownWarning(unittest.TestCase):
buf = io.StringIO() buf = io.StringIO()
with mock.patch.object(launch_mod.docker_mod, "build_image"), \ 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( mock.patch.object(
launch_mod, "egress_tls_init", launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")), return_value=(Path("/egress_ca"), Path("/egress_cert")),
@@ -20,7 +20,6 @@ from bot_bottle.backend.docker.provision import git as _git
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan 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"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
prompt_file=Path("/tmp/prompt.txt"), prompt_file=Path("/tmp/prompt.txt"),
proxy_plan=PipelockProxyPlan(
yaml_path=Path("/tmp/pipelock.yaml"), slug="demo-abc12",
),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
+3 -31
View File
@@ -24,12 +24,11 @@ def _bottle(routes): # type: ignore
}).bottles["dev"] }).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( return EgressRoute(
host=host, host=host,
auth_scheme="Bearer", auth_scheme="Bearer",
token_ref=token_ref, token_ref=token_ref,
tls_passthrough=tls_passthrough,
) )
@@ -133,19 +132,6 @@ class TestRoutesForBottleManifestOnly(unittest.TestCase):
effective = [r.host for r in egress_routes_for_bottle(b)] effective = [r.host for r in egress_routes_for_bottle(b)]
self.assertEqual(["x.example"], effective) 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): class TestProviderRouteMerge(unittest.TestCase):
@@ -163,7 +149,7 @@ class TestProviderRouteMerge(unittest.TestCase):
def test_unauthenticated_provider_route_appends_without_token_slot(self): def test_unauthenticated_provider_route_appends_without_token_slot(self):
b = _bottle([]) b = _bottle([])
pr = EgressRoute(host="api.openai.com", tls_passthrough=True) pr = EgressRoute(host="api.openai.com")
routes = egress_routes_for_bottle(b, (pr,)) routes = egress_routes_for_bottle(b, (pr,))
self.assertEqual(1, len(routes)) self.assertEqual(1, len(routes))
self.assertEqual("api.openai.com", routes[0].host) self.assertEqual("api.openai.com", routes[0].host)
@@ -175,13 +161,12 @@ class TestProviderRouteMerge(unittest.TestCase):
def test_provider_route_wins_over_bare_manifest_route(self): def test_provider_route_wins_over_bare_manifest_route(self):
# Provisioned host wins outright; manifest path_allowlist is dropped. # Provisioned host wins outright; manifest path_allowlist is dropped.
b = _bottle([{"host": "api.openai.com", "path_allowlist": ["/v1/"]}]) 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,)) routes = egress_routes_for_bottle(b, (pr,))
self.assertEqual(1, len(routes)) self.assertEqual(1, len(routes))
self.assertEqual("", routes[0].auth_scheme) self.assertEqual("", routes[0].auth_scheme)
self.assertEqual("", routes[0].token_env) self.assertEqual("", routes[0].token_env)
self.assertEqual("", routes[0].token_ref) self.assertEqual("", routes[0].token_ref)
self.assertTrue(routes[0].tls_passthrough)
self.assertEqual((), routes[0].path_allowlist) self.assertEqual((), routes[0].path_allowlist)
self.assertEqual({}, egress_token_env_map(routes)) self.assertEqual({}, egress_token_env_map(routes))
@@ -222,19 +207,6 @@ class TestProviderRouteMerge(unittest.TestCase):
self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref) self.assertEqual(CODEX_HOST_CREDENTIAL_TOKEN_REF, routes[0].token_ref)
self.assertEqual("GH_PAT", routes[1].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): class TestTokenEnvMap(unittest.TestCase):
def test_only_authenticated_routes_contribute(self): def test_only_authenticated_routes_contribute(self):
b = _bottle([ b = _bottle([
+3 -5
View File
@@ -180,7 +180,7 @@ class TestMatchRoute(unittest.TestCase):
def test_wildcard_hosts_not_supported(self): def test_wildcard_hosts_not_supported(self):
# `*.example.com` is treated as a literal host string by # `*.example.com` is treated as a literal host string by
# the exact-only matcher. Removed from the design after # 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"),) routes = (Route(host="*.example.com"),)
self.assertIsNone(match_route(routes, "foo.example.com")) self.assertIsNone(match_route(routes, "foo.example.com"))
self.assertIsNone(match_route(routes, "example.com")) self.assertIsNone(match_route(routes, "example.com"))
@@ -191,10 +191,8 @@ class TestMatchRoute(unittest.TestCase):
class TestDecide(unittest.TestCase): class TestDecide(unittest.TestCase):
def test_no_matching_route_blocks(self): def test_no_matching_route_blocks(self):
# Defense-in-depth: egress gates the bottle's allowlist # Egress gates the bottle's allowlist. Any host the operator
# too, not just pipelock. Any host the operator didn't declare # didn't declare in egress.routes is 403'd at egress.
# in egress.routes is 403'd at egress before it
# ever reaches pipelock.
d = decide((), "elsewhere.example", "/anything", {}) d = decide((), "elsewhere.example", "/anything", {})
self.assertEqual("block", d.action) self.assertEqual("block", d.action)
self.assertIn("allowlist", d.reason) self.assertIn("allowlist", d.reason)
-75
View File
@@ -6,9 +6,7 @@ import unittest
from bot_bottle.backend.docker.egress_apply import ( from bot_bottle.backend.docker.egress_apply import (
EgressApplyError, EgressApplyError,
_hosts_in_routes,
_merge_single_route, _merge_single_route,
_pipelock_safe_hosts,
validate_routes_content, validate_routes_content,
) )
from bot_bottle.yaml_subset import parse_yaml_subset 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): class TestMergeSingleRoute(unittest.TestCase):
BASE = _ROUTES_ONE BASE = _ROUTES_ONE
@@ -214,40 +174,5 @@ class TestMergeSingleRoute(unittest.TestCase):
_merge_single_route("routes:\n\tbad", {"host": "x.example"}) _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__": if __name__ == "__main__":
unittest.main() unittest.main()
+3 -50
View File
@@ -219,57 +219,10 @@ class TestRole(unittest.TestCase):
_bottle([{"host": "x.example", "role": ["x", 42]}]) _bottle([{"host": "x.example", "role": ["x", 42]}])
class TestPipelockPolicy(unittest.TestCase): class TestPipelockKeyRejected(unittest.TestCase):
def test_tls_passthrough_route_policy(self): def test_pipelock_key_rejected_as_unknown(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):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
_bottle([{"host": "x.example", "pipelock": True}]) _bottle([{"host": "x.example", "pipelock": {"tls_passthrough": 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}}])
class TestRouteValidation(unittest.TestCase): class TestRouteValidation(unittest.TestCase):
-169
View File
@@ -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()
-115
View File
@@ -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()
-356
View File
@@ -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()
-10
View File
@@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.workspace import workspace_plan 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: def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return DockerBottlePlan( return DockerBottlePlan(
@@ -121,7 +113,6 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
proxy_plan=_proxy_plan(tmp),
use_runsc=False, use_runsc=False,
) )
@@ -145,7 +136,6 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
agent_image_ref="bot-bottle-claude:latest", agent_image_ref="bot-bottle-claude:latest",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
proxy_plan=_proxy_plan(tmp),
) )
+40 -52
View File
@@ -29,11 +29,8 @@ from bot_bottle.sidecar_init import (
class TestEnvForDaemon(unittest.TestCase): class TestEnvForDaemon(unittest.TestCase):
"""Scope egress-only credential env vars to the egress daemon. """Scope egress-only credential env vars to the egress daemon.
Regression for issue #84: pipelock's `scan_env: true` matched The agent never has access to EGRESS_TOKEN_* slots, so stripping
`EGRESS_TOKEN_*` against egress's just-injected Authorization them from non-egress daemons loses no DLP coverage."""
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."""
_BASE = { _BASE = {
"PATH": "/usr/bin", "PATH": "/usr/bin",
@@ -47,26 +44,20 @@ class TestEnvForDaemon(unittest.TestCase):
env = _env_for_daemon("egress", self._BASE) env = _env_for_daemon("egress", self._BASE)
self.assertEqual(self._BASE, env) self.assertEqual(self._BASE, env)
def test_pipelock_loses_egress_tokens(self): def test_git_daemons_and_supervise_lose_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):
for name in ("git-gate", "git-http", "supervise"): for name in ("git-gate", "git-http", "supervise"):
env = _env_for_daemon(name, self._BASE) env = _env_for_daemon(name, self._BASE)
self.assertNotIn("EGRESS_TOKEN_0", env) self.assertNotIn("EGRESS_TOKEN_0", env)
self.assertNotIn("EGRESS_TOKEN_1", 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): def test_returns_independent_dict(self):
# Caller mutation mustn't affect the original. # 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" env["X"] = "y"
self.assertNotIn("X", self._BASE) self.assertNotIn("X", self._BASE)
@@ -78,7 +69,6 @@ class TestSelectedDaemons(unittest.TestCase):
_DAEMONS = ( _DAEMONS = (
_DaemonSpec("egress", ("/bin/sh", "-c", ":")), _DaemonSpec("egress", ("/bin/sh", "-c", ":")),
_DaemonSpec("pipelock", ("/bin/sh", "-c", ":")),
_DaemonSpec("git-gate", ("/bin/sh", "-c", ":")), _DaemonSpec("git-gate", ("/bin/sh", "-c", ":")),
_DaemonSpec("supervise", ("/bin/sh", "-c", ":")), _DaemonSpec("supervise", ("/bin/sh", "-c", ":")),
) )
@@ -86,35 +76,34 @@ class TestSelectedDaemons(unittest.TestCase):
def test_unset_returns_all(self): def test_unset_returns_all(self):
got = _selected_daemons({}, all_daemons=self._DAEMONS) got = _selected_daemons({}, all_daemons=self._DAEMONS)
self.assertEqual([d.name for d in got], self.assertEqual([d.name for d in got],
["egress", "pipelock", "git-gate", "supervise"]) ["egress", "git-gate", "supervise"])
def test_empty_returns_all(self): def test_empty_returns_all(self):
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""}, got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": ""},
all_daemons=self._DAEMONS) all_daemons=self._DAEMONS)
self.assertEqual(4, len(got)) self.assertEqual(3, len(got))
def test_whitespace_only_returns_all(self): def test_whitespace_only_returns_all(self):
got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "}, got = _selected_daemons({"BOT_BOTTLE_SIDECAR_DAEMONS": " "},
all_daemons=self._DAEMONS) all_daemons=self._DAEMONS)
self.assertEqual(4, len(got)) self.assertEqual(3, len(got))
def test_explicit_subset(self): def test_explicit_subset(self):
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,pipelock"}, {"BOT_BOTTLE_SIDECAR_DAEMONS": "egress,git-gate"},
all_daemons=self._DAEMONS, 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): def test_preserves_canonical_order(self):
# Order in the env var doesn't matter; the result follows # Order in the env var doesn't matter; the result follows
# the canonical _DAEMONS order so egress starts before # the canonical _DAEMONS order so egress starts first.
# pipelock (race-window reason).
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,pipelock,egress"}, {"BOT_BOTTLE_SIDECAR_DAEMONS": "supervise,git-gate,egress"},
all_daemons=self._DAEMONS, all_daemons=self._DAEMONS,
) )
self.assertEqual([d.name for d in got], self.assertEqual([d.name for d in got],
["egress", "pipelock", "supervise"]) ["egress", "git-gate", "supervise"])
def test_unknown_names_ignored(self): def test_unknown_names_ignored(self):
got = _selected_daemons( got = _selected_daemons(
@@ -125,10 +114,10 @@ class TestSelectedDaemons(unittest.TestCase):
def test_whitespace_in_names_stripped(self): def test_whitespace_in_names_stripped(self):
got = _selected_daemons( got = _selected_daemons(
{"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , pipelock "}, {"BOT_BOTTLE_SIDECAR_DAEMONS": " egress , git-gate "},
all_daemons=self._DAEMONS, 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): class TestSupervisor(unittest.TestCase):
@@ -279,25 +268,24 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_restart_daemon_replaces_in_place(self): def test_restart_daemon_replaces_in_place(self):
# pipelock_apply.py sends SIGUSR1 to the bundle, supervisor # Restart one daemon; the other (supervise, the MCP server
# restarts the pipelock daemon, supervise (the other # in production) must remain untouched.
# daemon's MCP server in production) stays up.
specs = [ specs = [
_DaemonSpec("pipelock", ("/bin/sleep", "30")), _DaemonSpec("git-gate", ("/bin/sleep", "30")),
_DaemonSpec("supervise", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) 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 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) self.assertTrue(ok)
# Pipelock got a fresh PID — different process. # git-gate got a fresh PID — different process.
new_pipelock_pid = sup.procs[0][1].pid new_git_gate_pid = sup.procs[0][1].pid
self.assertNotEqual(old_pipelock_pid, new_pipelock_pid) self.assertNotEqual(old_git_gate_pid, new_git_gate_pid)
# Supervise's PID is unchanged — it was NOT restarted. # Supervise's PID is unchanged — it was NOT restarted.
self.assertEqual(supervise_pid, sup.procs[1][1].pid) self.assertEqual(supervise_pid, sup.procs[1][1].pid)
self.assertIsNone(sup.procs[1][1].poll(), self.assertIsNone(sup.procs[1][1].poll(),
@@ -308,38 +296,38 @@ class TestSupervisor(unittest.TestCase):
def test_request_restart_is_drained_by_tick(self): def test_request_restart_is_drained_by_tick(self):
specs = [ specs = [
_DaemonSpec("pipelock", ("/bin/sleep", "30")), _DaemonSpec("git-gate", ("/bin/sleep", "30")),
_DaemonSpec("supervise", ("/bin/sleep", "30")), _DaemonSpec("supervise", ("/bin/sleep", "30")),
] ]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) 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 supervise_pid = sup.procs[1][1].pid
ok = sup.request_restart("pipelock") ok = sup.request_restart("git-gate")
self.assertTrue(ok) self.assertTrue(ok)
# The non-blocking request path only records intent. # 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() done = sup.tick()
self.assertFalse(done) 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) self.assertEqual(supervise_pid, sup.procs[1][1].pid)
sup.request_shutdown(reason="cleanup") sup.request_shutdown(reason="cleanup")
self._drive(sup) self._drive(sup)
def test_repeated_restart_requests_coalesce(self): def test_repeated_restart_requests_coalesce(self):
specs = [_DaemonSpec("pipelock", ("/bin/sleep", "30"))] specs = [_DaemonSpec("git-gate", ("/bin/sleep", "30"))]
sup = _Supervisor(specs) sup = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) time.sleep(0.1)
self.assertTrue(sup.request_restart("pipelock")) self.assertTrue(sup.request_restart("git-gate"))
self.assertTrue(sup.request_restart("pipelock")) self.assertTrue(sup.request_restart("git-gate"))
self.assertEqual({"pipelock"}, sup._restart_requested) self.assertEqual({"git-gate"}, sup._restart_requested)
old_pid = sup.procs[0][1].pid old_pid = sup.procs[0][1].pid
sup.tick() sup.tick()
@@ -374,23 +362,23 @@ class TestSupervisor(unittest.TestCase):
self._drive(sup) self._drive(sup)
def test_restart_during_shutdown_is_no_op(self): 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 = _Supervisor(specs)
sup.start_all() sup.start_all()
sup.request_shutdown(reason="test") sup.request_shutdown(reason="test")
ok = sup.restart_daemon("pipelock") ok = sup.restart_daemon("git-gate")
self.assertFalse(ok, self.assertFalse(ok,
"must not respawn a daemon during teardown") "must not respawn a daemon during teardown")
self._drive(sup) self._drive(sup)
def test_pending_restart_dropped_during_shutdown(self): 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 = _Supervisor(specs)
sup.start_all() sup.start_all()
time.sleep(0.1) time.sleep(0.1)
old_pid = sup.procs[0][1].pid 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") sup.request_shutdown(reason="test")
self.assertEqual(set(), sup._restart_requested) self.assertEqual(set(), sup._restart_requested)
self._drive(sup) self._drive(sup)
-2
View File
@@ -56,7 +56,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet", patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet",
return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")), 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.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.Egress") as mock_eg,
patch("bot_bottle.backend.smolmachines.prepare.Supervise"), patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
patch( patch(
@@ -65,7 +64,6 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"), patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
): ):
mock_gg.return_value.prepare.return_value = MagicMock() mock_gg.return_value.prepare.return_value = MagicMock()
mock_pl.return_value.prepare.return_value = MagicMock()
mock_eg.return_value.prepare.return_value = MagicMock() mock_eg.return_value.prepare.return_value = MagicMock()
def _make_provision(**kwargs): # type: ignore def _make_provision(**kwargs): # type: ignore
return AgentProvisionPlan( return AgentProvisionPlan(
+8 -38
View File
@@ -32,7 +32,6 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest from bot_bottle.manifest import GitEntry, Manifest
from bot_bottle.pipelock import PipelockProxyPlan
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -71,7 +70,6 @@ def _plan(
stage_dir: Path | None = None, stage_dir: Path | None = None,
egress_routes: tuple[EgressRoute, ...] = (), egress_routes: tuple[EgressRoute, ...] = (),
egress_ca_path: Path = Path(), egress_ca_path: Path = Path(),
pipelock_ca_path: Path = Path(),
supervise: bool = False, supervise: bool = False,
bundle_ip: str = "192.168.50.2", bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555", agent_git_gate_host: str = "127.0.0.1:55555",
@@ -131,11 +129,6 @@ def _plan(
agent_image_ref="bot-bottle-claude:latest", agent_image_ref="bot-bottle-claude:latest",
guest_env=dict(guest_env or {}), guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), 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( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -235,16 +228,13 @@ def _write_self_signed_cert(path: Path) -> None:
class TestProvisionCA(unittest.TestCase): class TestProvisionCA(unittest.TestCase):
"""provision_ca selects the right CA cert (egress when the """provision_ca always uses the egress MITM CA and dispatches
bottle has routes, else pipelock) and dispatches
cp_in + exec in the right order.""" cp_in + exec in the right order."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.") self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-ca.")
self.tmp = Path(self._tmp.name) self.tmp = Path(self._tmp.name)
self.pipelock_ca = self.tmp / "pipelock-ca.pem"
self.egress_ca = self.tmp / "egress-ca.pem" self.egress_ca = self.tmp / "egress-ca.pem"
_write_self_signed_cert(self.pipelock_ca)
_write_self_signed_cert(self.egress_ca) _write_self_signed_cert(self.egress_ca)
def tearDown(self): def tearDown(self):
@@ -259,40 +249,22 @@ class TestProvisionCA(unittest.TestCase):
stderr="", stderr="",
) )
def test_pipelock_path_when_no_routes(self): def test_egress_ca_always_installed(self):
plan = _plan(pipelock_ca_path=self.pipelock_ca) plan = _plan(egress_ca_path=self.egress_ca)
bottle = _make_bottle(exec_result=self._UPDATE_OK) bottle = _make_bottle(exec_result=self._UPDATE_OK)
_ca.provision_ca(plan, bottle) _ca.provision_ca(plan, bottle)
bottle.cp_in.assert_called_once_with( bottle.cp_in.assert_called_once_with(
str(self.pipelock_ca), str(self.egress_ca),
_ca.AGENT_CA_PATH, _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() bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0] script = bottle.exec.call_args.args[0]
self.assertIn("chmod 644", script) self.assertIn("chmod 644", script)
self.assertIn("update-ca-certificates", script) self.assertIn("update-ca-certificates", script)
self.assertEqual("root", bottle.exec.call_args.kwargs.get("user")) 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): 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( killed = ExecResult(
returncode=137, returncode=137,
stdout="Updating certificates in /etc/ssl/certs...\n", stdout="Updating certificates in /etc/ssl/certs...\n",
@@ -308,10 +280,8 @@ class TestProvisionCA(unittest.TestCase):
self.assertEqual(2, bottle.exec.call_count) self.assertEqual(2, bottle.exec.call_count)
sleep.assert_called_once_with(1.0) sleep.assert_called_once_with(1.0)
def test_dies_when_selected_cert_missing(self): def test_dies_when_egress_cert_missing(self):
# Plan claims a pipelock cert at a path that doesn't exist — plan = _plan(egress_ca_path=self.tmp / "does-not-exist.pem")
# something went wrong in launch's pipelock_tls_init.
plan = _plan(pipelock_ca_path=self.tmp / "does-not-exist.pem")
bottle = _make_bottle() bottle = _make_bottle()
with self.assertRaises(SystemExit): with self.assertRaises(SystemExit):
_ca.provision_ca(plan, bottle) _ca.provision_ca(plan, bottle)
@@ -414,7 +384,7 @@ class TestBundleLaunchSpec(unittest.TestCase):
spec = _bundle_launch_spec(plan, "net", "127.0.0.16") spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertEqual( self.assertEqual(
"egress,pipelock,git-gate,git-http", "egress,git-gate,git-http",
spec.daemons_csv, spec.daemons_csv,
) )
self.assertIn(9420, spec.ports_to_publish) self.assertIn(9420, spec.ports_to_publish)
@@ -134,34 +134,35 @@ class TestStartBundle(unittest.TestCase):
def test_daemons_env_passed_in(self): def test_daemons_env_passed_in(self):
with self._patch_run() as m: 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] argv = m.call_args.args[0]
self.assertIn("-e", argv) self.assertIn("-e", argv)
self.assertIn( self.assertIn(
"BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock,supervise", "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise",
argv, argv,
) )
def test_environment_entries_pass_through(self): def test_environment_entries_pass_through(self):
with self._patch_run() as m: with self._patch_run() as m:
start_bundle(_spec(environment=( start_bundle(_spec(environment=(
"EGRESS_UPSTREAM_PROXY=http://...",
"SUPERVISE_BOTTLE_SLUG=demo-abc12", "SUPERVISE_BOTTLE_SLUG=demo-abc12",
"EGRESS_TOKEN_0", # bare-name → host env inherit "EGRESS_TOKEN_0", # bare-name → host env inherit
))) )))
argv = m.call_args.args[0] argv = m.call_args.args[0]
self.assertIn("EGRESS_UPSTREAM_PROXY=http://...", argv)
self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv) self.assertIn("SUPERVISE_BOTTLE_SLUG=demo-abc12", argv)
self.assertIn("EGRESS_TOKEN_0", argv) self.assertIn("EGRESS_TOKEN_0", argv)
def test_volumes_render_with_ro_flag(self): def test_volumes_render_with_ro_flag(self):
with self._patch_run() as m: with self._patch_run() as m:
start_bundle(_spec(volumes=( 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), ("/host/queue", "/run/supervise/queue", False),
))) )))
argv = m.call_args.args[0] 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) self.assertIn("/host/queue:/run/supervise/queue", argv)
def test_failure_dies(self): def test_failure_dies(self):
+5 -8
View File
@@ -18,7 +18,6 @@ from bot_bottle.supervise import (
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
archive_proposal, archive_proposal,
audit_log_path, audit_log_path,
list_pending_proposals, list_pending_proposals,
@@ -247,13 +246,13 @@ class TestAuditLog(unittest.TestCase):
write_audit_entry(AuditEntry( write_audit_entry(AuditEntry(
timestamp=f"2026-05-25T12:00:0{i}+00:00", timestamp=f"2026-05-25T12:00:0{i}+00:00",
bottle_slug="dev", bottle_slug="dev",
component="pipelock", component="egress",
operator_action=STATUS_APPROVED, operator_action=STATUS_APPROVED,
operator_notes=f"n{i}", operator_notes=f"n{i}",
justification="", justification="",
diff="", diff="",
)) ))
path = audit_log_path("pipelock", "dev") path = audit_log_path("egress", "dev")
with path.open() as f: with path.open() as f:
lines = [line for line in f if line.strip()] lines = [line for line in f if line.strip()]
self.assertEqual(3, len(lines)) self.assertEqual(3, len(lines))
@@ -273,7 +272,7 @@ class TestAuditLog(unittest.TestCase):
write_audit_entry(AuditEntry( write_audit_entry(AuditEntry(
timestamp="t", timestamp="t",
bottle_slug="dev", bottle_slug="dev",
component="pipelock", component="egress",
operator_action=STATUS_APPROVED, operator_action=STATUS_APPROVED,
operator_notes="", operator_notes="",
justification="", justification="",
@@ -289,7 +288,7 @@ class TestAuditLog(unittest.TestCase):
diff="", diff="",
)) ))
self.assertEqual(1, len(read_audit_entries("cred-proxy", "dev"))) 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"))) self.assertEqual(1, len(read_audit_entries("cred-proxy", "other")))
def test_read_audit_entries_missing_log_returns_empty(self): def test_read_audit_entries_missing_log_returns_empty(self):
@@ -320,16 +319,14 @@ class TestToolConstants(unittest.TestCase):
self.assertEqual( self.assertEqual(
( (
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
supervise.TOOL_LIST_EGRESS_ROUTES, supervise.TOOL_LIST_EGRESS_ROUTES,
), ),
supervise.TOOLS, 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_EGRESS_BLOCK, supervise.COMPONENT_FOR_TOOL)
self.assertIn(TOOL_PIPELOCK_BLOCK, supervise.COMPONENT_FOR_TOOL)
self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL) self.assertNotIn(TOOL_CAPABILITY_BLOCK, supervise.COMPONENT_FOR_TOOL)
-109
View File
@@ -18,7 +18,6 @@ from pathlib import Path
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.backend.docker.capability_apply import CapabilityApplyError
from bot_bottle.backend.docker.egress_apply import EgressApplyError 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.cli import supervise as supervise_cli
from bot_bottle.supervise import ( from bot_bottle.supervise import (
Proposal, Proposal,
@@ -27,7 +26,6 @@ from bot_bottle.supervise import (
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
read_audit_entries, read_audit_entries,
read_response, read_response,
sha256_hex, 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: 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 = { payloads = {
TOOL_EGRESS_BLOCK: '{"routes": []}\n', TOOL_EGRESS_BLOCK: '{"routes": []}\n',
TOOL_PIPELOCK_BLOCK: "https://example.com/path",
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
} }
payload = payloads.get(tool, "") payload = payloads.get(tool, "")
@@ -128,26 +121,18 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() self._setup_fake_home()
self._original_add_route = supervise_cli.add_route 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 self._original_apply_capability = supervise_cli.apply_capability_change
# Default stubs: succeed with deterministic before/after so the # Default stubs: succeed with deterministic before/after so the
# audit log shows a non-empty diff. # audit log shows a non-empty diff.
supervise_cli.add_route = lambda slug, content: ( # type: ignore supervise_cli.add_route = lambda slug, content: ( # type: ignore
'{"routes": []}\n', '{"routes": [{"host": "x"}]}\n', '{"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 supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
"FROM old\n", content, "FROM old\n", content,
) )
def tearDown(self): def tearDown(self):
supervise_cli.add_route = self._original_add_route 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 supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home() self._teardown_fake_home()
@@ -192,15 +177,7 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK) qp = self._enqueue(tool=TOOL_CAPABILITY_BLOCK)
supervise_cli.approve(qp) supervise_cli.approve(qp)
# No audit log for capability-block (per PRD 0013 / 0016). # 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("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): class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
@@ -299,91 +276,6 @@ class TestEgressApplyWiring(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("", entries[0].diff) 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): class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0016 Phase 3: approve() on a capability-block proposal """PRD 0016 Phase 3: approve() on a capability-block proposal
calls apply_capability_change, archives the proposal afterward 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 # capability-block has no audit log per PRD 0013 — its record
# lives in the per-bottle Dockerfile + transcript state. # lives in the per-bottle Dockerfile + transcript state.
self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
self.assertEqual([], read_audit_entries("pipelock", "dev"))
def test_proposal_archived_after_apply(self): def test_proposal_archived_after_apply(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
@@ -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: <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()
+6 -31
View File
@@ -47,28 +47,6 @@ from bot_bottle.supervise_server import (
class TestValidation(unittest.TestCase): 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): def test_capability_block_accepts_anything_nonempty(self):
validate_proposed_file( validate_proposed_file(
_sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_CAPABILITY_BLOCK,
@@ -78,12 +56,10 @@ class TestValidation(unittest.TestCase):
def test_empty_proposed_file_rejected_for_tools_with_file_field(self): def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
# egress-block has structured input (validated in # egress-block has structured input (validated in
# _validate_and_bundle_egress_route, not here) and # _validate_and_bundle_egress_route, not here) and
# list-egress-routes takes no input. Only the other # list-egress-routes takes no input. Only capability-block
# two go through `validate_proposed_file`. # goes through `validate_proposed_file`.
for tool in (_sv.TOOL_PIPELOCK_BLOCK, _sv.TOOL_CAPABILITY_BLOCK): with self.assertRaises(_RpcError):
with self.subTest(tool=tool): validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t")
with self.assertRaises(_RpcError):
validate_proposed_file(tool, " \n\t")
# --- JSON-RPC parsing ------------------------------------------------------ # --- JSON-RPC parsing ------------------------------------------------------
@@ -166,7 +142,6 @@ class TestHandleToolsList(unittest.TestCase):
self.assertEqual( self.assertEqual(
sorted([ sorted([
_sv.TOOL_EGRESS_BLOCK, _sv.TOOL_EGRESS_BLOCK,
_sv.TOOL_PIPELOCK_BLOCK,
_sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_CAPABILITY_BLOCK,
_sv.TOOL_LIST_EGRESS_ROUTES, _sv.TOOL_LIST_EGRESS_ROUTES,
]), ]),
@@ -251,9 +226,9 @@ class TestHandleToolsCall(unittest.TestCase):
try: try:
result = handle_tools_call( result = handle_tools_call(
{ {
"name": _sv.TOOL_PIPELOCK_BLOCK, "name": _sv.TOOL_CAPABILITY_BLOCK,
"arguments": { "arguments": {
"failed_url": "https://example.com/path", "dockerfile": "FROM python:3.13\n",
"justification": "needed for tests", "justification": "needed for tests",
}, },
}, },