From c49eae4f30d8f04860add1391e7e1f2fea5aba6c Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:29:32 +0200 Subject: [PATCH] plane(2.3): egress -- one TLS rule over store host, Git hosts and Model hosts; no http rule policy_for(task, workspaces, model, store_host, receiver_host=None) emits one tlsPassthrough rule on 443 over the sorted union of the memory store's host, the bound Workspaces' https Git hosts and the bound Model's egress hosts. There is no http rule: an http Git repo is refused by name (it could not be reached), a store given as an address is refused naming S3_ENDPOINT (the gateway allows hostnames only). receiver_host adds the old receiver's http rule for the path group 2 keeps until 2.6; the reconciler passes it by keyword (passing the receiver's address positionally would have raised outside the worker's try and hung the run on the queue -- 2.4 hardens the worker). Tests: the store-only policy, the Model-bound policy, the two refusals, the receiver exception, and every committed template under plane/tasks/ rendering exactly the store plus, for verify and roles, the provider host. Gates: pytest 2027 passed, 42 skipped, exit 0; ruff check 0; ruff format 0; mypy 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- .kiro/specs/plane-on-kubernetes/tasks.md | 2 +- src/openultrasast/plane/egress.py | 33 +++++++++---- src/openultrasast/plane/reconciler.py | 2 +- tests/test_plane_egress.py | 63 +++++++++++++++++++----- 4 files changed, 76 insertions(+), 24 deletions(-) diff --git a/.kiro/specs/plane-on-kubernetes/tasks.md b/.kiro/specs/plane-on-kubernetes/tasks.md index e026bcf..817f757 100644 --- a/.kiro/specs/plane-on-kubernetes/tasks.md +++ b/.kiro/specs/plane-on-kubernetes/tasks.md @@ -86,7 +86,7 @@ go-ahead** with a ceiling; steps that need a hand on a cluster that does not exi - `tests/test_plane_runner.py`: accepted body with `delivery`; 400 on an `http` or IP URL; PUT to a local server; inputs fetched from presigned-shaped URLs; the marker and retry-only boot unchanged. - _Requirements: 3.1_ -- [ ] 2.3 Egress without the receiver +- [x] 2.3 Egress without the receiver - `src/openultrasast/plane/egress.py`: `policy_for(task, workspaces, model, store_host)` emits one `tlsPassthrough` rule on 443 over the sorted union of store host, Git hosts, Model hosts; no `http` rule; an IP store host raises naming `S3_ENDPOINT`. diff --git a/src/openultrasast/plane/egress.py b/src/openultrasast/plane/egress.py index 58d3d9f..57a487f 100644 --- a/src/openultrasast/plane/egress.py +++ b/src/openultrasast/plane/egress.py @@ -84,17 +84,32 @@ def git_hosts(workspaces: Iterable[Workspace]) -> tuple[list[str], list[str]]: 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.""" +def policy_for( + task: Task, + workspaces: Iterable[Workspace], + model: Model | None, + store_host: str | None, + receiver_host: str | None = None, +) -> dict[str, Any]: + """The task's EgressPolicy body (plane-on-kubernetes design section 3): one ``tlsPassthrough`` rule on 443 over + the sorted union of the store's hostname (``store_host``: where the runner PUTs its tar and GETs its inputs), the + bound Workspaces' https Git hosts and the bound Model's ``openultrasast.io/egress-hosts``. No ``http`` rule, unless + ``receiver_host`` names the old receiver (the path task 2.6 removes). An http Git repo cannot be reached under + this policy and is refused; a store given as an address is refused naming ``S3_ENDPOINT`` (the gateway allows + hostnames only).""" 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]}}}) + raise ValueError(f"Task/{task.metadata.name}: http Git repos ({', '.join(plain)}) cannot be reached: the policy grants TLS only") + hosts = set(tls) | set(model.egress_hosts if model else ()) + if store_host: + if _is_ip(store_host): + raise ValueError(f"Task/{task.metadata.name}: the store endpoint is an address ({store_host}); S3_ENDPOINT must be a hostname") + hosts.add(store_host.lower()) + rules: list[dict[str, Any]] = [] + if receiver_host and not _is_ip(receiver_host): # the receiver path, kept for group 2 only (task 2.6 deletes it) + rules.append({"http": {"hostnames": [receiver_host.lower()], "ports": {"numbers": [80]}}}) + if hosts: + rules.append({"tlsPassthrough": {"hostnames": sorted(hosts), "ports": {"numbers": [443]}}}) return {"rules": rules} diff --git a/src/openultrasast/plane/reconciler.py b/src/openultrasast/plane/reconciler.py index 882c078..efab978 100644 --- a/src/openultrasast/plane/reconciler.py +++ b/src/openultrasast/plane/reconciler.py @@ -341,7 +341,7 @@ def _execute( docs = render_task(run, entry, manifests, receiver.url, receiver.dial) manifest.write_text(yaml.safe_dump_all(docs, sort_keys=False), encoding="utf-8") model = manifests.models.get(task.metadata.annotations.get(MODEL_ANNOTATION) or "") - policy = policy_for(task, [manifests.workspaces[b.name] for b in task.workspaces], model, urlsplit(receiver.url).hostname) + policy = policy_for(task, [manifests.workspaces[b.name] for b in task.workspaces], model, None, urlsplit(receiver.url).hostname) try: # Req 4.6: the bound Model's credential is read here and only ever sent in the start request start = router.starter(atespace, ax_name, run.metadata.name, credentials(model)) ax.apply(manifest) # creates the Substrate actor; its egress is denied until the policy exists diff --git a/tests/test_plane_egress.py b/tests/test_plane_egress.py index fad22a3..c369967 100644 --- a/tests/test_plane_egress.py +++ b/tests/test_plane_egress.py @@ -1,8 +1,10 @@ """ai-service-plane task 0: the per-task egress policy, its kubectl-ate calls, and the runner's dialled delivery. -Agent Substrate's egress gateway denies by default; :func:`egress.policy_for` must grant exactly the receiver (HTTP -by name), the bound Workspaces' Git hosts and the bound Model's API host (TLS passthrough), and nothing a gateway -would reject or that would intercept TLS: no IP address, no wildcard, no ``https`` rule. +Agent Substrate's egress gateway denies by default; :func:`egress.policy_for` must grant exactly the memory store's +host (where the runner PUTs its tar and GETs its inputs), the bound Workspaces' Git hosts and the bound Model's API +host, all as one TLS passthrough rule on 443 (plane-on-kubernetes 2.3), and nothing a gateway would reject or that +would intercept TLS: no IP address, no wildcard, no ``https`` rule, no ``http`` rule (the receiver path, kept for +group 2 only, is the one exception). """ from __future__ import annotations @@ -70,24 +72,59 @@ def assert_tight(policy: dict) -> None: ipaddress.ip_address(host) -def test_model_free_task_gets_only_the_receiver(manifests) -> None: - policy = egress.policy_for(manifests.tasks["facts"], [], None, egress.RECEIVER_HOST) - assert policy == {"rules": [{"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}]} +STORE = "files.store.example" + + +def test_model_free_task_gets_only_the_store(manifests) -> None: + policy = egress.policy_for(manifests.tasks["facts"], [], None, STORE) + assert policy == {"rules": [{"tlsPassthrough": {"hostnames": [STORE], "ports": {"numbers": [443]}}}]} assert_tight(policy) + assert egress.policy_for(manifests.tasks["facts"], [], None, None) == {"rules": []}, "no store, no Git, no Model: nothing" + + +def test_the_receiver_path_adds_the_one_http_rule_until_it_is_removed(manifests) -> None: + policy = egress.policy_for(manifests.tasks["facts"], [], None, None, receiver_host=egress.RECEIVER_HOST) + assert policy == {"rules": [{"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}]} + assert egress.policy_for(manifests.tasks["facts"], [], None, None, receiver_host="127.0.0.1") == {"rules": []} def test_model_bound_task_with_git_workspaces(manifests) -> None: task = manifests.tasks["verify"] - policy = egress.policy_for(task, [manifests.workspaces["cases"]], manifests.models["deepseek-flash"], egress.RECEIVER_HOST) + policy = egress.policy_for(task, [manifests.workspaces["cases"]], manifests.models["deepseek-flash"], STORE) assert policy["rules"] == [ - {"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}, - {"tlsPassthrough": {"hostnames": ["api.deepseek.com", "github.com"], "ports": {"numbers": [443]}}}, - ], "one github.com for two repos; the ssh and IP-address repos add nothing" + {"tlsPassthrough": {"hostnames": ["api.deepseek.com", STORE, "github.com"], "ports": {"numbers": [443]}}}, + ], "one rule: the store, one github.com for two repos, the Model host; the ssh and IP-address repos add nothing" assert_tight(policy) -def test_an_ip_receiver_is_not_granted(manifests) -> None: - assert egress.policy_for(manifests.tasks["facts"], [], None, "127.0.0.1") == {"rules": []} +def test_an_ip_store_endpoint_is_refused_naming_the_variable(manifests) -> None: + with pytest.raises(ValueError, match="S3_ENDPOINT must be a hostname"): + egress.policy_for(manifests.tasks["facts"], [], None, "127.0.0.1") + + +def test_an_http_git_repo_is_refused(tmp_path: Path) -> None: + path = tmp_path / "m.yaml" + path.write_text(MANIFESTS.replace("https://github.com/example-org/alpha", "http://mirror.example/alpha"), encoding="utf-8") + loaded = load_manifests([path]) + with pytest.raises(ValueError, match="http Git repos .*mirror.example.* cannot be reached"): + egress.policy_for(loaded.tasks["verify"], [loaded.workspaces["cases"]], None, STORE) + + +def test_every_committed_template_renders_the_expected_policy() -> None: + """For each template under plane/tasks/: the store, the Model's hosts when bound, nothing else; one TLS rule.""" + from openultrasast.plane.generate import TEMPLATES + + plane = Path("plane") + loaded = load_manifests([plane / "tasks" / f"{name}.yaml" for name in TEMPLATES] + sorted((plane / "models").glob("*.yaml"))) + assert set(loaded.tasks) == set(TEMPLATES) + for name, task in loaded.tasks.items(): + model = loaded.models.get(task.metadata.annotations.get("openultrasast.io/model") or "") + policy = egress.policy_for(task, [], model, STORE) + expected = sorted({STORE, *(model.egress_hosts if model else ())}) + assert policy == {"rules": [{"tlsPassthrough": {"hostnames": expected, "ports": {"numbers": [443]}}}]}, name + assert_tight(policy) + bound = {n for n, t in loaded.tasks.items() if "openultrasast.io/model" in t.metadata.annotations} + assert bound == {"verify", "roles"}, "the model-bound templates, and only they, get the provider host" def test_deepseek_model_manifest_names_the_endpoint_host() -> None: @@ -121,7 +158,7 @@ def calls(tmp_path: Path) -> list[str]: return [" ".join(line.split()[2:4]) for line in (tmp_path / "ax.log").read_text().splitlines()] -POLICY = {"rules": [{"http": {"hostnames": [egress.RECEIVER_HOST], "ports": {"numbers": [80]}}}]} +POLICY = {"rules": [{"tlsPassthrough": {"hostnames": ["files.store.example"], "ports": {"numbers": [443]}}}]} def test_apply_creates_reads_back_and_delete_confirms_the_cascade(tmp_path: Path, kubectl_ate: Path) -> None: