From 8ce141e6cb40e0b474475f85ca94c3a99a2ecbcc Mon Sep 17 00:00:00 2001 From: Vineeth Sai Narajala Date: Mon, 2 Nov 2026 17:41:40 +0000 Subject: [PATCH 1/2] feat(supervisor): pass caller identity to middleware Signed-off-by: Vineeth Sai Narajala --- .../src/lib.rs | 167 ++++++++++++++++-- .../src/websocket.rs | 19 +- crates/openshell-supervisor-network/README.md | 9 + .../src/l7/middleware.rs | 20 ++- .../src/l7/relay.rs | 58 +++++- .../src/l7/websocket.rs | 5 + .../openshell-supervisor-network/src/opa.rs | 1 + .../src/proxy/relay.rs | 24 +++ .../supervisor-middleware/operations.mdx | 16 ++ rfc/0009-supervisor-middleware/README.md | 2 +- 10 files changed, 285 insertions(+), 36 deletions(-) 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..240df6bc09 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -8,6 +8,15 @@ 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, or the binary cannot fit, the process field is +absent. + 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..30aef4bcb8 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,8 @@ mod tests { workspace: "wrks-default".into(), policy_name: "api-policy".into(), binary_path: "/usr/bin/curl".into(), - ancestors: Vec::new(), + binary_pid: Some(42), + ancestors: vec!["/usr/bin/claude".into()], cmdline_paths: Vec::new(), secret_resolver: None, ..Default::default() @@ -1247,6 +1243,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..ae4d272806 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, + /// PID resolved for the admitted connection, when meaningful. + pub binary_pid: Option, /// Ancestor paths. pub ancestors: Vec, /// Cmdline paths. @@ -88,6 +90,31 @@ pub struct L7EvalContext { } impl L7EvalContext { + pub(crate) fn originating_process(&self) -> Option { + (!self.binary_path.is_empty()).then(|| openshell_core::proto::Process { + binary: self.binary_path.clone(), + pid: self.binary_pid.unwrap_or_default(), + ancestors: self.ancestors.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 +887,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 +1795,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 +4074,8 @@ mod tests { workspace: "team-a".into(), policy_name: "api-policy".into(), binary_path: "/usr/bin/curl".into(), - ancestors: Vec::new(), + binary_pid: Some(42), + 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,9 @@ network_policies: port: 443, workspace: "workspace-1".into(), policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + binary_pid: Some(42), + ancestors: vec!["/usr/bin/claude".into()], ..Default::default() }; let runner = openshell_supervisor_middleware::ChainRunner::default(); @@ -7645,6 +7679,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/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 495c22241b..c9843e2b5f 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -81,6 +81,7 @@ pub(super) fn http_context( .as_ref() .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(), + binary_pid: decision.binary_pid, ancestors: decision .ancestors .iter() @@ -389,6 +390,7 @@ mod tests { request_default_port: Some(80), policy_name: "test".to_string(), binary_path: String::new(), + binary_pid: None, ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, @@ -404,6 +406,28 @@ 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.binary_pid, Some(42)); + assert_eq!(context.ancestors, ["/usr/bin/claude"]); + } + 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..a9b04f19cd 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 available (otherwise `0`), and `ancestors` lists executable paths from nearest parent outward. 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 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/rfc/0009-supervisor-middleware/README.md b/rfc/0009-supervisor-middleware/README.md index 1f6eacde7b..9643b3ec4b 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. 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, the field is absent. Middleware must treat missing process data as unavailable rather than as an authorization failure. ### Relationship to RFC 0010 From e1a3fc7561ce27c55dcf6ba3b839f5db081a1bc3 Mon Sep 17 00:00:00 2001 From: Vineeth Sai Narajala Date: Mon, 2 Nov 2026 18:18:48 +0000 Subject: [PATCH 2/2] fix(supervisor): preserve exact middleware caller identity Signed-off-by: Vineeth Sai Narajala --- crates/openshell-supervisor-network/README.md | 6 +- .../src/l7/middleware.rs | 6 +- .../src/l7/relay.rs | 22 +++--- .../openshell-supervisor-network/src/proxy.rs | 73 +++++++++++++++--- .../src/proxy/egress.rs | 2 +- .../src/proxy/relay.rs | 76 ++++++++++++++++++- .../supervisor-middleware/operations.mdx | 4 +- proto/supervisor_middleware.proto | 2 +- rfc/0009-supervisor-middleware/README.md | 2 +- 9 files changed, 161 insertions(+), 32 deletions(-) diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md index 240df6bc09..8c4824486d 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -14,8 +14,10 @@ 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, or the binary cannot fit, the process field is -absent. +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 diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 30aef4bcb8..14cd6d9485 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -1213,7 +1213,11 @@ mod tests { workspace: "wrks-default".into(), policy_name: "api-policy".into(), binary_path: "/usr/bin/curl".into(), - binary_pid: Some(42), + 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, diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index ae4d272806..9cf3f09f7c 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -58,8 +58,8 @@ pub struct L7EvalContext { pub policy_name: String, /// Binary path (for cross-layer Rego evaluation). pub binary_path: String, - /// PID resolved for the admitted connection, when meaningful. - pub binary_pid: Option, + /// Exact process identity captured at admission for middleware. + pub(crate) middleware_process: Option, /// Ancestor paths. pub ancestors: Vec, /// Cmdline paths. @@ -91,11 +91,7 @@ pub struct L7EvalContext { impl L7EvalContext { pub(crate) fn originating_process(&self) -> Option { - (!self.binary_path.is_empty()).then(|| openshell_core::proto::Process { - binary: self.binary_path.clone(), - pid: self.binary_pid.unwrap_or_default(), - ancestors: self.ancestors.clone(), - }) + self.middleware_process.clone() } pub(crate) fn middleware_request_context( @@ -4074,7 +4070,11 @@ mod tests { workspace: "team-a".into(), policy_name: "api-policy".into(), binary_path: "/usr/bin/curl".into(), - binary_pid: Some(42), + 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, @@ -7662,7 +7662,11 @@ network_policies: workspace: "workspace-1".into(), policy_name: "api".into(), binary_path: "/usr/bin/curl".into(), - binary_pid: Some(42), + 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() }; 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 c9843e2b5f..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,7 +97,7 @@ pub(super) fn http_context( .as_ref() .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(), - binary_pid: decision.binary_pid, + middleware_process, ancestors: decision .ancestors .iter() @@ -390,7 +406,7 @@ mod tests { request_default_port: Some(80), policy_name: "test".to_string(), binary_path: String::new(), - binary_pid: None, + middleware_process: None, ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, @@ -424,10 +440,64 @@ mod tests { }, ); assert_eq!(context.binary_path, "/usr/bin/curl"); - assert_eq!(context.binary_pid, Some(42)); + 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 a9b04f19cd..87989e4739 100644 --- a/docs/extensibility/supervisor-middleware/operations.mdx +++ b/docs/extensibility/supervisor-middleware/operations.mdx @@ -26,9 +26,9 @@ 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 available (otherwise `0`), and `ancestors` lists executable paths from nearest parent outward. The identity is fixed for the connection, including later requests on a persistent connection. +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 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. +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: 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 9643b3ec4b..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. HTTP request, HTTP response preflight, and WebSocket preflight evaluations carry it when the supervisor resolved a caller. The PID is zero when unavailable. 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, the field is absent. Middleware must treat missing process data as unavailable rather than as an authorization failure. +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