Skip to content
Closed
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
77 changes: 52 additions & 25 deletions crates/openshell-driver-kubernetes/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@ impl KubernetesComputeDriver {
enable_user_namespaces: self.config.enable_user_namespaces,
workspace_default_storage_size: &self.config.workspace_default_storage_size,
sa_token_ttl_secs: self.config.effective_sa_token_ttl_secs(),
is_platform_mode: sandbox
.spec
.as_ref()
.and_then(|s| s.policy.as_ref())
.map_or(false, |p| p.network_enforcement == 1),
};
obj.data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), &params);
let api = self.api();
Expand Down Expand Up @@ -823,6 +828,7 @@ fn apply_supervisor_sideload(
supervisor_image: &str,
supervisor_image_pull_policy: &str,
method: SupervisorSideloadMethod,
is_platform_mode: bool,
) {
let Some(spec) = pod_template.get_mut("spec").and_then(|v| v.as_object_mut()) else {
return;
Expand Down Expand Up @@ -882,16 +888,16 @@ fn apply_supervisor_sideload(
serde_json::json!([format!("{}/openshell-sandbox", SUPERVISOR_MOUNT_PATH)]),
);

// Force the supervisor to run as root (UID 0). Sandbox images may set
// a non-root USER directive (e.g. `USER sandbox`), but the supervisor
// needs root to create network namespaces, set up the proxy, and
// configure Landlock/seccomp. The supervisor itself drops privileges
// for child processes via the policy's `run_as_user`/`run_as_group`.
let security_context = container
.entry("securityContext")
.or_insert_with(|| serde_json::json!({}));
if let Some(sc) = security_context.as_object_mut() {
sc.insert("runAsUser".to_string(), serde_json::json!(0));
// In namespace mode, force root (UID 0) so the supervisor can create
// network namespaces and drop privileges for child processes.
// In platform mode, keep the image's default non-root user.
if !is_platform_mode {
let security_context = container
.entry("securityContext")
.or_insert_with(|| serde_json::json!({}));
if let Some(sc) = security_context.as_object_mut() {
sc.insert("runAsUser".to_string(), serde_json::json!(0));
}
}

// Add volume mount
Expand Down Expand Up @@ -1044,6 +1050,10 @@ struct SandboxPodParams<'a> {
/// Lifetime (seconds) of the projected `ServiceAccount` token used
/// for the bootstrap `IssueSandboxToken` exchange.
sa_token_ttl_secs: i64,
/// Platform network enforcement mode (Issue #899). When true, sandbox
/// pods are emitted without elevated capabilities, compatible with
/// restricted-v2 SCC and restricted Pod Security Standard.
is_platform_mode: bool,
}

impl Default for SandboxPodParams<'_> {
Expand All @@ -1065,6 +1075,7 @@ impl Default for SandboxPodParams<'_> {
enable_user_namespaces: false,
workspace_default_storage_size: DEFAULT_WORKSPACE_STORAGE_SIZE,
sa_token_ttl_secs: 3600,
is_platform_mode: false,
}
}
}
Expand Down Expand Up @@ -1265,22 +1276,32 @@ fn sandbox_template_to_k8s(

container.insert("env".to_string(), serde_json::Value::Array(env));

let mut capabilities: Vec<&str> = vec!["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYSLOG"];
if use_user_namespaces {
// In a user namespace the bounding set is reset. SETUID/SETGID are
// needed for the supervisor to drop privileges to the sandbox user.
// DAC_READ_SEARCH is needed for cross-UID /proc/<pid>/fd/ access
// for process identity resolution in network policy enforcement.
capabilities.extend(["SETUID", "SETGID", "DAC_READ_SEARCH"]);
if params.is_platform_mode {
// Platform mode: zero elevated capabilities. Compatible with
// restricted-v2 SCC and restricted Pod Security Standard.
container.insert(
"securityContext".to_string(),
serde_json::json!({
"allowPrivilegeEscalation": false,
"capabilities": {
"drop": ["ALL"]
}
}),
);
} else {
let mut capabilities: Vec<&str> = vec!["SYS_ADMIN", "NET_ADMIN", "SYS_PTRACE", "SYSLOG"];
if use_user_namespaces {
capabilities.extend(["SETUID", "SETGID", "DAC_READ_SEARCH"]);
}
container.insert(
"securityContext".to_string(),
serde_json::json!({
"capabilities": {
"add": capabilities
}
}),
);
}
container.insert(
"securityContext".to_string(),
serde_json::json!({
"capabilities": {
"add": capabilities
}
}),
);

// Mount client TLS secret for mTLS to the server, plus the projected
// ServiceAccount token used to bootstrap the sandbox's gateway JWT
Expand Down Expand Up @@ -1363,6 +1384,7 @@ fn sandbox_template_to_k8s(
params.supervisor_image,
params.supervisor_image_pull_policy,
params.supervisor_sideload_method,
params.is_platform_mode,
);

// Inject workspace persistence (init container + PVC volume mount) so
Expand Down Expand Up @@ -1750,6 +1772,7 @@ mod tests {
"custom-image:latest",
"IfNotPresent",
SupervisorSideloadMethod::InitContainer,
false,
);

let sc = &pod_template["spec"]["containers"][0]["securityContext"];
Expand Down Expand Up @@ -1779,6 +1802,7 @@ mod tests {
"supervisor-image:latest",
"IfNotPresent",
SupervisorSideloadMethod::InitContainer,
false,
);

let sc = &pod_template["spec"]["containers"][0]["securityContext"];
Expand All @@ -1804,6 +1828,7 @@ mod tests {
"supervisor-image:latest",
"IfNotPresent",
SupervisorSideloadMethod::InitContainer,
false,
);

// Volume should be an emptyDir
Expand Down Expand Up @@ -1878,6 +1903,7 @@ mod tests {
"supervisor-image:latest",
"IfNotPresent",
SupervisorSideloadMethod::ImageVolume,
false,
);

let volumes = pod_template["spec"]["volumes"]
Expand Down Expand Up @@ -1932,6 +1958,7 @@ mod tests {
"supervisor-image:latest",
"",
SupervisorSideloadMethod::ImageVolume,
false,
);

let volume = &pod_template["spec"]["volumes"][0];
Expand Down
20 changes: 20 additions & 0 deletions crates/openshell-policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1821,4 +1821,24 @@ network_policies:
"port >65535 should fail to parse"
);
}

#[test]
fn platform_mode_round_trip() {
let mut policy = restrictive_default_policy();
policy.network_enforcement = 1; // PLATFORM
let yaml = serialize_sandbox_policy(&policy).unwrap();
let parsed = parse_sandbox_policy(&yaml).unwrap();
assert_eq!(parsed.network_enforcement, 1);
}

#[test]
fn platform_mode_passes_validation() {
let mut policy = restrictive_default_policy();
policy.network_enforcement = 1; // PLATFORM
let result = validate_sandbox_policy(&policy);
assert!(
result.is_ok(),
"Platform mode should pass validation: {result:?}"
);
}
}
49 changes: 42 additions & 7 deletions crates/openshell-sandbox/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,10 @@ pub async fn run_sandbox(

// Generate ephemeral CA and TLS state for HTTPS L7 inspection.
// The CA cert is written to disk so sandbox processes can trust it.
let (tls_state, ca_file_paths) = if matches!(policy.network.mode, NetworkMode::Proxy) {
let (tls_state, ca_file_paths) = if matches!(
policy.network.mode,
NetworkMode::Proxy | NetworkMode::Platform
) {
match SandboxCa::generate() {
Ok(ca) => {
let tls_dir = std::path::Path::new("/etc/openshell-tls");
Expand Down Expand Up @@ -601,7 +604,10 @@ pub async fn run_sandbox(
let entrypoint_pid = Arc::new(AtomicU32::new(0));

let (_proxy, denial_rx, bypass_denial_tx, activity_rx, bypass_activity_tx) =
if matches!(policy.network.mode, NetworkMode::Proxy) {
if matches!(
policy.network.mode,
NetworkMode::Proxy | NetworkMode::Platform
) {
let proxy_policy = policy.network.proxy.as_ref().ok_or_else(|| {
miette::miette!(
"Network mode is set to proxy but no proxy configuration was provided"
Expand All @@ -626,6 +632,20 @@ pub async fn run_sandbox(
SocketAddr::new(ns.host_ip(), port)
});

// Platform mode: no netns, bind proxy to loopback.
#[cfg(target_os = "linux")]
let bind_addr = bind_addr.or_else(|| {
if matches!(policy.network.mode, NetworkMode::Platform) {
let port = proxy_policy.http_addr.map_or(3128, |addr| addr.port());
Some(SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
port,
))
} else {
None
}
});

#[cfg(not(target_os = "linux"))]
let bind_addr: Option<SocketAddr> = None;

Expand Down Expand Up @@ -705,18 +725,30 @@ pub async fn run_sandbox(
#[cfg(not(target_os = "linux"))]
let ssh_netns_fd: Option<i32> = None;

let ssh_proxy_url = if matches!(policy.network.mode, NetworkMode::Proxy) {
let ssh_proxy_url = if matches!(
policy.network.mode,
NetworkMode::Proxy | NetworkMode::Platform
) {
#[cfg(target_os = "linux")]
{
netns.as_ref().map(|ns| {
if let Some(ns) = netns.as_ref() {
let port = policy
.network
.proxy
.as_ref()
.and_then(|p| p.http_addr)
.map_or(3128, |addr| addr.port());
format!("http://{}:{port}", ns.host_ip())
})
Some(format!("http://{}:{port}", ns.host_ip()))
} else {
// Platform mode: proxy on loopback
let port = policy
.network
.proxy
.as_ref()
.and_then(|p| p.http_addr)
.map_or(3128, |addr| addr.port());
Some(format!("http://127.0.0.1:{port}"))
}
}
#[cfg(not(target_os = "linux"))]
{
Expand Down Expand Up @@ -1730,7 +1762,10 @@ where

fn enrich_sandbox_baseline_paths(policy: &mut SandboxPolicy) {
let (ro, rw) =
active_baseline_enrichment_paths(matches!(policy.network.mode, NetworkMode::Proxy));
active_baseline_enrichment_paths(matches!(
policy.network.mode,
NetworkMode::Proxy | NetworkMode::Platform
));
let modified = enrich_sandbox_baseline_paths_with(policy, &ro, &rw, std::path::Path::exists);

if modified {
Expand Down
15 changes: 11 additions & 4 deletions crates/openshell-sandbox/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

use openshell_core::proto::{
FilesystemPolicy as ProtoFilesystemPolicy, LandlockPolicy as ProtoLandlockPolicy,
ProcessPolicy as ProtoProcessPolicy, SandboxPolicy as ProtoSandboxPolicy,
NetworkEnforcementMode, ProcessPolicy as ProtoProcessPolicy,
SandboxPolicy as ProtoSandboxPolicy,
};
use std::net::SocketAddr;
use std::path::PathBuf;
Expand Down Expand Up @@ -62,6 +63,9 @@ pub enum NetworkMode {
Block,
Proxy,
Allow,
/// Platform mode: Landlock + seccomp + loopback proxy, no network namespace.
/// Compatible with restricted-v2 SCC and restricted Pod Security Standard.
Platform,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -99,10 +103,13 @@ impl TryFrom<ProtoSandboxPolicy> for SandboxPolicy {
type Error = miette::Report;

fn try_from(proto: ProtoSandboxPolicy) -> Result<Self, Self::Error> {
// In cluster mode we always run with proxy networking so all egress
// can be evaluated by OPA and `inference.local` is always addressable.
let mode = match proto.network_enforcement() {
NetworkEnforcementMode::Namespace => NetworkMode::Proxy,
NetworkEnforcementMode::Platform => NetworkMode::Platform,
};

let network = NetworkPolicy {
mode: NetworkMode::Proxy,
mode,
proxy: Some(ProxyPolicy { http_addr: None }),
};

Expand Down
35 changes: 18 additions & 17 deletions crates/openshell-sandbox/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,27 +226,25 @@ impl ProcessHandle {
cmd.current_dir(dir);
}

if matches!(policy.network.mode, NetworkMode::Proxy) {
if matches!(
policy.network.mode,
NetworkMode::Proxy | NetworkMode::Platform
) {
let proxy = policy.network.proxy.as_ref().ok_or_else(|| {
miette::miette!(
"Network mode is set to proxy but no proxy configuration was provided"
)
})?;
// When using network namespace, set proxy URL to the veth host IP
if netns_fd.is_some() {
// The proxy is on 10.200.0.1:3128 (or configured port)
let port = proxy.http_addr.map_or(3128, |addr| addr.port());
let proxy_url = format!("http://10.200.0.1:{port}");
// Both uppercase and lowercase variants: curl/wget use uppercase,
// gRPC C-core (libgrpc) checks lowercase http_proxy/https_proxy.
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
cmd.env(key, value);
}
} else if let Some(http_addr) = proxy.http_addr {
let proxy_url = format!("http://{http_addr}");
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
cmd.env(key, value);
}
let port = proxy.http_addr.map_or(3128, |addr| addr.port());
let proxy_url = if netns_fd.is_some() {
// Namespace mode: proxy on veth host IP
format!("http://10.200.0.1:{port}")
} else {
// Platform mode (or non-Linux): proxy on loopback
format!("http://127.0.0.1:{port}")
};
for (key, value) in child_env::proxy_env_vars(&proxy_url) {
cmd.env(key, value);
}
}

Expand Down Expand Up @@ -368,7 +366,10 @@ impl ProcessHandle {
cmd.current_dir(dir);
}

if matches!(policy.network.mode, NetworkMode::Proxy) {
if matches!(
policy.network.mode,
NetworkMode::Proxy | NetworkMode::Platform
) {
let proxy = policy.network.proxy.as_ref().ok_or_else(|| {
miette::miette!(
"Network mode is set to proxy but no proxy configuration was provided"
Expand Down
5 changes: 4 additions & 1 deletion crates/openshell-sandbox/src/sandbox/linux/seccomp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ pub fn apply_supervisor_prelude() -> Result<()> {
}

pub fn apply(policy: &SandboxPolicy) -> Result<()> {
let allow_inet = matches!(policy.network.mode, NetworkMode::Proxy | NetworkMode::Allow);
let allow_inet = matches!(
policy.network.mode,
NetworkMode::Proxy | NetworkMode::Allow | NetworkMode::Platform
);
let main_filter = build_filter(allow_inet)?;
let clone3_filter = build_clone3_filter()?;

Expand Down
Loading
Loading