Remove pipelock #193
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user