mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-25 06:42:52 +08:00
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 |
146 lines
3.8 KiB
Python
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,
|
|
)
|