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
This commit is contained in:
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user