Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 190 additions & 0 deletions crates/openshell-driver-mxc/tests/wxc_exec_real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,97 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;

mod token_probe {
#![allow(unsafe_code)]

use std::ffi::c_void;
use std::ptr;

#[link(name = "kernel32")]
unsafe extern "system" {
fn GetCurrentProcess() -> isize;
fn CloseHandle(handle: isize) -> i32;
fn GetLastError() -> u32;
}

#[link(name = "advapi32")]
unsafe extern "system" {
fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32;
fn GetTokenInformation(
token: isize,
class: i32,
info: *mut c_void,
len: u32,
return_len: *mut u32,
) -> i32;
fn OpenSCManagerW(machine: *const u16, database: *const u16, access: u32) -> isize;
fn CloseServiceHandle(handle: isize) -> i32;
}

fn token_u32(token: isize, class: i32) -> Option<u32> {
let mut value = 0u32;
let mut returned = 0u32;
let ok = unsafe {
GetTokenInformation(
token,
class,
(&raw mut value).cast(),
u32::try_from(size_of::<u32>()).expect("u32 size fits Win32 length"),
&raw mut returned,
)
};
(ok != 0).then_some(value)
}

fn has_appcontainer_sid(token: isize) -> bool {
let mut buf = [0u8; 256];
let mut returned = 0u32;
let ok = unsafe {
GetTokenInformation(
token,
31, // TokenAppContainerSid
buf.as_mut_ptr().cast(),
u32::try_from(buf.len()).expect("token buffer length fits u32"),
&raw mut returned,
)
};
ok != 0 && !unsafe { ptr::read_unaligned(buf.as_ptr().cast::<*const c_void>()) }.is_null()
}

fn service_manager_create_access() -> (bool, u32) {
let manager = unsafe {
OpenSCManagerW(
ptr::null(),
ptr::null(),
0x0002, // SC_MANAGER_CREATE_SERVICE
)
};
if manager == 0 {
return (false, unsafe { GetLastError() });
}
unsafe { CloseServiceHandle(manager) };
(true, 0)
}

pub fn snapshot() -> serde_json::Value {
let mut token = 0isize;
let opened = unsafe { OpenProcessToken(GetCurrentProcess(), 0x0008, &raw mut token) };
assert_ne!(opened, 0, "OpenProcessToken failed");

let (can_create_service, create_service_error) = service_manager_create_access();
let snapshot = serde_json::json!({
"is_appcontainer": token_u32(token, 29), // TokenIsAppContainer
"has_appcontainer_sid": has_appcontainer_sid(token),
"can_create_service": can_create_service,
"create_service_error": create_service_error,
});
unsafe { CloseHandle(token) };
snapshot
}
}

const TOKEN_PROBE_MARKER: &str = "OPENSHELL_MXC_TOKEN_PROBE=";

// ── Path resolution ──────────────────────────────────────────────────────────

/// Resolve the path to `wxc-exec.exe`.
Expand Down Expand Up @@ -477,6 +568,17 @@ fn dryrun_accepts_split_policy_output() {
// These skip on this box (processcontainer velocity keys not enabled;
// isolation_session backend absent). They PASS where backends are live.

/// Entrypoint used by `pc_oneshot_token_is_appcontainer_without_admin_access`.
/// The parent test relaunches this integration-test binary inside MXC so the
/// probe observes the workload token rather than the host test runner's token.
#[test]
fn child_token_probe_entry() {
if std::env::var("OPENSHELL_MXC_CHILD_TOKEN_PROBE").as_deref() != Ok("1") {
return;
}
println!("{TOKEN_PROBE_MARKER}{}", token_probe::snapshot());
}

/// Probe the processcontainer backend.
///
/// Runs a trivial one-shot (`cmd /c exit 0`, user-owned temp grant). Returns
Expand Down Expand Up @@ -801,6 +903,94 @@ fn pc_oneshot_in_policy_write_succeeds() {
);
}

/// Verify the default `ProcessContainer` token and an administrator-gated
/// access attempt. `whoami /all` is not sufficient for this assertion: the
/// package SID is exposed through `TokenAppContainerSid`, and `AppContainer`
/// access is the intersection of the user/group and package/capability grants.
#[test]
#[ignore = "requires real wxc-exec"]
fn pc_oneshot_token_is_appcontainer_without_admin_access() {
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;
}

let (tempdir, temp_path) = temp_fixture();
let test_exe = std::env::current_exe().expect("resolve integration-test executable");
let test_exe_parent = test_exe
.parent()
.expect("integration-test executable has a parent")
.to_string_lossy()
.into_owned();
let command_line = format!(
"\"{}\" --exact child_token_probe_entry --nocapture",
test_exe.display()
);
let mut child_env = vec!["OPENSHELL_MXC_CHILD_TOKEN_PROBE=1".to_string()];
child_env.extend(
["SYSTEMROOT", "WINDIR", "PATH", "COMSPEC", "LOCALAPPDATA"]
.into_iter()
.filter_map(|key| {
std::env::var(key)
.ok()
.map(|value| format!("{key}={value}"))
}),
);
let config = serde_json::json!({
"version": "0.8.0-alpha",
"containerId": "pc-token-identity",
"containment": "processcontainer",
"process": {
"commandLine": command_line,
"cwd": temp_path,
"env": child_env,
"timeout": 30_000,
},
"filesystem": {
"readwritePaths": [temp_path],
"readonlyPaths": [test_exe_parent],
},
"processContainer": {
"leastPrivilege": false,
},
"ui": {
"disable": false,
"clipboard": "none",
"injection": false,
},
});
let json = serde_json::to_string(&config).unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
let out = Command::new(&wxc)
.arg("--config-base64")
.arg(&b64)
.output()
.expect("wxc-exec token probe spawn");
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
out.status.success(),
"token probe should exit successfully\nstdout={stdout}\nstderr={stderr}"
);
let snapshot: serde_json::Value = stdout
.lines()
.find_map(|line| line.trim().strip_prefix(TOKEN_PROBE_MARKER))
.map_or_else(
|| panic!("token probe marker missing\nstdout={stdout}\nstderr={stderr}"),
|value| serde_json::from_str(value).expect("parse token probe JSON"),
);

assert_eq!(snapshot["is_appcontainer"], 1);
assert_eq!(snapshot["has_appcontainer_sid"], true);
assert_eq!(snapshot["can_create_service"], false);
assert_eq!(snapshot["create_service_error"], 5); // ERROR_ACCESS_DENIED
drop(tempdir);
}

/// Run an HTTPS request through the real driver and `ProcessContainer`. The
/// workload explicitly reads the injected bundle before curl uses it, proving
/// that the driver's internal TLS share is reachable from the `AppContainer`.
Expand Down
1 change: 1 addition & 0 deletions crates/openshell-supervisor-network/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ pub async fn start_host_proxy(config: HostProxyConfig) -> Result<HostProxyHandle
config.activity_tx,
ready_rx,
&upstream_proxy_args,
None,
)
.await?;

Expand Down
11 changes: 11 additions & 0 deletions docs/reference/gateway-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,17 @@ debug = false
etw_audit = false
```

The default `pc_least_privilege = false` still runs the workload with an
AppContainer token. Set it to `true` to request the stricter Less Privileged
AppContainer (LPAC) variant. Windows AppContainer tokens retain the launching
account's user SID and group SIDs. Seeing the same username or
`BUILTIN\\Administrators` in `whoami /all` therefore does not mean the sandbox
can exercise administrator access: Windows
requires both the user/group and AppContainer package/capability sides of an
access check to grant the requested operation. The MXC real-binary integration
suite verifies `TokenIsAppContainer`, `TokenAppContainerSid`, and denial of a
non-mutating administrator-gated Service Control Manager open.

Set `egress_proxy = true` with `egress_proxy_addr = "127.0.0.1:18080"` to enable the Windows Pattern C split. The address must be a `127.0.0.1:PORT` socket. The driver allocates a unique ephemeral port per sandbox, injects that listener through proxy environment variables, and stages the public proxy CA beneath the sandbox's configured `<cwd>/.openshell-proxy/<sandbox-id>/`. A non-empty per-sandbox `cwd` is therefore required when governed egress is enabled; sandbox-specific subdirectories prevent concurrent sandboxes from overwriting each other's trust files. MXC denies direct Internet egress but allows `127.0.0.1/32`; this permits dynamic forwarding but also means the sandbox can reach unrelated host services bound to loopback.

MXC rejects policies containing `network_middlewares` before launch because this host-proxy path does not receive the gateway middleware registry.
Expand Down
Loading