From 2fc671c5e238ed8542d4583691aab2dd5b60c591 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 9 Mar 2026 21:31:41 -0700 Subject: [PATCH 01/10] refactor(sandbox): move secrets to supervisor placeholders --- architecture/sandbox-providers.md | 76 +++++++--- crates/navigator-sandbox/src/l7/relay.rs | 20 ++- crates/navigator-sandbox/src/l7/rest.rs | 41 ++++- crates/navigator-sandbox/src/lib.rs | 6 + crates/navigator-sandbox/src/process.rs | 60 +++++++- crates/navigator-sandbox/src/proxy.rs | 51 +++++-- crates/navigator-sandbox/src/secrets.rs | 172 +++++++++++++++++++++ crates/navigator-sandbox/src/ssh.rs | 184 +++++++++++++---------- e2e/python/test_sandbox_providers.py | 153 +++++++++++++++++-- 9 files changed, 613 insertions(+), 150 deletions(-) create mode 100644 crates/navigator-sandbox/src/secrets.rs diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index f6fdf38b3..5d6ed6499 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -11,10 +11,12 @@ providers centralize that concern: a user configures a provider once, and any sa needs that external service can reference it. At sandbox creation time, providers are validated and associated with the sandbox. The -sandbox supervisor then fetches credentials at runtime and injects them as environment -variables into every child process it spawns. Access is enforced through the sandbox -policy — the policy decides which outbound requests are allowed or denied based on -the providers attached to that sandbox. +sandbox supervisor then fetches credentials at runtime, keeps the real secret values in +supervisor-only memory, and injects placeholder environment variables into every child +process it spawns. When outbound traffic is allowed through the sandbox proxy, the +supervisor rewrites those placeholders back to the real secret values before forwarding. +Access is enforced through the sandbox policy — the policy decides which outbound +requests are allowed or denied based on the providers attached to that sandbox. Core goals: @@ -57,7 +59,8 @@ The gRPC surface is defined in `proto/navigator.proto`: - persistence using `object_type = "provider"`. - `crates/navigator-sandbox` - sandbox supervisor fetches provider credentials via gRPC at startup, - - injects credentials as environment variables into entrypoint and SSH child processes. + - injects placeholder env vars into entrypoint and SSH child processes, + - resolves placeholders back to real secrets in the outbound proxy path. ## Provider Plugins @@ -242,12 +245,18 @@ In `run_sandbox()` (`crates/navigator-sandbox/src/lib.rs`): 2. fetches provider credentials via gRPC (`GetSandboxProviderEnvironment`), 3. if the fetch fails, continues with an empty map (graceful degradation with a warning). -The returned `provider_env` `HashMap` is then threaded to both the -entrypoint process spawner and the SSH server. +The returned `provider_env` `HashMap` is immediately transformed into: + +- a child-visible env map with placeholder values such as + `nemo-placeholder:env:ANTHROPIC_API_KEY`, and +- a supervisor-only in-memory registry mapping each placeholder back to its real secret. + +The placeholder env map is threaded to the entrypoint process spawner and SSH server. +The registry is threaded to the proxy so it can rewrite outbound headers. ### Child Process Environment Variable Injection -Provider credentials are injected into child processes in two places, covering all +Provider placeholders are injected into child processes in two places, covering all process spawning paths inside the sandbox: **1. Entrypoint process** (`crates/navigator-sandbox/src/process.rs`): @@ -257,14 +266,16 @@ let mut cmd = Command::new(program); cmd.args(args) .env("OPENSHELL_SANDBOX", "1"); -// Set provider environment variables (credentials fetched at runtime). +// Set provider environment variables (supervisor-managed placeholders). for (key, value) in provider_env { cmd.env(key, value); } ``` This uses `tokio::process::Command`. The `.env()` call adds each variable to the child's -inherited environment without clearing it. +inherited environment without clearing it. The spawn path also explicitly removes +`NEMOCLAW_SSH_HANDSHAKE_SECRET` so the handshake secret does not leak into the agent +entrypoint process. After provider env vars, proxy env vars (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, etc.) are also set when `NetworkMode` is `Proxy`. The child is then launched with namespace @@ -281,14 +292,30 @@ cmd.env("OPENSHELL_SANDBOX", "1") .env("USER", "sandbox") .env("TERM", term); -// Set provider environment variables (credentials fetched at runtime). +// Set provider environment variables (supervisor-managed placeholders). for (key, value) in provider_env { cmd.env(key, value); } ``` This uses `std::process::Command`. The `SshHandler` holds the `provider_env` map and -passes it to `spawn_pty_shell()` for each new shell or exec request. +passes it to `spawn_pty_shell()` for each new shell or exec request. SSH child processes +start from `env_clear()`, so the handshake secret is not present there. + +### Proxy-Time Secret Resolution + +When a sandboxed tool uses one of these placeholder env vars to populate an outbound HTTP +header (for example `Authorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY`), the +sandbox proxy rewrites the placeholder to the real secret value immediately before the +request is forwarded upstream. + +This applies to: + +- forward-proxy HTTP requests, and +- L7-inspected REST requests inside CONNECT tunnels. + +The real secret value remains in supervisor memory only; it is not re-injected into the +child process environment. ### End-to-End Flow @@ -309,13 +336,17 @@ CLI: openshell sandbox create -- claude K8s: pod starts navigator-sandbox binary +-- OPENSHELL_SANDBOX_ID and OPENSHELL_ENDPOINT set in pod env | - Sandbox supervisor: run_sandbox() - +-- Fetches policy via gRPC - +-- Fetches provider env via gRPC - | +-- Gateway resolves: "claude" -> credentials -> {ANTHROPIC_API_KEY: "sk-..."} - +-- Spawns entrypoint: cmd.env("ANTHROPIC_API_KEY", "sk-...") - +-- SSH server holds provider_env - +-- Each SSH shell: cmd.env("ANTHROPIC_API_KEY", "sk-...") + Sandbox supervisor: run_sandbox() + +-- Fetches policy via gRPC + +-- Fetches provider env via gRPC + | +-- Gateway resolves: "claude" -> credentials -> {ANTHROPIC_API_KEY: "sk-..."} + +-- Builds placeholder registry + | +-- child env: {ANTHROPIC_API_KEY: "nemo-placeholder:env:ANTHROPIC_API_KEY"} + | +-- supervisor registry: {"nemo-placeholder:env:ANTHROPIC_API_KEY": "sk-..."} + +-- Spawns entrypoint with placeholder env + +-- SSH server holds placeholder env + | +-- Each SSH shell: cmd.env("ANTHROPIC_API_KEY", "nemo-placeholder:env:ANTHROPIC_API_KEY") + +-- Proxy rewrites outbound auth header placeholders -> real secrets ``` ## Persistence and Validation @@ -336,6 +367,10 @@ Providers are stored with `object_type = "provider"` in the shared object store. - CLI displays only non-sensitive summaries (counts/key names where relevant). - Credentials are never persisted in the sandbox spec — they exist only in the provider store and are fetched at runtime by the sandbox supervisor. +- Child processes never receive the raw provider secret values; they only receive + placeholders, and the supervisor resolves those placeholders during outbound proxying. +- `NEMOCLAW_SSH_HANDSHAKE_SECRET` is required by the supervisor/SSH server path but is + explicitly kept out of spawned sandbox child-process environments. ## Test Strategy @@ -344,3 +379,6 @@ Providers are stored with `object_type = "provider"` in the shared object store. - Mocked discovery context tests cover env and path-based behavior. - CLI and gateway integration tests validate end-to-end RPC compatibility. - `resolve_provider_environment` unit tests in `crates/navigator-server/src/grpc.rs`. +- sandbox unit tests validate placeholder generation and header rewriting. +- E2E sandbox tests verify placeholders are visible in child env, outbound proxy traffic + is rewritten with the real secret, and the SSH handshake secret is absent from exec env. diff --git a/crates/navigator-sandbox/src/l7/relay.rs b/crates/navigator-sandbox/src/l7/relay.rs index 46ca059c3..b8b923cd6 100644 --- a/crates/navigator-sandbox/src/l7/relay.rs +++ b/crates/navigator-sandbox/src/l7/relay.rs @@ -8,10 +8,10 @@ //! and either forwards or denies the request. use crate::l7::provider::L7Provider; -use crate::l7::rest::RestProvider; use crate::l7::{EnforcementMode, L7EndpointConfig, L7Protocol, L7RequestInfo}; +use crate::secrets::SecretResolver; use miette::{IntoDiagnostic, Result, miette}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use tokio::io::{AsyncRead, AsyncWrite}; use tracing::{debug, info, warn}; @@ -29,6 +29,8 @@ pub struct L7EvalContext { pub ancestors: Vec, /// Cmdline paths. pub cmdline_paths: Vec, + /// Supervisor-only placeholder resolver for outbound headers. + pub(crate) secret_resolver: Option>, } /// Run protocol-aware L7 inspection on a tunnel. @@ -78,11 +80,9 @@ where C: AsyncRead + AsyncWrite + Unpin + Send, U: AsyncRead + AsyncWrite + Unpin + Send, { - let provider = RestProvider; - loop { // Parse one HTTP request from client - let req = match provider.parse_request(client).await { + let req = match crate::l7::rest::RestProvider.parse_request(client).await { Ok(Some(req)) => req, Ok(None) => return Ok(()), // Client closed connection Err(e) => { @@ -134,7 +134,13 @@ where if allowed || config.enforcement == EnforcementMode::Audit { // Forward request to upstream and relay response - let reusable = provider.relay(&req, client, upstream).await?; + let reusable = crate::l7::rest::relay_http_request_with_resolver( + &req, + client, + upstream, + ctx.secret_resolver.as_deref(), + ) + .await?; if !reusable { debug!( host = %ctx.host, @@ -145,7 +151,7 @@ where } } else { // Enforce mode: deny with 403 and close connection - provider + crate::l7::rest::RestProvider .deny(&req, &ctx.policy_name, &reason, client) .await?; return Ok(()); diff --git a/crates/navigator-sandbox/src/l7/rest.rs b/crates/navigator-sandbox/src/l7/rest.rs index b609c708f..9e572e979 100644 --- a/crates/navigator-sandbox/src/l7/rest.rs +++ b/crates/navigator-sandbox/src/l7/rest.rs @@ -8,6 +8,7 @@ //! and chunked transfer encoding for body framing. use crate::l7::provider::{BodyLength, L7Provider, L7Request}; +use crate::secrets::rewrite_http_header_block; use miette::{IntoDiagnostic, Result, miette}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; @@ -121,27 +122,38 @@ where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { - // Find the actual header end in raw_header + relay_http_request_with_resolver(req, client, upstream, None).await +} + +pub(crate) async fn relay_http_request_with_resolver( + req: &L7Request, + client: &mut C, + upstream: &mut U, + resolver: Option<&crate::secrets::SecretResolver>, +) -> Result +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ let header_end = req .raw_header .windows(4) .position(|w| w == b"\r\n\r\n") .map_or(req.raw_header.len(), |p| p + 4); - // Forward request headers to upstream + let rewritten_header = rewrite_http_header_block(&req.raw_header[..header_end], resolver); + upstream - .write_all(&req.raw_header[..header_end]) + .write_all(&rewritten_header) .await .into_diagnostic()?; - // Forward any overflow body bytes that were read with headers let overflow = &req.raw_header[header_end..]; if !overflow.is_empty() { upstream.write_all(overflow).await.into_diagnostic()?; } let overflow_len = overflow.len() as u64; - // Forward remaining request body match req.body_length { BodyLength::ContentLength(len) => { let remaining = len.saturating_sub(overflow_len); @@ -155,8 +167,6 @@ where BodyLength::None => {} } upstream.flush().await.into_diagnostic()?; - - // Read and forward response from upstream back to client relay_response(&req.action, upstream, client).await } @@ -580,6 +590,7 @@ fn is_benign_close(err: &std::io::Error) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::secrets::SecretResolver; #[test] fn parse_content_length() { @@ -930,4 +941,20 @@ mod tests { client_read.read_to_end(&mut received).await.unwrap(); assert!(String::from_utf8_lossy(&received).contains("hello")); } + + #[test] + fn rewrite_header_block_resolves_placeholder_auth_headers() { + let (_, resolver) = SecretResolver::from_provider_env( + [("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())] + .into_iter() + .collect(), + ); + let raw = b"GET /v1/messages HTTP/1.1\r\nAuthorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY\r\nHost: example.com\r\n\r\n"; + + let rewritten = rewrite_http_header_block(raw, resolver.as_ref()); + let rewritten = String::from_utf8(rewritten).expect("utf8"); + + assert!(rewritten.contains("Authorization: Bearer sk-test\r\n")); + assert!(!rewritten.contains("nemo-placeholder:env:ANTHROPIC_API_KEY")); + } } diff --git a/crates/navigator-sandbox/src/lib.rs b/crates/navigator-sandbox/src/lib.rs index 3094e01ef..e89a40ba9 100644 --- a/crates/navigator-sandbox/src/lib.rs +++ b/crates/navigator-sandbox/src/lib.rs @@ -15,6 +15,7 @@ mod process; pub mod procfs; pub mod proxy; mod sandbox; +mod secrets; mod ssh; use miette::{IntoDiagnostic, Result}; @@ -38,6 +39,7 @@ use crate::policy::{NetworkMode, NetworkPolicy, ProxyPolicy, SandboxPolicy}; use crate::proxy::ProxyHandle; #[cfg(target_os = "linux")] use crate::sandbox::linux::netns::NetworkNamespace; +use crate::secrets::SecretResolver; pub use process::{ProcessHandle, ProcessStatus}; /// Default interval (seconds) for re-fetching the inference route bundle from @@ -195,6 +197,9 @@ pub async fn run_sandbox( std::collections::HashMap::new() }; + let (provider_env, secret_resolver) = SecretResolver::from_provider_env(provider_env); + let secret_resolver = secret_resolver.map(Arc::new); + // Create identity cache for SHA256 TOFU when OPA is active let identity_cache = opa_engine .as_ref() @@ -311,6 +316,7 @@ pub async fn run_sandbox( entrypoint_pid.clone(), tls_state, inference_ctx, + secret_resolver.clone(), ) .await?, ) diff --git a/crates/navigator-sandbox/src/process.rs b/crates/navigator-sandbox/src/process.rs index 2f841c2c7..211bc1d51 100644 --- a/crates/navigator-sandbox/src/process.rs +++ b/crates/navigator-sandbox/src/process.rs @@ -21,6 +21,18 @@ use std::process::Stdio; use tokio::process::{Child, Command}; use tracing::{debug, warn}; +const SSH_HANDSHAKE_SECRET_ENV: &str = "NEMOCLAW_SSH_HANDSHAKE_SECRET"; + +fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { + for (key, value) in provider_env { + cmd.env(key, value); + } +} + +fn scrub_sensitive_env(cmd: &mut Command) { + cmd.env_remove(SSH_HANDSHAKE_SECRET_ENV); +} + /// Handle to a running process. pub struct ProcessHandle { child: Child, @@ -103,10 +115,8 @@ impl ProcessHandle { .kill_on_drop(true) .env("OPENSHELL_SANDBOX", "1"); - // Set provider environment variables (credentials fetched at runtime). - for (key, value) in provider_env { - cmd.env(key, value); - } + scrub_sensitive_env(&mut cmd); + inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workdir { cmd.current_dir(dir); @@ -215,10 +225,8 @@ impl ProcessHandle { .kill_on_drop(true) .env("OPENSHELL_SANDBOX", "1"); - // Set provider environment variables (credentials fetched at runtime). - for (key, value) in provider_env { - cmd.env(key, value); - } + scrub_sensitive_env(&mut cmd); + inject_provider_env(&mut cmd, provider_env); if let Some(dir) = workdir { cmd.current_dir(dir); @@ -502,6 +510,7 @@ mod tests { use crate::policy::{ FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, }; + use std::process::Stdio as StdStdio; /// Helper to create a minimal `SandboxPolicy` with the given process policy. fn policy_with_process(process: ProcessPolicy) -> SandboxPolicy { @@ -583,4 +592,39 @@ mod tests { "expected 'not found' in error: {msg}" ); } + + #[tokio::test] + async fn scrub_sensitive_env_removes_ssh_handshake_secret() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.stdin(StdStdio::null()) + .stdout(StdStdio::piped()) + .stderr(StdStdio::null()) + .env(SSH_HANDSHAKE_SECRET_ENV, "super-secret"); + + scrub_sensitive_env(&mut cmd); + + let output = cmd.output().await.expect("spawn env"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!(!stdout.contains(SSH_HANDSHAKE_SECRET_ENV)); + } + + #[tokio::test] + async fn inject_provider_env_sets_placeholder_values() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.stdin(StdStdio::null()) + .stdout(StdStdio::piped()) + .stderr(StdStdio::null()); + + let provider_env = std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "nemo-placeholder:env:ANTHROPIC_API_KEY".to_string(), + )) + .collect(); + + inject_provider_env(&mut cmd, &provider_env); + + let output = cmd.output().await.expect("spawn env"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!(stdout.contains("ANTHROPIC_API_KEY=nemo-placeholder:env:ANTHROPIC_API_KEY")); + } } diff --git a/crates/navigator-sandbox/src/proxy.rs b/crates/navigator-sandbox/src/proxy.rs index 6f2761384..c57f90752 100644 --- a/crates/navigator-sandbox/src/proxy.rs +++ b/crates/navigator-sandbox/src/proxy.rs @@ -7,6 +7,7 @@ use crate::identity::BinaryIdentityCache; use crate::l7::tls::ProxyTlsState; use crate::opa::{NetworkAction, OpaEngine}; use crate::policy::ProxyPolicy; +use crate::secrets::{SecretResolver, rewrite_header_line}; use miette::{IntoDiagnostic, Result}; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -128,6 +129,7 @@ impl ProxyHandle { entrypoint_pid: Arc, tls_state: Option>, inference_ctx: Option>, + secret_resolver: Option>, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no @@ -156,9 +158,11 @@ impl ProxyHandle { let spid = entrypoint_pid.clone(); let tls = tls_state.clone(); let inf = inference_ctx.clone(); + let resolver = secret_resolver.clone(); tokio::spawn(async move { if let Err(err) = - handle_tcp_connection(stream, opa, cache, spid, tls, inf).await + handle_tcp_connection(stream, opa, cache, spid, tls, inf, resolver) + .await { warn!(error = %err, "Proxy connection error"); } @@ -197,6 +201,7 @@ async fn handle_tcp_connection( entrypoint_pid: Arc, tls_state: Option>, inference_ctx: Option>, + secret_resolver: Option>, ) -> Result<()> { let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; @@ -239,6 +244,7 @@ async fn handle_tcp_connection( opa_engine, identity_cache, entrypoint_pid, + secret_resolver, ) .await; } @@ -418,6 +424,7 @@ async fn handle_tcp_connection( .iter() .map(|p| p.to_string_lossy().into_owned()) .collect(), + secret_resolver: secret_resolver.clone(), }; if l7_config.tls == crate::l7::TlsMode::Terminate { @@ -1302,7 +1309,12 @@ fn parse_proxy_uri(uri: &str) -> Result<(String, String, u16, String)> { /// strips proxy hop-by-hop headers, injects `Connection: close` and `Via`. /// /// Returns the rewritten request bytes (headers + any overflow body bytes). -fn rewrite_forward_request(raw: &[u8], used: usize, path: &str) -> Vec { +fn rewrite_forward_request( + raw: &[u8], + used: usize, + path: &str, + secret_resolver: Option<&SecretResolver>, +) -> Vec { let header_end = raw[..used] .windows(4) .position(|w| w == b"\r\n\r\n") @@ -1354,8 +1366,12 @@ fn rewrite_forward_request(raw: &[u8], used: usize, path: &str) -> Vec { continue; } - // Pass through other headers - output.extend_from_slice(line.as_bytes()); + let rewritten_line = match secret_resolver { + Some(resolver) => rewrite_header_line(line, resolver), + None => line.to_string(), + }; + + output.extend_from_slice(rewritten_line.as_bytes()); output.extend_from_slice(b"\r\n"); if lower.starts_with("via:") { @@ -1396,6 +1412,7 @@ async fn handle_forward_proxy( opa_engine: Arc, identity_cache: Arc, entrypoint_pid: Arc, + secret_resolver: Option>, ) -> Result<()> { // 1. Parse the absolute-form URI let (scheme, host, port, path) = match parse_proxy_uri(target_uri) { @@ -1605,7 +1622,7 @@ async fn handle_forward_proxy( ); // 9. Rewrite request and forward to upstream - let rewritten = rewrite_forward_request(buf, used, &path); + let rewritten = rewrite_forward_request(buf, used, &path, secret_resolver.as_deref()); upstream.write_all(&rewritten).await.into_diagnostic()?; // 10. Relay remaining traffic bidirectionally (supports streaming) @@ -2224,7 +2241,7 @@ mod tests { fn test_rewrite_get_request() { let raw = b"GET http://10.0.0.1:8000/api HTTP/1.1\r\nHost: 10.0.0.1:8000\r\nAccept: */*\r\n\r\n"; - let result = rewrite_forward_request(raw, raw.len(), "/api"); + let result = rewrite_forward_request(raw, raw.len(), "/api", None); let result_str = String::from_utf8_lossy(&result); assert!(result_str.starts_with("GET /api HTTP/1.1\r\n")); assert!(result_str.contains("Host: 10.0.0.1:8000")); @@ -2235,7 +2252,7 @@ mod tests { #[test] fn test_rewrite_strips_proxy_headers() { let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nProxy-Authorization: Basic abc\r\nProxy-Connection: keep-alive\r\nAccept: */*\r\n\r\n"; - let result = rewrite_forward_request(raw, raw.len(), "/p"); + let result = rewrite_forward_request(raw, raw.len(), "/p", None); let result_str = String::from_utf8_lossy(&result); assert!( !result_str @@ -2249,7 +2266,7 @@ mod tests { #[test] fn test_rewrite_replaces_connection_header() { let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nConnection: keep-alive\r\n\r\n"; - let result = rewrite_forward_request(raw, raw.len(), "/p"); + let result = rewrite_forward_request(raw, raw.len(), "/p", None); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("Connection: close")); assert!(!result_str.contains("keep-alive")); @@ -2258,7 +2275,7 @@ mod tests { #[test] fn test_rewrite_preserves_body_overflow() { let raw = b"POST http://host/api HTTP/1.1\r\nHost: host\r\nContent-Length: 13\r\n\r\n{\"key\":\"val\"}"; - let result = rewrite_forward_request(raw, raw.len(), "/api"); + let result = rewrite_forward_request(raw, raw.len(), "/api", None); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("{\"key\":\"val\"}")); assert!(result_str.contains("POST /api HTTP/1.1")); @@ -2267,10 +2284,24 @@ mod tests { #[test] fn test_rewrite_preserves_existing_via() { let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nVia: 1.0 upstream\r\n\r\n"; - let result = rewrite_forward_request(raw, raw.len(), "/p"); + let result = rewrite_forward_request(raw, raw.len(), "/p", None); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("Via: 1.0 upstream")); // Should not add a second Via header assert!(!result_str.contains("Via: 1.1 navigator-sandbox")); } + + #[test] + fn test_rewrite_resolves_placeholder_auth_headers() { + let (_, resolver) = SecretResolver::from_provider_env( + [("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())] + .into_iter() + .collect(), + ); + let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nAuthorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY\r\n\r\n"; + let result = rewrite_forward_request(raw, raw.len(), "/p", resolver.as_ref()); + let result_str = String::from_utf8_lossy(&result); + assert!(result_str.contains("Authorization: Bearer sk-test")); + assert!(!result_str.contains("nemo-placeholder:env:ANTHROPIC_API_KEY")); + } } diff --git a/crates/navigator-sandbox/src/secrets.rs b/crates/navigator-sandbox/src/secrets.rs new file mode 100644 index 000000000..0e1cca7ae --- /dev/null +++ b/crates/navigator-sandbox/src/secrets.rs @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; + +const PLACEHOLDER_PREFIX: &str = "nemo-placeholder:env:"; + +#[derive(Debug, Clone, Default)] +pub(crate) struct SecretResolver { + by_placeholder: HashMap, +} + +impl SecretResolver { + pub(crate) fn from_provider_env( + provider_env: HashMap, + ) -> (HashMap, Option) { + if provider_env.is_empty() { + return (HashMap::new(), None); + } + + let mut child_env = HashMap::with_capacity(provider_env.len()); + let mut by_placeholder = HashMap::with_capacity(provider_env.len()); + + for (key, value) in provider_env { + let placeholder = placeholder_for_env_key(&key); + child_env.insert(key, placeholder.clone()); + by_placeholder.insert(placeholder, value); + } + + (child_env, Some(Self { by_placeholder })) + } + + pub(crate) fn resolve_placeholder(&self, value: &str) -> Option<&str> { + self.by_placeholder.get(value).map(String::as_str) + } + + pub(crate) fn rewrite_header_value(&self, value: &str) -> Option { + if let Some(secret) = self.resolve_placeholder(value.trim()) { + return Some(secret.to_string()); + } + + let trimmed = value.trim(); + let split_at = trimmed.find(char::is_whitespace)?; + let prefix = &trimmed[..split_at]; + let candidate = trimmed[split_at..].trim(); + let secret = self.resolve_placeholder(candidate)?; + Some(format!("{prefix} {secret}")) + } +} + +pub(crate) fn placeholder_for_env_key(key: &str) -> String { + format!("{PLACEHOLDER_PREFIX}{key}") +} + +pub(crate) fn rewrite_http_header_block(raw: &[u8], resolver: Option<&SecretResolver>) -> Vec { + let Some(resolver) = resolver else { + return raw.to_vec(); + }; + + let Some(header_end) = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) else { + return raw.to_vec(); + }; + + let header_str = String::from_utf8_lossy(&raw[..header_end]); + let mut lines = header_str.split("\r\n"); + let Some(request_line) = lines.next() else { + return raw.to_vec(); + }; + + let mut output = Vec::with_capacity(raw.len()); + output.extend_from_slice(request_line.as_bytes()); + output.extend_from_slice(b"\r\n"); + + for line in lines { + if line.is_empty() { + break; + } + + output.extend_from_slice(rewrite_header_line(line, resolver).as_bytes()); + output.extend_from_slice(b"\r\n"); + } + + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(&raw[header_end..]); + output +} + +pub(crate) fn rewrite_header_line(line: &str, resolver: &SecretResolver) -> String { + let Some((name, value)) = line.split_once(':') else { + return line.to_string(); + }; + + match resolver.rewrite_header_value(value.trim()) { + Some(rewritten) => format!("{name}: {rewritten}"), + None => line.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_env_is_replaced_with_placeholders() { + let (child_env, resolver) = SecretResolver::from_provider_env( + [("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())] + .into_iter() + .collect(), + ); + + assert_eq!( + child_env.get("ANTHROPIC_API_KEY"), + Some(&"nemo-placeholder:env:ANTHROPIC_API_KEY".to_string()) + ); + assert_eq!( + resolver + .as_ref() + .and_then(|resolver| resolver + .resolve_placeholder("nemo-placeholder:env:ANTHROPIC_API_KEY")), + Some("sk-test") + ); + } + + #[test] + fn rewrites_exact_placeholder_header_values() { + let (_, resolver) = SecretResolver::from_provider_env( + [("CUSTOM_TOKEN".to_string(), "secret-token".to_string())] + .into_iter() + .collect(), + ); + let resolver = resolver.expect("resolver"); + + assert_eq!( + rewrite_header_line("x-api-key: nemo-placeholder:env:CUSTOM_TOKEN", &resolver), + "x-api-key: secret-token" + ); + } + + #[test] + fn rewrites_bearer_placeholder_header_values() { + let (_, resolver) = SecretResolver::from_provider_env( + [("ANTHROPIC_API_KEY".to_string(), "sk-test".to_string())] + .into_iter() + .collect(), + ); + let resolver = resolver.expect("resolver"); + + assert_eq!( + rewrite_header_line( + "Authorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY", + &resolver, + ), + "Authorization: Bearer sk-test" + ); + } + + #[test] + fn rewrites_http_header_blocks_and_preserves_body() { + let (_, resolver) = SecretResolver::from_provider_env( + [("CUSTOM_TOKEN".to_string(), "secret-token".to_string())] + .into_iter() + .collect(), + ); + + let raw = b"POST /v1 HTTP/1.1\r\nAuthorization: Bearer nemo-placeholder:env:CUSTOM_TOKEN\r\nContent-Length: 5\r\n\r\nhello"; + let rewritten = rewrite_http_header_block(raw, resolver.as_ref()); + let rewritten = String::from_utf8(rewritten).expect("utf8"); + + assert!(rewritten.contains("Authorization: Bearer secret-token\r\n")); + assert!(rewritten.ends_with("\r\n\r\nhello")); + } +} diff --git a/crates/navigator-sandbox/src/ssh.rs b/crates/navigator-sandbox/src/ssh.rs index 867bd090b..7d7655a15 100644 --- a/crates/navigator-sandbox/src/ssh.rs +++ b/crates/navigator-sandbox/src/ssh.rs @@ -20,7 +20,7 @@ use std::io::{Read, Write}; use std::net::SocketAddr; use std::os::fd::{AsRawFd, RawFd}; use std::path::PathBuf; -use std::process::Command; +use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex, mpsc}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -28,6 +28,8 @@ use tokio::net::TcpListener; use tracing::{info, warn}; const PREFACE_MAGIC: &str = "NSSH1"; +#[cfg(test)] +const SSH_HANDSHAKE_SECRET_ENV: &str = "NEMOCLAW_SSH_HANDSHAKE_SECRET"; /// A time-bounded set of nonces used to detect replayed NSSH1 handshakes. /// Each entry records the `Instant` it was inserted; a background reaper task @@ -646,6 +648,46 @@ fn session_user_and_home(policy: &SandboxPolicy) -> (String, String) { } } +fn apply_child_env( + cmd: &mut Command, + session_home: &str, + session_user: &str, + term: &str, + proxy_url: Option<&str>, + ca_file_paths: Option<&(PathBuf, PathBuf)>, + provider_env: &HashMap, +) { + let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); + + cmd.env_clear() + .env("OPENSHELL_SANDBOX", "1") + .env("HOME", session_home) + .env("USER", session_user) + .env("SHELL", "/bin/bash") + .env("PATH", &path) + .env("TERM", term); + + if let Some(url) = proxy_url { + cmd.env("HTTP_PROXY", url) + .env("HTTPS_PROXY", url) + .env("ALL_PROXY", url) + .env("http_proxy", url) + .env("https_proxy", url) + .env("grpc_proxy", url); + } + + if let Some((ca_cert_path, combined_bundle_path)) = ca_file_paths { + cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path) + .env("SSL_CERT_FILE", combined_bundle_path) + .env("REQUESTS_CA_BUNDLE", combined_bundle_path) + .env("CURL_CA_BUNDLE", combined_bundle_path); + } + + for (key, value) in provider_env { + cmd.env(key, value); + } +} + #[allow(clippy::too_many_arguments)] fn spawn_pty_shell( policy: &SandboxPolicy, @@ -695,53 +737,19 @@ fn spawn_pty_shell( pty.term.as_str() }; - // Inherit PATH from the container (set via Dockerfile ENV) so that - // sandbox sessions see the same tool layout without hardcoding paths. - // Tool-specific env vars (VIRTUAL_ENV, UV_PYTHON_INSTALL_DIR, etc.) are - // set in /sandbox/.bashrc by the Dockerfile and sourced via login shell. - let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); - // Derive USER and HOME from the policy's run_as_user when available, // falling back to "sandbox" / "/sandbox" for backward compatibility. let (session_user, session_home) = session_user_and_home(policy); - - cmd.env_clear() - .stdin(stdin) - .stdout(stdout) - .stderr(stderr) - .env("OPENSHELL_SANDBOX", "1") - .env("HOME", &session_home) - .env("USER", &session_user) - .env("SHELL", "/bin/bash") - .env("PATH", &path) - .env("TERM", term); - - // Set proxy environment variables so cooperative tools (curl, wget, etc.) - // route traffic through the CONNECT proxy for OPA policy evaluation. - // Both uppercase and lowercase variants are needed: curl/wget use uppercase, - // gRPC C-core (libgrpc) checks lowercase http_proxy/https_proxy first. - if let Some(ref url) = proxy_url { - cmd.env("HTTP_PROXY", url) - .env("HTTPS_PROXY", url) - .env("ALL_PROXY", url) - .env("http_proxy", url) - .env("https_proxy", url) - .env("grpc_proxy", url); - } - - // Set TLS trust store env vars so SSH shell processes trust the ephemeral CA - if let Some(ref paths) = ca_file_paths { - let (ca_cert_path, combined_bundle_path) = paths.as_ref(); - cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path) - .env("SSL_CERT_FILE", combined_bundle_path) - .env("REQUESTS_CA_BUNDLE", combined_bundle_path) - .env("CURL_CA_BUNDLE", combined_bundle_path); - } - - // Set provider environment variables (credentials fetched at runtime). - for (key, value) in provider_env { - cmd.env(key, value); - } + apply_child_env( + &mut cmd, + &session_home, + &session_user, + term, + proxy_url.as_deref(), + ca_file_paths.as_deref(), + provider_env, + ); + cmd.stdin(stdin).stdout(stdout).stderr(stderr); if let Some(dir) = workdir.as_deref() { cmd.current_dir(dir); @@ -865,41 +873,19 @@ fn spawn_pipe_exec( }, ); - let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()); - let (session_user, session_home) = session_user_and_home(policy); - - cmd.env_clear() - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .env("OPENSHELL_SANDBOX", "1") - .env("HOME", &session_home) - .env("USER", &session_user) - .env("SHELL", "/bin/bash") - .env("PATH", &path) - .env("TERM", "dumb"); - - if let Some(ref url) = proxy_url { - cmd.env("HTTP_PROXY", url) - .env("HTTPS_PROXY", url) - .env("ALL_PROXY", url) - .env("http_proxy", url) - .env("https_proxy", url) - .env("grpc_proxy", url); - } - - if let Some(ref paths) = ca_file_paths { - let (ca_cert_path, combined_bundle_path) = paths.as_ref(); - cmd.env("NODE_EXTRA_CA_CERTS", ca_cert_path) - .env("SSL_CERT_FILE", combined_bundle_path) - .env("REQUESTS_CA_BUNDLE", combined_bundle_path) - .env("CURL_CA_BUNDLE", combined_bundle_path); - } - - for (key, value) in provider_env { - cmd.env(key, value); - } + apply_child_env( + &mut cmd, + &session_home, + &session_user, + "dumb", + proxy_url.as_deref(), + ca_file_paths.as_deref(), + provider_env, + ); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); if let Some(dir) = workdir.as_deref() { cmd.current_dir(dir); @@ -1094,6 +1080,7 @@ fn to_u16(value: u32) -> u16 { #[cfg(test)] mod tests { use super::*; + use std::process::Stdio; /// Verify that dropping the input sender (the operation `channel_eof` /// performs) causes the stdin writer loop to exit and close the child's @@ -1104,8 +1091,8 @@ mod tests { let (sender, receiver) = mpsc::channel::>(); let mut child = Command::new("cat") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) .spawn() .expect("failed to spawn cat"); @@ -1155,8 +1142,8 @@ mod tests { let mut child = Command::new("wc") .arg("-c") - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) .spawn() .expect("failed to spawn wc"); @@ -1296,4 +1283,35 @@ mod tests { assert!(verify_preface(&line1, secret, 300, &cache).unwrap()); assert!(verify_preface(&line2, secret, 300, &cache).unwrap()); } + + #[test] + fn apply_child_env_keeps_handshake_secret_out_of_ssh_children() { + let mut cmd = Command::new("/usr/bin/env"); + cmd.env(SSH_HANDSHAKE_SECRET_ENV, "should-not-leak") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + + let provider_env = std::iter::once(( + "ANTHROPIC_API_KEY".to_string(), + "nemo-placeholder:env:ANTHROPIC_API_KEY".to_string(), + )) + .collect(); + + apply_child_env( + &mut cmd, + "/sandbox", + "sandbox", + "dumb", + None, + None, + &provider_env, + ); + + let output = cmd.output().expect("spawn env"); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + + assert!(!stdout.contains(SSH_HANDSHAKE_SECRET_ENV)); + assert!(stdout.contains("ANTHROPIC_API_KEY=nemo-placeholder:env:ANTHROPIC_API_KEY")); + } } diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index e52010fef..424ab978f 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -1,12 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""E2e tests for provider credential injection into sandboxes. +"""E2E tests for supervisor-managed provider placeholders in sandboxes. Provider credentials are fetched at runtime by the sandbox supervisor via the -GetSandboxProviderEnvironment gRPC call. They should appear as environment -variables inside the sandbox process, but must NOT be present in the persisted -sandbox spec's environment map (they are never baked into the K8s pod spec). +GetSandboxProviderEnvironment gRPC call. Sandboxed child processes should see +placeholder values, while the supervisor proxy resolves those placeholders back +to the real credentials on outbound requests. Credentials must still never be +present in the persisted sandbox spec environment map. """ from __future__ import annotations @@ -77,18 +78,96 @@ def _delete_provider(stub: object, name: str) -> None: raise -# TODO: Once the sandbox supervisor sets provider credentials (rather than the -# server injecting them via exec environment), these tests will no longer be -# able to read credential values directly. Update the assertions to verify -# that the env vars are *present* (e.g. non-empty) without checking exact -# values, since the supervisor will be the sole source of truth. +_SANDBOX_IP = "10.200.0.2" +_FORWARD_PROXY_PORT = 19879 + + +def _proxy_policy() -> sandbox_pb2.SandboxPolicy: + return sandbox_pb2.SandboxPolicy( + version=1, + filesystem=sandbox_pb2.FilesystemPolicy( + include_workdir=True, + read_only=["/usr", "/lib", "/etc", "/app"], + read_write=["/sandbox", "/tmp"], + ), + landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"), + process=sandbox_pb2.ProcessPolicy( + run_as_user="sandbox", run_as_group="sandbox" + ), + network_policies={ + "internal_http": sandbox_pb2.NetworkPolicyRule( + name="internal_http", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host=_SANDBOX_IP, + port=_FORWARD_PROXY_PORT, + allowed_ips=["10.200.0.0/24"], + ) + ], + binaries=[sandbox_pb2.NetworkBinary(path="/**")], + ) + }, + ) + + +def _forward_proxy_reads_env_and_returns_auth_header(): + def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + import os + import socket + import threading + import time + from http.server import BaseHTTPRequestHandler, HTTPServer + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + auth = self.headers.get("Authorization", "MISSING") + body = auth.encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + server = HTTPServer(("0.0.0.0", int(target_port)), Handler) + thread = threading.Thread(target=server.handle_request, daemon=True) + thread.start() + time.sleep(0.5) + + token = os.environ["ANTHROPIC_API_KEY"] + conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) + try: + request = ( + f"GET http://{target_host}:{target_port}/ HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + f"Authorization: Bearer {token}\r\n" + "Connection: close\r\n\r\n" + ) + conn.sendall(request.encode()) + data = b"" + conn.settimeout(5) + while True: + try: + chunk = conn.recv(4096) + except socket.timeout: + break + if not chunk: + break + data += chunk + return data.decode("latin1") + finally: + conn.close() + server.server_close() + + return fn def test_provider_credentials_available_as_env_vars( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """Sandbox process can read provider credentials as environment variables.""" + """Sandbox child processes see provider env vars as placeholders.""" with provider( sandbox_client._stub, name="e2e-test-provider-env", @@ -108,14 +187,16 @@ def read_env_var() -> str: with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python(read_env_var) assert result.exit_code == 0, result.stderr - assert result.stdout.strip() == "sk-e2e-test-key-12345" + value = result.stdout.strip() + assert value == "nemo-placeholder:env:ANTHROPIC_API_KEY" + assert value != "sk-e2e-test-key-12345" def test_generic_provider_credentials_available_as_env_vars( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """Generic provider credentials are injected as arbitrary sandbox env vars.""" + """Generic provider env vars are placeholders, not raw secrets.""" with provider( sandbox_client._stub, name="e2e-test-generic-provider-env", @@ -142,7 +223,7 @@ def read_generic_env_vars() -> str: assert result.exit_code == 0, result.stderr assert ( result.stdout.strip() - == "token-generic-123|https://internal.example.test/api" + == "nemo-placeholder:env:CUSTOM_SERVICE_TOKEN|nemo-placeholder:env:CUSTOM_SERVICE_URL" ) @@ -150,7 +231,7 @@ def test_nvidia_provider_injects_nvidia_api_key_env_var( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """NVIDIA provider projects NVIDIA_API_KEY into sandbox process env.""" + """NVIDIA provider projects a placeholder env value into child processes.""" with provider( sandbox_client._stub, name="e2e-test-nvidia-provider-env", @@ -170,7 +251,47 @@ def read_nvidia_key() -> str: with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python(read_nvidia_key) assert result.exit_code == 0, result.stderr - assert result.stdout.strip() == "nvapi-e2e-test-key" + assert result.stdout.strip() == "nemo-placeholder:env:NVIDIA_API_KEY" + + +def test_provider_placeholder_is_resolved_by_proxy_on_outbound_request( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + with provider( + sandbox_client._stub, + name="e2e-test-provider-proxy-rewrite", + provider_type="claude", + credentials={"ANTHROPIC_API_KEY": "sk-proxy-rewrite-12345"}, + ) as provider_name: + spec = datamodel_pb2.SandboxSpec( + policy=_proxy_policy(), + providers=[provider_name], + ) + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python( + _forward_proxy_reads_env_and_returns_auth_header(), + args=("10.200.0.1", 3128, _SANDBOX_IP, _FORWARD_PROXY_PORT), + ) + assert result.exit_code == 0, result.stderr + assert "200 OK" in result.stdout + assert "Bearer sk-proxy-rewrite-12345" in result.stdout + assert "nemo-placeholder:env:ANTHROPIC_API_KEY" not in result.stdout + + +def test_ssh_handshake_secret_not_visible_in_exec_environment( + sandbox: Callable[..., Sandbox], +) -> None: + def read_handshake_secret() -> str: + import os + + return os.environ.get("NEMOCLAW_SSH_HANDSHAKE_SECRET", "NOT_SET") + + with sandbox(delete_on_exit=True) as sb: + result = sb.exec_python(read_handshake_secret) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "NOT_SET" def test_create_sandbox_rejects_unknown_provider( @@ -185,7 +306,7 @@ def test_create_sandbox_rejects_unknown_provider( sandbox_client.create(spec=spec) assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION - assert "nonexistent-provider-xyz" in exc_info.value.details() + assert "nonexistent-provider-xyz" in (exc_info.value.details() or "") def test_credentials_not_in_persisted_spec_environment( From 5d8f4f1e2080ce27f062e9a734ae4f5b2bd4c9dc Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Tue, 10 Mar 2026 00:40:41 -0700 Subject: [PATCH 02/10] test(e2e): expect provider env placeholders --- e2e/rust/tests/provider_auto_create.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/e2e/rust/tests/provider_auto_create.rs b/e2e/rust/tests/provider_auto_create.rs index 497b41acc..0d528d761 100644 --- a/e2e/rust/tests/provider_auto_create.rs +++ b/e2e/rust/tests/provider_auto_create.rs @@ -7,10 +7,11 @@ //! //! When `--provider claude` is passed and no provider named "claude" exists, //! the CLI should discover `ANTHROPIC_API_KEY` from the local environment, -//! auto-create a provider, and inject its credentials into the sandbox. +//! auto-create a provider, and inject a supervisor-managed placeholder into the +//! sandbox child process environment. //! //! The sandbox command (`printenv ANTHROPIC_API_KEY`) verifies that the -//! credential made it all the way through to the sandbox process environment. +//! placeholder made it all the way through to the sandbox process environment. //! //! Prerequisites: //! - A running openshell gateway (`openshell gateway start`) @@ -22,6 +23,7 @@ use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::output::{extract_field, strip_ansi}; const TEST_API_KEY: &str = "sk-e2e-auto-provider-test-key"; +const TEST_API_KEY_PLACEHOLDER: &str = "nemo-placeholder:env:ANTHROPIC_API_KEY"; /// Helper: delete a provider by name, ignoring errors. async fn delete_provider(name: &str) { @@ -46,7 +48,7 @@ async fn delete_sandbox(name: &str) { } /// `--provider claude --auto-providers` with `ANTHROPIC_API_KEY` set should -/// auto-create a "claude" provider and inject the credential into the sandbox. +/// auto-create a "claude" provider and inject a placeholder into the sandbox. #[tokio::test] async fn auto_created_provider_credential_available_in_sandbox() { // Clean up any leftover from a previous run. @@ -100,7 +102,12 @@ async fn auto_created_provider_credential_available_in_sandbox() { ); assert!( - clean.contains(TEST_API_KEY), - "sandbox should have ANTHROPIC_API_KEY in its environment:\n{clean}" + clean.contains(TEST_API_KEY_PLACEHOLDER), + "sandbox should have placeholder ANTHROPIC_API_KEY in its environment:\n{clean}" + ); + + assert!( + !clean.contains(TEST_API_KEY), + "sandbox should not expose the raw ANTHROPIC_API_KEY secret:\n{clean}" ); } From 8119e5c98e2eb972f0b7351eead8061b9ed5a8b1 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Wed, 11 Mar 2026 18:10:28 -0700 Subject: [PATCH 03/10] test(sandbox): verify real secrets reach destination via CONNECT L7 proxy Add e2e tests with dummy echo servers that confirm placeholder-to-secret rewriting works end-to-end through the CONNECT tunnel with L7 REST inspection. Also add Rust unit tests for the full round-trip flow from provider env through header rewriting. --- crates/navigator-sandbox/src/secrets.rs | 90 ++++++++ e2e/python/test_sandbox_providers.py | 293 ++++++++++++++++++++++++ 2 files changed, 383 insertions(+) diff --git a/crates/navigator-sandbox/src/secrets.rs b/crates/navigator-sandbox/src/secrets.rs index 0e1cca7ae..03466c6b1 100644 --- a/crates/navigator-sandbox/src/secrets.rs +++ b/crates/navigator-sandbox/src/secrets.rs @@ -169,4 +169,94 @@ mod tests { assert!(rewritten.contains("Authorization: Bearer secret-token\r\n")); assert!(rewritten.ends_with("\r\n\r\nhello")); } + + /// Simulates the full round-trip: provider env → child placeholders → + /// HTTP headers → rewrite. This is the exact flow that occurs when a + /// sandbox child process reads placeholder env vars, constructs an HTTP + /// request, and the proxy rewrites headers before forwarding upstream. + #[test] + fn full_round_trip_child_env_to_rewritten_headers() { + let provider_env: HashMap = [ + ( + "ANTHROPIC_API_KEY".to_string(), + "sk-real-key-12345".to_string(), + ), + ( + "CUSTOM_SERVICE_TOKEN".to_string(), + "tok-real-svc-67890".to_string(), + ), + ] + .into_iter() + .collect(); + + let (child_env, resolver) = SecretResolver::from_provider_env(provider_env); + + // Child process reads placeholders from the environment + let auth_value = child_env.get("ANTHROPIC_API_KEY").unwrap(); + let token_value = child_env.get("CUSTOM_SERVICE_TOKEN").unwrap(); + assert!(auth_value.starts_with(PLACEHOLDER_PREFIX)); + assert!(token_value.starts_with(PLACEHOLDER_PREFIX)); + + // Child constructs an HTTP request using those placeholders + let raw = format!( + "GET /v1/messages HTTP/1.1\r\n\ + Host: api.example.com\r\n\ + Authorization: Bearer {auth_value}\r\n\ + x-api-key: {token_value}\r\n\ + Content-Length: 0\r\n\r\n" + ); + + // Proxy rewrites headers + let rewritten = rewrite_http_header_block(raw.as_bytes(), resolver.as_ref()); + let rewritten = String::from_utf8(rewritten).expect("utf8"); + + // Real secrets must appear in the rewritten headers + assert!( + rewritten.contains("Authorization: Bearer sk-real-key-12345\r\n"), + "Expected rewritten Authorization header, got: {rewritten}" + ); + assert!( + rewritten.contains("x-api-key: tok-real-svc-67890\r\n"), + "Expected rewritten x-api-key header, got: {rewritten}" + ); + + // Placeholders must not appear + assert!( + !rewritten.contains("nemo-placeholder:env:"), + "Placeholder leaked into rewritten request: {rewritten}" + ); + + // Request line and non-secret headers must be preserved + assert!(rewritten.starts_with("GET /v1/messages HTTP/1.1\r\n")); + assert!(rewritten.contains("Host: api.example.com\r\n")); + assert!(rewritten.contains("Content-Length: 0\r\n")); + } + + #[test] + fn non_secret_headers_are_not_modified() { + let (_, resolver) = SecretResolver::from_provider_env( + [("API_KEY".to_string(), "secret".to_string())] + .into_iter() + .collect(), + ); + + let raw = b"GET / HTTP/1.1\r\nHost: example.com\r\nAccept: application/json\r\nContent-Type: text/plain\r\n\r\n"; + let rewritten = rewrite_http_header_block(raw, resolver.as_ref()); + // The output should be byte-identical since no placeholders are present + assert_eq!(raw.as_slice(), rewritten.as_slice()); + } + + #[test] + fn empty_provider_env_produces_no_resolver() { + let (child_env, resolver) = SecretResolver::from_provider_env(HashMap::new()); + assert!(child_env.is_empty()); + assert!(resolver.is_none()); + } + + #[test] + fn rewrite_with_no_resolver_returns_original() { + let raw = b"GET / HTTP/1.1\r\nAuthorization: Bearer my-token\r\n\r\n"; + let rewritten = rewrite_http_header_block(raw, None); + assert_eq!(raw.as_slice(), rewritten.as_slice()); + } } diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 424ab978f..4873fdbac 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -80,6 +80,7 @@ def _delete_provider(stub: object, name: str) -> None: _SANDBOX_IP = "10.200.0.2" _FORWARD_PROXY_PORT = 19879 +_CONNECT_L7_PORT = 19880 def _proxy_policy() -> sandbox_pb2.SandboxPolicy: @@ -110,6 +111,38 @@ def _proxy_policy() -> sandbox_pb2.SandboxPolicy: ) +def _connect_l7_policy() -> sandbox_pb2.SandboxPolicy: + """Policy with a CONNECT-eligible endpoint using L7 REST inspection.""" + return sandbox_pb2.SandboxPolicy( + version=1, + filesystem=sandbox_pb2.FilesystemPolicy( + include_workdir=True, + read_only=["/usr", "/lib", "/etc", "/app"], + read_write=["/sandbox", "/tmp"], + ), + landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"), + process=sandbox_pb2.ProcessPolicy( + run_as_user="sandbox", run_as_group="sandbox" + ), + network_policies={ + "internal_l7": sandbox_pb2.NetworkPolicyRule( + name="internal_l7", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host=_SANDBOX_IP, + port=_CONNECT_L7_PORT, + protocol="rest", + enforcement="enforce", + access="full", + allowed_ips=["10.200.0.0/24"], + ) + ], + binaries=[sandbox_pb2.NetworkBinary(path="/**")], + ) + }, + ) + + def _forward_proxy_reads_env_and_returns_auth_header(): def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: import os @@ -163,6 +196,174 @@ def log_message(self, format, *args): return fn +def _connect_l7_echo_server_returns_auth_header(): + """Return a closure that starts an echo HTTP server and sends a CONNECT + request through the proxy, returning what the server received.""" + + def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + import os + import socket + import threading + import time + from http.server import BaseHTTPRequestHandler, HTTPServer + + class EchoHandler(BaseHTTPRequestHandler): + """Echoes back the Authorization header received from the proxy.""" + + def do_GET(self): + auth = self.headers.get("Authorization", "MISSING") + # Also include x-api-key for multi-header testing + api_key = self.headers.get("x-api-key", "MISSING") + body = f"auth={auth}\nx-api-key={api_key}".encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + server = HTTPServer(("0.0.0.0", int(target_port)), EchoHandler) + thread = threading.Thread(target=server.handle_request, daemon=True) + thread.start() + time.sleep(0.5) + + # Read the placeholder env var (the child process only sees placeholders) + token = os.environ.get("ANTHROPIC_API_KEY", "NOT_SET") + + conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) + try: + # 1. CONNECT through the proxy + connect_req = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n\r\n" + ) + conn.sendall(connect_req.encode()) + + # Read CONNECT response + connect_resp = b"" + while b"\r\n\r\n" not in connect_resp: + chunk = conn.recv(256) + if not chunk: + break + connect_resp += chunk + connect_status = connect_resp.decode("latin1") + + if "200" not in connect_status: + return f"CONNECT failed: {connect_status.strip()}" + + # 2. Send HTTP request through the established tunnel + # The proxy will do L7 inspection and rewrite placeholders + request = ( + f"GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + f"Authorization: Bearer {token}\r\n" + f"Connection: close\r\n\r\n" + ) + conn.sendall(request.encode()) + + # 3. Read the response from the echo server + data = b"" + conn.settimeout(5) + while True: + try: + chunk = conn.recv(4096) + except socket.timeout: + break + if not chunk: + break + data += chunk + return data.decode("latin1") + finally: + conn.close() + server.server_close() + + return fn + + +def _connect_l7_echo_multiple_secrets(): + """Return a closure that tests multiple provider secrets are rewritten + through the CONNECT L7 proxy path.""" + + def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + import os + import socket + import threading + import time + from http.server import BaseHTTPRequestHandler, HTTPServer + + class EchoHandler(BaseHTTPRequestHandler): + def do_GET(self): + auth = self.headers.get("Authorization", "MISSING") + api_key = self.headers.get("x-api-key", "MISSING") + custom = self.headers.get("x-custom-token", "MISSING") + body = f"auth={auth}\nx-api-key={api_key}\nx-custom-token={custom}".encode() + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + server = HTTPServer(("0.0.0.0", int(target_port)), EchoHandler) + thread = threading.Thread(target=server.handle_request, daemon=True) + thread.start() + time.sleep(0.5) + + # Read placeholder env vars + api_key = os.environ.get("MY_API_KEY", "NOT_SET") + custom_token = os.environ.get("CUSTOM_SERVICE_TOKEN", "NOT_SET") + + conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) + try: + connect_req = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n\r\n" + ) + conn.sendall(connect_req.encode()) + + connect_resp = b"" + while b"\r\n\r\n" not in connect_resp: + chunk = conn.recv(256) + if not chunk: + break + connect_resp += chunk + + if "200" not in connect_resp.decode("latin1"): + return f"CONNECT failed: {connect_resp.decode('latin1').strip()}" + + # Send request with multiple placeholder headers + request = ( + f"GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + f"Authorization: Bearer {api_key}\r\n" + f"x-api-key: {api_key}\r\n" + f"x-custom-token: {custom_token}\r\n" + f"Connection: close\r\n\r\n" + ) + conn.sendall(request.encode()) + + data = b"" + conn.settimeout(5) + while True: + try: + chunk = conn.recv(4096) + except socket.timeout: + break + if not chunk: + break + data += chunk + return data.decode("latin1") + finally: + conn.close() + server.server_close() + + return fn + + def test_provider_credentials_available_as_env_vars( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, @@ -280,6 +481,98 @@ def test_provider_placeholder_is_resolved_by_proxy_on_outbound_request( assert "nemo-placeholder:env:ANTHROPIC_API_KEY" not in result.stdout +def test_provider_secret_resolved_via_connect_l7_proxy( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """Real secret reaches the destination through a CONNECT tunnel with L7 REST + inspection. + + A dummy HTTP echo server runs inside the sandbox. The child process sends a + CONNECT request through the proxy and then an HTTP request carrying the + placeholder env var in the Authorization header. The proxy performs L7 + inspection and rewrites the placeholder to the real credential before + forwarding. The echo server reflects the received header back so we can + assert the *real* secret arrived at the destination. + """ + with provider( + sandbox_client._stub, + name="e2e-test-connect-l7-rewrite", + provider_type="claude", + credentials={"ANTHROPIC_API_KEY": "sk-connect-l7-secret-99"}, + ) as provider_name: + spec = datamodel_pb2.SandboxSpec( + policy=_connect_l7_policy(), + providers=[provider_name], + ) + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python( + _connect_l7_echo_server_returns_auth_header(), + args=("10.200.0.1", 3128, _SANDBOX_IP, _CONNECT_L7_PORT), + ) + assert result.exit_code == 0, result.stderr + # The echo server should have received the real secret + assert "200 OK" in result.stdout, ( + f"Expected 200 OK in response, got: {result.stdout}" + ) + assert "auth=Bearer sk-connect-l7-secret-99" in result.stdout, ( + f"Real secret not found in echo response: {result.stdout}" + ) + # The placeholder must NOT appear in the response + assert "nemo-placeholder:env:ANTHROPIC_API_KEY" not in result.stdout + + +def test_provider_multiple_secrets_resolved_via_connect_l7_proxy( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """Multiple provider secrets are rewritten through CONNECT L7 proxy. + + Verifies that when a request carries several headers each containing a + different provider placeholder, every placeholder is resolved to the + corresponding real credential by the time the request reaches the + destination. + """ + with provider( + sandbox_client._stub, + name="e2e-test-connect-l7-multi", + provider_type="generic", + credentials={ + "MY_API_KEY": "real-api-key-abc", + "CUSTOM_SERVICE_TOKEN": "real-custom-token-xyz", + }, + ) as provider_name: + spec = datamodel_pb2.SandboxSpec( + policy=_connect_l7_policy(), + providers=[provider_name], + ) + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python( + _connect_l7_echo_multiple_secrets(), + args=("10.200.0.1", 3128, _SANDBOX_IP, _CONNECT_L7_PORT), + ) + assert result.exit_code == 0, result.stderr + assert "200 OK" in result.stdout, ( + f"Expected 200 OK in response, got: {result.stdout}" + ) + # Bearer prefix + exact match for Authorization header + assert "auth=Bearer real-api-key-abc" in result.stdout, ( + f"API key not rewritten in Authorization header: {result.stdout}" + ) + # Exact match for x-api-key (no Bearer prefix) + assert "x-api-key=real-api-key-abc" in result.stdout, ( + f"API key not rewritten in x-api-key header: {result.stdout}" + ) + # Exact match for x-custom-token + assert "x-custom-token=real-custom-token-xyz" in result.stdout, ( + f"Custom token not rewritten in x-custom-token header: {result.stdout}" + ) + # No placeholders should appear + assert "nemo-placeholder:env:" not in result.stdout + + def test_ssh_handshake_secret_not_visible_in_exec_environment( sandbox: Callable[..., Sandbox], ) -> None: From 26680ede8f52c8c61d5ed401e2920bc8b7725349 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 00:43:13 -0700 Subject: [PATCH 04/10] test(sandbox): add credential rewriting tests and clean up e2e helpers Add two async unit tests in rest.rs verifying that the L7 relay correctly rewrites credential placeholders (and that omitting the resolver leaks them). Clean up test_sandbox_providers.py by consolidating duplicate policy helpers and closure factories (~80 lines removed). Update the local-inference example policy with proper TLS termination config for NVIDIA and OpenCode endpoints. --- crates/navigator-sandbox/src/l7/rest.rs | 173 ++++++++ e2e/python/test_sandbox_providers.py | 443 ++++++++----------- examples/local-inference/sandbox-policy.yaml | 30 +- 3 files changed, 383 insertions(+), 263 deletions(-) diff --git a/crates/navigator-sandbox/src/l7/rest.rs b/crates/navigator-sandbox/src/l7/rest.rs index 9e572e979..6a6c7c762 100644 --- a/crates/navigator-sandbox/src/l7/rest.rs +++ b/crates/navigator-sandbox/src/l7/rest.rs @@ -957,4 +957,177 @@ mod tests { assert!(rewritten.contains("Authorization: Bearer sk-test\r\n")); assert!(!rewritten.contains("nemo-placeholder:env:ANTHROPIC_API_KEY")); } + + /// Verifies that `relay_http_request_with_resolver` rewrites credential + /// placeholders in request headers before forwarding to upstream. + /// + /// This is the code path exercised when an endpoint has `protocol: rest` + /// and `tls: terminate` — the proxy terminates TLS, sees plaintext HTTP, + /// and replaces placeholder tokens with real secrets. + /// + /// Without this test, a misconfigured endpoint (missing `tls: terminate`) + /// silently leaks placeholder strings like `nemo-placeholder:env:NVIDIA_API_KEY` + /// to the upstream API, causing 401 Unauthorized errors. + #[tokio::test] + async fn relay_request_with_resolver_rewrites_credential_placeholders() { + let provider_env: std::collections::HashMap = [( + "NVIDIA_API_KEY".to_string(), + "nvapi-real-secret-key".to_string(), + )] + .into_iter() + .collect(); + + let (child_env, resolver) = SecretResolver::from_provider_env(provider_env); + let placeholder = child_env.get("NVIDIA_API_KEY").unwrap(); + + let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); + let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); + + let req = L7Request { + action: "POST".to_string(), + target: "/v1/chat/completions".to_string(), + raw_header: format!( + "POST /v1/chat/completions HTTP/1.1\r\n\ + Host: integrate.api.nvidia.com\r\n\ + Authorization: Bearer {placeholder}\r\n\ + Content-Length: 2\r\n\r\n{{}}" + ) + .into_bytes(), + body_length: BodyLength::ContentLength(2), + }; + + // Mock upstream: read the forwarded request, capture it, send response + let upstream_task = tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + let mut total = 0; + loop { + let n = upstream_side.read(&mut buf[total..]).await.unwrap(); + if n == 0 { + break; + } + total += n; + if let Some(hdr_end) = buf[..total].windows(4).position(|w| w == b"\r\n\r\n") { + if total >= hdr_end + 4 + 2 { + break; + } + } + } + upstream_side + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .unwrap(); + upstream_side.flush().await.unwrap(); + String::from_utf8_lossy(&buf[..total]).to_string() + }); + + // Run the relay with a resolver — simulates the TLS-terminate path + let relay = tokio::time::timeout( + std::time::Duration::from_secs(5), + relay_http_request_with_resolver( + &req, + &mut proxy_to_client, + &mut proxy_to_upstream, + resolver.as_ref(), + ), + ) + .await + .expect("relay must not deadlock"); + relay.expect("relay should succeed"); + + let forwarded = upstream_task.await.expect("upstream task should complete"); + + // The real secret must appear in what upstream received + assert!( + forwarded.contains("Authorization: Bearer nvapi-real-secret-key\r\n"), + "Expected real API key in upstream request, got: {forwarded}" + ); + // The placeholder must NOT appear + assert!( + !forwarded.contains("nemo-placeholder:env:"), + "Placeholder leaked to upstream: {forwarded}" + ); + // Other headers must be preserved + assert!(forwarded.contains("Host: integrate.api.nvidia.com\r\n")); + } + + /// Verifies that without a `SecretResolver` (i.e. the L4-only raw tunnel + /// path, or no TLS termination), credential placeholders pass through + /// unmodified. This documents the behavior that causes 401 errors when + /// `tls: terminate` is missing from the endpoint config. + #[tokio::test] + async fn relay_request_without_resolver_leaks_placeholders() { + let (child_env, _resolver) = SecretResolver::from_provider_env( + [("NVIDIA_API_KEY".to_string(), "nvapi-secret".to_string())] + .into_iter() + .collect(), + ); + let placeholder = child_env.get("NVIDIA_API_KEY").unwrap(); + + let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); + let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); + + let req = L7Request { + action: "POST".to_string(), + target: "/v1/chat/completions".to_string(), + raw_header: format!( + "POST /v1/chat/completions HTTP/1.1\r\n\ + Host: integrate.api.nvidia.com\r\n\ + Authorization: Bearer {placeholder}\r\n\ + Content-Length: 2\r\n\r\n{{}}" + ) + .into_bytes(), + body_length: BodyLength::ContentLength(2), + }; + + let upstream_task = tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + let mut total = 0; + loop { + let n = upstream_side.read(&mut buf[total..]).await.unwrap(); + if n == 0 { + break; + } + total += n; + if let Some(hdr_end) = buf[..total].windows(4).position(|w| w == b"\r\n\r\n") { + if total >= hdr_end + 4 + 2 { + break; + } + } + } + upstream_side + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .unwrap(); + upstream_side.flush().await.unwrap(); + String::from_utf8_lossy(&buf[..total]).to_string() + }); + + // Pass `None` for the resolver — simulates the L4 path where no + // rewriting occurs. + let relay = tokio::time::timeout( + std::time::Duration::from_secs(5), + relay_http_request_with_resolver( + &req, + &mut proxy_to_client, + &mut proxy_to_upstream, + None, // <-- No resolver, as in the L4 raw tunnel path + ), + ) + .await + .expect("relay must not deadlock"); + relay.expect("relay should succeed"); + + let forwarded = upstream_task.await.expect("upstream task should complete"); + + // Without a resolver, the placeholder LEAKS to upstream — this is the + // documented behavior that causes 401s when `tls: terminate` is missing. + assert!( + forwarded.contains("nemo-placeholder:env:NVIDIA_API_KEY"), + "Expected placeholder to leak without resolver, got: {forwarded}" + ); + assert!( + !forwarded.contains("nvapi-secret"), + "Real secret should NOT appear without resolver, got: {forwarded}" + ); + } } diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 4873fdbac..b6d269700 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -26,64 +26,26 @@ from openshell import Sandbox, SandboxClient -def _default_policy() -> sandbox_pb2.SandboxPolicy: - return sandbox_pb2.SandboxPolicy( - version=1, - filesystem=sandbox_pb2.FilesystemPolicy( - include_workdir=True, - read_only=["/usr", "/lib", "/etc", "/app"], - read_write=["/sandbox", "/tmp"], - ), - landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"), - process=sandbox_pb2.ProcessPolicy( - run_as_user="sandbox", run_as_group="sandbox" - ), - ) - - -@contextmanager -def provider( - stub: object, - *, - name: str, - provider_type: str, - credentials: dict[str, str], -) -> Iterator[str]: - """Create a provider for the duration of the block, then delete it.""" - # Clean up any leftover from a previous run. - _delete_provider(stub, name) - stub.CreateProvider( - navigator_pb2.CreateProviderRequest( - provider=datamodel_pb2.Provider( - name=name, - type=provider_type, - credentials=credentials, - ) - ) - ) - try: - yield name - finally: - _delete_provider(stub, name) - - -def _delete_provider(stub: object, name: str) -> None: - """Delete a provider, ignoring not-found errors.""" - try: - stub.DeleteProvider(navigator_pb2.DeleteProviderRequest(name=name)) - except grpc.RpcError as exc: - if hasattr(exc, "code") and exc.code() == grpc.StatusCode.NOT_FOUND: - pass - else: - raise - +# --------------------------------------------------------------------------- +# Shared constants +# --------------------------------------------------------------------------- _SANDBOX_IP = "10.200.0.2" +_PROXY_HOST = "10.200.0.1" +_PROXY_PORT = 3128 _FORWARD_PROXY_PORT = 19879 _CONNECT_L7_PORT = 19880 -def _proxy_policy() -> sandbox_pb2.SandboxPolicy: +# --------------------------------------------------------------------------- +# Policy helpers +# --------------------------------------------------------------------------- + + +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=sandbox_pb2.FilesystemPolicy( @@ -95,6 +57,12 @@ def _proxy_policy() -> sandbox_pb2.SandboxPolicy: process=sandbox_pb2.ProcessPolicy( run_as_user="sandbox", run_as_group="sandbox" ), + network_policies=network_policies or {}, + ) + + +def _forward_proxy_policy() -> sandbox_pb2.SandboxPolicy: + return _base_policy( network_policies={ "internal_http": sandbox_pb2.NetworkPolicyRule( name="internal_http", @@ -113,17 +81,7 @@ def _proxy_policy() -> sandbox_pb2.SandboxPolicy: def _connect_l7_policy() -> sandbox_pb2.SandboxPolicy: """Policy with a CONNECT-eligible endpoint using L7 REST inspection.""" - return sandbox_pb2.SandboxPolicy( - version=1, - filesystem=sandbox_pb2.FilesystemPolicy( - include_workdir=True, - read_only=["/usr", "/lib", "/etc", "/app"], - read_write=["/sandbox", "/tmp"], - ), - landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"), - process=sandbox_pb2.ProcessPolicy( - run_as_user="sandbox", run_as_group="sandbox" - ), + return _base_policy( network_policies={ "internal_l7": sandbox_pb2.NetworkPolicyRule( name="internal_l7", @@ -143,7 +101,60 @@ def _connect_l7_policy() -> sandbox_pb2.SandboxPolicy: ) -def _forward_proxy_reads_env_and_returns_auth_header(): +# --------------------------------------------------------------------------- +# Provider lifecycle helper +# --------------------------------------------------------------------------- + + +@contextmanager +def provider( + stub: object, + *, + name: str, + provider_type: str, + credentials: dict[str, str], +) -> Iterator[str]: + """Create a provider for the duration of the block, then delete it.""" + _delete_provider(stub, name) + stub.CreateProvider( + navigator_pb2.CreateProviderRequest( + provider=datamodel_pb2.Provider( + name=name, + type=provider_type, + credentials=credentials, + ) + ) + ) + try: + yield name + finally: + _delete_provider(stub, name) + + +def _delete_provider(stub: object, name: str) -> None: + """Delete a provider, ignoring not-found errors.""" + try: + stub.DeleteProvider(navigator_pb2.DeleteProviderRequest(name=name)) + except grpc.RpcError as exc: + if hasattr(exc, "code") and exc.code() == grpc.StatusCode.NOT_FOUND: + pass + else: + raise + + +# --------------------------------------------------------------------------- +# In-sandbox echo server + proxy request helpers +# +# These closures are serialized via cloudpickle and run *inside* the sandbox. +# They share a common pattern: start a tiny HTTP server, send a request +# through the proxy, and return the raw response. +# --------------------------------------------------------------------------- + + +def _echo_server_via_forward_proxy(): + """Return a closure that sends a forward-proxy (plain HTTP) request + through the sandbox proxy and returns the raw response.""" + def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: import os import socket @@ -160,35 +171,23 @@ def do_GET(self): self.end_headers() self.wfile.write(body) - def log_message(self, format, *args): + def log_message(self, *a): pass server = HTTPServer(("0.0.0.0", int(target_port)), Handler) - thread = threading.Thread(target=server.handle_request, daemon=True) - thread.start() + threading.Thread(target=server.handle_request, daemon=True).start() time.sleep(0.5) token = os.environ["ANTHROPIC_API_KEY"] conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) try: - request = ( + conn.sendall( f"GET http://{target_host}:{target_port}/ HTTP/1.1\r\n" f"Host: {target_host}:{target_port}\r\n" f"Authorization: Bearer {token}\r\n" - "Connection: close\r\n\r\n" + f"Connection: close\r\n\r\n".encode() ) - conn.sendall(request.encode()) - data = b"" - conn.settimeout(5) - while True: - try: - chunk = conn.recv(4096) - except socket.timeout: - break - if not chunk: - break - data += chunk - return data.decode("latin1") + return _recv_all(conn) finally: conn.close() server.server_close() @@ -196,9 +195,20 @@ def log_message(self, format, *args): return fn -def _connect_l7_echo_server_returns_auth_header(): - """Return a closure that starts an echo HTTP server and sends a CONNECT - request through the proxy, returning what the server received.""" +def _echo_server_via_connect_l7(env_vars: dict[str, str] | None = None): + """Return a closure that sends a CONNECT + L7 request through the proxy. + + *env_vars* maps header names to environment variable names. Defaults to + ``{"Authorization": "ANTHROPIC_API_KEY"}`` for the single-secret case. + For multi-secret tests pass e.g. + ``{"Authorization": "MY_API_KEY", "x-api-key": "MY_API_KEY", + "x-custom-token": "CUSTOM_SERVICE_TOKEN"}``. + """ + if env_vars is None: + env_vars = {"Authorization": "ANTHROPIC_API_KEY"} + + # Capture into the closure so cloudpickle serializes the value. + _env_vars = dict(env_vars) def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: import os @@ -207,74 +217,60 @@ def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> import time from http.server import BaseHTTPRequestHandler, HTTPServer - class EchoHandler(BaseHTTPRequestHandler): - """Echoes back the Authorization header received from the proxy.""" + captured_env_vars = _env_vars # noqa: F841 -- used by Handler + class Handler(BaseHTTPRequestHandler): def do_GET(self): - auth = self.headers.get("Authorization", "MISSING") - # Also include x-api-key for multi-header testing - api_key = self.headers.get("x-api-key", "MISSING") - body = f"auth={auth}\nx-api-key={api_key}".encode() + parts = [] + for hdr_name in captured_env_vars: + parts.append(f"{hdr_name}={self.headers.get(hdr_name, 'MISSING')}") + body = "\n".join(parts).encode() self.send_response(200) self.send_header("Content-Length", str(len(body))) self.send_header("Connection", "close") self.end_headers() self.wfile.write(body) - def log_message(self, format, *args): + def log_message(self, *a): pass - server = HTTPServer(("0.0.0.0", int(target_port)), EchoHandler) - thread = threading.Thread(target=server.handle_request, daemon=True) - thread.start() + server = HTTPServer(("0.0.0.0", int(target_port)), Handler) + threading.Thread(target=server.handle_request, daemon=True).start() time.sleep(0.5) - # Read the placeholder env var (the child process only sees placeholders) - token = os.environ.get("ANTHROPIC_API_KEY", "NOT_SET") + # Build request headers from placeholder env vars + header_lines = [ + f"GET / HTTP/1.1", + f"Host: {target_host}:{target_port}", + ] + for hdr_name, env_name in captured_env_vars.items(): + val = os.environ.get(env_name, "NOT_SET") + if hdr_name == "Authorization": + header_lines.append(f"Authorization: Bearer {val}") + else: + header_lines.append(f"{hdr_name}: {val}") + header_lines.append("Connection: close") + request = "\r\n".join(header_lines) + "\r\n\r\n" conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) try: - # 1. CONNECT through the proxy - connect_req = ( + # CONNECT tunnel + conn.sendall( f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n\r\n" + f"Host: {target_host}:{target_port}\r\n\r\n".encode() ) - conn.sendall(connect_req.encode()) - - # Read CONNECT response connect_resp = b"" while b"\r\n\r\n" not in connect_resp: chunk = conn.recv(256) if not chunk: break connect_resp += chunk - connect_status = connect_resp.decode("latin1") - - if "200" not in connect_status: - return f"CONNECT failed: {connect_status.strip()}" + if "200" not in connect_resp.decode("latin1"): + return f"CONNECT failed: {connect_resp.decode('latin1').strip()}" - # 2. Send HTTP request through the established tunnel - # The proxy will do L7 inspection and rewrite placeholders - request = ( - f"GET / HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n" - f"Authorization: Bearer {token}\r\n" - f"Connection: close\r\n\r\n" - ) + # HTTP request through the tunnel conn.sendall(request.encode()) - - # 3. Read the response from the echo server - data = b"" - conn.settimeout(5) - while True: - try: - chunk = conn.recv(4096) - except socket.timeout: - break - if not chunk: - break - data += chunk - return data.decode("latin1") + return _recv_all(conn) finally: conn.close() server.server_close() @@ -282,86 +278,26 @@ def log_message(self, format, *args): return fn -def _connect_l7_echo_multiple_secrets(): - """Return a closure that tests multiple provider secrets are rewritten - through the CONNECT L7 proxy path.""" +def _recv_all(conn, timeout: float = 5.0) -> str: + """Read all available data from a socket until EOF or timeout.""" + import socket as _socket - def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: - import os - import socket - import threading - import time - from http.server import BaseHTTPRequestHandler, HTTPServer - - class EchoHandler(BaseHTTPRequestHandler): - def do_GET(self): - auth = self.headers.get("Authorization", "MISSING") - api_key = self.headers.get("x-api-key", "MISSING") - custom = self.headers.get("x-custom-token", "MISSING") - body = f"auth={auth}\nx-api-key={api_key}\nx-custom-token={custom}".encode() - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - - def log_message(self, format, *args): - pass - - server = HTTPServer(("0.0.0.0", int(target_port)), EchoHandler) - thread = threading.Thread(target=server.handle_request, daemon=True) - thread.start() - time.sleep(0.5) - - # Read placeholder env vars - api_key = os.environ.get("MY_API_KEY", "NOT_SET") - custom_token = os.environ.get("CUSTOM_SERVICE_TOKEN", "NOT_SET") - - conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) + data = b"" + conn.settimeout(timeout) + while True: try: - connect_req = ( - f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n\r\n" - ) - conn.sendall(connect_req.encode()) - - connect_resp = b"" - while b"\r\n\r\n" not in connect_resp: - chunk = conn.recv(256) - if not chunk: - break - connect_resp += chunk - - if "200" not in connect_resp.decode("latin1"): - return f"CONNECT failed: {connect_resp.decode('latin1').strip()}" - - # Send request with multiple placeholder headers - request = ( - f"GET / HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n" - f"Authorization: Bearer {api_key}\r\n" - f"x-api-key: {api_key}\r\n" - f"x-custom-token: {custom_token}\r\n" - f"Connection: close\r\n\r\n" - ) - conn.sendall(request.encode()) + chunk = conn.recv(4096) + except (_socket.timeout, TimeoutError): + break + if not chunk: + break + data += chunk + return data.decode("latin1") - data = b"" - conn.settimeout(5) - while True: - try: - chunk = conn.recv(4096) - except socket.timeout: - break - if not chunk: - break - data += chunk - return data.decode("latin1") - finally: - conn.close() - server.server_close() - return fn +# =========================================================================== +# Tests: placeholder visibility +# =========================================================================== def test_provider_credentials_available_as_env_vars( @@ -376,7 +312,7 @@ def test_provider_credentials_available_as_env_vars( credentials={"ANTHROPIC_API_KEY": "sk-e2e-test-key-12345"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_default_policy(), + policy=_base_policy(), providers=[provider_name], ) @@ -408,7 +344,7 @@ def test_generic_provider_credentials_available_as_env_vars( }, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_default_policy(), + policy=_base_policy(), providers=[provider_name], ) @@ -440,7 +376,7 @@ def test_nvidia_provider_injects_nvidia_api_key_env_var( credentials={"NVIDIA_API_KEY": "nvapi-e2e-test-key"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_default_policy(), + policy=_base_policy(), providers=[provider_name], ) @@ -455,10 +391,16 @@ def read_nvidia_key() -> str: assert result.stdout.strip() == "nemo-placeholder:env:NVIDIA_API_KEY" +# =========================================================================== +# Tests: proxy credential rewriting +# =========================================================================== + + def test_provider_placeholder_is_resolved_by_proxy_on_outbound_request( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: + """Forward-proxy path: placeholder in Authorization header is rewritten.""" with provider( sandbox_client._stub, name="e2e-test-provider-proxy-rewrite", @@ -466,14 +408,14 @@ def test_provider_placeholder_is_resolved_by_proxy_on_outbound_request( credentials={"ANTHROPIC_API_KEY": "sk-proxy-rewrite-12345"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_proxy_policy(), + policy=_forward_proxy_policy(), providers=[provider_name], ) with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python( - _forward_proxy_reads_env_and_returns_auth_header(), - args=("10.200.0.1", 3128, _SANDBOX_IP, _FORWARD_PROXY_PORT), + _echo_server_via_forward_proxy(), + args=(_PROXY_HOST, _PROXY_PORT, _SANDBOX_IP, _FORWARD_PROXY_PORT), ) assert result.exit_code == 0, result.stderr assert "200 OK" in result.stdout @@ -485,16 +427,7 @@ def test_provider_secret_resolved_via_connect_l7_proxy( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """Real secret reaches the destination through a CONNECT tunnel with L7 REST - inspection. - - A dummy HTTP echo server runs inside the sandbox. The child process sends a - CONNECT request through the proxy and then an HTTP request carrying the - placeholder env var in the Authorization header. The proxy performs L7 - inspection and rewrites the placeholder to the real credential before - forwarding. The echo server reflects the received header back so we can - assert the *real* secret arrived at the destination. - """ + """CONNECT + L7 path: placeholder is rewritten before reaching upstream.""" with provider( sandbox_client._stub, name="e2e-test-connect-l7-rewrite", @@ -508,32 +441,22 @@ def test_provider_secret_resolved_via_connect_l7_proxy( with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python( - _connect_l7_echo_server_returns_auth_header(), - args=("10.200.0.1", 3128, _SANDBOX_IP, _CONNECT_L7_PORT), + _echo_server_via_connect_l7(), + args=(_PROXY_HOST, _PROXY_PORT, _SANDBOX_IP, _CONNECT_L7_PORT), ) assert result.exit_code == 0, result.stderr - # The echo server should have received the real secret - assert "200 OK" in result.stdout, ( - f"Expected 200 OK in response, got: {result.stdout}" - ) - assert "auth=Bearer sk-connect-l7-secret-99" in result.stdout, ( - f"Real secret not found in echo response: {result.stdout}" + assert "200 OK" in result.stdout, f"Expected 200 OK, got: {result.stdout}" + assert "Authorization=Bearer sk-connect-l7-secret-99" in result.stdout, ( + f"Real secret not found: {result.stdout}" ) - # The placeholder must NOT appear in the response - assert "nemo-placeholder:env:ANTHROPIC_API_KEY" not in result.stdout + assert "nemo-placeholder:env:" not in result.stdout def test_provider_multiple_secrets_resolved_via_connect_l7_proxy( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """Multiple provider secrets are rewritten through CONNECT L7 proxy. - - Verifies that when a request carries several headers each containing a - different provider placeholder, every placeholder is resolved to the - corresponding real credential by the time the request reaches the - destination. - """ + """Multiple provider secrets are rewritten through CONNECT + L7 proxy.""" with provider( sandbox_client._stub, name="e2e-test-connect-l7-multi", @@ -550,29 +473,28 @@ def test_provider_multiple_secrets_resolved_via_connect_l7_proxy( with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python( - _connect_l7_echo_multiple_secrets(), - args=("10.200.0.1", 3128, _SANDBOX_IP, _CONNECT_L7_PORT), + _echo_server_via_connect_l7( + env_vars={ + "Authorization": "MY_API_KEY", + "x-api-key": "MY_API_KEY", + "x-custom-token": "CUSTOM_SERVICE_TOKEN", + } + ), + args=(_PROXY_HOST, _PROXY_PORT, _SANDBOX_IP, _CONNECT_L7_PORT), ) assert result.exit_code == 0, result.stderr - assert "200 OK" in result.stdout, ( - f"Expected 200 OK in response, got: {result.stdout}" - ) - # Bearer prefix + exact match for Authorization header - assert "auth=Bearer real-api-key-abc" in result.stdout, ( - f"API key not rewritten in Authorization header: {result.stdout}" - ) - # Exact match for x-api-key (no Bearer prefix) - assert "x-api-key=real-api-key-abc" in result.stdout, ( - f"API key not rewritten in x-api-key header: {result.stdout}" - ) - # Exact match for x-custom-token - assert "x-custom-token=real-custom-token-xyz" in result.stdout, ( - f"Custom token not rewritten in x-custom-token header: {result.stdout}" - ) - # No placeholders should appear + assert "200 OK" in result.stdout, f"Expected 200 OK, got: {result.stdout}" + assert "Authorization=Bearer real-api-key-abc" in result.stdout + assert "x-api-key=real-api-key-abc" in result.stdout + assert "x-custom-token=real-custom-token-xyz" in result.stdout assert "nemo-placeholder:env:" not in result.stdout +# =========================================================================== +# Tests: security & edge cases +# =========================================================================== + + def test_ssh_handshake_secret_not_visible_in_exec_environment( sandbox: Callable[..., Sandbox], ) -> None: @@ -592,7 +514,7 @@ def test_create_sandbox_rejects_unknown_provider( ) -> None: """CreateSandbox fails fast when a provider name does not exist.""" spec = datamodel_pb2.SandboxSpec( - policy=_default_policy(), + policy=_base_policy(), providers=["nonexistent-provider-xyz"], ) with pytest.raises(grpc.RpcError) as exc_info: @@ -614,12 +536,11 @@ def test_credentials_not_in_persisted_spec_environment( credentials={"ANTHROPIC_API_KEY": "sk-should-not-persist"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_default_policy(), + policy=_base_policy(), providers=[provider_name], ) with sandbox(spec=spec, delete_on_exit=True) as sb: - # Fetch the sandbox record from the server and inspect spec.environment. fetched = sandbox_client._stub.GetSandbox( navigator_pb2.GetSandboxRequest(name=sb.sandbox.name) ) @@ -629,9 +550,9 @@ def test_credentials_not_in_persisted_spec_environment( ) -# --------------------------------------------------------------------------- -# Provider update merge semantics -# --------------------------------------------------------------------------- +# =========================================================================== +# Tests: provider update merge semantics +# =========================================================================== def test_update_provider_preserves_unset_credentials_and_config( @@ -654,7 +575,6 @@ def test_update_provider_preserves_unset_credentials_and_config( ) ) - # Update only KEY_A; send empty config map (= no change). stub.UpdateProvider( navigator_pb2.UpdateProviderRequest( provider=datamodel_pb2.Provider( @@ -733,7 +653,6 @@ def test_update_provider_merges_config_preserves_credentials( ) ) - # Send only config update, empty credentials map. stub.UpdateProvider( navigator_pb2.UpdateProviderRequest( provider=datamodel_pb2.Provider( diff --git a/examples/local-inference/sandbox-policy.yaml b/examples/local-inference/sandbox-policy.yaml index 549a31c9c..7c9f3b306 100644 --- a/examples/local-inference/sandbox-policy.yaml +++ b/examples/local-inference/sandbox-policy.yaml @@ -25,4 +25,32 @@ process: run_as_user: sandbox run_as_group: sandbox -# No network policies means all outbound connections are denied and only inference.local is allowed. +network_policies: + opencode: + name: opencode + endpoints: + - host: models.dev + port: 443 + - host: registry.npmjs.org + port: 443 + - host: opencode.ai + port: 443 + binaries: + - path: /usr/lib/node_modules/opencode-ai/bin/.opencode + - path: /usr/bin/node + - path: /usr/local/bin/opencode + nvidia_inference: + name: nvidia-inference + endpoints: + - host: integrate.api.nvidia.com + port: 443 + protocol: rest + tls: terminate + enforcement: enforce + access: full + binaries: + - path: /usr/lib/node_modules/opencode-ai/bin/.opencode + - path: /usr/bin/node + - path: /usr/local/bin/opencode + - path: /usr/bin/curl + - path: /bin/bash From a90642587309372486f9aa40045464ea2122bba0 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 00:50:48 -0700 Subject: [PATCH 05/10] wip --- examples/local-inference/sandbox-policy.yaml | 30 +------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/examples/local-inference/sandbox-policy.yaml b/examples/local-inference/sandbox-policy.yaml index 7c9f3b306..549a31c9c 100644 --- a/examples/local-inference/sandbox-policy.yaml +++ b/examples/local-inference/sandbox-policy.yaml @@ -25,32 +25,4 @@ process: run_as_user: sandbox run_as_group: sandbox -network_policies: - opencode: - name: opencode - endpoints: - - host: models.dev - port: 443 - - host: registry.npmjs.org - port: 443 - - host: opencode.ai - port: 443 - binaries: - - path: /usr/lib/node_modules/opencode-ai/bin/.opencode - - path: /usr/bin/node - - path: /usr/local/bin/opencode - nvidia_inference: - name: nvidia-inference - endpoints: - - host: integrate.api.nvidia.com - port: 443 - protocol: rest - tls: terminate - enforcement: enforce - access: full - binaries: - - path: /usr/lib/node_modules/opencode-ai/bin/.opencode - - path: /usr/bin/node - - path: /usr/local/bin/opencode - - path: /usr/bin/curl - - path: /bin/bash +# No network policies means all outbound connections are denied and only inference.local is allowed. From 9524de75b2aa57eaa078b9a2b03988f404a01e5f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 00:58:28 -0700 Subject: [PATCH 06/10] refactor(sandbox): rename placeholder prefix to openshell:resolve:env: Replace the legacy nemo-placeholder:env: prefix with openshell:resolve:env: across all source, test, and documentation files for consistency with the OpenShell product branding. --- architecture/sandbox-providers.md | 10 ++++---- crates/navigator-sandbox/src/l7/rest.rs | 10 ++++---- crates/navigator-sandbox/src/process.rs | 4 +-- crates/navigator-sandbox/src/proxy.rs | 4 +-- crates/navigator-sandbox/src/secrets.rs | 14 +++++----- crates/navigator-sandbox/src/ssh.rs | 4 +-- e2e/python/test_sandbox_providers.py | 34 ++++++++++++++++--------- e2e/rust/tests/provider_auto_create.rs | 24 ++++++++++++++++- 8 files changed, 68 insertions(+), 36 deletions(-) diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index 5d6ed6499..00120630e 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -248,7 +248,7 @@ In `run_sandbox()` (`crates/navigator-sandbox/src/lib.rs`): The returned `provider_env` `HashMap` is immediately transformed into: - a child-visible env map with placeholder values such as - `nemo-placeholder:env:ANTHROPIC_API_KEY`, and + `openshell:resolve:env:ANTHROPIC_API_KEY`, and - a supervisor-only in-memory registry mapping each placeholder back to its real secret. The placeholder env map is threaded to the entrypoint process spawner and SSH server. @@ -305,7 +305,7 @@ start from `env_clear()`, so the handshake secret is not present there. ### Proxy-Time Secret Resolution When a sandboxed tool uses one of these placeholder env vars to populate an outbound HTTP -header (for example `Authorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY`), the +header (for example `Authorization: Bearer openshell:resolve:env:ANTHROPIC_API_KEY`), the sandbox proxy rewrites the placeholder to the real secret value immediately before the request is forwarded upstream. @@ -341,11 +341,11 @@ CLI: openshell sandbox create -- claude +-- Fetches provider env via gRPC | +-- Gateway resolves: "claude" -> credentials -> {ANTHROPIC_API_KEY: "sk-..."} +-- Builds placeholder registry - | +-- child env: {ANTHROPIC_API_KEY: "nemo-placeholder:env:ANTHROPIC_API_KEY"} - | +-- supervisor registry: {"nemo-placeholder:env:ANTHROPIC_API_KEY": "sk-..."} + | +-- child env: {ANTHROPIC_API_KEY: "openshell:resolve:env:ANTHROPIC_API_KEY"} + | +-- supervisor registry: {"openshell:resolve:env:ANTHROPIC_API_KEY": "sk-..."} +-- Spawns entrypoint with placeholder env +-- SSH server holds placeholder env - | +-- Each SSH shell: cmd.env("ANTHROPIC_API_KEY", "nemo-placeholder:env:ANTHROPIC_API_KEY") + | +-- Each SSH shell: cmd.env("ANTHROPIC_API_KEY", "openshell:resolve:env:ANTHROPIC_API_KEY") +-- Proxy rewrites outbound auth header placeholders -> real secrets ``` diff --git a/crates/navigator-sandbox/src/l7/rest.rs b/crates/navigator-sandbox/src/l7/rest.rs index 6a6c7c762..f61d1242c 100644 --- a/crates/navigator-sandbox/src/l7/rest.rs +++ b/crates/navigator-sandbox/src/l7/rest.rs @@ -949,13 +949,13 @@ mod tests { .into_iter() .collect(), ); - let raw = b"GET /v1/messages HTTP/1.1\r\nAuthorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY\r\nHost: example.com\r\n\r\n"; + let raw = b"GET /v1/messages HTTP/1.1\r\nAuthorization: Bearer openshell:resolve:env:ANTHROPIC_API_KEY\r\nHost: example.com\r\n\r\n"; let rewritten = rewrite_http_header_block(raw, resolver.as_ref()); let rewritten = String::from_utf8(rewritten).expect("utf8"); assert!(rewritten.contains("Authorization: Bearer sk-test\r\n")); - assert!(!rewritten.contains("nemo-placeholder:env:ANTHROPIC_API_KEY")); + assert!(!rewritten.contains("openshell:resolve:env:ANTHROPIC_API_KEY")); } /// Verifies that `relay_http_request_with_resolver` rewrites credential @@ -966,7 +966,7 @@ mod tests { /// and replaces placeholder tokens with real secrets. /// /// Without this test, a misconfigured endpoint (missing `tls: terminate`) - /// silently leaks placeholder strings like `nemo-placeholder:env:NVIDIA_API_KEY` + /// silently leaks placeholder strings like `openshell:resolve:env:NVIDIA_API_KEY` /// to the upstream API, causing 401 Unauthorized errors. #[tokio::test] async fn relay_request_with_resolver_rewrites_credential_placeholders() { @@ -1043,7 +1043,7 @@ mod tests { ); // The placeholder must NOT appear assert!( - !forwarded.contains("nemo-placeholder:env:"), + !forwarded.contains("openshell:resolve:env:"), "Placeholder leaked to upstream: {forwarded}" ); // Other headers must be preserved @@ -1122,7 +1122,7 @@ mod tests { // Without a resolver, the placeholder LEAKS to upstream — this is the // documented behavior that causes 401s when `tls: terminate` is missing. assert!( - forwarded.contains("nemo-placeholder:env:NVIDIA_API_KEY"), + forwarded.contains("openshell:resolve:env:NVIDIA_API_KEY"), "Expected placeholder to leak without resolver, got: {forwarded}" ); assert!( diff --git a/crates/navigator-sandbox/src/process.rs b/crates/navigator-sandbox/src/process.rs index 211bc1d51..ca4477a41 100644 --- a/crates/navigator-sandbox/src/process.rs +++ b/crates/navigator-sandbox/src/process.rs @@ -617,7 +617,7 @@ mod tests { let provider_env = std::iter::once(( "ANTHROPIC_API_KEY".to_string(), - "nemo-placeholder:env:ANTHROPIC_API_KEY".to_string(), + "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), )) .collect(); @@ -625,6 +625,6 @@ mod tests { let output = cmd.output().await.expect("spawn env"); let stdout = String::from_utf8(output.stdout).expect("utf8"); - assert!(stdout.contains("ANTHROPIC_API_KEY=nemo-placeholder:env:ANTHROPIC_API_KEY")); + assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); } } diff --git a/crates/navigator-sandbox/src/proxy.rs b/crates/navigator-sandbox/src/proxy.rs index c57f90752..b935e85ef 100644 --- a/crates/navigator-sandbox/src/proxy.rs +++ b/crates/navigator-sandbox/src/proxy.rs @@ -2298,10 +2298,10 @@ mod tests { .into_iter() .collect(), ); - let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nAuthorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY\r\n\r\n"; + let raw = b"GET http://host/p HTTP/1.1\r\nHost: host\r\nAuthorization: Bearer openshell:resolve:env:ANTHROPIC_API_KEY\r\n\r\n"; let result = rewrite_forward_request(raw, raw.len(), "/p", resolver.as_ref()); let result_str = String::from_utf8_lossy(&result); assert!(result_str.contains("Authorization: Bearer sk-test")); - assert!(!result_str.contains("nemo-placeholder:env:ANTHROPIC_API_KEY")); + assert!(!result_str.contains("openshell:resolve:env:ANTHROPIC_API_KEY")); } } diff --git a/crates/navigator-sandbox/src/secrets.rs b/crates/navigator-sandbox/src/secrets.rs index 03466c6b1..4ee1ee846 100644 --- a/crates/navigator-sandbox/src/secrets.rs +++ b/crates/navigator-sandbox/src/secrets.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; -const PLACEHOLDER_PREFIX: &str = "nemo-placeholder:env:"; +const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:"; #[derive(Debug, Clone, Default)] pub(crate) struct SecretResolver { @@ -110,13 +110,13 @@ mod tests { assert_eq!( child_env.get("ANTHROPIC_API_KEY"), - Some(&"nemo-placeholder:env:ANTHROPIC_API_KEY".to_string()) + Some(&"openshell:resolve:env:ANTHROPIC_API_KEY".to_string()) ); assert_eq!( resolver .as_ref() .and_then(|resolver| resolver - .resolve_placeholder("nemo-placeholder:env:ANTHROPIC_API_KEY")), + .resolve_placeholder("openshell:resolve:env:ANTHROPIC_API_KEY")), Some("sk-test") ); } @@ -131,7 +131,7 @@ mod tests { let resolver = resolver.expect("resolver"); assert_eq!( - rewrite_header_line("x-api-key: nemo-placeholder:env:CUSTOM_TOKEN", &resolver), + rewrite_header_line("x-api-key: openshell:resolve:env:CUSTOM_TOKEN", &resolver), "x-api-key: secret-token" ); } @@ -147,7 +147,7 @@ mod tests { assert_eq!( rewrite_header_line( - "Authorization: Bearer nemo-placeholder:env:ANTHROPIC_API_KEY", + "Authorization: Bearer openshell:resolve:env:ANTHROPIC_API_KEY", &resolver, ), "Authorization: Bearer sk-test" @@ -162,7 +162,7 @@ mod tests { .collect(), ); - let raw = b"POST /v1 HTTP/1.1\r\nAuthorization: Bearer nemo-placeholder:env:CUSTOM_TOKEN\r\nContent-Length: 5\r\n\r\nhello"; + let raw = b"POST /v1 HTTP/1.1\r\nAuthorization: Bearer openshell:resolve:env:CUSTOM_TOKEN\r\nContent-Length: 5\r\n\r\nhello"; let rewritten = rewrite_http_header_block(raw, resolver.as_ref()); let rewritten = String::from_utf8(rewritten).expect("utf8"); @@ -222,7 +222,7 @@ mod tests { // Placeholders must not appear assert!( - !rewritten.contains("nemo-placeholder:env:"), + !rewritten.contains("openshell:resolve:env:"), "Placeholder leaked into rewritten request: {rewritten}" ); diff --git a/crates/navigator-sandbox/src/ssh.rs b/crates/navigator-sandbox/src/ssh.rs index 7d7655a15..568d09638 100644 --- a/crates/navigator-sandbox/src/ssh.rs +++ b/crates/navigator-sandbox/src/ssh.rs @@ -1294,7 +1294,7 @@ mod tests { let provider_env = std::iter::once(( "ANTHROPIC_API_KEY".to_string(), - "nemo-placeholder:env:ANTHROPIC_API_KEY".to_string(), + "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), )) .collect(); @@ -1312,6 +1312,6 @@ mod tests { let stdout = String::from_utf8(output.stdout).expect("utf8"); assert!(!stdout.contains(SSH_HANDSHAKE_SECRET_ENV)); - assert!(stdout.contains("ANTHROPIC_API_KEY=nemo-placeholder:env:ANTHROPIC_API_KEY")); + assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); } } diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index b6d269700..a12370bb5 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -159,7 +159,6 @@ def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> import os import socket import threading - import time from http.server import BaseHTTPRequestHandler, HTTPServer class Handler(BaseHTTPRequestHandler): @@ -175,8 +174,14 @@ def log_message(self, *a): pass server = HTTPServer(("0.0.0.0", int(target_port)), Handler) - threading.Thread(target=server.handle_request, daemon=True).start() - time.sleep(0.5) + ready = threading.Event() + + def serve_once(): + ready.set() + server.handle_request() + + threading.Thread(target=serve_once, daemon=True).start() + assert ready.wait(timeout=2.0), "echo server thread did not start" token = os.environ["ANTHROPIC_API_KEY"] conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) @@ -214,7 +219,6 @@ def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> import os import socket import threading - import time from http.server import BaseHTTPRequestHandler, HTTPServer captured_env_vars = _env_vars # noqa: F841 -- used by Handler @@ -235,8 +239,14 @@ def log_message(self, *a): pass server = HTTPServer(("0.0.0.0", int(target_port)), Handler) - threading.Thread(target=server.handle_request, daemon=True).start() - time.sleep(0.5) + ready = threading.Event() + + def serve_once(): + ready.set() + server.handle_request() + + threading.Thread(target=serve_once, daemon=True).start() + assert ready.wait(timeout=2.0), "echo server thread did not start" # Build request headers from placeholder env vars header_lines = [ @@ -325,7 +335,7 @@ def read_env_var() -> str: result = sb.exec_python(read_env_var) assert result.exit_code == 0, result.stderr value = result.stdout.strip() - assert value == "nemo-placeholder:env:ANTHROPIC_API_KEY" + assert value == "openshell:resolve:env:ANTHROPIC_API_KEY" assert value != "sk-e2e-test-key-12345" @@ -360,7 +370,7 @@ def read_generic_env_vars() -> str: assert result.exit_code == 0, result.stderr assert ( result.stdout.strip() - == "nemo-placeholder:env:CUSTOM_SERVICE_TOKEN|nemo-placeholder:env:CUSTOM_SERVICE_URL" + == "openshell:resolve:env:CUSTOM_SERVICE_TOKEN|openshell:resolve:env:CUSTOM_SERVICE_URL" ) @@ -388,7 +398,7 @@ def read_nvidia_key() -> str: with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python(read_nvidia_key) assert result.exit_code == 0, result.stderr - assert result.stdout.strip() == "nemo-placeholder:env:NVIDIA_API_KEY" + assert result.stdout.strip() == "openshell:resolve:env:NVIDIA_API_KEY" # =========================================================================== @@ -420,7 +430,7 @@ def test_provider_placeholder_is_resolved_by_proxy_on_outbound_request( assert result.exit_code == 0, result.stderr assert "200 OK" in result.stdout assert "Bearer sk-proxy-rewrite-12345" in result.stdout - assert "nemo-placeholder:env:ANTHROPIC_API_KEY" not in result.stdout + assert "openshell:resolve:env:ANTHROPIC_API_KEY" not in result.stdout def test_provider_secret_resolved_via_connect_l7_proxy( @@ -449,7 +459,7 @@ def test_provider_secret_resolved_via_connect_l7_proxy( assert "Authorization=Bearer sk-connect-l7-secret-99" in result.stdout, ( f"Real secret not found: {result.stdout}" ) - assert "nemo-placeholder:env:" not in result.stdout + assert "openshell:resolve:env:" not in result.stdout def test_provider_multiple_secrets_resolved_via_connect_l7_proxy( @@ -487,7 +497,7 @@ def test_provider_multiple_secrets_resolved_via_connect_l7_proxy( assert "Authorization=Bearer real-api-key-abc" in result.stdout assert "x-api-key=real-api-key-abc" in result.stdout assert "x-custom-token=real-custom-token-xyz" in result.stdout - assert "nemo-placeholder:env:" not in result.stdout + assert "openshell:resolve:env:" not in result.stdout # =========================================================================== diff --git a/e2e/rust/tests/provider_auto_create.rs b/e2e/rust/tests/provider_auto_create.rs index 0d528d761..62fe5a461 100644 --- a/e2e/rust/tests/provider_auto_create.rs +++ b/e2e/rust/tests/provider_auto_create.rs @@ -18,12 +18,14 @@ //! - The `openshell` binary (built automatically from the workspace) use std::process::Stdio; +use std::sync::Mutex; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::output::{extract_field, strip_ansi}; const TEST_API_KEY: &str = "sk-e2e-auto-provider-test-key"; -const TEST_API_KEY_PLACEHOLDER: &str = "nemo-placeholder:env:ANTHROPIC_API_KEY"; +const TEST_API_KEY_PLACEHOLDER: &str = "openshell:resolve:env:ANTHROPIC_API_KEY"; +static CLAUDE_PROVIDER_LOCK: Mutex<()> = Mutex::new(()); /// Helper: delete a provider by name, ignoring errors. async fn delete_provider(name: &str) { @@ -36,6 +38,17 @@ async fn delete_provider(name: &str) { let _ = cmd.status().await; } +/// Helper: check whether a provider already exists. +async fn provider_exists(name: &str) -> bool { + let mut cmd = openshell_cmd(); + cmd.arg("provider") + .arg("get") + .arg(name) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + cmd.status().await.is_ok_and(|status| status.success()) +} + /// Helper: delete a sandbox by name, ignoring errors. async fn delete_sandbox(name: &str) { let mut cmd = openshell_cmd(); @@ -51,6 +64,15 @@ async fn delete_sandbox(name: &str) { /// auto-create a "claude" provider and inject a placeholder into the sandbox. #[tokio::test] async fn auto_created_provider_credential_available_in_sandbox() { + let _provider_lock = CLAUDE_PROVIDER_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + + if provider_exists("claude").await { + eprintln!("Skipping test: existing provider 'claude' would make shared state unsafe"); + return; + } + // Clean up any leftover from a previous run. delete_provider("claude").await; From 7c3624d43f7b34bf4969d9707196bf91bdb75d57 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 01:09:39 -0700 Subject: [PATCH 07/10] test(e2e): remove proxy credential rewriting e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The echo-server-based proxy rewriting tests run both client and server inside the sandbox, so they only prove the proxy rewrites headers to a destination the sandbox itself controls — not that an external service receives real credentials. Remove the 3 tests and all associated helpers. --- e2e/python/test_sandbox_providers.py | 333 +-------------------------- 1 file changed, 8 insertions(+), 325 deletions(-) diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index a12370bb5..577eb4c83 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -5,9 +5,8 @@ Provider credentials are fetched at runtime by the sandbox supervisor via the GetSandboxProviderEnvironment gRPC call. Sandboxed child processes should see -placeholder values, while the supervisor proxy resolves those placeholders back -to the real credentials on outbound requests. Credentials must still never be -present in the persisted sandbox spec environment map. +placeholder values (not raw secrets). Credentials must never be present in the +persisted sandbox spec environment map. """ from __future__ import annotations @@ -26,25 +25,12 @@ from openshell import Sandbox, SandboxClient -# --------------------------------------------------------------------------- -# Shared constants -# --------------------------------------------------------------------------- - -_SANDBOX_IP = "10.200.0.2" -_PROXY_HOST = "10.200.0.1" -_PROXY_PORT = 3128 -_FORWARD_PROXY_PORT = 19879 -_CONNECT_L7_PORT = 19880 - - # --------------------------------------------------------------------------- # Policy helpers # --------------------------------------------------------------------------- -def _base_policy( - network_policies: dict[str, sandbox_pb2.NetworkPolicyRule] | None = None, -) -> sandbox_pb2.SandboxPolicy: +def _default_policy() -> sandbox_pb2.SandboxPolicy: """Build a sandbox policy with standard filesystem/process/landlock settings.""" return sandbox_pb2.SandboxPolicy( version=1, @@ -57,47 +43,6 @@ def _base_policy( process=sandbox_pb2.ProcessPolicy( run_as_user="sandbox", run_as_group="sandbox" ), - network_policies=network_policies or {}, - ) - - -def _forward_proxy_policy() -> sandbox_pb2.SandboxPolicy: - return _base_policy( - network_policies={ - "internal_http": sandbox_pb2.NetworkPolicyRule( - name="internal_http", - endpoints=[ - sandbox_pb2.NetworkEndpoint( - host=_SANDBOX_IP, - port=_FORWARD_PROXY_PORT, - allowed_ips=["10.200.0.0/24"], - ) - ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], - ) - }, - ) - - -def _connect_l7_policy() -> sandbox_pb2.SandboxPolicy: - """Policy with a CONNECT-eligible endpoint using L7 REST inspection.""" - return _base_policy( - network_policies={ - "internal_l7": sandbox_pb2.NetworkPolicyRule( - name="internal_l7", - endpoints=[ - sandbox_pb2.NetworkEndpoint( - host=_SANDBOX_IP, - port=_CONNECT_L7_PORT, - protocol="rest", - enforcement="enforce", - access="full", - allowed_ips=["10.200.0.0/24"], - ) - ], - binaries=[sandbox_pb2.NetworkBinary(path="/**")], - ) - }, ) @@ -142,169 +87,6 @@ def _delete_provider(stub: object, name: str) -> None: raise -# --------------------------------------------------------------------------- -# In-sandbox echo server + proxy request helpers -# -# These closures are serialized via cloudpickle and run *inside* the sandbox. -# They share a common pattern: start a tiny HTTP server, send a request -# through the proxy, and return the raw response. -# --------------------------------------------------------------------------- - - -def _echo_server_via_forward_proxy(): - """Return a closure that sends a forward-proxy (plain HTTP) request - through the sandbox proxy and returns the raw response.""" - - def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: - import os - import socket - import threading - from http.server import BaseHTTPRequestHandler, HTTPServer - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - auth = self.headers.get("Authorization", "MISSING") - body = auth.encode() - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *a): - pass - - server = HTTPServer(("0.0.0.0", int(target_port)), Handler) - ready = threading.Event() - - def serve_once(): - ready.set() - server.handle_request() - - threading.Thread(target=serve_once, daemon=True).start() - assert ready.wait(timeout=2.0), "echo server thread did not start" - - token = os.environ["ANTHROPIC_API_KEY"] - conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) - try: - conn.sendall( - f"GET http://{target_host}:{target_port}/ HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n" - f"Authorization: Bearer {token}\r\n" - f"Connection: close\r\n\r\n".encode() - ) - return _recv_all(conn) - finally: - conn.close() - server.server_close() - - return fn - - -def _echo_server_via_connect_l7(env_vars: dict[str, str] | None = None): - """Return a closure that sends a CONNECT + L7 request through the proxy. - - *env_vars* maps header names to environment variable names. Defaults to - ``{"Authorization": "ANTHROPIC_API_KEY"}`` for the single-secret case. - For multi-secret tests pass e.g. - ``{"Authorization": "MY_API_KEY", "x-api-key": "MY_API_KEY", - "x-custom-token": "CUSTOM_SERVICE_TOKEN"}``. - """ - if env_vars is None: - env_vars = {"Authorization": "ANTHROPIC_API_KEY"} - - # Capture into the closure so cloudpickle serializes the value. - _env_vars = dict(env_vars) - - def fn(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: - import os - import socket - import threading - from http.server import BaseHTTPRequestHandler, HTTPServer - - captured_env_vars = _env_vars # noqa: F841 -- used by Handler - - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - parts = [] - for hdr_name in captured_env_vars: - parts.append(f"{hdr_name}={self.headers.get(hdr_name, 'MISSING')}") - body = "\n".join(parts).encode() - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - - def log_message(self, *a): - pass - - server = HTTPServer(("0.0.0.0", int(target_port)), Handler) - ready = threading.Event() - - def serve_once(): - ready.set() - server.handle_request() - - threading.Thread(target=serve_once, daemon=True).start() - assert ready.wait(timeout=2.0), "echo server thread did not start" - - # Build request headers from placeholder env vars - header_lines = [ - f"GET / HTTP/1.1", - f"Host: {target_host}:{target_port}", - ] - for hdr_name, env_name in captured_env_vars.items(): - val = os.environ.get(env_name, "NOT_SET") - if hdr_name == "Authorization": - header_lines.append(f"Authorization: Bearer {val}") - else: - header_lines.append(f"{hdr_name}: {val}") - header_lines.append("Connection: close") - request = "\r\n".join(header_lines) + "\r\n\r\n" - - conn = socket.create_connection((proxy_host, int(proxy_port)), timeout=10) - try: - # CONNECT tunnel - conn.sendall( - f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" - f"Host: {target_host}:{target_port}\r\n\r\n".encode() - ) - connect_resp = b"" - while b"\r\n\r\n" not in connect_resp: - chunk = conn.recv(256) - if not chunk: - break - connect_resp += chunk - if "200" not in connect_resp.decode("latin1"): - return f"CONNECT failed: {connect_resp.decode('latin1').strip()}" - - # HTTP request through the tunnel - conn.sendall(request.encode()) - return _recv_all(conn) - finally: - conn.close() - server.server_close() - - return fn - - -def _recv_all(conn, timeout: float = 5.0) -> str: - """Read all available data from a socket until EOF or timeout.""" - import socket as _socket - - data = b"" - conn.settimeout(timeout) - while True: - try: - chunk = conn.recv(4096) - except (_socket.timeout, TimeoutError): - break - if not chunk: - break - data += chunk - return data.decode("latin1") - - # =========================================================================== # Tests: placeholder visibility # =========================================================================== @@ -322,7 +104,7 @@ def test_provider_credentials_available_as_env_vars( credentials={"ANTHROPIC_API_KEY": "sk-e2e-test-key-12345"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_base_policy(), + policy=_default_policy(), providers=[provider_name], ) @@ -354,7 +136,7 @@ def test_generic_provider_credentials_available_as_env_vars( }, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_base_policy(), + policy=_default_policy(), providers=[provider_name], ) @@ -386,7 +168,7 @@ def test_nvidia_provider_injects_nvidia_api_key_env_var( credentials={"NVIDIA_API_KEY": "nvapi-e2e-test-key"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_base_policy(), + policy=_default_policy(), providers=[provider_name], ) @@ -401,105 +183,6 @@ def read_nvidia_key() -> str: assert result.stdout.strip() == "openshell:resolve:env:NVIDIA_API_KEY" -# =========================================================================== -# Tests: proxy credential rewriting -# =========================================================================== - - -def test_provider_placeholder_is_resolved_by_proxy_on_outbound_request( - sandbox: Callable[..., Sandbox], - sandbox_client: SandboxClient, -) -> None: - """Forward-proxy path: placeholder in Authorization header is rewritten.""" - with provider( - sandbox_client._stub, - name="e2e-test-provider-proxy-rewrite", - provider_type="claude", - credentials={"ANTHROPIC_API_KEY": "sk-proxy-rewrite-12345"}, - ) as provider_name: - spec = datamodel_pb2.SandboxSpec( - policy=_forward_proxy_policy(), - providers=[provider_name], - ) - - with sandbox(spec=spec, delete_on_exit=True) as sb: - result = sb.exec_python( - _echo_server_via_forward_proxy(), - args=(_PROXY_HOST, _PROXY_PORT, _SANDBOX_IP, _FORWARD_PROXY_PORT), - ) - assert result.exit_code == 0, result.stderr - assert "200 OK" in result.stdout - assert "Bearer sk-proxy-rewrite-12345" in result.stdout - assert "openshell:resolve:env:ANTHROPIC_API_KEY" not in result.stdout - - -def test_provider_secret_resolved_via_connect_l7_proxy( - sandbox: Callable[..., Sandbox], - sandbox_client: SandboxClient, -) -> None: - """CONNECT + L7 path: placeholder is rewritten before reaching upstream.""" - with provider( - sandbox_client._stub, - name="e2e-test-connect-l7-rewrite", - provider_type="claude", - credentials={"ANTHROPIC_API_KEY": "sk-connect-l7-secret-99"}, - ) as provider_name: - spec = datamodel_pb2.SandboxSpec( - policy=_connect_l7_policy(), - providers=[provider_name], - ) - - with sandbox(spec=spec, delete_on_exit=True) as sb: - result = sb.exec_python( - _echo_server_via_connect_l7(), - args=(_PROXY_HOST, _PROXY_PORT, _SANDBOX_IP, _CONNECT_L7_PORT), - ) - assert result.exit_code == 0, result.stderr - assert "200 OK" in result.stdout, f"Expected 200 OK, got: {result.stdout}" - assert "Authorization=Bearer sk-connect-l7-secret-99" in result.stdout, ( - f"Real secret not found: {result.stdout}" - ) - assert "openshell:resolve:env:" not in result.stdout - - -def test_provider_multiple_secrets_resolved_via_connect_l7_proxy( - sandbox: Callable[..., Sandbox], - sandbox_client: SandboxClient, -) -> None: - """Multiple provider secrets are rewritten through CONNECT + L7 proxy.""" - with provider( - sandbox_client._stub, - name="e2e-test-connect-l7-multi", - provider_type="generic", - credentials={ - "MY_API_KEY": "real-api-key-abc", - "CUSTOM_SERVICE_TOKEN": "real-custom-token-xyz", - }, - ) as provider_name: - spec = datamodel_pb2.SandboxSpec( - policy=_connect_l7_policy(), - providers=[provider_name], - ) - - with sandbox(spec=spec, delete_on_exit=True) as sb: - result = sb.exec_python( - _echo_server_via_connect_l7( - env_vars={ - "Authorization": "MY_API_KEY", - "x-api-key": "MY_API_KEY", - "x-custom-token": "CUSTOM_SERVICE_TOKEN", - } - ), - args=(_PROXY_HOST, _PROXY_PORT, _SANDBOX_IP, _CONNECT_L7_PORT), - ) - assert result.exit_code == 0, result.stderr - assert "200 OK" in result.stdout, f"Expected 200 OK, got: {result.stdout}" - assert "Authorization=Bearer real-api-key-abc" in result.stdout - assert "x-api-key=real-api-key-abc" in result.stdout - assert "x-custom-token=real-custom-token-xyz" in result.stdout - assert "openshell:resolve:env:" not in result.stdout - - # =========================================================================== # Tests: security & edge cases # =========================================================================== @@ -524,7 +207,7 @@ def test_create_sandbox_rejects_unknown_provider( ) -> None: """CreateSandbox fails fast when a provider name does not exist.""" spec = datamodel_pb2.SandboxSpec( - policy=_base_policy(), + policy=_default_policy(), providers=["nonexistent-provider-xyz"], ) with pytest.raises(grpc.RpcError) as exc_info: @@ -546,7 +229,7 @@ def test_credentials_not_in_persisted_spec_environment( credentials={"ANTHROPIC_API_KEY": "sk-should-not-persist"}, ) as provider_name: spec = datamodel_pb2.SandboxSpec( - policy=_base_policy(), + policy=_default_policy(), providers=[provider_name], ) From 663e5c88de27943687179ce58efb8b28ef029e30 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 01:11:09 -0700 Subject: [PATCH 08/10] feat(sandbox): add opencode network policy and fix nvidia_inference TLS config Add opencode network policy with models.dev, registry.npmjs.org, and opencode.ai endpoints. Fix nvidia_inference to include protocol: rest and tls: terminate so the proxy can rewrite credential placeholders. Add correct OpenCode binary paths to both policies. --- deploy/docker/sandbox/dev-sandbox-policy.yaml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/deploy/docker/sandbox/dev-sandbox-policy.yaml b/deploy/docker/sandbox/dev-sandbox-policy.yaml index 421a69f17..6ce93cdf9 100644 --- a/deploy/docker/sandbox/dev-sandbox-policy.yaml +++ b/deploy/docker/sandbox/dev-sandbox-policy.yaml @@ -72,14 +72,27 @@ network_policies: binaries: - { path: /usr/bin/git } + opencode: + name: opencode + endpoints: + - { host: models.dev, port: 443 } + - { host: registry.npmjs.org, port: 443 } + - { host: opencode.ai, port: 443 } + binaries: + - { path: /usr/lib/node_modules/opencode-ai/bin/.opencode } + - { path: /usr/bin/node } + - { path: /usr/local/bin/opencode } + nvidia_inference: name: nvidia-inference endpoints: - - { host: integrate.api.nvidia.com, port: 443 } + - { host: integrate.api.nvidia.com, port: 443, protocol: rest, enforcement: enforce, access: full, tls: terminate } binaries: + - { path: /usr/lib/node_modules/opencode-ai/bin/.opencode } + - { path: /usr/bin/node } + - { path: /usr/local/bin/opencode } - { path: /usr/bin/curl } - { path: /bin/bash } - - { path: /usr/local/bin/opencode } # --- GitHub REST API (read-only) --- github_rest_api: From 83e55cffb9b960ab0aa7556433f4ea03214ba5a0 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 01:16:00 -0700 Subject: [PATCH 09/10] wip --- deploy/docker/sandbox/dev-sandbox-policy.yaml | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/deploy/docker/sandbox/dev-sandbox-policy.yaml b/deploy/docker/sandbox/dev-sandbox-policy.yaml index 6ce93cdf9..02ef4b1f2 100644 --- a/deploy/docker/sandbox/dev-sandbox-policy.yaml +++ b/deploy/docker/sandbox/dev-sandbox-policy.yaml @@ -72,27 +72,14 @@ network_policies: binaries: - { path: /usr/bin/git } - opencode: - name: opencode - endpoints: - - { host: models.dev, port: 443 } - - { host: registry.npmjs.org, port: 443 } - - { host: opencode.ai, port: 443 } - binaries: - - { path: /usr/lib/node_modules/opencode-ai/bin/.opencode } - - { path: /usr/bin/node } - - { path: /usr/local/bin/opencode } - nvidia_inference: name: nvidia-inference endpoints: - - { host: integrate.api.nvidia.com, port: 443, protocol: rest, enforcement: enforce, access: full, tls: terminate } + - { host: integrate.api.nvidia.com, port: 443 } binaries: - - { path: /usr/lib/node_modules/opencode-ai/bin/.opencode } - - { path: /usr/bin/node } - - { path: /usr/local/bin/opencode } - { path: /usr/bin/curl } - { path: /bin/bash } + - { path: /usr/local/bin/opencode } # --- GitHub REST API (read-only) --- github_rest_api: @@ -158,3 +145,17 @@ network_policies: - { path: /usr/bin/curl } - { path: /usr/bin/wget } - { path: "/sandbox/.cursor-server/**" } + + opencode: + name: opencode + endpoints: + - host: registry.npmjs.org + port: 443 + - host: opencode.ai + port: 443 + - host: integrate.api.nvidia.com + port: 443 + binaries: + - path: /usr/lib/node_modules/opencode-ai/bin/.opencode + - path: /usr/bin/node + - path: /usr/local/bin/opencode From 12e22e0cf689416f4805fde4fe441a991736d63d Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Thu, 12 Mar 2026 10:08:05 -0700 Subject: [PATCH 10/10] style(sandbox): fix rustfmt formatting for handle_tcp_connection call --- crates/navigator-sandbox/src/proxy.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/navigator-sandbox/src/proxy.rs b/crates/navigator-sandbox/src/proxy.rs index beadb90d6..b644120d7 100644 --- a/crates/navigator-sandbox/src/proxy.rs +++ b/crates/navigator-sandbox/src/proxy.rs @@ -164,9 +164,10 @@ impl ProxyHandle { let resolver = secret_resolver.clone(); let dtx = denial_tx.clone(); tokio::spawn(async move { - if let Err(err) = - handle_tcp_connection(stream, opa, cache, spid, tls, inf, resolver, dtx) - .await + if let Err(err) = handle_tcp_connection( + stream, opa, cache, spid, tls, inf, resolver, dtx, + ) + .await { warn!(error = %err, "Proxy connection error"); }