Skip to content
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ terminal-colorsaurus = "1.0"
miette = { version = "7", features = ["fancy"] }
thiserror = "2"

# Windows platform APIs (ETW/TDH audit consumer in openshell-driver-mxc; Windows-only)
windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Time"] }
# Windows platform APIs (MXC audit and host-proxy process identity; Windows-only)
windows = { version = "0.62", features = ["Wdk_System_Threading", "Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_System_Diagnostics_Etw", "Win32_System_Threading", "Win32_System_Time"] }
anyhow = "1"

# Logging/Tracing
Expand Down
9 changes: 0 additions & 9 deletions crates/openshell-driver-mxc/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -668,14 +668,6 @@ fn allocate_sandbox_proxy_addr(
const MINIMAL_WINDOWS_BOOTSTRAP_ENV: [&str; 5] =
["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"];

fn host_proxy_binary_path(config: &MxcSandboxConfig) -> PathBuf {
config
.command
.first()
.filter(|command| !command.trim().is_empty())
.map_or_else(|| PathBuf::from("mxc-agent"), PathBuf::from)
}

const TLS_ENV_KEYS: [&str; 6] = [
"NODE_EXTRA_CA_CERTS",
"DENO_CERT",
Expand Down Expand Up @@ -1621,7 +1613,6 @@ async fn run_lifecycle(
openshell_supervisor_network::host::HostProxyConfig {
bind_addr: addr,
policy: proxy_policy,
binary_path: host_proxy_binary_path(&sandbox_config),
client_auth: proxy_auth.host_client_auth(),
sandbox_id: Some(sandbox_id.clone()),
sandbox_name: Some(sandbox_name.clone()),
Expand Down
165 changes: 165 additions & 0 deletions crates/openshell-driver-mxc/tests/wxc_exec_real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1125,6 +1125,171 @@ async fn pc_https_egress_reads_injected_ca_bundle() {
);
}

/// Prove that host-proxy binary policy follows the process that owns each TCP
/// connection, rather than the sandbox entry command. This deliberately uses
/// L4 CONNECT policy so the assertion is independent of TLS/L7 enforcement.
#[tokio::test]
#[ignore = "requires real wxc-exec and outbound HTTPS"]
async fn pc_proxy_scopes_network_policy_to_socket_owner() {
let Some(wxc) = wxc_path() else {
eprintln!("SKIP: wxc-exec not found");
return;
};
if let Err(reason) = probe_processcontainer(&wxc) {
eprintln!("SKIP: processcontainer not live: {reason}");
return;
}

// QueryFullProcessImageNameW returns this Win32 spelling on the Windows
// test image. Keep the spelling exact here; path and case normalization
// are covered separately.
let cmd = PathBuf::from(r"C:\Windows\System32\cmd.exe");
let curl = PathBuf::from(r"C:\Windows\System32\curl.exe");
if !cmd.exists() || !curl.exists() {
eprintln!(
"SKIP: expected Windows binaries are absent (cmd={}, curl={})",
cmd.display(),
curl.display()
);
return;
}

let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
run_proxy_binary_scope_case(&wxc, "pc-owner-allow-child", &cmd, &curl, &curl, true).await;
run_proxy_binary_scope_case(&wxc, "pc-owner-deny-child", &cmd, &curl, &cmd, false).await;
}

async fn run_proxy_binary_scope_case(
wxc: &Path,
sandbox_id: &str,
cmd: &Path,
curl: &Path,
allowed_binary: &Path,
expect_allowed: bool,
) {
let output_dir = tempfile::tempdir().expect("proxy scope output directory");
let output_path = output_dir.path().join("example.html");
let diagnostic_path = output_dir.path().join("curl-diagnostic.txt");
let output_dir_string = output_dir.path().to_string_lossy().into_owned();
let command = vec![
cmd.to_string_lossy().into_owned(),
"/d".to_string(),
"/c".to_string(),
format!(
"echo proxy-scope 1>\"{}\" && \"{}\" --fail --silent --show-error --ssl-no-revoke --cacert \"%CURL_CA_BUNDLE%\" https://example.com/ --output \"{}\" 2>>\"{}\"",
diagnostic_path.display(),
curl.display(),
output_path.display(),
diagnostic_path.display()
),
];
let serde_json::Value::Object(driver_config) = serde_json::json!({
"command": command,
"cwd": output_dir_string,
}) else {
unreachable!();
};
let policy = SandboxPolicy {
version: 1,
filesystem: Some(FilesystemPolicy {
include_workdir: false,
read_only: Vec::new(),
read_write: vec![output_dir_string],
}),
network_policies: std::collections::HashMap::from([(
"https_example".to_string(),
NetworkPolicyRule {
name: "https-example".to_string(),
endpoints: vec![NetworkEndpoint {
host: "example.com".to_string(),
ports: vec![443],
..Default::default()
}],
binaries: vec![NetworkBinary {
path: allowed_binary.to_string_lossy().into_owned(),
}],
},
)]),
..Default::default()
};
let sandbox = DriverSandbox {
id: sandbox_id.to_string(),
name: sandbox_id.to_string(),
spec: Some(DriverSandboxSpec {
template: Some(DriverSandboxTemplate {
driver_config: Some(
openshell_core::proto_struct::json_object_to_struct(driver_config)
.expect("driver config"),
),
..Default::default()
}),
policy: Some(policy),
..Default::default()
}),
..Default::default()
};
let backend = MxcComputeBackend::new(MxcComputeConfig {
wxc_exec_path: wxc.to_string_lossy().into_owned(),
egress_proxy: true,
egress_proxy_addr: "127.0.0.1:18080".to_string(),
..Default::default()
});
backend
.create_sandbox(&sandbox)
.await
.expect("real proxy-scope sandbox create accepted");

let mut terminal_condition = None;
for _ in 0..600 {
if let Some(observed) = backend.get_sandbox(sandbox_id).await
&& let Some(condition) = observed
.status
.and_then(|status| status.conditions.into_iter().find(|c| c.r#type == "Ready"))
&& matches!(
condition.reason.as_str(),
"AgentCompleted" | "ExecFailed" | "ProvisionFailed"
)
{
terminal_condition = Some(condition);
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
let condition = terminal_condition.expect("proxy-scope sandbox should terminate");
let diagnostic = std::fs::read_to_string(&diagnostic_path)
.unwrap_or_else(|error| format!("failed to read curl diagnostic: {error}"));
backend
.delete_sandbox(sandbox_id, sandbox_id)
.await
.expect("delete completed proxy-scope sandbox");

if expect_allowed {
assert_eq!(
condition.reason, "AgentCompleted",
"declared child binary must be allowed: {}; diagnostic: {diagnostic}",
condition.message
);
assert!(
std::fs::metadata(&output_path).is_ok_and(|metadata| metadata.len() > 0),
"allowed curl response should be non-empty; diagnostic: {diagnostic}"
);
} else {
assert_eq!(
condition.reason, "ExecFailed",
"entry-command grant must not be inherited by curl: {}; diagnostic: {diagnostic}",
condition.message
);
assert!(
diagnostic.contains("403"),
"undeclared curl child should receive proxy 403; diagnostic: {diagnostic}"
);
assert!(
!output_path.exists(),
"denied curl child must not write an HTTPS response"
);
}
}

/// Write to a path OUTSIDE the granted dir; assert exit non-zero and file absent.
/// This is the genuine OS default-deny proof — the `AppContainer` blocks the write
/// without requiring any host ACL lockdown. The mock can only fake this.
Expand Down
55 changes: 16 additions & 39 deletions crates/openshell-supervisor-network/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,6 @@ pub struct HostProxyConfig {
pub bind_addr: SocketAddr,
/// Network-only policy produced by the compute driver's policy split.
pub policy: ProtoSandboxPolicy,
/// Static process identity used when the platform cannot recover the
/// socket-owning sandbox process. Policy binaries must match this path for
/// L4/L7 allow rules to pass.
pub binary_path: PathBuf,
/// Per-sandbox client authentication. Host-side MXC proxies must set this
/// so another sandbox cannot borrow this proxy's identity and policy.
pub client_auth: HostProxyClientAuth,
Expand Down Expand Up @@ -250,10 +246,9 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result<HostProxyHandle
(None, None, None)
}
};
let identity_mode = ProxyIdentityMode::static_binary_with_client_auth(
config.binary_path,
Some(config.client_auth.expected_proxy_authorization),
)?
let identity_mode = ProxyIdentityMode::windows_with_client_auth(Some(
config.client_auth.expected_proxy_authorization,
))
.with_event_context(event_context);
let proxy = ProxyHandle::start_with_bind_addr(
&proxy_policy,
Expand Down Expand Up @@ -292,14 +287,13 @@ mod tests {

use super::*;

fn test_config(bind_addr: SocketAddr, binary_path: PathBuf) -> HostProxyConfig {
fn test_config(bind_addr: SocketAddr) -> HostProxyConfig {
HostProxyConfig {
bind_addr,
policy: ProtoSandboxPolicy {
version: 1,
..Default::default()
},
binary_path,
client_auth: HostProxyClientAuth::basic("openshell", "test-secret"),
sandbox_id: Some("sandbox-123".to_string()),
sandbox_name: Some("agent-box".to_string()),
Expand All @@ -314,7 +308,7 @@ mod tests {
#[test]
fn host_proxy_event_context_uses_configured_sandbox_identity() {
let bind_addr = "127.0.0.1:18080".parse().unwrap();
let config = test_config(bind_addr, PathBuf::from("agent.exe"));
let config = test_config(bind_addr);

let context = host_proxy_event_context(&config).unwrap();

Expand All @@ -326,10 +320,7 @@ mod tests {

#[test]
fn host_proxy_event_context_rejects_missing_sandbox_identity() {
let mut config = test_config(
"127.0.0.1:18080".parse().unwrap(),
PathBuf::from("agent.exe"),
);
let mut config = test_config("127.0.0.1:18080".parse().unwrap());
config.sandbox_id = Some(" ".to_string());

let error = host_proxy_event_context(&config).unwrap_err();
Expand Down Expand Up @@ -369,7 +360,9 @@ mod tests {
let mut client = TcpStream::connect(addr).await.unwrap();
client.write_all(request.as_bytes()).await.unwrap();
let mut response = Vec::new();
tokio::time::timeout(Duration::from_secs(2), client.read_to_end(&mut response))
// The first authenticated CONNECT performs a full executable hash for
// TOFU identity binding; debug test binaries can be hundreds of MB.
tokio::time::timeout(Duration::from_secs(10), client.read_to_end(&mut response))
.await
.unwrap()
.unwrap();
Expand All @@ -378,11 +371,7 @@ mod tests {

#[tokio::test]
async fn rejects_non_loopback_bind_addr() {
let result = start_host_proxy(test_config(
([192, 0, 2, 1], 0).into(),
PathBuf::from("missing-agent.exe"),
))
.await;
let result = start_host_proxy(test_config(([192, 0, 2, 1], 0).into())).await;

let Err(err) = result else {
panic!("host proxy should reject non-loopback bind addresses");
Expand All @@ -395,10 +384,7 @@ mod tests {

#[tokio::test]
async fn rejects_middleware_policy_without_registry() {
let mut config = test_config(
([127, 0, 0, 1], 0).into(),
PathBuf::from("missing-agent.exe"),
);
let mut config = test_config(([127, 0, 0, 1], 0).into());
config.policy.network_middlewares.insert(
"redactor".into(),
NetworkMiddlewareConfig {
Expand Down Expand Up @@ -427,15 +413,9 @@ mod tests {
#[tokio::test]
async fn starts_loopback_proxy_and_serves_policy_local() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let binary = tempfile::NamedTempFile::new().unwrap();
std::fs::write(binary.path(), b"agent").unwrap();

let handle = start_host_proxy(test_config(
([127, 0, 0, 1], 0).into(),
binary.path().to_path_buf(),
))
.await
.unwrap();
let handle = start_host_proxy(test_config(([127, 0, 0, 1], 0).into()))
.await
.unwrap();

let addr = handle.http_addr().expect("proxy should report bound addr");
assert!(addr.ip().is_loopback());
Expand Down Expand Up @@ -475,9 +455,6 @@ mod tests {

#[tokio::test]
async fn per_sandbox_credentials_reject_missing_wrong_cross_and_duplicate_auth() {
let binary = tempfile::NamedTempFile::new().unwrap();
std::fs::write(binary.path(), b"agent").unwrap();

let auth_a = HostProxyClientAuth::basic("openshell", "sandbox-a-secret");
let auth_b = HostProxyClientAuth::basic("openshell", "sandbox-b-secret");
// Node's EnvHttpProxyAgent currently emits the field name in lower
Expand All @@ -491,11 +468,11 @@ mod tests {
auth_b.expected_proxy_authorization
);

let mut config_a = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf());
let mut config_a = test_config(([127, 0, 0, 1], 0).into());
config_a.client_auth = auth_a;
let proxy_a = start_host_proxy(config_a).await.unwrap();

let mut config_b = test_config(([127, 0, 0, 1], 0).into(), binary.path().to_path_buf());
let mut config_b = test_config(([127, 0, 0, 1], 0).into());
config_b.client_auth = auth_b;
let proxy_b = start_host_proxy(config_b).await.unwrap();

Expand Down
Loading
Loading