Files
OpenShell/python/navigator/inference.py
T
Piotr Mlocek aab1e34243 fix(sandbox): enforce network namespace and proxy policy in SSH sessions (!17)
Closes #24

## Problem

SSH sessions into the sandbox were **not entering the network namespace** or receiving proxy environment variables. This meant every command run via SSH (the only user-facing path) had unrestricted internet access, completely bypassing OPA network policy enforcement.

The root cause: the SSH server was started **before** the network namespace and proxy were created in `lib.rs`, so it never received the netns fd or proxy URL.

Additionally, **gRPC inference from within the sandbox did not work at all** — even after fixing the netns, multiple issues prevented the Python SDK from reaching the navigator server through the CONNECT proxy.

## Changes

### Sandbox binary

**Core fix — reorder startup + thread netns through SSH:**
- `lib.rs`: Move netns + proxy creation before SSH server start. Compute `ssh_netns_fd` and `ssh_proxy_url`, pass them to `run_ssh_server()`.
- `ssh.rs`: Thread `netns_fd` and `proxy_url` through the full SSH call chain into `spawn_pty_shell()`. Set proxy env vars on the shell command. Call `setns(fd, CLONE_NEWNET)` in `install_pre_exec()`.

**Proxy — control plane allowlist + IPv6 socket lookup:**
- `proxy.rs`: Connections to the navigator endpoint (derived from `NAVIGATOR_ENDPOINT`) are always allowed without OPA evaluation, logged with `engine=control_plane`. This is infrastructure the sandbox needs to function, not a user-configurable policy.
- `procfs.rs`: Extended `parse_proc_net_tcp` to also check `/proc/<pid>/net/tcp6`. gRPC C-core uses `AF_INET6` sockets even for IPv4 connections, so its TCP entries were invisible to the proxy's identity resolver. Also fixed port parsing to use `rsplit_once(':')` for correct IPv6 address handling.

**Proxy env vars — lowercase variants for gRPC C-core:**
- `process.rs` + `ssh.rs`: Added lowercase `http_proxy`, `https_proxy`, `grpc_proxy` alongside uppercase. gRPC C-core (libgrpc) checks lowercase first and was ignoring the uppercase-only vars.

### Server

- `sandbox/mod.rs`: Added `CAP_SYS_PTRACE` to sandbox pod security context. Required for the proxy (root) to read `/proc/<pid>/fd/` of sandbox-user processes for binary identity resolution.

### Python SDK

- `inference.py`: Strip `http://`/`https://` scheme from endpoint before passing to `grpc.insecure_channel()`, which expects `host:port`. Default `endpoint` and `sandbox_id` from `NAVIGATOR_ENDPOINT` / `NAVIGATOR_SANDBOX_ID` env vars so `Inference()` works inside sandboxes with no arguments.

### Build / infra

- `ci.toml`: Added `--cap-add=SYS_PTRACE` to `mise run sandbox` to mirror the k8s pod capabilities.

### Documentation

- `architecture/sandbox.md`: Documented `CAP_SYS_PTRACE` requirement and the full set of proxy env vars (uppercase + lowercase).

## Testing

All 29 sandbox unit tests pass. `mise run pre-commit` passes (fmt, clippy, all workspace tests, python lint).

E2E verified on a live cluster:

| Test | Result |
|------|--------|
| Proxy env vars (6 vars, upper+lowercase) | PASS |
| Blocked endpoints (google, anthropic via curl) | PASS — all denied |
| **gRPC inference from SSH session** (`Inference()` with no args, env var defaults) | **PASS** |
| Proxy log: navigator requests show `engine=control_plane` | PASS |
| Proxy log: blocked requests show `engine=opa` with correct deny reasons | PASS |
2026-02-12 14:48:00 -08:00

146 lines
3.8 KiB
Python

from __future__ import annotations
import os
from dataclasses import dataclass
import grpc
from navigator._proto import inference_pb2, inference_pb2_grpc
SANDBOX_ID_HEADER = "x-sandbox-id"
ENDPOINT_ENV = "NAVIGATOR_ENDPOINT"
SANDBOX_ID_ENV = "NAVIGATOR_SANDBOX_ID"
@dataclass
class Message:
role: str
content: str
reasoning_content: str | None = None
@dataclass
class Choice:
index: int
message: Message
finish_reason: str
@dataclass
class Usage:
prompt_tokens: int
completion_tokens: int
total_tokens: int
@dataclass
class CompletionResponse:
id: str
model: str
created: int
choices: list[Choice]
usage: Usage | None
class Inference:
"""Client for the Navigator Inference gRPC service."""
def __init__(
self,
endpoint: str | None = None,
*,
sandbox_id: str | None = None,
) -> None:
endpoint = endpoint or os.environ.get(ENDPOINT_ENV, "")
if not endpoint:
raise ValueError(f"endpoint must be provided or set via ${ENDPOINT_ENV}")
sandbox_id = (
sandbox_id if sandbox_id is not None else os.environ.get(SANDBOX_ID_ENV, "")
)
# grpc.insecure_channel expects host:port, not a URL with scheme.
# Strip http:// prefix if present to avoid malformed CONNECT targets
# when gRPC routes through an HTTP proxy.
target = endpoint
for prefix in ("http://", "https://"):
if target.startswith(prefix):
target = target[len(prefix) :]
break
self._channel = grpc.insecure_channel(target)
self._stub = inference_pb2_grpc.InferenceStub(self._channel)
self._sandbox_id = sandbox_id
def close(self) -> None:
self._channel.close()
def __enter__(self) -> Inference:
return self
def __exit__(self, *args: object) -> None:
self.close()
def completion(
self,
*,
messages: list[dict[str, str]],
routing_hint: str = "local",
temperature: float | None = None,
max_tokens: int | None = None,
top_p: float | None = None,
) -> CompletionResponse:
proto_messages = [
inference_pb2.ChatMessage(role=m["role"], content=m["content"])
for m in messages
]
request = inference_pb2.CompletionRequest(
routing_hint=routing_hint,
messages=proto_messages,
)
if temperature is not None:
request.temperature = temperature
if max_tokens is not None:
request.max_tokens = max_tokens
if top_p is not None:
request.top_p = top_p
metadata = []
if self._sandbox_id:
metadata.append((SANDBOX_ID_HEADER, self._sandbox_id))
response = self._stub.Completion(request, metadata=metadata or None)
return _to_response(response)
def _to_response(proto: inference_pb2.CompletionResponse) -> CompletionResponse:
choices = [
Choice(
index=c.index,
message=Message(
role=c.message.role,
content=c.message.content,
reasoning_content=(
c.message.reasoning_content
if c.message.HasField("reasoning_content")
else None
),
),
finish_reason=c.finish_reason,
)
for c in proto.choices
]
usage = None
if proto.HasField("usage"):
usage = Usage(
prompt_tokens=proto.usage.prompt_tokens,
completion_tokens=proto.usage.completion_tokens,
total_tokens=proto.usage.total_tokens,
)
return CompletionResponse(
id=proto.id,
model=proto.model,
created=proto.created,
choices=choices,
usage=usage,
)