Files
OpenShell/e2e/python/test_inference_routing.py
T
Piotr Mlocek 34fd3cbf64 feat(inference): inference interception and routing (!38)
Closes #67

## Summary

Implements transparent inference interception and routing for sandboxed AI agents. The sandbox proxy intercepts outbound AI SDK calls (OpenAI, Anthropic) and reroutes them through the gateway to policy-controlled backends — enabling organizations to redirect inference traffic to local or self-hosted models without modifying agent code.

**Decision model** — a tri-state OPA evaluation for every CONNECT request:
1. Binary + endpoint explicitly allowed in `network_policies` → **allow** (pass through)
2. Not explicitly allowed + `inference.allowed_routes` configured → **inspect for inference** (TLS intercept, detect API patterns, route through gateway)
3. Otherwise → **deny**

No endpoint declarations or binary lists needed for inference routing. Just configure `inference.allowed_routes`.

## Key Changes

### Sandbox (interception)
- **OPA policy**: New `network_action` Rego rule with three outcomes (`allow`, `inspect_for_inference`, `deny`). New `NetworkAction` enum replaces `PolicyDecision.allowed` bool for the proxy's main decision path.
- **Proxy**: New `InspectForInference` path — TLS-terminates client, parses HTTP, detects inference API patterns (`POST /v1/chat/completions`, `/v1/completions`, `/v1/messages`), strips auth headers, forwards via gRPC.
- **New module**: `l7/inference.rs` — `InferenceApiPattern`, `detect_inference_pattern()`, HTTP request/response parsing.
- **gRPC client**: New `proxy_inference()` for sandbox→gateway forwarding.
- **Sandbox init**: Creates OPA engine when inference is configured, even without `network_policies`.

### Gateway (dispatch)
- **InferenceService**: `ProxyInference` RPC loads sandbox policy, resolves allowed routes, dispatches to router. Full CRUD for inference routes.
- **Proto**: `InferenceRoute`, `InferenceRouteSpec`, `ProxyInferenceRequest/Response`, Inference gRPC service.

### Router (backend proxying)
- **New crate**: `navigator-router` with `Router`, `proxy_with_candidates()`, protocol-based route selection, backend HTTP proxying with auth header rewriting.
- **Mock support** for testing (`mock://` scheme).

### CLI
- `nav inference create/update/delete/list` commands for route management.

### Python SDK
- Updated protobuf bindings. Removed old `inference.py` client (replaced by transparent interception).

### Documentation
- New `architecture/inference-routing.md` — end-to-end system documentation.
- Updated `architecture/sandbox.md` — proxy, OPA, and source index sections.
- Updated `architecture/README.md` — new subsystem overview and diagram.

## Addendum: Chunked Transfer Compatibility

This branch now also fixes intercepted SDK requests that send chunked request bodies:

- `inspect_for_inference` now accepts `Transfer-Encoding: chunked` and decodes chunked request bodies before forwarding to the gateway
- Removed the prior `411 Length Required` response for chunked intercepted requests
- Added request/response header sanitization for framing and hop-by-hop headers (`content-length`, `transfer-encoding`, `connection`, etc.) to keep forwarded requests and returned responses valid
- Added unit tests for chunked parsing and header sanitization

Note: this improves compatibility for streaming-style SDK request patterns; true token-by-token passthrough response streaming is still a separate follow-up.

## Minimal Policy for Inference Routing

```yaml
inference:
  allowed_routes:
    - local
```

Any outgoing connection from a binary not explicitly allowed in `network_policies` will be intercepted and checked for inference API patterns.

## Test Plan

- [x] `cargo test --workspace` — all tests pass
- [x] `mise run pre-commit` — all checks pass
- [x] E2E: OpenAI chat completions routed through gateway
- [x] E2E: Anthropic messages routed through gateway
- [x] E2E: Python OpenAI SDK from sandbox (`examples/inference/inference.py`)
- [x] E2E test: `e2e/python/test_inference_routing.py`
2026-02-23 11:48:48 -08:00

143 lines
5.0 KiB
Python

"""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 and reroutes through the gateway's ProxyInference gRPC
endpoint, which forwards to the policy-allowed inference route (configured with
`mock://` for testing).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from navigator._proto import datamodel_pb2, sandbox_pb2
if TYPE_CHECKING:
from collections.abc import Callable
from navigator import 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() -> 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=["e2e_mock_local"]),
filesystem=_BASE_FILESYSTEM,
landlock=_BASE_LANDLOCK,
process=_BASE_PROCESS,
)
# =============================================================================
# Tests
# =============================================================================
def test_inference_call_rerouted_through_gateway(
sandbox: Callable[..., Sandbox],
mock_inference_route: str,
) -> None:
"""Inference call to undeclared endpoint is intercepted and rerouted.
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 through the gateway's ProxyInference RPC
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 navigator 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.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()