Files
OpenShell/e2e/python/test_policy_validation.py
John T. MyersandJohn Myers b8d873f68f feat(proxy): support plain HTTP forward proxy for private IP endpoints (#158)
* feat(proxy): support plain HTTP forward proxy for private IP endpoints

Add forward proxy mode to the sandbox proxy so that standard HTTP
libraries (httpx, requests, etc.) work with HTTP_PROXY for plain HTTP
calls to private IP endpoints. Previously, non-CONNECT methods were
unconditionally rejected with 403.

The forward proxy path requires all three conditions to be met:
- OPA policy explicitly allows the destination
- The matched endpoint has allowed_ips configured
- All resolved IPs are RFC 1918 private

This ensures plain HTTP never reaches the public internet while enabling
seamless access to internal services without custom CONNECT tunnel code.

Implementation:
- parse_proxy_uri(): parses absolute-form URIs into components
- rewrite_forward_request(): rewrites to origin-form, strips hop-by-hop
  headers, adds Via and Connection: close
- handle_forward_proxy(): full handler with OPA eval, SSRF checks,
  private-IP gate, upstream connect, and bidirectional relay
- Updated dispatch in handle_tcp_connection to route non-CONNECT methods

Includes 14 unit tests and 6 E2E tests (FWD-1 through FWD-6).
CONNECT path remains completely untouched.

Closes #155

* fix(proxy): remove InspectForInference match arm removed by #146

The inference routing simplification in #146 reduced NetworkAction to
Allow/Deny, removing InspectForInference. Drop the dead match arm from
handle_forward_proxy.

* fix(sandbox): restore BestEffort as default Landlock compatibility

The Landlock V2 upgrade in #151 changed the default from BestEffort to
HardRequirement. This causes all proxy-mode sandboxes to crash with
Permission denied when the policy omits the landlock field, because the
child process gets locked to only /etc/navigator-tls and /sandbox.

Restore BestEffort as the default so policies without an explicit
landlock field degrade gracefully.

Fixes #161

* fix(sandbox): inject baseline filesystem paths for proxy-mode sandboxes

Proxy-mode sandboxes need baseline filesystem paths (/usr, /lib, /etc,
/app, /var/log read-only; /sandbox, /tmp read-write) for the child
process to function under Landlock. Without these, the child can't exec
binaries, resolve DNS, or load shared libraries.

The supervisor now enriches the policy with these baseline paths at
startup, covering both standalone (file) and gateway (gRPC) modes. For
gateway mode, the enriched policy is synced back so users see the
effective policy via 'nemoclaw sandbox get'.

The gateway validation is relaxed to allow additive filesystem changes
(new paths can be added, existing paths cannot be removed) to support
the supervisor's enrichment sync-back.

Includes 2 E2E tests: BFS-1 (missing filesystem_policy) and BFS-2
(incomplete filesystem_policy).

Fixes #161

* fix(e2e): update assertion for relaxed filesystem validation message

---------

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

165 lines
5.4 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""E2E tests for server-side policy safety validation.
These tests verify that the gRPC server rejects sandbox creation and policy
updates that contain unsafe content (root process identity, path traversal,
overly broad filesystem paths).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import grpc
import pytest
from navigator._proto import datamodel_pb2, navigator_pb2, sandbox_pb2
if TYPE_CHECKING:
from collections.abc import Callable
from navigator import Sandbox, SandboxClient
# =============================================================================
# Policy helpers
# =============================================================================
_SAFE_FILESYSTEM = sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr", "/lib", "/etc", "/app", "/var/log"],
read_write=["/sandbox", "/tmp"],
)
_SAFE_LANDLOCK = sandbox_pb2.LandlockPolicy(compatibility="best_effort")
_SAFE_PROCESS = sandbox_pb2.ProcessPolicy(run_as_user="sandbox", run_as_group="sandbox")
def _safe_policy() -> sandbox_pb2.SandboxPolicy:
"""Build a safe baseline policy for testing."""
return sandbox_pb2.SandboxPolicy(
version=1,
filesystem=_SAFE_FILESYSTEM,
landlock=_SAFE_LANDLOCK,
process=_SAFE_PROCESS,
)
# =============================================================================
# Tests
# =============================================================================
def test_create_sandbox_rejects_root_user(
sandbox_client: SandboxClient,
) -> None:
"""Server rejects CreateSandbox with run_as_user='root'."""
policy = sandbox_pb2.SandboxPolicy(
version=1,
filesystem=_SAFE_FILESYSTEM,
landlock=_SAFE_LANDLOCK,
process=sandbox_pb2.ProcessPolicy(
run_as_user="root",
run_as_group="sandbox",
),
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
stub = sandbox_client._stub
with pytest.raises(grpc.RpcError) as exc_info:
stub.CreateSandbox(navigator_pb2.CreateSandboxRequest(name="", spec=spec))
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
assert "root" in exc_info.value.details().lower()
def test_create_sandbox_rejects_path_traversal(
sandbox_client: SandboxClient,
) -> None:
"""Server rejects CreateSandbox with '..' in filesystem paths."""
policy = sandbox_pb2.SandboxPolicy(
version=1,
filesystem=sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr/../etc/shadow"],
read_write=["/tmp"],
),
landlock=_SAFE_LANDLOCK,
process=_SAFE_PROCESS,
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
stub = sandbox_client._stub
with pytest.raises(grpc.RpcError) as exc_info:
stub.CreateSandbox(navigator_pb2.CreateSandboxRequest(name="", spec=spec))
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
assert "traversal" in exc_info.value.details().lower()
def test_create_sandbox_rejects_overly_broad_paths(
sandbox_client: SandboxClient,
) -> None:
"""Server rejects CreateSandbox with read_write=['/']."""
policy = sandbox_pb2.SandboxPolicy(
version=1,
filesystem=sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr"],
read_write=["/"],
),
landlock=_SAFE_LANDLOCK,
process=_SAFE_PROCESS,
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
stub = sandbox_client._stub
with pytest.raises(grpc.RpcError) as exc_info:
stub.CreateSandbox(navigator_pb2.CreateSandboxRequest(name="", spec=spec))
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
assert "broad" in exc_info.value.details().lower()
def test_update_policy_rejects_immutable_fields(
sandbox: Callable[..., Sandbox],
sandbox_client: SandboxClient,
) -> None:
"""UpdateSandboxPolicy rejects removal of filesystem paths on a live sandbox.
Filesystem paths are enforced by Landlock at sandbox startup and cannot be
removed after the fact. This test verifies that the server rejects updates
that remove existing read_only paths, which also prevents unsafe content
from being introduced via policy updates.
"""
safe_policy = _safe_policy()
spec = datamodel_pb2.SandboxSpec(policy=safe_policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
sandbox_name = sb.sandbox.name
stub = sandbox_client._stub
# Try to update with a modified filesystem policy (immutable field)
unsafe_policy = sandbox_pb2.SandboxPolicy(
version=1,
filesystem=sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr/../etc/shadow"],
read_write=["/tmp"],
),
landlock=_SAFE_LANDLOCK,
process=_SAFE_PROCESS,
)
with pytest.raises(grpc.RpcError) as exc_info:
stub.UpdateSandboxPolicy(
navigator_pb2.UpdateSandboxPolicyRequest(
name=sandbox_name,
policy=unsafe_policy,
)
)
assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT
assert "cannot be removed" in exc_info.value.details().lower()