chore: remove pipelock from supervise plane and egress layer
lint / lint (push) Failing after 1m29s
test / unit (pull_request) Failing after 33s
test / integration (pull_request) Failing after 19s

- 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
This commit is contained in:
2026-06-04 21:15:36 +00:00
parent 9eb5eef676
commit ce8cb5f0f1
5 changed files with 35 additions and 213 deletions
+5 -58
View File
@@ -3,9 +3,7 @@ act on them (approve / modify / reject).
Curses-based TUI; modify-then-approve shells out to $EDITOR. The Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handlers wire to the per-tool remediation engines: approval handlers wire to the per-tool remediation engines:
PRD 0014 (egress, retargeted from cred-proxy in PRD 0017 PRD 0014 (egress) writes routes.yaml + SIGHUPs egress; PRD 0016
chunk 3) writes routes.yaml + SIGHUPs egress; PRD 0015
(pipelock) writes the allowlist + restarts pipelock; PRD 0016
(capability) rebuilds the bottle Dockerfile. (capability) rebuilds the bottle Dockerfile.
""" """
@@ -29,13 +27,6 @@ from ..backend.docker.capability_apply import (
apply_capability_change, apply_capability_change,
) )
from ..backend.docker.egress_apply import EgressApplyError, add_route from ..backend.docker.egress_apply import EgressApplyError, add_route
from ..backend.docker.pipelock_apply import (
PipelockApplyError,
apply_allowlist_change,
fetch_current_allowlist,
parse_allowlist_content,
render_allowlist_content,
)
from ..log import Die, error, info from ..log import Die, error, info
from ..supervise import ( from ..supervise import (
COMPONENT_FOR_TOOL, COMPONENT_FOR_TOOL,
@@ -47,7 +38,6 @@ from ..supervise import (
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
archive_proposal, archive_proposal,
list_pending_proposals, list_pending_proposals,
render_diff, render_diff,
@@ -71,7 +61,7 @@ class QueuedProposal:
# Errors any remediation engine may raise. Caught by the TUI key # Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps # handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses. # the proposal pending rather than crashing curses.
ApplyError = (EgressApplyError, PipelockApplyError, CapabilityApplyError) ApplyError = (EgressApplyError, CapabilityApplyError)
def discover_pending() -> list[QueuedProposal]: def discover_pending() -> list[QueuedProposal]:
@@ -116,33 +106,12 @@ def _detail_lines(
out.extend((" " + line, 0) for line in p.justification.splitlines() or [""]) out.extend((" " + line, 0) for line in p.justification.splitlines() or [""])
out.extend([ out.extend([
("", 0), ("", 0),
(_proposed_payload_label(p.tool) + ":", 0), ("proposed file:", 0),
]) ])
out.extend((line, 0) for line in p.proposed_file.splitlines() or [""]) out.extend((line, 0) for line in p.proposed_file.splitlines() or [""])
if p.tool == TOOL_PIPELOCK_BLOCK:
host = _failed_url_host(p.proposed_file)
if host:
out.append(("", 0))
out.append((host, green_attr))
return out return out
def _failed_url_host(url: str) -> str:
"""Best-effort hostname extraction from a pipelock-block proposal."""
import urllib.parse
try:
return urllib.parse.urlsplit(url.strip()).hostname or ""
except ValueError:
return ""
def _proposed_payload_label(tool: str) -> str:
if tool == TOOL_PIPELOCK_BLOCK:
return "failed URL"
return "proposed file"
def _suffix_for_tool(tool: str) -> str: def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK: if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile" return ".dockerfile"
@@ -167,10 +136,6 @@ def approve(
diff_before, diff_after = add_route( diff_before, diff_after = add_route(
qp.proposal.bottle_slug, file_to_apply, qp.proposal.bottle_slug, file_to_apply,
) )
elif qp.proposal.tool == TOOL_PIPELOCK_BLOCK:
diff_before, diff_after = _apply_pipelock_url(
qp.proposal.bottle_slug, file_to_apply,
)
elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK: elif qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
_meta = read_metadata(qp.proposal.bottle_slug) _meta = read_metadata(qp.proposal.bottle_slug)
if _meta is not None and not _meta.compose_project: if _meta is not None and not _meta.compose_project:
@@ -210,23 +175,6 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="") _write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
def _apply_pipelock_url(slug: str, failed_url: str) -> tuple[str, str]:
"""Merge a pipelock-block failed URL's host into the allowlist."""
import urllib.parse
parsed = urllib.parse.urlsplit(failed_url.strip())
host = parsed.hostname or ""
if not host:
raise PipelockApplyError(
f"proposed failed_url has no extractable host: {failed_url!r}"
)
current = fetch_current_allowlist(slug)
hosts = parse_allowlist_content(current)
if host not in hosts:
hosts.append(host)
return apply_allowlist_change(slug, render_allowlist_content(hosts))
def _write_audit( def _write_audit(
qp: QueuedProposal, qp: QueuedProposal,
*, *,
@@ -235,7 +183,7 @@ def _write_audit(
diff_before: str, diff_before: str,
diff_after: str, diff_after: str,
) -> None: ) -> None:
"""Audit log for egress / pipelock tools.""" """Audit log for egress tool."""
component = COMPONENT_FOR_TOOL.get(qp.proposal.tool) component = COMPONENT_FOR_TOOL.get(qp.proposal.tool)
if component is None: if component is None:
return return
@@ -467,8 +415,7 @@ def _render(
cursor = "> " if i == selected else " " cursor = "> " if i == selected else " "
line = ( line = (
f"{cursor}{ts_short} " f"{cursor}{ts_short} "
f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]} " f"[{p.bottle_slug}] {p.tool:<18} {p.id[:8]}"
f"{_proposed_payload_label(p.tool)}"
) )
attr = curses.A_REVERSE if i == selected else curses.A_NORMAL attr = curses.A_REVERSE if i == selected else curses.A_NORMAL
stdscr.addnstr(row, 0, line, w - 1, attr) stdscr.addnstr(row, 0, line, w - 1, attr)
+10 -29
View File
@@ -4,8 +4,7 @@ Replaces the cred-proxy sidecar (PRD 0010) with a mitmproxy-based
sidecar that becomes the agent's `HTTP_PROXY` / `HTTPS_PROXY`. It sidecar that becomes the agent's `HTTP_PROXY` / `HTTPS_PROXY`. It
owns three jobs: owns three jobs:
1. MITM the agent's HTTPS with the per-bottle CA (moved from 1. MITM the agent's HTTPS with the per-bottle CA.
pipelock).
2. Enforce manifest-declared `path_allowlist` per route. 2. Enforce manifest-declared `path_allowlist` per route.
3. Inject `Authorization` headers for routes that declare an 3. Inject `Authorization` headers for routes that declare an
`auth` block, the same way cred-proxy does today. `auth` block, the same way cred-proxy does today.
@@ -48,9 +47,8 @@ EGRESS_HOSTNAME = "egress"
# In-container path the addon reads. Pre-created in # In-container path the addon reads. Pre-created in
# `Dockerfile.sidecars` so the host bind-mount can drop the file # `Dockerfile.sidecars` so the host bind-mount can drop the file
# directly. Content is YAML (hand-rolled by `egress_render_routes` # directly. Content is YAML (hand-rolled by `egress_render_routes`,
# in the style of `pipelock_render_yaml`, parsed by `yaml_subset` # parsed by `yaml_subset` inside the addon).
# inside the addon).
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml" EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
@@ -70,15 +68,11 @@ class EgressRoute(Route):
`roles` carries the manifest route's role tuple (reserved for `roles` carries the manifest route's role tuple (reserved for
future use; always empty today). future use; always empty today).
`tls_passthrough` signals that pipelock must not TLS-MITM this `roles` carries the manifest route's role tuple (reserved for
host — either because the manifest declared `pipelock.tls_passthrough: future use; always empty today)."""
true` (lifted in `egress_manifest_routes`) or because a provider
route set it (e.g. egress injects its own Bearer on that host
after the agent boundary and pipelock's header DLP would block it)."""
token_ref: str = "" token_ref: str = ""
roles: tuple[str, ...] = () roles: tuple[str, ...] = ()
tls_passthrough: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -87,10 +81,10 @@ class EgressPlan:
The slug + routes_path + routes + token_env_map fields are The slug + routes_path + routes + token_env_map fields are
filled at prepare time (host-side, side-effect-free on docker). filled at prepare time (host-side, side-effect-free on docker).
The network + CA + pipelock fields are populated by the backend's The network + CA fields are populated by the backend's launch step
launch step via `dataclasses.replace` once those resources via `dataclasses.replace` once those resources exist. Empty defaults
exist. Empty defaults are sentinels meaning "not yet set"; are sentinels meaning "not yet set"; `.start` validates that they are
`.start` validates that they are populated. populated.
`token_env_map` is `{<token_env in container>: <token_ref on host>}`. `token_env_map` is `{<token_env in container>: <token_ref on host>}`.
The backend's start step reads `os.environ[token_ref]` and The backend's start step reads `os.environ[token_ref]` and
@@ -108,16 +102,6 @@ class EgressPlan:
key) for installing into the agent's trust store via key) for installing into the agent's trust store via
`provision_ca`. Separate file rather than re-parsing the `provision_ca`. Separate file rather than re-parsing the
concat so secrets and trust artefacts stay on distinct paths. concat so secrets and trust artefacts stay on distinct paths.
`pipelock_ca_host_path` is the host path of the pipelock CA
(cert only). `.start` docker-cps it into the sidecar so the
proxy's outbound HTTPS client trusts pipelock's MITM on the
egress → upstream leg.
`pipelock_proxy_url` is the URL egress sets as `HTTPS_PROXY`
in its environ so outbound HTTPS traverses pipelock — keeping
pipelock's hostname allowlist + DLP body scanner on the
egress → upstream leg.
""" """
slug: str slug: str
@@ -128,8 +112,6 @@ class EgressPlan:
egress_network: str = "" egress_network: str = ""
mitmproxy_ca_host_path: Path = Path() mitmproxy_ca_host_path: Path = Path()
mitmproxy_ca_cert_only_host_path: Path = Path() mitmproxy_ca_cert_only_host_path: Path = Path()
pipelock_ca_host_path: Path = Path()
pipelock_proxy_url: str = ""
def egress_manifest_routes( def egress_manifest_routes(
@@ -147,7 +129,6 @@ def egress_manifest_routes(
auth_scheme=r.AuthScheme, auth_scheme=r.AuthScheme,
token_ref=r.TokenRef, token_ref=r.TokenRef,
roles=r.Role, roles=r.Role,
tls_passthrough=r.Pipelock.TlsPassthrough,
)) ))
return tuple(out) return tuple(out)
@@ -306,7 +287,7 @@ class Egress(ABC):
forward values from the host's environ into the sidecar's environ. forward values from the host's environ into the sidecar's environ.
Returned plan is incomplete: the launch step must fill Returned plan is incomplete: the launch step must fill
`internal_network` / `egress_network` / `pipelock_proxy_url` `internal_network` / `egress_network`
via `dataclasses.replace` before passing it to `.start`.""" via `dataclasses.replace` before passing it to `.start`."""
routes = egress_routes_for_bottle(bottle, provider_routes) routes = egress_routes_for_bottle(bottle, provider_routes)
routes_path = stage_dir / "egress_routes.yaml" routes_path = stage_dir / "egress_routes.yaml"
+3 -31
View File
@@ -1,7 +1,7 @@
"""Per-bottle sidecar supervisor (PRD 0024 chunk 1). """Per-bottle sidecar supervisor (PRD 0024 chunk 1).
PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns PID 1 inside the `bot-bottle-sidecars` bundle image. Spawns
the configured daemons (egress, pipelock, git-gate, supervise), the configured daemons (egress, git-gate, supervise),
forwards SIGTERM/SIGINT to each child, and propagates per-daemon forwards SIGTERM/SIGINT to each child, and propagates per-daemon
stdout+stderr to the container log with a `[name] ` prefix. stdout+stderr to the container log with a `[name] ` prefix.
@@ -19,7 +19,7 @@ PR; the interim policy is "don't take the bundle down for one
sick daemon." sick daemon."
Daemon subset is env-driven. The compose renderer narrows it via Daemon subset is env-driven. The compose renderer narrows it via
`BOT_BOTTLE_SIDECAR_DAEMONS=egress,pipelock` for bottles that `BOT_BOTTLE_SIDECAR_DAEMONS=egress` for bottles that
don't use git-gate or supervise. Default: all daemons. don't use git-gate or supervise. Default: all daemons.
Stdlib-only by design — adding supervisord/s6/runit for four Stdlib-only by design — adding supervisord/s6/runit for four
@@ -57,14 +57,7 @@ class _DaemonSpec:
# Env-var name prefixes that carry egress-only credentials. # Env-var name prefixes that carry egress-only credentials.
# `egress_apply.py` assigns `EGRESS_TOKEN_<n>` slots that egress # `egress_apply.py` assigns `EGRESS_TOKEN_<n>` slots that egress
# reads to inject `Authorization` headers on configured routes; # reads to inject `Authorization` headers on configured routes;
# every other daemon in the bundle (especially pipelock with # no other daemon in the bundle should see these values.
# `scan_env: true`) MUST NOT see these values or it'll match the
# injected token in the request egress just sent and 403-block
# the legitimate traffic (issue #84). The agent itself runs in a
# different machine and never has access to these slots in the
# first place, so stripping them from non-egress daemons loses no
# DLP coverage — pipelock can't catch the exfil of a value the
# agent doesn't have.
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) _EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",)
@@ -81,22 +74,8 @@ def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
} }
# Order matters only for first-launch race-window reasons: egress
# starts first so pipelock's upstream connect succeeds during
# pipelock's own startup. git-gate and supervise are independent.
# Pipelock binds 0.0.0.0:8888 explicitly. Without `--listen` it
# defaults to 127.0.0.1 which would be unreachable from sibling
# services on the docker network. The legacy four-sidecar
# compose renderer passed the same flag; the bundle keeps the
# explicit binding.
_DAEMONS: tuple[_DaemonSpec, ...] = ( _DAEMONS: tuple[_DaemonSpec, ...] = (
_DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")), _DaemonSpec("egress", ("/bin/sh", "/app/egress-entrypoint.sh")),
_DaemonSpec(
"pipelock",
("/usr/local/bin/pipelock", "run",
"--config", "/etc/pipelock.yaml",
"--listen", "0.0.0.0:8888"),
),
_DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")), _DaemonSpec("git-gate", ("/bin/sh", "/git-gate-entrypoint.sh")),
_DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")), _DaemonSpec("git-http", ("python3", "/app/git_http_backend.py")),
_DaemonSpec("supervise", ("python3", "/app/supervise_server.py")), _DaemonSpec("supervise", ("python3", "/app/supervise_server.py")),
@@ -367,13 +346,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)
+1 -6
View File
@@ -6,8 +6,7 @@ sits on the bottle's internal network and exposes three MCP tools the
agent calls when it hits a stuck-recovery category: agent calls when it hits a stuck-recovery category:
* egress-block — agent proposes a new routes.yaml * egress-block — agent proposes a new routes.yaml
* pipelock-block — agent proposes a new pipelock allowlist * capability-block — agent proposes a new agent Dockerfile
* capability-block — agent proposes a new agent Dockerfile
Each tool call: the agent passes the full proposed file plus a Each tool call: the agent passes the full proposed file plus a
justification text. The sidecar validates the proposal syntactically, justification text. The sidecar validates the proposal syntactically,
@@ -50,12 +49,10 @@ SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 SUPERVISE_PORT = 9100
TOOL_EGRESS_BLOCK = "egress-block" TOOL_EGRESS_BLOCK = "egress-block"
TOOL_PIPELOCK_BLOCK = "pipelock-block"
TOOL_CAPABILITY_BLOCK = "capability-block" TOOL_CAPABILITY_BLOCK = "capability-block"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = ( TOOLS: tuple[str, ...] = (
TOOL_EGRESS_BLOCK, TOOL_EGRESS_BLOCK,
TOOL_PIPELOCK_BLOCK,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_LIST_EGRESS_ROUTES, TOOL_LIST_EGRESS_ROUTES,
) )
@@ -76,7 +73,6 @@ EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
# record laid down in PRD 0016. # record laid down in PRD 0016.
COMPONENT_FOR_TOOL: dict[str, str] = { COMPONENT_FOR_TOOL: dict[str, str] = {
TOOL_EGRESS_BLOCK: "egress", TOOL_EGRESS_BLOCK: "egress",
TOOL_PIPELOCK_BLOCK: "pipelock",
} }
STATUS_APPROVED = "approved" STATUS_APPROVED = "approved"
@@ -562,7 +558,6 @@ __all__ = [
"TOOL_CAPABILITY_BLOCK", "TOOL_CAPABILITY_BLOCK",
"TOOL_EGRESS_BLOCK", "TOOL_EGRESS_BLOCK",
"TOOL_LIST_EGRESS_ROUTES", "TOOL_LIST_EGRESS_ROUTES",
"TOOL_PIPELOCK_BLOCK",
"archive_proposal", "archive_proposal",
"audit_dir", "audit_dir",
"audit_log_path", "audit_log_path",
+16 -89
View File
@@ -1,8 +1,8 @@
"""Supervise sidecar HTTP server (PRD 0013). """Supervise sidecar HTTP server (PRD 0013).
Per-bottle MCP server exposing three tools `egress-block`, Per-bottle MCP server exposing two tools `egress-block`,
`pipelock-block`, `capability-block` that the agent calls to `capability-block` that the agent calls to propose config changes
propose config changes when stuck. Each tool call: when stuck. Each tool call:
1. Validates the proposed file syntactically. 1. Validates the proposed file syntactically.
2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from 2. Writes a Proposal to /run/supervise/queue/ (bind-mounted from
@@ -18,7 +18,7 @@ Speaks MCP over HTTP+JSON-RPC. Methods handled:
* `initialize` handshake; returns server info + caps. * `initialize` handshake; returns server info + caps.
* `notifications/initialized` ack-only. * `notifications/initialized` ack-only.
* `tools/list` returns the three tool definitions. * `tools/list` returns the tool definitions.
* `tools/call` validates, queues, blocks, returns. * `tools/call` validates, queues, blocks, returns.
Everything else returns JSON-RPC error -32601 (method not found). Everything else returns JSON-RPC error -32601 (method not found).
@@ -151,8 +151,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 +203,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 +215,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 +246,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 +268,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