diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 63e71c869b..57c202b9a6 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -38,9 +38,9 @@ use openshell_core::proto::middleware::v1::supervisor_middleware_server::Supervi use openshell_core::proto::{ Decision, Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, MiddlewareBinding, MiddlewareDescribeRequest, MiddlewareManifest, NetworkMiddlewareConfig, - RequestContext, SandboxPolicy, SupervisorMiddlewareBinding, SupervisorMiddlewareOperation, - SupervisorMiddlewarePhase, SupervisorMiddlewareService, ValidateConfigRequest, - ValidateConfigResponse, + Process, RequestContext, SandboxPolicy, SupervisorMiddlewareBinding, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, SupervisorMiddlewareService, + ValidateConfigRequest, ValidateConfigResponse, }; use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; use tonic::{Request, Response as TonicResponse, Status as TonicStatus}; @@ -299,6 +299,42 @@ pub const MAX_MIDDLEWARE_PAYLOAD_BYTES: usize = 4 * 1024 * 1024; pub const MAX_MIDDLEWARE_CONFIG_BYTES: usize = 64 * 1024; /// Largest encoded request identity context attached to one evaluation. pub const MAX_MIDDLEWARE_CONTEXT_BYTES: usize = 4 * 1024; + +/// Add the admitted connection's process identity without making an otherwise +/// valid request context exceed the middleware envelope limit. +/// +/// Keep ancestors from the nearest parent outward. Omit a leaf that cannot fit. +pub fn request_context_with_process( + mut context: RequestContext, + process: Option, +) -> RequestContext { + let Some(mut process) = process.filter(|process| !process.binary.is_empty()) else { + return context; + }; + let ancestors = std::mem::take(&mut process.ancestors); + context.originating_process = Some(process); + if context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { + context.originating_process = None; + return context; + } + for ancestor in ancestors { + let process = context + .originating_process + .as_mut() + .expect("process is present"); + process.ancestors.push(ancestor); + if context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { + context + .originating_process + .as_mut() + .expect("process is present") + .ancestors + .pop(); + break; + } + } + context +} /// Largest encoded destination and request target attached to one evaluation. pub const MAX_MIDDLEWARE_TARGET_BYTES: usize = 32 * 1024; /// Largest number of request header lines exposed to one middleware. @@ -466,6 +502,8 @@ pub struct HttpRequestInput { pub sandbox_id: String, pub sandbox_name: String, pub workspace: String, + /// Identity resolved when the connection was admitted, if available. + pub originating_process: Option, pub scheme: String, pub host: String, pub port: u16, @@ -1694,6 +1732,7 @@ impl ChainRunner { sandbox_id, sandbox_name, workspace, + originating_process, scheme, host, port, @@ -1707,13 +1746,16 @@ impl ChainRunner { // The request envelope is moved into one stable chain state. Built-ins // borrow these values for every stage; only the gRPC adapter clones them // when an operator service requires an owned protobuf message. - let context = RequestContext { - request_id, - sandbox_id, - sandbox: sandbox_name, - workspace, - originating_process: None, - }; + let context = request_context_with_process( + RequestContext { + request_id, + sandbox_id, + sandbox: sandbox_name, + workspace, + originating_process: None, + }, + originating_process, + ); let target = HttpRequestTarget { scheme, host, @@ -2107,6 +2149,82 @@ mod tests { use tokio_stream::wrappers::TcpListenerStream; + #[test] + fn process_context_keeps_nearest_ancestors_within_limit() { + let base = RequestContext { + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox: "sandbox-name".into(), + workspace: "workspace".into(), + originating_process: None, + }; + let ancestors: Vec = (0..100) + .map(|index| format!("/usr/bin/parent-{index}-{}", "x".repeat(128))) + .collect(); + let context = request_context_with_process( + base.clone(), + Some(Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: ancestors.clone(), + }), + ); + let process = context.originating_process.as_ref().expect("leaf fits"); + assert_eq!(process.binary, "/usr/bin/curl"); + assert_eq!(process.pid, 42); + assert!(!process.ancestors.is_empty()); + assert!(process.ancestors.len() < ancestors.len()); + assert_eq!(process.ancestors, ancestors[..process.ancestors.len()]); + assert!(context.encoded_len() <= MAX_MIDDLEWARE_CONTEXT_BYTES); + + let oversized_leaf = request_context_with_process( + base.clone(), + Some(Process { + binary: "x".repeat(MAX_MIDDLEWARE_CONTEXT_BYTES), + pid: 42, + ancestors: Vec::new(), + }), + ); + assert_eq!(oversized_leaf, base); + assert_eq!(request_context_with_process(base.clone(), None), base); + } + + #[tokio::test] + async fn websocket_preflight_receives_originating_process() { + let service = OpenAiRedactionService::default(); + let observed = Arc::clone(&service.preflight); + let runner = ChainRunner::from_endpoint(Arc::new(service)); + let mut input = websocket_preflight_input("caller-session"); + let process = Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }; + input.originating_process = Some(process.clone()); + + let outcome = runner + .preflight_websocket( + &[websocket_entry( + "scope", + "test/openai-websocket-redactor", + 0, + OnError::FailClosed, + )], + input, + ) + .await + .expect("preflight"); + assert!(outcome.allowed); + let preflight = observed.lock().expect("preflight lock"); + let context = preflight + .as_ref() + .expect("preflight") + .context + .as_ref() + .expect("context"); + assert_eq!(context.originating_process.as_ref(), Some(&process)); + } + fn proto_duration(value: &str) -> prost_types::Duration { let duration = match (value.strip_suffix("ms"), value.strip_suffix('s')) { (Some(milliseconds), _) => { @@ -2178,6 +2296,7 @@ mod tests { sandbox_id: "sbx-id".into(), sandbox_name: "sbx-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "https".into(), host: "api.example.com".into(), port: 443, @@ -2318,6 +2437,11 @@ mod tests { .map(|entry| std::ptr::from_ref(&entry.entry.config).addr()) .collect(); let mut request = input("payload"); + request.originating_process = Some(Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }); request.headers = vec![("x-test".into(), "value".into())]; let expected_body = request.body.as_ptr().addr(); let expected_request_id = request.request_id.as_ptr().addr(); @@ -2334,7 +2458,7 @@ mod tests { assert_eq!(outcome.body.as_ptr().addr(), expected_body); assert_eq!(received.len(), 2); assert_eq!(received[0].phase, SupervisorMiddlewarePhase::PreCredentials); - assert!(!received[0].originating_process_present); + assert!(received[0].originating_process_present); assert_eq!(received[0].request_id, expected_request_id); assert_eq!(received[0].host, expected_host); assert_eq!(received[0].first_header_name, expected_header_name); @@ -3484,6 +3608,11 @@ mod tests { on_error: OnError::FailClosed, }; let mut request = input("payload"); + request.originating_process = Some(Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into(), "/usr/bin/bash".into()], + }); request.headers = vec![ ("x-api-key".into(), "first-value".into()), ("accept".into(), "application/json".into()), @@ -3520,7 +3649,14 @@ mod tests { assert_eq!(context.sandbox_id, "sbx-id"); assert_eq!(context.sandbox, "sbx-name"); assert_eq!(context.workspace, "wrks-default"); - assert!(context.originating_process.is_none()); + assert_eq!( + context.originating_process.as_ref(), + Some(&Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into(), "/usr/bin/bash".into()], + }) + ); let target = received[0].target.as_ref().expect("request target"); assert_eq!(target.scheme, "https"); assert_eq!(target.host, "api.example.com"); @@ -5122,6 +5258,7 @@ mod tests { sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5566,6 +5703,7 @@ mod tests { sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5634,6 +5772,7 @@ mod tests { sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5698,6 +5837,7 @@ mod tests { sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5771,6 +5911,7 @@ mod tests { sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5857,6 +5998,7 @@ mod tests { sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "ws".into(), host: "api.openai.com".into(), port: 80, @@ -6154,6 +6296,7 @@ mod tests { sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "wrks-default".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, diff --git a/crates/openshell-supervisor-middleware/src/websocket.rs b/crates/openshell-supervisor-middleware/src/websocket.rs index 8c9cea9005..6f357d8a1f 100644 --- a/crates/openshell-supervisor-middleware/src/websocket.rs +++ b/crates/openshell-supervisor-middleware/src/websocket.rs @@ -42,6 +42,8 @@ pub struct WebSocketPreflightInput { pub sandbox_id: String, pub sandbox_name: String, pub workspace: String, + /// Identity resolved when the connection was admitted, if available. + pub originating_process: Option, pub scheme: String, pub host: String, pub port: u16, @@ -954,13 +956,16 @@ async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) let preflight = WebSocketPreflight { session_id: input.session_id, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - context: Some(RequestContext { - request_id: input.request_id, - sandbox_id: input.sandbox_id, - sandbox: input.sandbox_name, - workspace: input.workspace, - originating_process: None, - }), + context: Some(crate::request_context_with_process( + RequestContext { + request_id: input.request_id, + sandbox_id: input.sandbox_id, + sandbox: input.sandbox_name, + workspace: input.workspace, + originating_process: None, + }, + input.originating_process, + )), target: Some(HttpRequestTarget { scheme: input.scheme, host: input.host, diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md index cf842d5041..8c4824486d 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -8,6 +8,17 @@ stdin. Re-executing the test binary checks multiple holders with the same binary identity; executing a different binary checks ambiguous shared-socket identity. The fixture kills and reaps the child when the parent test ends. +## Middleware caller context + +The L7 relay retains the process identity resolved at connection admission and +passes its binary path, PID, and nearest-first executable ancestors to request, +response preflight, and WebSocket preflight middleware contexts. The middleware +runtime trims distant ancestors to keep the encoded context within 4 KiB. If +attribution is unavailable, a path cannot be represented as UTF-8, or the +binary cannot fit, the process field is absent. When multiple same-identity +processes hold the socket, the process field retains their common binary and +ancestry but reports PID zero because the sender cannot be identified. + The manual proxy performance baseline still counts allocations. Its test-only global allocator uses `openshell-driver-vm::allocation_tracking`; the unsafe allocator implementation belongs to the VM crate. The VM dependency disables diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index d2344958b7..14cd6d9485 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -86,13 +86,7 @@ impl HttpMiddlewareExchange { crate::l7::rest::HttpResponseMiddlewareRelay { chain: &self.chain, runner: &self.runner, - request_context: openshell_core::proto::RequestContext { - request_id: self.request_id.clone(), - sandbox_id: sandbox.sandbox_id.clone(), - sandbox: sandbox.sandbox_name.clone(), - workspace: ctx.workspace.clone(), - originating_process: None, - }, + request_context: ctx.middleware_request_context(self.request_id.clone(), &sandbox), target: openshell_core::proto::HttpRequestTarget { scheme: scheme.to_string(), host: ctx.host.clone(), @@ -716,6 +710,7 @@ fn middleware_request_input_with_id( sandbox_id: sandbox.sandbox_id.clone(), sandbox_name: sandbox.sandbox_name.clone(), workspace: ctx.workspace.clone(), + originating_process: ctx.originating_process(), scheme: scheme.into(), host: ctx.host.clone(), port: ctx.port, @@ -1218,7 +1213,12 @@ mod tests { workspace: "wrks-default".into(), policy_name: "api-policy".into(), binary_path: "/usr/bin/curl".into(), - ancestors: Vec::new(), + middleware_process: Some(openshell_core::proto::Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }), + ancestors: vec!["/usr/bin/claude".into()], cmdline_paths: Vec::new(), secret_resolver: None, ..Default::default() @@ -1247,6 +1247,14 @@ mod tests { assert_eq!(input.sandbox_id, "sbx-123"); assert_eq!(input.workspace, "wrks-default"); assert_eq!(input.request_id, "exchange-123"); + assert_eq!( + input.originating_process, + Some(openshell_core::proto::Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }) + ); } #[tokio::test] diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 4685a8417c..9cf3f09f7c 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -58,6 +58,8 @@ pub struct L7EvalContext { pub policy_name: String, /// Binary path (for cross-layer Rego evaluation). pub binary_path: String, + /// Exact process identity captured at admission for middleware. + pub(crate) middleware_process: Option, /// Ancestor paths. pub ancestors: Vec, /// Cmdline paths. @@ -88,6 +90,27 @@ pub struct L7EvalContext { } impl L7EvalContext { + pub(crate) fn originating_process(&self) -> Option { + self.middleware_process.clone() + } + + pub(crate) fn middleware_request_context( + &self, + request_id: String, + sandbox: &openshell_ocsf::EventContext, + ) -> openshell_core::proto::RequestContext { + openshell_supervisor_middleware::request_context_with_process( + openshell_core::proto::RequestContext { + request_id, + sandbox_id: sandbox.sandbox_id.clone(), + sandbox: sandbox.sandbox_name.clone(), + workspace: self.workspace.clone(), + originating_process: None, + }, + self.originating_process(), + ) + } + /// Whether the inspected transport is plaintext rather than TLS-terminated. fn is_plaintext(&self) -> bool { self.request_default_port == Some(80) @@ -860,13 +883,7 @@ pub(crate) fn http_response_middleware_relay<'a>( crate::l7::rest::HttpResponseMiddlewareRelay { chain, runner, - request_context: openshell_core::proto::RequestContext { - request_id: request_id.to_string(), - sandbox_id: sandbox.sandbox_id.clone(), - sandbox: sandbox.sandbox_name.clone(), - workspace: ctx.workspace.clone(), - originating_process: None, - }, + request_context: ctx.middleware_request_context(request_id.to_string(), &sandbox), target: openshell_core::proto::HttpRequestTarget { scheme: scheme.to_string(), host: ctx.host.clone(), @@ -1774,6 +1791,7 @@ fn websocket_preflight_input( sandbox_id: sandbox.sandbox_id.clone(), sandbox_name: sandbox.sandbox_name.clone(), workspace: ctx.workspace.clone(), + originating_process: ctx.originating_process(), scheme: scheme.to_string(), host: ctx.host.clone(), port: ctx.port, @@ -4052,7 +4070,12 @@ mod tests { workspace: "team-a".into(), policy_name: "api-policy".into(), binary_path: "/usr/bin/curl".into(), - ancestors: Vec::new(), + middleware_process: Some(openshell_core::proto::Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }), + ancestors: vec!["/usr/bin/claude".into()], cmdline_paths: Vec::new(), secret_resolver: None, ..Default::default() @@ -4071,6 +4094,14 @@ mod tests { assert_eq!(input.sandbox_id, "sbx-123"); assert_eq!(input.sandbox_name, "nightly-build"); assert_eq!(input.workspace, "team-a"); + assert_eq!( + input.originating_process, + Some(openshell_core::proto::Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }) + ); } #[test] @@ -7630,6 +7661,13 @@ network_policies: port: 443, workspace: "workspace-1".into(), policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + middleware_process: Some(openshell_core::proto::Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }), + ancestors: vec!["/usr/bin/claude".into()], ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::default(); @@ -7645,6 +7683,14 @@ network_policies: ); assert_eq!(response.request_context.request_id, "exchange-123"); + assert_eq!( + response.request_context.originating_process, + Some(openshell_core::proto::Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }) + ); assert_eq!( response.target.query, "cursor=next+page&token=%5BREDACTED%5D" diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index 772953e302..1e3792cff0 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3793,6 +3793,7 @@ network_policies: sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "workspace".into(), + originating_process: None, scheme: scheme.into(), host: "api.openai.com".into(), port: if scheme == "wss" { 443 } else { 80 }, @@ -3866,6 +3867,7 @@ network_policies: sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "team-a".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -4824,6 +4826,7 @@ network_policies: sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "workspace".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -4975,6 +4978,7 @@ network_policies: sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "workspace".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5097,6 +5101,7 @@ network_policies: sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "workspace".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 2d1a73a949..54e2746548 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -11455,6 +11455,7 @@ network_policies: sandbox_id: "sandbox".into(), sandbox_name: "sandbox-name".into(), workspace: "workspace".into(), + originating_process: None, scheme: "wss".into(), host: "api.openai.com".into(), port: 443, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 7560a3aeaf..bb465d2491 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3414,7 +3414,7 @@ async fn handle_mediated_connection( #[cfg(target_os = "linux")] struct ResolvedIdentity { bin_path: PathBuf, - binary_pid: u32, + binary_pid: Option, ancestors: Vec, cmdline_paths: Vec, bin_hash: String, @@ -3501,7 +3501,7 @@ fn resolve_owner_identity( Ok(ResolvedIdentity { bin_path, - binary_pid: owner_pid, + binary_pid: Some(owner_pid), ancestors, cmdline_paths, bin_hash, @@ -3593,10 +3593,7 @@ fn resolve_process_identity( .skip(1) .any(|identity| identity.policy_key() != first_key) { - let mut pids: Vec = identities - .iter() - .map(|identity| identity.binary_pid) - .collect(); + let mut pids: Vec = socket_owners.owners.iter().map(|owner| owner.pid).collect(); pids.sort_unstable(); return Err(IdentityError { reason: format!( @@ -3613,9 +3610,12 @@ fn resolve_process_identity( }); } + let multiple_owners = identities.len() > 1; let mut identity = identities.swap_remove(0); - if let Some(lowest_pid) = socket_owners.owners.iter().map(|owner| owner.pid).min() { - identity.binary_pid = lowest_pid; + if multiple_owners { + // The owners share a policy identity, but procfs cannot tell which + // process wrote a request on this socket. + identity.binary_pid = None; } Ok(identity) } @@ -3726,7 +3726,7 @@ fn authorize_egress_intent_inner( identity: ProcessIdentityEvidence::Available, endpoint: EndpointDecision::from_authorization(&authorization), binary: Some(bin_path), - binary_pid: Some(binary_pid), + binary_pid, ancestors, cmdline_paths, }, @@ -3734,7 +3734,7 @@ fn authorize_egress_intent_inner( format!("policy evaluation error: {e}"), ProcessIdentityEvidence::Available, Some(bin_path), - Some(binary_pid), + binary_pid, ancestors, cmdline_paths, ), @@ -15617,7 +15617,8 @@ network_policies: match result { Ok(identity) => { assert_eq!( - identity.binary_pid, child_pid, + identity.binary_pid, + Some(child_pid), "expected the hot-swapped bash child to own the socket" ); assert_eq!( @@ -15719,7 +15720,7 @@ network_policies: match result { Ok(identity) => panic!( - "resolve_process_identity unexpectedly succeeded for shared socket owned by PID {}", + "resolve_process_identity unexpectedly succeeded for shared socket owned by PID {:?}", identity.binary_pid ), Err(err) => { @@ -15742,6 +15743,54 @@ network_policies: } } + #[cfg(target_os = "linux")] + #[test] + fn resolve_process_identity_omits_pid_for_shared_same_identity_socket() { + use crate::identity::BinaryIdentityCache; + use std::net::{TcpListener, TcpStream}; + use std::time::{Duration, Instant}; + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener"); + let proxy_addr = listener.local_addr().expect("listener address"); + let stream = TcpStream::connect(proxy_addr).expect("connect"); + let workload_addr = stream.local_addr().expect("workload address"); + let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); + let (_accepted, _) = listener.accept().expect("accept"); + + let first = crate::test_support::spawn_socket_holder(&stream, false); + let second = crate::test_support::spawn_socket_holder(&stream, false); + drop(stream); + + let entrypoint_pid = std::process::id(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(owners) = + crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, connection) + && owners.owners.len() == 2 + && owners.owners.iter().any(|owner| owner.pid == first.0.id()) + && owners.owners.iter().any(|owner| owner.pid == second.0.id()) + { + break; + } + assert!( + Instant::now() < deadline, + "both socket holders were not found" + ); + std::thread::sleep(Duration::from_millis(20)); + } + + let identity = + resolve_process_identity(entrypoint_pid, connection, &BinaryIdentityCache::new()) + .unwrap_or_else(|error| { + panic!("same-identity socket holders denied: {}", error.reason) + }); + assert_eq!(identity.binary_pid, None); + assert_eq!( + identity.bin_path, + std::env::current_exe().expect("test executable") + ); + } + #[tokio::test] async fn test_exit_receiver_fires_when_task_exits() { let (exited_tx, exited_rx) = tokio::sync::oneshot::channel::<()>(); diff --git a/crates/openshell-supervisor-network/src/proxy/egress.rs b/crates/openshell-supervisor-network/src/proxy/egress.rs index 77b89a9b7e..581e839172 100644 --- a/crates/openshell-supervisor-network/src/proxy/egress.rs +++ b/crates/openshell-supervisor-network/src/proxy/egress.rs @@ -162,7 +162,7 @@ pub(super) struct EgressDecision { pub(super) endpoint: EndpointDecision, /// Resolved binary path. pub(super) binary: Option, - /// PID owning the socket. + /// PID owning the socket when the owner is unambiguous. pub(super) binary_pid: Option, /// Ancestor binary paths from process tree walk. pub(super) ancestors: Vec, diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 495c22241b..1b26c8e9b4 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -51,6 +51,22 @@ pub(super) fn http_context( agent_proposals: openshell_core::proposals::AgentProposals, signals: RelaySignals, ) -> L7EvalContext { + let middleware_process = decision.binary.as_ref().and_then(|binary| { + let binary = binary.to_str()?; + if binary.is_empty() { + return None; + } + let ancestors = decision + .ancestors + .iter() + .map(|ancestor| ancestor.to_str().map(str::to_owned)) + .collect::>>()?; + Some(openshell_core::proto::Process { + binary: binary.to_owned(), + pid: decision.binary_pid.unwrap_or_default(), + ancestors, + }) + }); // Provider-backed credentials must be acquired from the live state for // each request after middleware/token-grant awaits. Keep only the legacy // resolver fallback when no live provider state exists. @@ -81,6 +97,7 @@ pub(super) fn http_context( .as_ref() .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(), + middleware_process, ancestors: decision .ancestors .iter() @@ -389,6 +406,7 @@ mod tests { request_default_port: Some(80), policy_name: "test".to_string(), binary_path: String::new(), + middleware_process: None, ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, @@ -404,6 +422,82 @@ mod tests { } } + #[test] + fn http_context_keeps_admitted_process_identity() { + let mut decision = decision(1); + decision.binary = Some("/usr/bin/curl".into()); + decision.binary_pid = Some(42); + decision.ancestors = vec!["/usr/bin/claude".into()]; + let context = http_context( + &decision, + None, + None, + None, + openshell_core::proposals::AgentProposals::default(), + RelaySignals { + activity: None, + endpoint_observation: None, + }, + ); + assert_eq!(context.binary_path, "/usr/bin/curl"); + assert_eq!( + context.originating_process(), + Some(openshell_core::proto::Process { + binary: "/usr/bin/curl".into(), + pid: 42, + ancestors: vec!["/usr/bin/claude".into()], + }) + ); + assert_eq!(context.ancestors, ["/usr/bin/claude"]); + } + + #[test] + fn http_context_uses_zero_for_unknown_pid() { + let mut decision = decision(1); + decision.binary = Some("/usr/bin/curl".into()); + let context = http_context( + &decision, + None, + None, + None, + openshell_core::proposals::AgentProposals::default(), + RelaySignals { + activity: None, + endpoint_observation: None, + }, + ); + assert_eq!(context.originating_process().expect("process").pid, 0); + } + + #[cfg(unix)] + #[test] + fn http_context_omits_process_with_non_utf8_identity_path() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + use std::path::PathBuf; + + let context_for = |decision: &EgressDecision| { + http_context( + decision, + None, + None, + None, + openshell_core::proposals::AgentProposals::default(), + RelaySignals { + activity: None, + endpoint_observation: None, + }, + ) + }; + let mut decision = decision(1); + decision.binary = Some(PathBuf::from(OsString::from_vec(b"/bin/\xff".to_vec()))); + assert!(context_for(&decision).originating_process().is_none()); + + decision.binary = Some("/usr/bin/curl".into()); + decision.ancestors = vec![PathBuf::from(OsString::from_vec(b"/bin/\xfe".to_vec()))]; + assert!(context_for(&decision).originating_process().is_none()); + } + async fn assert_response_lifecycle(mut caller: C, mut client: P) where C: AsyncRead + AsyncWrite + Unpin, diff --git a/docs/extensibility/supervisor-middleware/operations.mdx b/docs/extensibility/supervisor-middleware/operations.mdx index 50a01b33f9..87989e4739 100644 --- a/docs/extensibility/supervisor-middleware/operations.mdx +++ b/docs/extensibility/supervisor-middleware/operations.mdx @@ -26,6 +26,22 @@ When several middleware match the same traffic, OpenShell calls them in ascendin Every operation includes a request context. The context always includes `sandbox_id`. Use it for authorization, persistence, and correlation. The context also includes `sandbox` and `workspace` names when available. Names can be reused, so use them only for display and logging. +When OpenShell resolves the caller at network admission, the request context includes `originating_process` on HTTP request, HTTP response preflight, and WebSocket preflight evaluations. Its `binary` is the resolved executable path, `pid` is the sandbox process ID when unambiguous (otherwise `0`), and `ancestors` lists executable paths from nearest parent outward. When multiple processes with the same identity hold a socket, OpenShell reports their common binary and ancestry with `pid: 0` because it cannot identify which holder sent a request. The identity is fixed for the connection, including later requests on a persistent connection. + +The field is absent when caller identity is unavailable, such as in the standalone endpoint-only proxy or on a platform without process attribution. It is also absent if a binary or ancestor path cannot be represented exactly as UTF-8, or if the binary alone cannot fit in the 4 KiB encoded context limit. OpenShell keeps the binary and nearest ancestors when a long chain exceeds that limit, dropping the most distant ancestors first. Treat an absent field as unavailable process data. + +For example, a request sent by `curl` started by a coding agent can carry: + +```json +{ + "originating_process": { + "binary": "/usr/bin/curl", + "pid": 42, + "ancestors": ["/usr/bin/claude"] + } +} +``` + Operations that include headers deliver them in wire order, and repeated headers stay separate. OpenShell removes credential, routing, and hop-by-hop headers before sending them to your service. It also removes framing headers from request input. Response preflight retains upstream `Content-Length`, `Content-Encoding`, and `Content-Range` as read-only metadata, unless `Connection` names them as hop-by-hop headers. Your service cannot change or remove these fields; OpenShell handles downstream framing. Every result can include an optional reason code, findings, and metadata: diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 6c0cc947da..451115bf7e 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -619,7 +619,7 @@ message HttpRequestTarget { message Process { // Executable path for the originating process. string binary = 1; - // Process id within the sandbox. + // Process id within the sandbox, or zero if unavailable or ambiguous. uint32 pid = 2; // Executable paths for ancestor processes, nearest parent first. repeated string ancestors = 3; diff --git a/rfc/0009-supervisor-middleware/README.md b/rfc/0009-supervisor-middleware/README.md index 1f6eacde7b..3110a909b0 100644 --- a/rfc/0009-supervisor-middleware/README.md +++ b/rfc/0009-supervisor-middleware/README.md @@ -298,7 +298,7 @@ For WebSocket traffic, a service advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` Inspectable WebSocket text input and replacements share the 4 MiB platform cap. The operator's `max_payload_bytes` is the shared HTTP-body and WebSocket-text ceiling, further constrained by each operation binding's capability. It does not bound binary pass-through, which retains a separate raw-frame safety limit. Logical messages use a protobuf `oneof` with `string text` and `bytes binary` variants; results use an optional matching replacement `oneof`, whose presence also represents an empty replacement without a separate boolean. Protobuf decoding enforces UTF-8 for text, and OpenShell rejects replacement variants that would change the message type. A complete text message holds one process-wide admission permit for its entire chain; preflight fan-out holds one permit until every stage resolves. Permit waiting is backpressure and does not consume the per-message deadline. Per-stage timeouts are also bounded by a 30-second total chain budget, which applies to HTTP chains too. This bound controls concurrency and peak buffered inspection memory; it is not rate limiting. -The `originating_process` is the same identity OpenShell resolves on the egress path - the binary, pid, and ancestor chain it uses for binary-scoped network policy and OCSF audit. It is per-connection rather than strictly per-request and is optional. Middleware must treat missing process data as unavailable rather than as an authorization failure. The initial implementation leaves this field unset until reliable propagation is available. +The `originating_process` is the same identity OpenShell resolves on the egress path - the binary, pid, and ancestor chain it uses for binary-scoped network policy and OCSF audit. It is per-connection rather than strictly per-request and is optional. HTTP request, HTTP response preflight, and WebSocket preflight evaluations carry it when the supervisor resolved a caller. The PID is zero when unavailable, including when multiple same-identity processes hold the socket and no sender PID can be proven. If the full ancestor chain would exceed the 4 KiB encoded context limit, OpenShell keeps the binary and nearest ancestors, dropping the most distant ancestors first. If the binary itself cannot fit, or a binary or ancestor path cannot be represented exactly as UTF-8, the field is absent. Middleware must treat missing process data as unavailable rather than as an authorization failure. ### Relationship to RFC 0010