From ce8cb5f0f1955fadb8742a80472e01b59e539319 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 21:15:36 +0000 Subject: [PATCH] chore: remove pipelock from supervise plane and egress layer - Remove TOOL_PIPELOCK_BLOCK from supervise.py constants and TOOLS tuple - Remove pipelock-block tool definition from supervise_server.py - Remove _apply_pipelock_url and pipelock imports from cli/supervise.py - Strip pipelock fields (pipelock_ca_host_path, pipelock_proxy_url, tls_passthrough) from egress.py EgressPlan/EgressRoute - Remove pipelock daemon from sidecar_init.py _DAEMONS and SIGUSR1 handler --- bot_bottle/cli/supervise.py | 63 ++------------------ bot_bottle/egress.py | 39 ++++-------- bot_bottle/sidecar_init.py | 34 +---------- bot_bottle/supervise.py | 7 +-- bot_bottle/supervise_server.py | 105 +++++---------------------------- 5 files changed, 35 insertions(+), 213 deletions(-) diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index 86e6215..3bd5975 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -3,9 +3,7 @@ act on them (approve / modify / reject). Curses-based TUI; modify-then-approve shells out to $EDITOR. The approval handlers wire to the per-tool remediation engines: -PRD 0014 (egress, retargeted from cred-proxy in PRD 0017 -chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015 -(pipelock) writes the allowlist + restarts pipelock; PRD 0016 +PRD 0014 (egress) writes routes.yaml + SIGHUPs egress; PRD 0016 (capability) rebuilds the bottle Dockerfile. """ @@ -29,13 +27,6 @@ from ..backend.docker.capability_apply import ( apply_capability_change, ) from ..backend.docker.egress_apply import EgressApplyError, add_route -from ..backend.docker.pipelock_apply import ( - PipelockApplyError, - apply_allowlist_change, - fetch_current_allowlist, - parse_allowlist_content, - render_allowlist_content, -) from ..log import Die, error, info from ..supervise import ( COMPONENT_FOR_TOOL, @@ -47,7 +38,6 @@ from ..supervise import ( STATUS_REJECTED, TOOL_CAPABILITY_BLOCK, TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, archive_proposal, list_pending_proposals, render_diff, @@ -71,7 +61,7 @@ class QueuedProposal: # Errors any remediation engine may raise. Caught by the TUI key # handlers and surfaced in the status line so a failed apply keeps # the proposal pending rather than crashing curses. -ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) +ApplyError = (EgressApplyError, CapabilityApplyError) def discover_pending() -> list[QueuedProposal]: @@ -116,33 +106,12 @@ def _detail_lines( out.extend((" " + line, 0) for line in p.justification.splitlines() or [""]) out.extend([ ("", 0), - (_proposed_payload_label(p.tool) + ":", 0), + ("proposed file:", 0), ]) out.extend((line, 0) for line in p.proposed_file.splitlines() or [""]) - if p.tool == TOOL_PIPELOCK_BLOCK: - host = _failed_url_host(p.proposed_file) - if host: - out.append(("", 0)) - out.append((host, green_attr)) return out -def _failed_url_host(url: str) -> str: - """Best-effort hostname extraction from a pipelock-block proposal.""" - import urllib.parse - - try: - return urllib.parse.urlsplit(url.strip()).hostname or "" - except ValueError: - return "" - - -def _proposed_payload_label(tool: str) -> str: - if tool == TOOL_PIPELOCK_BLOCK: - return "failed URL" - return "proposed file" - - def _suffix_for_tool(tool: str) -> str: if tool == TOOL_CAPABILITY_BLOCK: return ".dockerfile" @@ -167,10 +136,6 @@ def approve( diff_before, diff_after = add_route( qp.proposal.bottle_slug, file_to_apply, ) - elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK: - diff_before, diff_after = _apply_pipelock_url( - qp.proposal.bottle_slug, file_to_apply, - ) elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK: _meta = read_metadata(qp.proposal.bottle_slug) if _meta is not None and not _meta.compose_project: @@ -210,23 +175,6 @@ def reject(qp: QueuedProposal, *, reason: str) -> None: _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") -def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]: - """Merge a pipelock-block failed URL's host into the allowlist.""" - import urllib.parse - - parsed = urllib.parse.urlsplit(failed_url.strip()) - host = parsed.hostname or "" - if not host: - raise PipelockApplyError( - f"proposed failed_url has no extractable host: {failed_url!r}" - ) - current = fetch_current_allowlist(slug) - hosts = parse_allowlist_content(current) - if host not in hosts: - hosts.append(host) - return apply_allowlist_change(slug, render_allowlist_content(hosts)) - - def _write_audit( qp: QueuedProposal, *, @@ -235,7 +183,7 @@ def _write_audit( diff_before: str, diff_after: str, ) -> None: - """Audit log for egress / pipelock tools.""" + """Audit log for egress tool.""" component = COMPONENT_FOR_TOOL.get(qp.proposal.tool) if component is None: return @@ -467,8 +415,7 @@ def _render( cursor = "> " if i == selected else " " line = ( f"{cursor}{ts_short} " - f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]} " - f"{_proposed_payload_label(p.tool)}" + f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]}" ) attr = curses.A_REVERSE if i == selected else curses.A_NORMAL stdscr.addnstr(row, 0, line, w - 1, attr) diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index d5f546e..662342c 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -4,8 +4,7 @@ Replaces the cred-proxy sidecar (PRD 0010) with a mitmproxy-based sidecar that becomes the agent's `HTTP_PROXY` / `HTTPS_PROXY`. It owns three jobs: - 1. MITM the agent's HTTPS with the per-bottle CA (moved from - pipelock). + 1. MITM the agent's HTTPS with the per-bottle CA. 2. Enforce manifest-declared `path_allowlist` per route. 3. Inject `Authorization` headers for routes that declare an `auth` block, the same way cred-proxy does today. @@ -48,9 +47,8 @@ EGRESS_HOSTNAME = "egress" # In-container path the addon reads. Pre-created in # `Dockerfile.sidecars` so the host bind-mount can drop the file -# directly. Content is YAML (hand-rolled by `egress_render_routes` -# in the style of `pipelock_render_yaml`, parsed by `yaml_subset` -# inside the addon). +# directly. Content is YAML (hand-rolled by `egress_render_routes`, +# parsed by `yaml_subset` inside the addon). EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml" @@ -70,15 +68,11 @@ class EgressRoute(Route): `roles` carries the manifest route's role tuple (reserved for future use; always empty today). - `tls_passthrough` signals that pipelock must not TLS-MITM this - host — either because the manifest declared `pipelock.tls_passthrough: - true` (lifted in `egress_manifest_routes`) or because a provider - route set it (e.g. egress injects its own Bearer on that host - after the agent boundary and pipelock's header DLP would block it).""" + `roles` carries the manifest route's role tuple (reserved for + future use; always empty today).""" token_ref: str = "" roles: tuple[str, ...] = () - tls_passthrough: bool = False @dataclass(frozen=True) @@ -87,10 +81,10 @@ class EgressPlan: The slug + routes_path + routes + token_env_map fields are filled at prepare time (host-side, side-effect-free on docker). - The network + CA + pipelock fields are populated by the backend's - launch step via `dataclasses.replace` once those resources - exist. Empty defaults are sentinels meaning "not yet set"; - `.start` validates that they are populated. + The network + CA fields are populated by the backend's launch step + via `dataclasses.replace` once those resources exist. Empty defaults + are sentinels meaning "not yet set"; `.start` validates that they are + populated. `token_env_map` is `{: }`. The backend's start step reads `os.environ[token_ref]` and @@ -108,16 +102,6 @@ class EgressPlan: key) for installing into the agent's trust store via `provision_ca`. Separate file rather than re-parsing the concat so secrets and trust artefacts stay on distinct paths. - - `pipelock_ca_host_path` is the host path of the pipelock CA - (cert only). `.start` docker-cps it into the sidecar so the - proxy's outbound HTTPS client trusts pipelock's MITM on the - egress → upstream leg. - - `pipelock_proxy_url` is the URL egress sets as `HTTPS_PROXY` - in its environ so outbound HTTPS traverses pipelock — keeping - pipelock's hostname allowlist + DLP body scanner on the - egress → upstream leg. """ slug: str @@ -128,8 +112,6 @@ class EgressPlan: egress_network: str = "" mitmproxy_ca_host_path: Path = Path() mitmproxy_ca_cert_only_host_path: Path = Path() - pipelock_ca_host_path: Path = Path() - pipelock_proxy_url: str = "" def egress_manifest_routes( @@ -147,7 +129,6 @@ def egress_manifest_routes( auth_scheme=r.AuthScheme, token_ref=r.TokenRef, roles=r.Role, - tls_passthrough=r.Pipelock.TlsPassthrough, )) return tuple(out) @@ -306,7 +287,7 @@ class Egress(ABC): forward values from the host's environ into the sidecar's environ. Returned plan is incomplete: the launch step must fill - `internal_network` / `egress_network` / `pipelock_proxy_url` + `internal_network` / `egress_network` via `dataclasses.replace` before passing it to `.start`.""" routes = egress_routes_for_bottle(bottle, provider_routes) routes_path = stage_dir / "egress_routes.yaml" diff --git a/bot_bottle/sidecar_init.py b/bot_bottle/sidecar_init.py index 44cb63e..afabd0a 100644 --- a/bot_bottle/sidecar_init.py +++ b/bot_bottle/sidecar_init.py @@ -1,7 +1,7 @@ """Per-bottle sidecar supervisor (PRD 0024 chunk 1). PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns -the configured daemons (egress, pipelock, git-gate, supervise), +the configured daemons (egress, git-gate, supervise), forwards SIGTERM/SIGINT to each child, and propagates per-daemon stdout+stderr to the container log with a `[name] ` prefix. @@ -19,7 +19,7 @@ PR; the interim policy is "don't take the bundle down for one sick daemon." Daemon subset is env-driven. The compose renderer narrows it via -`BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that +`BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that don't use git-gate or supervise. Default: all daemons. Stdlib-only by design — adding supervisord/s6/runit for four @@ -57,14 +57,7 @@ class _DaemonSpec: # Env-var name prefixes that carry egress-only credentials. # `egress_apply.py` assigns `EGRESS_TOKEN_` slots that egress # reads to inject `Authorization` headers on configured routes; -# every other daemon in the bundle (especially pipelock with -# `scan_env: true`) MUST NOT see these values or it'll match the -# injected token in the request egress just sent and 403-block -# the legitimate traffic (issue #84). The agent itself runs in a -# different machine and never has access to these slots in the -# first place, so stripping them from non-egress daemons loses no -# DLP coverage — pipelock can't catch the exfil of a value the -# agent doesn't have. +# no other daemon in the bundle should see these values. _EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) @@ -81,22 +74,8 @@ def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]: } -# Order matters only for first-launch race-window reasons: egress -# starts first so pipelock's upstream connect succeeds during -# pipelock's own startup. git-gate and supervise are independent. -# Pipelock binds 0.0.0.0:8888 explicitly. Without `--listen` it -# defaults to 127.0.0.1 which would be unreachable from sibling -# services on the docker network. The legacy four-sidecar -# compose renderer passed the same flag; the bundle keeps the -# explicit binding. _DAEMONS: tuple[_DaemonSpec, ...] = ( _DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")), - _DaemonSpec( - "pipelock", - ("/usr/local/bin/pipelock", "run", - "--config", "/etc/pipelock.yaml", - "--listen", "0.0.0.0:8888"), - ), _DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")), _DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")), _DaemonSpec("supervise", ("python3", "/app/supervise_server.py")), @@ -367,13 +346,6 @@ def main(argv: Sequence[str] | None = None) -> int: # delivers SIGHUP to PID 1 (this supervisor); forward it to # mitmdump so it reloads its addon. signal.signal(signal.SIGHUP, lambda *_: sup.forward_signal(signal.SIGHUP, "egress")) # type: ignore - # SIGUSR1 pipelock-restart path: pipelock_apply.py runs - # `docker kill --signal USR1 ` after writing - # pipelock.yaml. Pipelock has no in-process reload, so the - # supervisor restarts the pipelock daemon in place (other - # daemons keep running — specifically supervise, whose MCP - # socket would drop on a whole-container `docker restart`). - signal.signal(signal.SIGUSR1, lambda *_: sup.request_restart("pipelock")) # type: ignore while not sup.tick(): time.sleep(_POLL_INTERVAL) diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 10ca381..3e26c46 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -6,8 +6,7 @@ sits on the bottle's internal network and exposes three MCP tools the agent calls when it hits a stuck-recovery category: * egress-block — agent proposes a new routes.yaml - * pipelock-block — agent proposes a new pipelock allowlist - * capability-block — agent proposes a new agent Dockerfile + * capability-block — agent proposes a new agent Dockerfile Each tool call: the agent passes the full proposed file plus a justification text. The sidecar validates the proposal syntactically, @@ -50,12 +49,10 @@ SUPERVISE_HOSTNAME = "supervise" SUPERVISE_PORT = 9100 TOOL_EGRESS_BLOCK = "egress-block" -TOOL_PIPELOCK_BLOCK = "pipelock-block" TOOL_CAPABILITY_BLOCK = "capability-block" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOLS: tuple[str, ...] = ( TOOL_EGRESS_BLOCK, - TOOL_PIPELOCK_BLOCK, TOOL_CAPABILITY_BLOCK, TOOL_LIST_EGRESS_ROUTES, ) @@ -76,7 +73,6 @@ EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist" # record laid down in PRD 0016. COMPONENT_FOR_TOOL: dict[str, str] = { TOOL_EGRESS_BLOCK: "egress", - TOOL_PIPELOCK_BLOCK: "pipelock", } STATUS_APPROVED = "approved" @@ -562,7 +558,6 @@ __all__ = [ "TOOL_CAPABILITY_BLOCK", "TOOL_EGRESS_BLOCK", "TOOL_LIST_EGRESS_ROUTES", - "TOOL_PIPELOCK_BLOCK", "archive_proposal", "audit_dir", "audit_log_path", diff --git a/bot_bottle/supervise_server.py b/bot_bottle/supervise_server.py index 90ad6c6..6413215 100644 --- a/bot_bottle/supervise_server.py +++ b/bot_bottle/supervise_server.py @@ -1,8 +1,8 @@ """Supervise sidecar HTTP server (PRD 0013). -Per-bottle MCP server exposing three tools — `egress-block`, -`pipelock-block`, `capability-block` — that the agent calls to -propose config changes when stuck. Each tool call: +Per-bottle MCP server exposing two tools — `egress-block`, +`capability-block` — that the agent calls to propose config changes +when stuck. Each tool call: 1. Validates the proposed file syntactically. 2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from @@ -18,7 +18,7 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled: * `initialize` — handshake; returns server info + caps. * `notifications/initialized` — ack-only. - * `tools/list` — returns the three tool definitions. + * `tools/list` — returns the tool definitions. * `tools/call` — validates, queues, blocks, returns. Everything else returns JSON-RPC error -32601 (method not found). @@ -151,8 +151,8 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ "or rejects in the supervise TUI. On approval the " "supervisor writes the merged routes.yaml, SIGHUPs " "egress (atomic swap, no dropped connections), and " - "mirrors the host onto pipelock's allowlist for the " - "downstream gate." + "writes the merged routes.yaml and SIGHUPs egress " + "(atomic swap, no dropped connections)." ), "inputSchema": { "type": "object", @@ -203,15 +203,11 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ "name": _sv.TOOL_LIST_EGRESS_ROUTES, "description": ( "List the current egress route table — the bottle's " - "primary egress allowlist. Returns JSON with one entry " - "per allowed host, each carrying its path_allowlist (if " - "any) and whether the proxy injects Authorization for " - "the route. Use this before composing an " - "`egress-block` proposal so the new routes file " - "extends the live one rather than replacing it. " - "Pipelock's allowlist is a mirror of this set — every " - "host listed here is also reachable through pipelock's " - "downstream hostname gate." + "allowlist. Returns JSON with one entry per allowed host, " + "each carrying its path_allowlist (if any) and whether " + "the proxy injects Authorization for the route. Use this " + "before composing an `egress-block` proposal so the new " + "routes file extends the live one rather than replacing it." ), "inputSchema": { "type": "object", @@ -219,48 +215,12 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ "additionalProperties": False, }, }, - { - "name": _sv.TOOL_PIPELOCK_BLOCK, - "description": ( - "Call when pipelock refused your outbound request and " - "the failing host is genuinely missing from the bottle's " - "allowlist (vs. blocked for DLP reasons — those need a " - "different remediation). In practice pipelock's allowlist " - "is now a mirror of the egress routes set by " - "`egress-block`, so prefer that tool when you want " - "to add a host. This tool stays available for the rare " - "case where pipelock and egress have diverged. " - "Pass the full URL you tried to hit (scheme + host + " - "path); the supervisor extracts the hostname and merges " - "it into pipelock's allowlist. On approval the " - "supervisor restarts pipelock." - ), - "inputSchema": { - "type": "object", - "properties": { - "failed_url": { - "type": "string", - "description": ( - "The full URL pipelock blocked, e.g. " - "https://api.github.com/repos/foo/bar. Scheme " - "and hostname are required; path is recorded " - "as operator context." - ), - }, - "justification": { - "type": "string", - "description": "Why the new host should be allowed.", - }, - }, - "required": ["failed_url", "justification"], - }, - }, { "name": _sv.TOOL_CAPABILITY_BLOCK, "description": ( "Call when the bottle is missing a tool, skill, permission, " "or env var you need — something that lives in the agent " - "Dockerfile rather than in routes or the pipelock allowlist. " + "Dockerfile rather than in the egress routes. " "Read the current Dockerfile from " "/etc/bot-bottle/current-config/Dockerfile, compose a " "modified version, and pass the full new file plus a " @@ -286,27 +246,10 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [ ] -# Map each tool to the input field that carries the agent's -# tool-specific payload (stored in Proposal.proposed_file as -# free-form text the apply path interprets per tool). -# -# egress-block: JSON object describing a SINGLE route to -# add — `{host, path_allowlist?, auth?}`. The -# supervisor merges this into the live routes -# file at approval time. -# pipelock-block: the full failed URL (scheme + host + path) — -# supervisor extracts the host, merges into the -# bottle's current allowlist; the path is shown -# to the operator for context (pipelock doesn't -# do path-level matching). -# capability-block: full proposed Dockerfile -# -# Egress-proxy-block doesn't use a single "field name" → the JSON -# payload is constructed from multiple structured input fields in -# `handle_egress_block`. The mapping stays one-entry-per-tool -# so the generic dispatch keeps working for the other two. +# Map each non-egress tool to the input field that carries the agent's +# payload (stored in Proposal.proposed_file). egress-block builds its +# payload from structured input fields in `handle_egress_block`. PROPOSED_FILE_FIELD: dict[str, str] = { - _sv.TOOL_PIPELOCK_BLOCK: "failed_url", _sv.TOOL_CAPABILITY_BLOCK: "dockerfile", } @@ -325,23 +268,7 @@ def validate_proposed_file(tool: str, content: str) -> None: enter the queue.""" if not content.strip(): raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty") - if tool == _sv.TOOL_PIPELOCK_BLOCK: - # `content` is the full failed URL. Require scheme + host so - # the supervisor can extract a hostname for the allowlist - # merge; the path is preserved for operator context. - parsed = urllib.parse.urlsplit(content.strip()) - if parsed.scheme not in ("http", "https"): - raise _RpcError( - ERR_INVALID_PARAMS, - f"{tool}: failed_url must start with http:// or https:// " - f"(got {content!r})", - ) - if not parsed.hostname: - raise _RpcError( - ERR_INVALID_PARAMS, - f"{tool}: failed_url is missing a hostname (got {content!r})", - ) - elif tool == _sv.TOOL_CAPABILITY_BLOCK: + if tool == _sv.TOOL_CAPABILITY_BLOCK: # Dockerfiles are too varied to validate syntactically beyond # non-empty. The operator reads the diff in the TUI. pass