Files
OpenShell/e2e/python/test_inference_routing.py
John T. MyersandJohn Myers c06117eb15 fix(proxy): return 403 for non-CONNECT requests, add deny logging, and revise error messages (#79)
Closes #42

- Change non-CONNECT proxy response from 405 to 403 to align with
  how CONNECT denials are surfaced
- Add structured deny logging for non-CONNECT requests with hostname
  extraction from absolute-form URIs
- Revise 7 user-facing error messages across proxy.rs and
  dev-sandbox-policy.rego to follow consistent principle: generic
  policy-deny messages for non-inference requests, descriptive messages
  for recognized inference endpoints
- Update E2E test assertion to match new error message
- Update architecture docs to reflect new behavior

Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
2026-03-03 13:06:17 -08:00

364 lines
12 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""E2E tests for inference interception and routing.
When a process inside the sandbox makes an inference API call (e.g. POST
/v1/chat/completions) to an endpoint not explicitly allowed by network policy,
the proxy intercepts it, TLS-terminates the connection, detects the inference
API pattern, and the sandbox routes the request locally to the configured
backend (configured with `mock://` for testing).
"""
from __future__ import annotations
import time
import grpc
from typing import TYPE_CHECKING
from navigator._proto import datamodel_pb2, sandbox_pb2
if TYPE_CHECKING:
from collections.abc import Callable
from navigator import InferenceRouteClient, Sandbox
# =============================================================================
# Policy helpers
# =============================================================================
_BASE_FILESYSTEM = sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr", "/lib", "/etc", "/app", "/var/log"],
read_write=["/sandbox", "/tmp"],
)
_BASE_LANDLOCK = sandbox_pb2.LandlockPolicy(compatibility="best_effort")
_BASE_PROCESS = sandbox_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox")
def _inference_routing_policy(
allowed_route: str = "e2e_mock_local",
) -> sandbox_pb2.SandboxPolicy:
"""Policy with inference routing enabled.
No network_policies needed — any connection from any binary to an endpoint
not in an explicit policy will be intercepted for inference when
allowed_routes is non-empty.
"""
return sandbox_pb2.SandboxPolicy(
version=1,
inference=sandbox_pb2.InferencePolicy(allowed_routes=[allowed_route]),
filesystem=_BASE_FILESYSTEM,
landlock=_BASE_LANDLOCK,
process=_BASE_PROCESS,
)
# =============================================================================
# Tests
# =============================================================================
def test_route_refresh_picks_up_route_created_after_sandbox_start(
sandbox: Callable[..., Sandbox],
inference_client: InferenceRouteClient,
) -> None:
"""Route refresh picks up a route created after sandbox startup.
Regression scenario:
1. Sandbox starts with inference allowed_routes configured but no matching route exists yet.
2. Initial inference request should be intercepted and return 503 (empty route cache).
3. Create the route after sandbox startup.
4. Background refresh should load the new route and subsequent requests should succeed.
"""
route_name = "e2e-mock-refresh-late"
route_hint = "e2e_mock_refresh_late"
spec = datamodel_pb2.SandboxSpec(policy=_inference_routing_policy(route_hint))
def call_chat_completions() -> str:
import json
import ssl
import urllib.error
import urllib.request
body = json.dumps(
{
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/chat/completions",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer dummy-key",
},
method="POST",
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
return f"http_error_{e.code}:{body}"
except Exception as e:
return f"error:{type(e).__name__}:{e}"
try:
inference_client.delete(route_name)
except grpc.RpcError:
pass
try:
with sandbox(spec=spec, delete_on_exit=True) as sb:
initial = sb.exec_python(call_chat_completions, timeout_seconds=60)
assert initial.exit_code == 0, f"stderr: {initial.stderr}"
initial_output = initial.stdout.strip()
assert initial_output.startswith("http_error_503"), initial_output
assert (
"inference endpoint detected without matching inference route"
in initial_output
), initial_output
inference_client.create(
name=route_name,
routing_hint=route_hint,
base_url="mock://e2e-refresh-late",
protocols=["openai_chat_completions"],
api_key="mock",
model_id="mock/late-route-model",
enabled=True,
)
deadline = time.time() + 95
last_output = initial_output
while time.time() < deadline:
result = sb.exec_python(call_chat_completions, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
last_output = result.stdout.strip()
if "Hello from nemoclaw mock backend" in last_output:
break
time.sleep(5)
assert "Hello from nemoclaw mock backend" in last_output, last_output
assert "mock/late-route-model" in last_output, last_output
finally:
try:
inference_client.delete(route_name)
except grpc.RpcError:
pass
def test_inference_call_routed_to_backend(
sandbox: Callable[..., Sandbox],
mock_inference_route: str,
) -> None:
"""Inference call to undeclared endpoint is intercepted and routed.
A Python process inside the sandbox calls the OpenAI chat completions
endpoint via raw urllib. Since api.openai.com is not in any network
policy, but inference routing is configured, the proxy should:
1. Detect no explicit policy match (inspect_for_inference)
2. TLS-terminate the connection
3. Detect the inference API pattern (POST /v1/chat/completions)
4. Forward locally via sandbox router to the policy-allowed backend
5. Return the mock response from the configured route
"""
spec = datamodel_pb2.SandboxSpec(policy=_inference_routing_policy())
def call_chat_completions() -> str:
import json
import ssl
import urllib.request
body = json.dumps(
{
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/chat/completions",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer dummy-key",
},
method="POST",
)
# The proxy will TLS-terminate, so we need to accept its cert.
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(call_chat_completions, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
output = result.stdout.strip()
assert "Hello from nemoclaw mock backend" in output
assert "mock/test-model" in output
def test_non_inference_request_denied(
sandbox: Callable[..., Sandbox],
mock_inference_route: str,
) -> None:
"""Non-inference HTTP request on an intercepted connection is denied.
A process making a non-inference request (e.g. GET /v1/models) to an
undeclared endpoint should be denied with 403 when inference routing
is configured — only recognized inference API patterns are routed.
"""
spec = datamodel_pb2.SandboxSpec(policy=_inference_routing_policy())
def make_non_inference_request() -> str:
import ssl
import urllib.error
import urllib.request
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
req = urllib.request.Request("https://api.openai.com/v1/models")
urllib.request.urlopen(req, timeout=10, context=ctx)
return "unexpected_success"
except urllib.error.HTTPError as e:
return f"http_error_{e.code}"
except Exception as e:
return f"error: {e}"
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(make_non_inference_request, timeout_seconds=30)
assert result.exit_code == 0, f"stderr: {result.stderr}"
assert "403" in result.stdout.strip()
def test_inference_anthropic_messages_protocol(
sandbox: Callable[..., Sandbox],
mock_anthropic_route: str,
) -> None:
"""Anthropic messages protocol (POST /v1/messages) is intercepted and routed.
Verifies multi-protocol routing: a request using the Anthropic messages
format is correctly detected and forwarded to a route configured with
the anthropic_messages protocol.
"""
policy = sandbox_pb2.SandboxPolicy(
version=1,
inference=sandbox_pb2.InferencePolicy(
allowed_routes=["e2e_mock_anthropic"],
),
filesystem=_BASE_FILESYSTEM,
landlock=_BASE_LANDLOCK,
process=_BASE_PROCESS,
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
def call_anthropic_messages() -> str:
import json
import ssl
import urllib.request
body = json.dumps(
{
"model": "claude-test",
"max_tokens": 64,
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
req = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
data=body,
headers={
"Content-Type": "application/json",
"x-api-key": "dummy-key",
"anthropic-version": "2023-06-01",
},
method="POST",
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(call_anthropic_messages, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
output = result.stdout.strip()
assert "Hello from nemoclaw mock backend" in output
assert "mock/claude-test" in output
def test_inference_route_filtering_by_allowed_routes(
sandbox: Callable[..., Sandbox],
mock_inference_route: str,
mock_disallowed_route: str,
) -> None:
"""Only routes in allowed_routes are available; others produce errors.
Two routes exist (e2e_mock_local and e2e_mock_disallowed), but the
policy only allows e2e_mock_local. A request that would match the
allowed route should succeed, while inference requests that can't
match any allowed route get an error from the sandbox router.
"""
# Policy only allows e2e_mock_local, NOT e2e_mock_disallowed
spec = datamodel_pb2.SandboxSpec(policy=_inference_routing_policy())
def call_allowed_route() -> str:
import json
import ssl
import urllib.request
body = json.dumps(
{
"model": "test-model",
"messages": [{"role": "user", "content": "hello"}],
}
).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/chat/completions",
data=body,
headers={
"Content-Type": "application/json",
"Authorization": "Bearer dummy",
},
method="POST",
)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
resp = urllib.request.urlopen(req, timeout=30, context=ctx)
return resp.read().decode()
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(call_allowed_route, timeout_seconds=60)
assert result.exit_code == 0, f"stderr: {result.stderr}"
output = result.stdout.strip()
# The allowed route (e2e_mock_local) should serve the request
assert "Hello from nemoclaw mock backend" in output
assert "mock/test-model" in output