From cd81700b157248172386a674ac2c7cd43a537100 Mon Sep 17 00:00:00 2001 From: John Myers Date: Thu, 16 Apr 2026 17:43:53 -0700 Subject: [PATCH 1/4] fix(sandbox): block AF_NETLINK in seccomp unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move AF_NETLINK to the unconditional socket-domain block list alongside AF_PACKET, AF_BLUETOOTH, and AF_VSOCK. Previously it was only blocked in NetworkMode::Block, leaving it accessible in Proxy mode where network namespace isolation already scopes netlink to the sandbox's own veth — making this a defense-in-depth hardening rather than a live exposure. Closes OS-94 --- crates/openshell-sandbox/src/sandbox/linux/seccomp.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index 854134cbf4..78a21cccc7 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -112,11 +112,11 @@ fn build_filter_rules(allow_inet: bool) -> Result let mut rules: BTreeMap> = BTreeMap::new(); // --- Socket domain blocks --- - let mut blocked_domains = vec![libc::AF_PACKET, libc::AF_BLUETOOTH, libc::AF_VSOCK]; + let mut blocked_domains = + vec![libc::AF_PACKET, libc::AF_BLUETOOTH, libc::AF_VSOCK, libc::AF_NETLINK]; if !allow_inet { blocked_domains.push(libc::AF_INET); blocked_domains.push(libc::AF_INET6); - blocked_domains.push(libc::AF_NETLINK); } for domain in blocked_domains { From 974049b9201b0fb9ce4a48858e27165348eaeeef Mon Sep 17 00:00:00 2001 From: John Myers Date: Thu, 16 Apr 2026 17:44:05 -0700 Subject: [PATCH 2/4] fix(sandbox): scope inference.local interception to port 443 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-OPA interception for inference.local matched on hostname alone, allowing any port to bypass OPA policy evaluation — including under deny-all (network_policies: {}). Add a port check so only port 443 takes the interception path; all other ports on inference.local now fall through to OPA and are subject to normal policy evaluation. Closes OS-95 --- crates/openshell-sandbox/src/proxy.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/openshell-sandbox/src/proxy.rs b/crates/openshell-sandbox/src/proxy.rs index 6f85e848e2..f91f2c551f 100644 --- a/crates/openshell-sandbox/src/proxy.rs +++ b/crates/openshell-sandbox/src/proxy.rs @@ -27,6 +27,7 @@ use tracing::{debug, warn}; const MAX_HEADER_BYTES: usize = 8192; const INFERENCE_LOCAL_HOST: &str = "inference.local"; +const INFERENCE_LOCAL_PORT: u16 = 443; /// Maximum total bytes for a streaming inference response body (32 MiB). const MAX_STREAMING_BODY: usize = 32 * 1024 * 1024; @@ -354,7 +355,7 @@ async fn handle_tcp_connection( let (host, port) = parse_target(target)?; let host_lc = host.to_ascii_lowercase(); - if host_lc == INFERENCE_LOCAL_HOST { + if host_lc == INFERENCE_LOCAL_HOST && port == INFERENCE_LOCAL_PORT { respond(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await?; let outcome = handle_inference_interception( client, From d952ed3e64c9fcd8e36d85e515f3758ef05ec979 Mon Sep 17 00:00:00 2001 From: John Myers Date: Thu, 16 Apr 2026 17:44:21 -0700 Subject: [PATCH 3/4] fix(sandbox): enforce RLIMIT_NPROC to prevent fork bomb DoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set a hard limit of 512 processes per UID in harden_child_process(), applied before privilege drop so the sandbox user cannot raise it. Prevents unrestricted fork() from exhausting the process table — most relevant for local dev mode where K8s pod cgroup pids.max is absent. Closes OS-96 --- crates/openshell-sandbox/src/process.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 4e92259294..85a57b4e72 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -49,6 +49,22 @@ pub(crate) fn harden_child_process() -> Result<()> { )); } + // Limit process creation to prevent fork bombs. 512 processes per UID is + // sufficient for typical agent workloads (shell, compilers, language servers) + // while preventing runaway forking. Set as a hard limit so the sandbox user + // cannot raise it after privilege drop. + let nproc_limit = libc::rlimit { + rlim_cur: 512, + rlim_max: 512, + }; + let rc = unsafe { libc::setrlimit(libc::RLIMIT_NPROC, &nproc_limit) }; + if rc != 0 { + return Err(miette::miette!( + "Failed to set RLIMIT_NPROC: {}", + std::io::Error::last_os_error() + )); + } + #[cfg(target_os = "linux")] { let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) }; From 8e5cab5a696433eadf5a29feb12bd9e8e6bb05fb Mon Sep 17 00:00:00 2001 From: John Myers Date: Thu, 16 Apr 2026 17:49:15 -0700 Subject: [PATCH 4/4] chore(sandbox): fix rustfmt formatting for seccomp blocked_domains --- crates/openshell-sandbox/src/sandbox/linux/seccomp.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs index 78a21cccc7..7b6f0f4127 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/seccomp.rs @@ -112,8 +112,12 @@ fn build_filter_rules(allow_inet: bool) -> Result let mut rules: BTreeMap> = BTreeMap::new(); // --- Socket domain blocks --- - let mut blocked_domains = - vec![libc::AF_PACKET, libc::AF_BLUETOOTH, libc::AF_VSOCK, libc::AF_NETLINK]; + let mut blocked_domains = vec![ + libc::AF_PACKET, + libc::AF_BLUETOOTH, + libc::AF_VSOCK, + libc::AF_NETLINK, + ]; if !allow_inet { blocked_domains.push(libc::AF_INET); blocked_domains.push(libc::AF_INET6);