Files
OpenShell/e2e/python/test_sandbox_policy.py
John Myers 757217f4bd feat(sandbox): support live policy updates, history, and policy-aware logs (!55)
Closes #78

## What You Can Do

### Live policy updates
Update a running sandbox's network policy without recreating it:
```bash
nav sandbox policy set <sandbox> --policy new-policy.yaml --wait --timeout 60
```
The sandbox hot-reloads the policy within 30s (configurable). On failure, the previous policy stays active (last-known-good).

Idempotent — submitting the same policy twice returns the existing version:
```
✓ Policy version 3 submitted (hash: a1b2c3d4e5f6)
$ nav sandbox policy set test --policy same.yaml
· Policy unchanged (version 3, hash: a1b2c3d4e5f6)
```

### Policy history & inspection
```bash
nav sandbox policy list <sandbox>          # version history with status
nav sandbox policy get <sandbox>           # current policy metadata
nav sandbox policy get <sandbox> --full    # print full policy as YAML
nav sandbox policy get <sandbox> --rev 2 --full  # specific revision as YAML
```

### Sandbox logs
Stream logs from both the gateway and sandbox supervisor in one view:
```bash
nav sandbox logs <sandbox>                      # one-shot, last 2000 lines
nav sandbox logs <sandbox> --tail               # live streaming
nav sandbox logs <sandbox> --source sandbox     # supervisor logs only
nav sandbox logs <sandbox> --source gateway     # gateway logs only
nav sandbox logs <sandbox> --level warn         # warnings and errors only
nav sandbox logs <sandbox> --since 5m           # last 5 minutes
nav sandbox logs <sandbox> --tail --source sandbox --level info
```

Each log line is tagged with its source and includes structured fields:
```
[1772055394.673] [sandbox] [INFO ] [navigator_sandbox::proxy] CONNECT action=allow dst_host=api.anthropic.com dst_port=443 policy=claude_code
[1772055061.005] [gateway] [INFO ] [navigator_server::grpc] GetSandboxPolicy served from policy history
```

---

## Implementation

### Proto changes
- 7 new RPCs: `UpdateSandboxPolicy`, `GetSandboxPolicyStatus`, `ListSandboxPolicies`, `ReportPolicyStatus`, `GetSandboxLogs`, `PushSandboxLogs` (client-streaming)
- `SandboxLogLine`: added `source` (gateway/sandbox), `fields` (structured key-value map)
- `WatchSandboxRequest`: added `log_since_ms`, `log_sources`, `log_min_level`
- `GetSandboxLogsRequest`: added `sources`, `min_level`
- `PolicyStatus` enum, `SandboxPolicyRevision` message
- `Sandbox.current_policy_version` field

### Server
- **Policy persistence**: New `sandbox_policies` table (SQLite + Postgres) with per-sandbox monotonic versions, status tracking, and policy hash
- **UpdateSandboxPolicy**: Validates static field immutability (filesystem/landlock/process), network mode consistency (Block↔Proxy), deterministic hash comparison for idempotent updates
- **Lazy backfill**: First `GetSandboxPolicy` call creates version 1 from `spec.policy` for existing sandboxes
- **Log broker**: `TracingLogBus::publish_external()` injects sandbox-pushed logs into the same broadcast channel + tail buffer (2000 lines). Server forces `source="sandbox"` and `sandbox_id` on all pushed logs
- **Source/level filtering**: Applied server-side in both `GetSandboxLogs` and `WatchSandbox` streams
- **Version supersession**: When a new version is loaded, all older pending+loaded versions are marked superseded

### Sandbox
- **`OpaEngine::reload_from_proto()`**: Full `from_proto()` pipeline (L7 validation, access preset expansion) with atomic engine swap. On failure, previous engine untouched (LKG)
- **Policy poll loop**: Background task polls every 30s (configurable via `NAVIGATOR_POLICY_POLL_INTERVAL_SECS`), reports status via `ReportPolicyStatus` RPC
- **`LogPushLayer`**: Tracing layer captures events at INFO+ (configurable via `NAVIGATOR_LOG_PUSH_LEVEL`), sends structured fields via `PushSandboxLogs` client-streaming RPC. Background task batches 50 lines / flushes every 500ms. Best-effort (drops on full channel, never blocks)
- **`CachedNavigatorClient`**: Persistent mTLS channel for both policy polling and log push

### Database migration
- `002_create_sandbox_policies.sql` (SQLite + Postgres)

## Tests
- **Unit**: 8 policy persistence tests (put/get/list/status/supersede/isolation)
- **Integration**: 4 test files updated with new RPC stubs
- **E2E**: `test_live_policy_update_and_logs` — full lifecycle: create → set same (unchanged) → push new → wait for load → verify connectivity → push same (unchanged) → verify history → fetch logs

## Documentation
- `architecture/sandbox.md`: Log streaming architecture, LogPushLayer, push task, server broker, source tagging, structured fields, CLI filtering, failure modes
- `architecture/security-policy.md`: Live update semantics, deterministic hashing, CLI filter flags, policy inspection
- `architecture/plans/issue-78-sandbox-log-streaming.md`: Design plan for log streaming

## Security
- Trust boundary documented: shared mTLS cert model (per-sandbox auth tracked in #80)
- Server forces `source="sandbox"` and `sandbox_id` on pushed logs (can't impersonate gateway or other sandboxes)
- Per-batch line cap (100) prevents flooding
2026-02-25 17:50:41 -08:00

1041 lines
38 KiB
Python

from __future__ import annotations
import json
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")
# Standard proxy address inside the sandbox network namespace
_PROXY_HOST = "10.200.0.1"
_PROXY_PORT = 3128
def _base_policy(
network_policies: dict[str, sandbox_pb2.NetworkPolicyRule] | None = None,
) -> sandbox_pb2.SandboxPolicy:
"""Build a sandbox policy with standard filesystem/process/landlock settings."""
return sandbox_pb2.SandboxPolicy(
version=1,
filesystem=_BASE_FILESYSTEM,
landlock=_BASE_LANDLOCK,
process=_BASE_PROCESS,
network_policies=network_policies or {},
)
def _policy_for_python_proxy_tests() -> sandbox_pb2.SandboxPolicy:
return _base_policy(
network_policies={
"python": sandbox_pb2.NetworkPolicyRule(
name="python",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.openai.com", port=443)
],
binaries=[sandbox_pb2.NetworkBinary(path="/app/.venv/bin/python")],
)
},
)
# =============================================================================
# Shared test function factories
#
# cloudpickle serializes module-level functions by reference (module + name).
# The sandbox doesn't have this module, so deserialization fails. These
# factories return closures that cloudpickle serializes by value instead.
# =============================================================================
def _proxy_connect():
"""Return a closure that sends a raw CONNECT and returns the status line."""
def fn(host, port):
import socket
conn = socket.create_connection(("10.200.0.1", 3128), timeout=10)
try:
conn.sendall(
f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}\r\n\r\n".encode()
)
return conn.recv(256).decode("latin1")
finally:
conn.close()
return fn
def _proxy_connect_then_http():
"""Return a closure that CONNECTs, does TLS + HTTP, returns JSON string."""
def fn(host, port, method="GET", path="/"):
import json as _json
import socket
import ssl
conn = socket.create_connection(("10.200.0.1", 3128), timeout=30)
try:
conn.sendall(
f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}\r\n\r\n".encode()
)
connect_resp = conn.recv(256).decode("latin1")
if "200" not in connect_resp:
return _json.dumps(
{"connect_status": connect_resp.strip(), "http_status": 0}
)
sock = conn
if port == 443:
import os
ctx = ssl.create_default_context()
ca_file = os.environ.get("SSL_CERT_FILE")
if ca_file:
ctx.load_verify_locations(ca_file)
sock = ctx.wrap_socket(conn, server_hostname=host)
sock.settimeout(15)
request = (
f"{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n"
)
sock.sendall(request.encode())
# Read response. The L7 relay loops back to parse the next
# request after relaying, so neither side closes — read until
# we have headers, then drain body with a short timeout.
data = b""
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
# Drain body with short timeout
sock.settimeout(2)
while len(data) < 65536:
try:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
except (socket.timeout, TimeoutError):
break
response = data.decode("latin1", errors="replace")
status_line = response.split("\r\n")[0] if response else ""
status_code = (
int(status_line.split()[1]) if len(status_line.split()) >= 2 else 0
)
header_end = response.find("\r\n\r\n")
headers_raw = response[:header_end] if header_end > 0 else ""
body = response[header_end + 4 :] if header_end > 0 else ""
return _json.dumps(
{
"connect_status": connect_resp.strip(),
"http_status": status_code,
"headers": headers_raw,
"body": body,
}
)
finally:
conn.close()
return fn
def _read_navigator_log():
"""Return a closure that reads the navigator log file."""
def fn():
try:
with open("/var/log/navigator.log") as f:
return f.read()
except FileNotFoundError:
return ""
return fn
def test_policy_applies_to_exec_commands(
sandbox: Callable[..., Sandbox],
) -> None:
def current_user() -> str:
import os
import pwd
return pwd.getpwuid(os.getuid()).pw_name
def write_allowed_files() -> str:
from pathlib import Path
Path("/sandbox/allowed.txt").write_text("ok")
Path("/tmp/allowed.txt").write_text("ok")
return "ok"
spec = datamodel_pb2.SandboxSpec(policy=_policy_for_python_proxy_tests())
with sandbox(spec=spec, delete_on_exit=True) as policy_sandbox:
user_result = policy_sandbox.exec_python(current_user)
assert user_result.exit_code == 0, user_result.stderr
assert user_result.stdout.strip() == "sandbox"
file_result = policy_sandbox.exec_python(write_allowed_files)
assert file_result.exit_code == 0, file_result.stderr
assert file_result.stdout.strip() == "ok"
def test_policy_blocks_unauthorized_proxy_connect(
sandbox: Callable[..., Sandbox],
) -> None:
spec = datamodel_pb2.SandboxSpec(policy=_policy_for_python_proxy_tests())
with sandbox(spec=spec, delete_on_exit=True) as policy_sandbox:
proxy_result = policy_sandbox.exec_python(
_proxy_connect(), args=("example.com", 443)
)
assert proxy_result.exit_code == 0, proxy_result.stderr
assert "403" in proxy_result.stdout
# =============================================================================
# L4 Tests -- Connection-level OPA policy (host:port + binary identity)
# =============================================================================
#
# L4-1: No network policies -> all CONNECT requests denied
# L4-2: Wildcard binary (/**) + specific endpoint -> any binary can connect
# but non-listed endpoints still denied
# L4-3: Binary-restricted policy -> matched binary allowed, others denied
# L4-4: Correct endpoint, wrong port -> denied
# L4-5: Multiple disjoint policies -> cross-policy access denied
# L4-6: Non-CONNECT HTTP method -> rejected with 405
# L4-7: Log fields are structured correctly (action, binary, policy, engine)
# =============================================================================
def test_l4_no_policy_denies_all(
sandbox: Callable[..., Sandbox],
) -> None:
"""L4-1: No matching endpoint in any network policy -> CONNECT denied.
We need at least one network policy so the proxy and network namespace
start (empty network_policies disables networking entirely, including
socket syscalls). The policy allows python->example.com:443 but
api.anthropic.com:443 should still be denied.
"""
policy = _base_policy(
network_policies={
"other": sandbox_pb2.NetworkPolicyRule(
name="other",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="example.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(_proxy_connect(), args=("api.anthropic.com", 443))
assert result.exit_code == 0, result.stderr
assert "403" in result.stdout
def test_l4_wildcard_binary_allows_any_binary(
sandbox: Callable[..., Sandbox],
) -> None:
"""L4-2: Wildcard binary glob allows python (and anything else) to connect."""
policy = _base_policy(
network_policies={
"wildcard": sandbox_pb2.NetworkPolicyRule(
name="wildcard",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# Python can reach the allowed endpoint
result = sb.exec_python(_proxy_connect(), args=("api.anthropic.com", 443))
assert result.exit_code == 0, result.stderr
assert "200" in result.stdout
# Non-listed endpoint is still denied
result = sb.exec_python(_proxy_connect(), args=("example.com", 443))
assert result.exit_code == 0, result.stderr
assert "403" in result.stdout
def test_l4_binary_restricted_denies_wrong_binary(
sandbox: Callable[..., Sandbox],
) -> None:
"""L4-3: Policy restricted to specific binary denies others.
Policy allows /usr/bin/curl -> api.anthropic.com:443.
Python (exec_python uses /app/.venv/bin/python) should be denied.
"""
policy = _base_policy(
network_policies={
"curl_only": sandbox_pb2.NetworkPolicyRule(
name="curl_only",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/usr/bin/curl")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# Python is NOT the allowed binary -> denied
result = sb.exec_python(_proxy_connect(), args=("api.anthropic.com", 443))
assert result.exit_code == 0, result.stderr
assert "403" in result.stdout
def test_l4_wrong_port_denied(
sandbox: Callable[..., Sandbox],
) -> None:
"""L4-4: Correct host but wrong port -> denied."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# Port 443 -> allowed
result = sb.exec_python(_proxy_connect(), args=("api.anthropic.com", 443))
assert result.exit_code == 0, result.stderr
assert "200" in result.stdout
# Port 80 -> denied
result = sb.exec_python(_proxy_connect(), args=("api.anthropic.com", 80))
assert result.exit_code == 0, result.stderr
assert "403" in result.stdout
def test_l4_cross_policy_denied(
sandbox: Callable[..., Sandbox],
) -> None:
"""L4-5: Multiple disjoint policies -> cross-policy access denied.
Policy A: python -> api.anthropic.com:443
Policy B: curl -> example.com:443
Python should NOT reach example.com (that's curl's policy).
"""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/app/.venv/bin/python")],
),
"other": sandbox_pb2.NetworkPolicyRule(
name="other",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="example.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/usr/bin/curl")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# Python -> its own policy endpoint: allowed
result = sb.exec_python(_proxy_connect(), args=("api.anthropic.com", 443))
assert result.exit_code == 0, result.stderr
assert "200" in result.stdout
# Python -> curl's policy endpoint: denied
result = sb.exec_python(_proxy_connect(), args=("example.com", 443))
assert result.exit_code == 0, result.stderr
assert "403" in result.stdout
def test_l4_non_connect_method_rejected(
sandbox: Callable[..., Sandbox],
) -> None:
"""L4-6: Non-CONNECT HTTP method -> rejected with 405."""
def send_get_to_proxy() -> str:
import socket
conn = socket.create_connection(("10.200.0.1", 3128), timeout=10)
try:
conn.sendall(
b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n"
)
return conn.recv(256).decode("latin1")
finally:
conn.close()
policy = _base_policy(
network_policies={
"any": sandbox_pb2.NetworkPolicyRule(
name="any",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="example.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(send_get_to_proxy)
assert result.exit_code == 0, result.stderr
assert "405" in result.stdout
def test_l4_log_fields(
sandbox: Callable[..., Sandbox],
) -> None:
"""L4-7: CONNECT log contains structured fields for allow and deny."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# Generate an allow
sb.exec_python(_proxy_connect(), args=("api.anthropic.com", 443))
# Generate a deny
sb.exec_python(_proxy_connect(), args=("example.com", 443))
log_result = sb.exec_python(_read_navigator_log())
assert log_result.exit_code == 0, log_result.stderr
log = log_result.stdout
# Verify structured fields in allow line
assert "action=allow" in log or 'action="allow"' in log or "action=allow" in log
assert "dst_host=api.anthropic.com" in log or "dst_host" in log
assert "engine=opa" in log or 'engine="opa"' in log
# Verify deny line exists
assert "action=deny" in log or 'action="deny"' in log
# =============================================================================
# SSRF Tests -- Internal IP rejection (defense-in-depth)
#
# The proxy resolves DNS before connecting and rejects any destination that
# resolves to a loopback, RFC1918 private, or link-local address. These
# tests verify the check works even when OPA policy explicitly allows the
# internal endpoint.
#
# SSRF-1: Loopback (127.0.0.1) blocked despite OPA allow
# SSRF-2: Cloud metadata (169.254.169.254) blocked despite OPA allow
# SSRF-3: Log shows "internal address" block reason
# =============================================================================
def test_ssrf_blocks_loopback_despite_policy_allow(
sandbox: Callable[..., Sandbox],
) -> None:
"""SSRF-1: CONNECT to 127.0.0.1 blocked even with explicit OPA allow."""
policy = _base_policy(
network_policies={
"internal": sandbox_pb2.NetworkPolicyRule(
name="internal",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="127.0.0.1", port=80),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(_proxy_connect(), args=("127.0.0.1", 80))
assert result.exit_code == 0, result.stderr
assert "403" in result.stdout
def test_ssrf_blocks_metadata_endpoint_despite_policy_allow(
sandbox: Callable[..., Sandbox],
) -> None:
"""SSRF-2: CONNECT to 169.254.169.254 blocked even with explicit OPA allow."""
policy = _base_policy(
network_policies={
"metadata": sandbox_pb2.NetworkPolicyRule(
name="metadata",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="169.254.169.254", port=80),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(_proxy_connect(), args=("169.254.169.254", 80))
assert result.exit_code == 0, result.stderr
assert "403" in result.stdout
def test_ssrf_log_shows_internal_address_block(
sandbox: Callable[..., Sandbox],
) -> None:
"""SSRF-3: Proxy log includes 'internal address' reason when SSRF check fires."""
policy = _base_policy(
network_policies={
"internal": sandbox_pb2.NetworkPolicyRule(
name="internal",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="127.0.0.1", port=80),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
sb.exec_python(_proxy_connect(), args=("127.0.0.1", 80))
log_result = sb.exec_python(_read_navigator_log())
assert log_result.exit_code == 0, log_result.stderr
log = log_result.stdout
assert "internal address" in log.lower(), (
f"Expected 'internal address' in proxy log, got:\n{log}"
)
# =============================================================================
# L7 Tests -- TLS termination HTTPS inspection (Phase 2: tls=terminate)
#
# These tests use api.anthropic.com:443 as a real HTTPS endpoint since the
# sandbox already has proxy connectivity. The ephemeral CA is trusted via
# SSL_CERT_FILE injected into the sandbox environment.
#
# L7-T1: TLS terminate + access=full allows HTTPS requests through
# L7-T2: TLS terminate + access=read-only denies HTTPS POST (enforce)
# L7-T3: TLS terminate + enforcement=audit logs but allows HTTPS POST
# L7-T4: TLS terminate with explicit path rules
# L7-T5: CA trust store is injected (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS)
# L7-T6: L7 deny response is valid JSON with expected fields
# L7-T7: L7 request logging includes structured fields
# L7-T8: Port 443 + protocol=rest without tls=terminate warns (L7 not evaluated)
# =============================================================================
def test_l7_tls_full_access_allows_all(
sandbox: Callable[..., Sandbox],
) -> None:
"""L7-T1: TLS terminate + access=full allows HTTPS GET through."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="api.anthropic.com",
port=443,
protocol="rest",
tls="terminate",
enforcement="enforce",
access="full",
),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "GET", "/v1/models"),
)
assert result.exit_code == 0, result.stderr
resp = json.loads(result.stdout)
assert "200" in resp["connect_status"]
# Upstream returns a real response (likely 401 without auth, but not 403 from proxy)
assert resp["http_status"] != 0
assert resp["http_status"] != 403 # Not a proxy deny
def test_l7_tls_read_only_denies_post(
sandbox: Callable[..., Sandbox],
) -> None:
"""L7-T2: TLS terminate + access=read-only denies HTTPS POST (enforce)."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="api.anthropic.com",
port=443,
protocol="rest",
tls="terminate",
enforcement="enforce",
access="read-only",
),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# GET should be allowed through (read-only permits GET)
get_result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "GET", "/v1/models"),
)
assert get_result.exit_code == 0, get_result.stderr
get_resp = json.loads(get_result.stdout)
assert get_resp["http_status"] != 403 # Not proxy denied
# POST should be denied by the proxy with 403
post_result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "POST", "/v1/messages"),
)
assert post_result.exit_code == 0, post_result.stderr
post_resp = json.loads(post_result.stdout)
assert post_resp["http_status"] == 403
assert "policy_denied" in post_resp["body"]
def test_l7_tls_audit_mode_allows_but_logs(
sandbox: Callable[..., Sandbox],
) -> None:
"""L7-T3: TLS terminate + enforcement=audit logs but allows HTTPS POST."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="api.anthropic.com",
port=443,
protocol="rest",
tls="terminate",
enforcement="audit",
access="read-only",
),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# POST goes through in audit mode (not denied)
post_result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "POST", "/v1/messages"),
)
assert post_result.exit_code == 0, post_result.stderr
post_resp = json.loads(post_result.stdout)
# Should NOT be 403 from proxy -- traffic is forwarded
assert post_resp["http_status"] != 403
# Log should contain audit decision
log_result = sb.exec_python(_read_navigator_log())
assert log_result.exit_code == 0, log_result.stderr
log = log_result.stdout
assert "l7_decision=audit" in log or 'l7_decision="audit"' in log
def test_l7_tls_explicit_path_rules(
sandbox: Callable[..., Sandbox],
) -> None:
"""L7-T4: TLS terminate with explicit path rules."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="api.anthropic.com",
port=443,
protocol="rest",
tls="terminate",
enforcement="enforce",
rules=[
sandbox_pb2.L7Rule(
allow=sandbox_pb2.L7Allow(method="GET", path="/v1/**"),
),
],
),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
# GET /v1/models -> allowed (matches /v1/**)
get_result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "GET", "/v1/models"),
)
assert get_result.exit_code == 0, get_result.stderr
get_resp = json.loads(get_result.stdout)
assert get_resp["http_status"] != 403
# POST /v1/messages -> denied (no POST rule)
post_result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "POST", "/v1/messages"),
)
assert post_result.exit_code == 0, post_result.stderr
post_resp = json.loads(post_result.stdout)
assert post_resp["http_status"] == 403
# GET /v2/anything -> denied (path doesn't match /v1/**)
v2_result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "GET", "/v2/anything"),
)
assert v2_result.exit_code == 0, v2_result.stderr
v2_resp = json.loads(v2_result.stdout)
assert v2_resp["http_status"] == 403
def test_l7_tls_ca_trust_store_injected(
sandbox: Callable[..., Sandbox],
) -> None:
"""L7-T5: Sandbox CA is injected into trust store environment variables."""
def check_ca_env() -> str:
import json as _json
import os
return _json.dumps(
{
"SSL_CERT_FILE": os.environ.get("SSL_CERT_FILE", ""),
"NODE_EXTRA_CA_CERTS": os.environ.get("NODE_EXTRA_CA_CERTS", ""),
"REQUESTS_CA_BUNDLE": os.environ.get("REQUESTS_CA_BUNDLE", ""),
"CURL_CA_BUNDLE": os.environ.get("CURL_CA_BUNDLE", ""),
"ca_cert_exists": os.path.exists("/etc/navigator-tls/navigator-ca.pem"),
"bundle_exists": os.path.exists("/etc/navigator-tls/ca-bundle.pem"),
}
)
policy = _base_policy(
network_policies={
"any": sandbox_pb2.NetworkPolicyRule(
name="any",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="example.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(check_ca_env)
assert result.exit_code == 0, result.stderr
env = json.loads(result.stdout)
assert env["ca_cert_exists"], "navigator-ca.pem should exist"
assert env["bundle_exists"], "ca-bundle.pem should exist"
assert "navigator-tls" in env["SSL_CERT_FILE"]
assert "navigator-tls" in env["NODE_EXTRA_CA_CERTS"]
def test_l7_tls_deny_response_format(
sandbox: Callable[..., Sandbox],
) -> None:
"""L7-T6: L7 deny response is valid JSON with expected fields."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="api.anthropic.com",
port=443,
protocol="rest",
tls="terminate",
enforcement="enforce",
access="read-only",
),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "DELETE", "/v1/anything"),
)
assert result.exit_code == 0, result.stderr
resp = json.loads(result.stdout)
assert resp["http_status"] == 403
# Verify response headers
assert "X-Navigator-Policy" in resp["headers"]
assert "application/json" in resp["headers"]
# Verify JSON body structure
body = json.loads(resp["body"])
assert body["error"] == "policy_denied"
assert "policy" in body
assert "rule" in body
assert "detail" in body
def test_l7_tls_log_fields(
sandbox: Callable[..., Sandbox],
) -> None:
"""L7-T7: L7 request logging includes structured fields."""
policy = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(
host="api.anthropic.com",
port=443,
protocol="rest",
tls="terminate",
enforcement="enforce",
access="full",
),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy)
with sandbox(spec=spec, delete_on_exit=True) as sb:
sb.exec_python(
_proxy_connect_then_http(),
args=("api.anthropic.com", 443, "GET", "/v1/models"),
)
log_result = sb.exec_python(_read_navigator_log())
assert log_result.exit_code == 0, log_result.stderr
log = log_result.stdout
assert "L7_REQUEST" in log
assert "l7_protocol" in log
assert "l7_action" in log
assert "l7_target" in log
assert "l7_decision" in log
# =============================================================================
# Live policy update + log streaming tests
#
# LPU-1: Create sandbox, verify initial policy is v1
# LPU-2: Set the same policy again -> unchanged (no new version)
# LPU-3: Push a different policy -> new version loaded, verify connectivity
# LPU-4: Push v2 again -> unchanged
# LPU-5: Fetch logs (one-shot + streaming) and verify both sources appear
# =============================================================================
def test_live_policy_update_and_logs(
sandbox: Callable[..., Sandbox],
sandbox_client: "SandboxClient",
) -> None:
"""End-to-end: live policy update lifecycle with log verification."""
from navigator._proto import navigator_pb2, sandbox_pb2
# --- Setup: two distinct policies ---
# Policy A: python can reach api.anthropic.com
policy_a = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
# Policy B: python can reach api.anthropic.com AND example.com
policy_b = _base_policy(
network_policies={
"anthropic": sandbox_pb2.NetworkPolicyRule(
name="anthropic",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="api.anthropic.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
"example": sandbox_pb2.NetworkPolicyRule(
name="example",
endpoints=[
sandbox_pb2.NetworkEndpoint(host="example.com", port=443),
],
binaries=[sandbox_pb2.NetworkBinary(path="/**")],
),
},
)
spec = datamodel_pb2.SandboxSpec(policy=policy_a)
stub = sandbox_client._stub
with sandbox(spec=spec, delete_on_exit=True) as sb:
sandbox_name = sb.sandbox.name
# --- LPU-1: Initial policy should be version 1 ---
status_resp = stub.GetSandboxPolicyStatus(
navigator_pb2.GetSandboxPolicyStatusRequest(name=sandbox_name, version=0)
)
assert status_resp.revision.version >= 1, "Initial policy should be at least v1"
initial_version = status_resp.revision.version
initial_hash = status_resp.revision.policy_hash
# --- LPU-2: Set the same policy -> no new version ---
update_resp = stub.UpdateSandboxPolicy(
navigator_pb2.UpdateSandboxPolicyRequest(
name=sandbox_name,
policy=policy_a,
)
)
assert update_resp.version == initial_version, (
f"Same policy should return existing version {initial_version}, "
f"got {update_resp.version}"
)
assert update_resp.policy_hash == initial_hash
# --- LPU-3: Push policy B -> new version ---
update_resp = stub.UpdateSandboxPolicy(
navigator_pb2.UpdateSandboxPolicyRequest(
name=sandbox_name,
policy=policy_b,
)
)
new_version = update_resp.version
assert new_version > initial_version, (
f"Different policy should create new version > {initial_version}, "
f"got {new_version}"
)
assert update_resp.policy_hash != initial_hash
# Wait for the sandbox to load the new policy (poll loop is 30s default).
import time
deadline = time.time() + 90
loaded = False
while time.time() < deadline:
status_resp = stub.GetSandboxPolicyStatus(
navigator_pb2.GetSandboxPolicyStatusRequest(
name=sandbox_name, version=new_version
)
)
status = status_resp.revision.status
if status == navigator_pb2.POLICY_STATUS_LOADED:
loaded = True
break
if status == navigator_pb2.POLICY_STATUS_FAILED:
pytest.fail(
f"Policy v{new_version} failed to load: "
f"{status_resp.revision.load_error}"
)
time.sleep(2)
assert loaded, f"Policy v{new_version} was not loaded within 90s"
# Verify the new policy works: example.com should now be allowed
result = sb.exec_python(_proxy_connect(), args=("example.com", 443))
assert result.exit_code == 0, result.stderr
assert "200" in result.stdout, (
f"example.com should be allowed after policy update, got: {result.stdout}"
)
# --- LPU-4: Push policy B again -> unchanged ---
update_resp = stub.UpdateSandboxPolicy(
navigator_pb2.UpdateSandboxPolicyRequest(
name=sandbox_name,
policy=policy_b,
)
)
assert update_resp.version == new_version, (
f"Same policy B should return existing version {new_version}, "
f"got {update_resp.version}"
)
# --- LPU-5: Verify policy history ---
list_resp = stub.ListSandboxPolicies(
navigator_pb2.ListSandboxPoliciesRequest(name=sandbox_name, limit=10)
)
versions = [r.version for r in list_resp.revisions]
assert new_version in versions
assert initial_version in versions
# Only one version should be Loaded
loaded_count = sum(
1
for r in list_resp.revisions
if r.status == navigator_pb2.POLICY_STATUS_LOADED
)
assert loaded_count == 1, (
f"Expected exactly 1 loaded version, got {loaded_count}: "
f"{[(r.version, r.status) for r in list_resp.revisions]}"
)
# --- LPU-6: Fetch logs (one-shot) and verify both sources ---
# Resolve sandbox ID for log RPCs
get_resp = stub.GetSandbox(
navigator_pb2.GetSandboxRequest(name=sandbox_name)
)
sandbox_id = get_resp.sandbox.id
logs_resp = stub.GetSandboxLogs(
navigator_pb2.GetSandboxLogsRequest(sandbox_id=sandbox_id, lines=500)
)
assert logs_resp.buffer_total > 0, "Expected some logs in the buffer"
sources = {log.source or "gateway" for log in logs_resp.logs}
assert "gateway" in sources, (
f"Expected gateway logs in response, got sources: {sources}"
)
# Sandbox logs may take a moment to arrive via the push stream.
# If they're present, verify the source tag.
if "sandbox" in sources:
sandbox_logs = [l for l in logs_resp.logs if l.source == "sandbox"]
assert len(sandbox_logs) > 0
# Verify structured fields are present on at least one sandbox log
has_fields = any(len(l.fields) > 0 for l in sandbox_logs)
# Not all sandbox logs have fields (e.g., "Starting sandbox" doesn't),
# so we just check at least one does if there are CONNECT logs
connect_logs = [l for l in sandbox_logs if "CONNECT" in l.message]
if connect_logs:
assert has_fields, "CONNECT logs should have structured fields"