diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index d011790950..cae477618c 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -14,7 +14,6 @@ use openshell_core::{driver_mounts, proto_struct}; use serde::Serialize; use serde_json::Value; use std::collections::{BTreeMap, HashSet}; -#[cfg(target_os = "linux")] use std::path::Path; /// Returns `true` when `SELinux` is enabled (enforcing or permissive). diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index d9972223ee..902b5165c2 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -437,6 +437,8 @@ pub enum TransformedBodyPolicy<'a> { pub struct HttpRequestInput { pub request_id: String, pub sandbox_id: String, + pub sandbox_name: String, + pub workspace: String, pub scheme: String, pub host: String, pub port: u16, @@ -1581,6 +1583,8 @@ impl ChainRunner { let HttpRequestInput { request_id, sandbox_id, + sandbox_name, + workspace, scheme, host, port, @@ -1597,6 +1601,8 @@ impl ChainRunner { let context = RequestContext { request_id, sandbox_id, + sandbox_name, + workspace, originating_process: None, }; let target = HttpRequestTarget { @@ -2047,7 +2053,9 @@ mod tests { fn input(body: &str) -> HttpRequestInput { HttpRequestInput { request_id: "req".into(), - sandbox_id: "sbx".into(), + sandbox_id: "sbx-id".into(), + sandbox_name: "sbx-name".into(), + workspace: "wrks-default".into(), scheme: "https".into(), host: "api.example.com".into(), port: 443, @@ -3215,7 +3223,9 @@ mod tests { assert_eq!(received[0].config.as_ref(), Some(&evaluation_config)); let context = received[0].context.as_ref().expect("request context"); assert_eq!(context.request_id, "req"); - assert_eq!(context.sandbox_id, "sbx"); + assert_eq!(context.sandbox_id, "sbx-id"); + assert_eq!(context.sandbox_name, "sbx-name"); + assert_eq!(context.workspace, "wrks-default"); assert!(context.originating_process.is_none()); let target = received[0].target.as_ref().expect("request target"); assert_eq!(target.scheme, "https"); @@ -4678,6 +4688,8 @@ mod tests { session_id: session_id.into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5111,6 +5123,8 @@ mod tests { session_id: "builtin-regex-session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5177,6 +5191,8 @@ mod tests { session_id: "builtin-regex-gap-session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5239,6 +5255,8 @@ mod tests { session_id: "in-process-session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5310,6 +5328,8 @@ mod tests { session_id: "ws-session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -5394,6 +5414,8 @@ mod tests { session_id: "ws-session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), scheme: "ws".into(), host: "api.openai.com".into(), port: 80, @@ -5689,6 +5711,8 @@ mod tests { session_id: "session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "wrks-default".into(), 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 cea7addb0d..1fd95021a8 100644 --- a/crates/openshell-supervisor-middleware/src/websocket.rs +++ b/crates/openshell-supervisor-middleware/src/websocket.rs @@ -38,6 +38,8 @@ pub struct WebSocketPreflightInput { pub session_id: String, pub request_id: String, pub sandbox_id: String, + pub sandbox_name: String, + pub workspace: String, pub scheme: String, pub host: String, pub port: u16, @@ -920,6 +922,8 @@ async fn open_stage(entry: DescribedChainEntry, input: WebSocketPreflightInput) context: Some(RequestContext { request_id: input.request_id, sandbox_id: input.sandbox_id, + sandbox_name: input.sandbox_name, + workspace: input.workspace, originating_process: None, }), target: Some(HttpRequestTarget { diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index b8c863f0cb..b82d2135f2 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -480,6 +480,7 @@ pub async fn apply_middleware_chain_for_scheme openshell_supervisor_middleware::HttpRequestInput { openshell_supervisor_middleware::HttpRequestInput { request_id: uuid::Uuid::new_v4().to_string(), - sandbox_id: openshell_ocsf::ctx::ctx().sandbox_id.clone(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), scheme: scheme.into(), host: ctx.host.clone(), port: ctx.port, @@ -1079,6 +1085,51 @@ mod tests { assert!(!body.to_string().contains("secret-value")); } + #[test] + fn middleware_input_carries_real_sandbox_name() { + let sandbox = openshell_ocsf::SandboxContext { + sandbox_id: "sbx-123".into(), + sandbox_name: "nightly-build".into(), + container_image: String::new(), + hostname: "h".into(), + product_version: "0".into(), + proxy_ip: [127, 0, 0, 1].into(), + proxy_port: 3128, + }; + + let eval = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "api-policy".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + secret_resolver: None, + ..Default::default() + }; + let req = crate::l7::provider::L7Request { + action: "POST".into(), + target: "/v1/messages".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + + let input = super::middleware_request_input( + &sandbox, + "https", + &req, + &eval, + Vec::new(), + Vec::new(), + String::new(), + Vec::new(), + ); + + assert_eq!(input.sandbox_name, "nightly-build"); + assert_eq!(input.sandbox_id, "sbx-123"); + } + #[tokio::test] async fn middleware_failure_uses_platform_response_without_policy_guidance() { let ctx = L7EvalContext { diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 6bd0a98476..dbb74e4bac 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -42,6 +42,8 @@ pub struct L7EvalContext { pub host: String, /// Port from the CONNECT request. pub port: u16, + /// Workspace the sandbox belongs to, learned from `GetSandboxConfigResponse`. + pub workspace: String, /// Default authority port for the inspected HTTP transport (80 for /// plaintext, 443 after TLS termination). pub(crate) request_default_port: Option, @@ -990,6 +992,8 @@ pub(crate) async fn websocket_middleware_preflight( session_id: uuid::Uuid::new_v4().to_string(), request_id: uuid::Uuid::new_v4().to_string(), sandbox_id: openshell_ocsf::ctx::ctx().sandbox_id.clone(), + sandbox_name: openshell_ocsf::ctx::ctx().sandbox_name.clone(), + workspace: ctx.workspace.clone(), scheme: scheme.to_string(), host: ctx.host.clone(), port: ctx.port, @@ -5870,6 +5874,7 @@ network_policies: }; let input = middleware_request_input( + openshell_ocsf::ctx::ctx(), "http", &req, &ctx, diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index 7ba286b103..bd83ac0731 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3367,6 +3367,10 @@ network_policies: close_on_first_message: bool, message_received: Option>, release_message: Option>, + // Opt-in capture of the preflight request context's workspace. Kept + // separate from `observed` so it does not perturb the session-event + // ordering the other tests assert on. + preflight_observed: Option>, } #[tonic::async_trait] @@ -3433,11 +3437,20 @@ network_policies: let close_on_first_message = self.close_on_first_message; let message_received = self.message_received.clone(); let release_message = self.release_message.clone(); + let preflight_observed = self.preflight_observed.clone(); let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); tokio::spawn(async move { while let Ok(Some(request)) = requests.message().await { let response = match request.event { - Some(web_socket_session_event::Event::Preflight(_)) => { + Some(web_socket_session_event::Event::Preflight(preflight)) => { + if let Some(preflight_observed) = &preflight_observed { + let workspace = preflight + .context + .as_ref() + .map(|context| context.workspace.clone()) + .unwrap_or_default(); + let _ = preflight_observed.send(workspace); + } Some(WebSocketSessionEventResult { result: Some( web_socket_session_event_result::Result::PreflightDecision( @@ -3598,6 +3611,8 @@ network_policies: session_id: "session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: scheme.into(), host: "api.openai.com".into(), port: if scheme == "wss" { 443 } else { 80 }, @@ -3616,6 +3631,80 @@ network_policies: ) } + // Companion to the HTTP-path assertion in openshell-supervisor-middleware: + // proves the WebSocket preflight forwards the request context's workspace to + // the middleware service. + #[tokio::test] + async fn websocket_preflight_forwards_workspace_to_middleware() { + use openshell_core::proto::SupervisorMiddlewareService; + use openshell_supervisor_middleware::{ChainEntry, MiddlewareRegistry, OnError}; + + let (preflight_tx, mut preflight_rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind WebSocket middleware"); + let address = listener.local_addr().expect("middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(OpenAiWebSocketRedactor { + preflight_observed: Some(preflight_tx), + ..Default::default() + })) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = MiddlewareRegistry::connect_services( + Vec::new(), + vec![SupervisorMiddlewareService { + name: "openai-redactor".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES + as u64, + timeout: "2s".into(), + tls_ca_cert_pem: Vec::new(), + audience: String::new(), + allow_insecure_transport: false, + }], + ) + .await + .expect("connect middleware"); + let runner = openshell_supervisor_middleware::ChainRunner::from_registry(registry); + runner + .preflight_websocket( + &[ChainEntry { + name: "redact-openai".into(), + implementation: "openai-redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + openshell_supervisor_middleware::WebSocketPreflightInput { + session_id: "session".into(), + request_id: "request".into(), + sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "team-a".into(), + scheme: "wss".into(), + host: "api.openai.com".into(), + port: 443, + path: "/v1/responses".into(), + requested_subprotocols: Vec::new(), + }, + ) + .await + .expect("preflight"); + + assert_eq!( + preflight_rx.recv().await, + Some("team-a".to_string()), + "WebSocket preflight must forward the workspace to the middleware" + ); + + let _ = shutdown_tx.send(()); + let _ = server_task.await; + } + async fn assert_invalid_close_termination(payload: Vec, expected_close_code: u16) { let (mut session, mut observed, shutdown_tx, server_task) = recording_middleware_session("wss").await; @@ -4482,6 +4571,8 @@ network_policies: session_id: "session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -4629,6 +4720,8 @@ network_policies: session_id: "disabled-session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, @@ -4748,6 +4841,8 @@ network_policies: session_id: "builtin-regex-session".into(), request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), 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 6dd92b40db..b169d476b3 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -7123,6 +7123,8 @@ network_policies: session_id, request_id: "request".into(), sandbox_id: "sandbox".into(), + sandbox_name: "sandbox-name".into(), + workspace: "workspace".into(), scheme: "wss".into(), host: "api.openai.com".into(), port: 443, diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index 13744a63f9..d3757ae494 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -133,6 +133,10 @@ impl PolicyLocalContext { *self.current_policy.write().await = Some(policy); } + pub fn workspace(&self) -> String { + self.workspace_rx.borrow().clone() + } + #[must_use] pub fn agent_proposals(&self) -> AgentProposals { self.agent_proposals.clone() diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index b4da286bf8..542bf46ed5 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1541,6 +1541,10 @@ async fn handle_tcp_connection( // gate needs it) and drives the raw-tunnel branch below. // Build request-processing context shared by CONNECT and forward HTTP. + let workspace = policy_local_ctx + .as_ref() + .map(|ctx| ctx.workspace()) + .unwrap_or_default(); let mut ctx = relay::http_context( &decision, provider_credentials, @@ -1548,6 +1552,7 @@ async fn handle_tcp_connection( activity_tx.clone(), dynamic_credentials.clone(), agent_proposals, + workspace, ); if effective_tls_skip { @@ -4339,6 +4344,10 @@ async fn handle_forward_proxy( .map_or_else(std::collections::HashMap::new, |query| { crate::l7::rest::parse_query_params(query).unwrap_or_default() }); + let workspace = policy_local_ctx + .as_ref() + .map(|ctx| ctx.workspace()) + .unwrap_or_default(); let mut l7_ctx = relay::http_context( &decision, provider_credentials, @@ -4346,6 +4355,7 @@ async fn handle_forward_proxy( activity_tx.cloned(), dynamic_credentials.clone(), agent_proposals, + workspace, ); l7_ctx.request_default_port = match scheme.as_str() { "http" => Some(80), diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 314ab53312..bc8f5f39d2 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -45,6 +45,7 @@ pub(super) fn http_context( activity_tx: Option, dynamic_credentials: Option, agent_proposals: openshell_core::proposals::AgentProposals, + workspace: String, ) -> L7EvalContext { // Provider-backed credentials must be acquired from the live state for // each request after middleware/token-grant awaits. Keep only the legacy @@ -87,6 +88,7 @@ pub(super) fn http_context( .as_ref() .map(|_| crate::l7::token_grant_injection::default_resolver()), agent_proposals, + workspace, } } @@ -356,6 +358,7 @@ mod tests { dynamic_credentials: None, token_grant_resolver: None, agent_proposals: openshell_core::proposals::AgentProposals::default(), + workspace: String::new(), } } diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 42beedeff9..978c21e0bf 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -41,6 +41,8 @@ If post-transformation policy evaluation itself fails, OpenShell denies the requ Middleware receives the request before credential injection. Operator-run services cannot inspect OpenShell-managed credentials. Middleware-visible request headers are delivered in wire order and repeated header names are preserved as separate entries. OpenShell filters credential, routing, framing, and hop-by-hop headers before invoking middleware. It rejects malformed request headers and unsupported transfer-coding sequences before middleware or policy dispatch. Headers named by a request's `Connection` field are omitted from middleware input and removed before forwarding, except for the validated WebSocket upgrade pair. +The request context identifies the originating sandbox to operator-run services. It carries the sandbox ID (`sandbox_id`), the sandbox name (`sandbox_name`), and the workspace (`workspace`), letting audit and approval interfaces show a human-readable name and its workspace instead of an opaque ID. `sandbox_name` and `workspace` are for display and logging only: names are workspace-scoped and may be reused for different sandbox instances, so services must use `sandbox_id` for authorization, persistence, durable correlation, and identity. All three are best-effort: a supervisor that cannot resolve a value, or an older supervisor that predates a field, sends an empty string. Services should fall back to the sandbox ID when the name or workspace is empty. + ## Choose a Middleware Type | Type | Registration | Payload limit | Deployment | diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 2c7c63c11d..27fd804bdf 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -280,6 +280,14 @@ message RequestContext { string sandbox_id = 2; // Workload process that originated the request, when available. Process originating_process = 3; + // Sandbox name that originated the request. For display and logging only. + // Names are workspace-scoped and may be reused for different sandbox + // instances, so consumers must use sandbox_id for authorization, persistence, + // durable correlation, and identity. + string sandbox_name = 4; + // Workspace the sandbox belongs to. For display and logging only; see the + // sandbox_name guidance above. + string workspace = 5; } // HttpRequestTarget describes the admitted HTTP destination and request target.