Files
Open_Ultra_SAST/src/openultrasast/plane/egress.py
T
Marius CiepluchandClaude Opus 5.5 89d000c704 fix(plane): fetch task-produced inputs from the receiver after the start
A producer's artifact rendered as spec.files of a generated <task>-inputs
Workspace ends up in ax's AX_WORKSPACES_YAML env value; Agent Substrate
rejects values over 32768 characters, so a 182 KB facts.json broke ax apply
on the live cluster. The reconciler now passes OUSAST_INPUTS instead, the
receiver serves a running task its declared inputs over GET /inputs/..., and
the runner fetches them after the start (failed fetch -> failed summary).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-09-29 21:35:28 +02:00

303 lines
15 KiB
Python

"""Per-task network egress on Agent Substrate (ai-service-plane, task 0: gateway + receiver Service).
Every TCP connection an actor opens (DNS aside) is redirected to Substrate's egress gateway
(``atenet-egress.ate-system``), which denies by default: an actor without an ``EgressPolicy`` gets no tunnel, and
a refused connection is accepted and then closed. :func:`policy_for` grants a task exactly what it needs:
* ``http`` on port 80 to the artifact receiver's cluster name (``ousast-receiver.ax-system.svc.cluster.local``, a
selector-less Service whose endpoint is this host's receiver, ``ops/ax/receiver-service.yaml.tmpl``). The actor
cannot resolve cluster names, so the runner dials ``OUSAST_ARTIFACT_DIAL`` (the Service's ClusterIP, port 80)
with the receiver's name as ``Host``. The gateway decides the policy on that ``Host`` but connects to the
address the actor dialled (measured live: agentgateway's upstream is the CONNECT authority, not the Host), so
the dial address must be one the gateway pod reaches: the ClusterIP, which kube-proxy maps to the host.
* ``tls_passthrough`` on 443 for the Git hosts of the bound Workspaces' ``https`` repos (smart HTTP talks to the
repo's host only) and for the bound Model's ``openultrasast.io/egress-hosts`` (``api.deepseek.com``).
Nothing else: no wildcard, no IP address (the gateway rejects them), no ``https`` rule (that one intercepts TLS and
the actor would have to trust the gateway CA, which ax templates do not mount). :class:`Egress` writes the policy
with ``kubectl ate create egress-policy`` after ``ax apply`` created the actor, reads it back, and before resume
waits ``OUSAST_EGRESS_SETTLE`` s (11) because the gateway caches a missing policy as deny for 10 s. The kubectl-ate
CLI has no delete verb: Substrate's store deletes the policy with its actor (``ON DELETE CASCADE``), so
:meth:`Egress.delete` runs after ``ax delete task`` and confirms the policy is gone.
"""
from __future__ import annotations
import io
import ipaddress
import json
import os
import shutil
import subprocess
import tarfile
import threading
import time
from collections.abc import Iterable, Mapping
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlsplit
from .manifests import Model, Task, Workspace
__all__ = [
"INPUTS_PATH",
"RECEIVER_HOST",
"RECEIVER_PORT",
"Egress",
"Receiver",
"git_hosts",
"receiver_cluster_ip",
"policy_for",
"receiver_address",
"rules_of",
]
RECEIVER_HOST = "ousast-receiver.ax-system.svc.cluster.local"
INPUTS_PATH = "/inputs/" # GET <receiver>/inputs/<producer>/<artifact>: a consumer fetches a declared input
RECEIVER_PORT = 18090 # the receiver Service's targetPort; ops/ax/up.sh renders the Service with the same number
_NOT_FOUND = ("not found", "notfound")
def _is_ip(host: str) -> bool:
try:
ipaddress.ip_address(host.strip("[]"))
except ValueError:
return False
return True
def git_hosts(workspaces: Iterable[Workspace]) -> tuple[list[str], list[str]]:
"""(https hosts, http hosts) of the Workspaces' ``spec.git[].repo`` URLs; ssh and IP-address repos add nothing."""
tls, plain = set[str](), set[str]()
for ws in workspaces:
for source in ws.git:
parts = urlsplit(source.repo)
host = (parts.hostname or "").lower()
if (
host
and not _is_ip(host)
and parts.scheme in ("https", "http")
and parts.port in (None, 443 if parts.scheme == "https" else 80)
):
(tls if parts.scheme == "https" else plain).add(host)
return sorted(tls), sorted(plain)
def policy_for(task: Task, workspaces: Iterable[Workspace], model: Model | None, receiver_host: str | None) -> dict[str, Any]:
"""The task's EgressPolicy body: the receiver over HTTP/80, Git and Model hosts as TLS passthrough on 443."""
tls, plain = git_hosts(workspaces)
tls = sorted(set(tls) | set(model.egress_hosts if model else ()))
if receiver_host and not _is_ip(receiver_host): # an address cannot be allowed; only the direct test mode has one
plain = sorted({*plain, receiver_host.lower()})
rules: list[dict[str, Any]] = []
if plain:
rules.append({"http": {"hostnames": plain, "ports": {"numbers": [80]}}})
if tls:
rules.append({"tlsPassthrough": {"hostnames": tls, "ports": {"numbers": [443]}}})
return {"rules": rules}
def rules_of(policy: Mapping[str, Any]) -> list[dict[str, Any]]:
"""The rules of a policy as ``kubectl ate get -o json`` prints them, normalised for comparison."""
out = []
for rule in policy.get("rules") or []:
for kind, body in rule.items():
key = "tlsPassthrough" if kind in ("tls_passthrough", "tlsPassthrough") else kind
ports = body.get("ports") or {}
norm_ports: dict[str, Any] = {"all": {}} if "all" in ports else {"numbers": sorted(int(n) for n in ports.get("numbers") or [])}
out.append({key: {"hostnames": sorted(body.get("hostnames") or []), "ports": norm_ports}})
return sorted(out, key=lambda r: json.dumps(r, sort_keys=True))
def receiver_cluster_ip(context: str | None = None) -> str | None:
"""The ClusterIP of the ``ousast-receiver`` Service (``kubectl get svc``); None when kubectl cannot say."""
context = context or "kind-" + (os.environ.get("KIND_CLUSTER_NAME") or "ousast")
argv = ["kubectl", "--context", context, "-n", "ax-system", "get", "svc", "ousast-receiver", "-o", "jsonpath={.spec.clusterIP}"]
try:
proc = subprocess.run(argv, capture_output=True, text=True, check=False, timeout=20)
except (OSError, subprocess.TimeoutExpired):
return None
address = proc.stdout.strip()
return address if proc.returncode == 0 and _is_ip(address) else None
def receiver_address(port: int) -> tuple[str, str | None]:
"""(``OUSAST_ARTIFACT_URL``, ``OUSAST_ARTIFACT_DIAL``) for a receiver bound on ``port``.
``OUSAST_ARTIFACT_HOST`` set: the old direct URL ``http://<host>:<port>/`` and no dial (tests, a host the actor
reaches without the gateway). Otherwise the receiver Service's name, dialled at ``OUSAST_ARTIFACT_DIAL`` or the
Service's ClusterIP on port 80: the gateway checks the policy against ``Host`` but connects to the address the
actor dialled (its CONNECT authority), so that address must reach the receiver from the gateway pod.
"""
host = os.environ.get("OUSAST_ARTIFACT_HOST")
if host:
return f"http://{host}:{port}/", None
dial = os.environ.get("OUSAST_ARTIFACT_DIAL")
if not dial:
address = receiver_cluster_ip()
dial = f"{address}:80" if address else None
return f"http://{RECEIVER_HOST}/", dial
class Egress:
"""``kubectl ate`` for one actor's egress policy; the executable is injectable so tests use a fake."""
def __init__(self, executable: str | None = None, context: str | None = None) -> None:
self.executable = executable or os.environ.get("OUSAST_KUBECTL_ATE") or "kubectl-ate"
self.context = context or "kind-" + (os.environ.get("KIND_CLUSTER_NAME") or "ousast")
def _run(self, *args: str, stdin: str | None = None) -> subprocess.CompletedProcess[str]:
argv = [self.executable, "--context", self.context, *args]
return subprocess.run(argv, input=stdin, capture_output=True, text=True, check=False)
@staticmethod
def _error(proc: subprocess.CompletedProcess[str]) -> str:
return (proc.stderr.strip() or proc.stdout.strip() or f"exit {proc.returncode}")[-400:]
def get(self, actor: str, atespace: str) -> dict[str, Any] | None:
"""The stored policy, None when there is none (or no actor); RuntimeError when kubectl-ate fails otherwise."""
proc = self._run("get", "egress-policy", actor, "-a", atespace, "-o", "json")
if proc.returncode != 0:
if "notfound" in self._error(proc).lower().replace(" ", ""):
return None
raise RuntimeError(f"kubectl ate get egress-policy {atespace}/{actor} failed: {self._error(proc)}")
if not proc.stdout.strip(): # an actor without a policy: exit 0, "has no egress policy" on stderr only
return None
try:
doc = json.loads(proc.stdout)
except ValueError as exc:
raise RuntimeError(f"kubectl ate get egress-policy {atespace}/{actor}: unreadable output ({exc})") from None
return doc if isinstance(doc, dict) else None
def apply(self, actor: str, atespace: str, policy: Mapping[str, Any], *, timeout: float | None = None) -> None:
"""Create (or replace) the policy, retrying while the actor does not exist yet, then read it back."""
limit = float(os.environ.get("OUSAST_EGRESS_TIMEOUT") or 60) if timeout is None else timeout
deadline, backoff = time.monotonic() + limit, 0.5
while True:
existing = self.get(actor, atespace)
if existing is None:
proc = self._run("create", "egress-policy", actor, "-a", atespace, "-f", "-", stdin=json.dumps(dict(policy)))
else:
meta = {k: existing.get("metadata", {}).get(k) for k in ("name", "atespace", "uid", "version")}
body = json.dumps({**dict(policy), "metadata": meta})
proc = self._run("update", "egress-policy", actor, "-a", atespace, "-f", "-", stdin=body)
if proc.returncode == 0:
break
error = self._error(proc)
retry = any(word in error.lower() for word in (*_NOT_FOUND, "already exists", "conflict", "unavailable"))
if not retry or time.monotonic() + backoff > deadline:
raise RuntimeError(f"egress policy for {atespace}/{actor} not written: {error}")
time.sleep(backoff)
backoff = min(backoff * 2, 5.0)
stored = self.get(actor, atespace)
if stored is None or rules_of(stored) != rules_of(policy):
raise RuntimeError(f"egress policy for {atespace}/{actor} reads back {stored!r}, not {dict(policy)!r}")
def delete(self, actor: str, atespace: str) -> str | None:
"""After ``ax delete task``: None once the policy is gone with its actor, else why it is still there."""
try:
stored = self.get(actor, atespace)
except RuntimeError as exc:
return str(exc)
return None if stored is None else f"egress policy for {atespace}/{actor} outlived its actor"
@staticmethod
def settle() -> None:
"""Wait out the gateway's cached deny (10 s) before the actor's first connection: ``OUSAST_EGRESS_SETTLE``."""
time.sleep(float(os.environ.get("OUSAST_EGRESS_SETTLE") or 11))
class Receiver(ThreadingHTTPServer):
"""Accepts one tar per running task and extracts it under ``<run dir>/<task>/`` (POST); serves a running task
the producer artifacts it declared as inputs (``GET /inputs/<producer>/<artifact>``, ``inputs[task]``)."""
daemon_threads = True
def __init__(self, run_name: str, base: Path, port: int) -> None:
super().__init__(("0.0.0.0", port), _Handler)
self.run_name, self.base, self.running, self.delivered = run_name, base, set[str](), set[str]()
self.inputs: dict[str, frozenset[str]] = {}
self.lock = threading.Lock()
self.url, self.dial = receiver_address(self.server_address[1])
def _task(self, headers: Any) -> str | None:
"""The ``X-Ousast-Task`` of a request naming this run and a task that is running now, else None."""
task = str(headers.get("X-Ousast-Task", ""))
with self.lock:
return task if headers.get("X-Ousast-Run") == self.run_name and task in self.running else None
def input_file(self, headers: Any, ref: str) -> tuple[int, str | Path]:
"""(200, the file) when ``ref`` is one of the requesting task's declared inputs, else (403/404, why).
``ref`` must equal a declared ``<producer>/<artifact>`` exactly and resolve to a regular file under the run
directory; nothing is normalised first, so ``..`` or an absolute path never names a declared input."""
task = self._task(headers)
if task is None:
return 403, "unknown run or task"
with self.lock:
declared = ref in self.inputs.get(task, frozenset())
if not declared:
return 403, f"{ref!r} is not an input of {task}"
root = self.base.resolve()
path = (root / ref).resolve()
if not path.is_relative_to(root) or not path.is_file():
return 404, f"{ref} was not delivered"
return 200, path
def accept(self, headers: Any, body: bytes) -> tuple[int, str]:
task = self._task(headers)
if task is None:
return 403, "unknown run or task"
if not str(headers.get("Content-Type", "")).startswith("application/x-tar"):
return 415, "expected application/x-tar"
dest = (self.base / task).resolve()
try:
with tarfile.open(fileobj=io.BytesIO(body), mode="r:") as tar:
members = tar.getmembers()
for m in members:
if m.issym() or m.islnk() or not (dest / m.name).resolve().is_relative_to(dest):
return 400, f"rejected member {m.name}"
dest.mkdir(parents=True, exist_ok=True)
tar.extractall(dest, members) # every member checked above
except tarfile.TarError as exc:
return 400, f"bad tar: {exc}"
with self.lock:
self.delivered.add(task)
return 200, "ok"
class _Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - http.server's naming
server: Receiver = self.server # type: ignore[assignment]
path = urlsplit(self.path).path
if not path.startswith(INPUTS_PATH):
self._text(404, "not found")
return
code, found = server.input_file(self.headers, unquote(path[len(INPUTS_PATH) :]))
if not isinstance(found, Path):
self._text(code, found)
return
with found.open("rb") as source: # streamed: a producer artifact can be far larger than any env value
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(os.fstat(source.fileno()).st_size))
self.end_headers()
shutil.copyfileobj(source, self.wfile, 1 << 16)
def do_POST(self) -> None: # noqa: N802 - http.server's naming
server: Receiver = self.server # type: ignore[assignment]
body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
self._text(*server.accept(self.headers, body))
def _text(self, code: int, text: str) -> None:
data = text.encode()
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format: str, *args: object) -> None:
return